From d1ff8d72533c80361e38de49a98d03fd783824c3 Mon Sep 17 00:00:00 2001 From: Tobias S Date: Wed, 2 Jan 2019 01:44:57 +0000 Subject: [PATCH] Add prototype for web-based raffle --- pinaxcon/raffle/__init__.py | 1 + pinaxcon/raffle/admin.py | 50 ++++++++++ pinaxcon/raffle/apps.py | 17 ++++ pinaxcon/raffle/migrations/0001_initial.py | 93 +++++++++++++++++++ .../migrations/0002_auto_20190102_1205.py | 32 +++++++ pinaxcon/raffle/migrations/__init__.py | 0 pinaxcon/raffle/mixins.py | 56 +++++++++++ pinaxcon/raffle/models.py | 85 +++++++++++++++++ pinaxcon/raffle/signals.py | 80 ++++++++++++++++ pinaxcon/raffle/urls.py | 10 ++ pinaxcon/raffle/views.py | 45 +++++++++ pinaxcon/settings.py | 1 + pinaxcon/templates/raffle.html | 20 ++++ pinaxcon/templates/raffle_draw.html | 56 +++++++++++ pinaxcon/urls.py | 3 +- 15 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 pinaxcon/raffle/__init__.py create mode 100644 pinaxcon/raffle/admin.py create mode 100644 pinaxcon/raffle/apps.py create mode 100644 pinaxcon/raffle/migrations/0001_initial.py create mode 100644 pinaxcon/raffle/migrations/0002_auto_20190102_1205.py create mode 100644 pinaxcon/raffle/migrations/__init__.py create mode 100644 pinaxcon/raffle/mixins.py create mode 100644 pinaxcon/raffle/models.py create mode 100644 pinaxcon/raffle/signals.py create mode 100644 pinaxcon/raffle/urls.py create mode 100644 pinaxcon/raffle/views.py create mode 100644 pinaxcon/templates/raffle.html create mode 100644 pinaxcon/templates/raffle_draw.html diff --git a/pinaxcon/raffle/__init__.py b/pinaxcon/raffle/__init__.py new file mode 100644 index 00000000..97123be3 --- /dev/null +++ b/pinaxcon/raffle/__init__.py @@ -0,0 +1 @@ +default_app_config = 'pinaxcon.raffle.apps.RaffleConfig' \ No newline at end of file diff --git a/pinaxcon/raffle/admin.py b/pinaxcon/raffle/admin.py new file mode 100644 index 00000000..0a5aa8b0 --- /dev/null +++ b/pinaxcon/raffle/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin + +from pinaxcon.raffle import models + + +class ReadOnlyMixin: + actions = None + list_display_links = None + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def save_model(self, request, obj, form, change): + return + + +class DrawAdmin(ReadOnlyMixin, admin.ModelAdmin): + list_display = ('raffle', 'drawn_time', 'drawn_by') + readonly_fields = ('raffle', 'drawn_time', 'drawn_by') + list_filter = ('raffle',) + + ordering = ('raffle', '-drawn_time') + + +class DrawnTicketAdmin(ReadOnlyMixin, admin.ModelAdmin): + list_display = ('draw', 'ticket') + readonly_fields = ('draw', 'ticket', 'lineitem', 'prize') + + +class AuditAdmin(ReadOnlyMixin, admin.ModelAdmin): + list_display = ('timestamp', 'raffle', 'prize', 'reason', 'user',) + list_filter = ('prize__raffle',) + readonly_fields = ('reason', 'prize', 'user') + + def raffle(self, instance): + return instance.prize.raffle + + +class PrizeAdmin(admin.ModelAdmin): + readonly_fields = ('winning_ticket',) + + +admin.site.register(models.Raffle) +admin.site.register(models.Prize, PrizeAdmin) +admin.site.register(models.Draw, DrawAdmin) +admin.site.register(models.DrawnTicket, DrawnTicketAdmin) +admin.site.register(models.PrizeAudit, AuditAdmin) diff --git a/pinaxcon/raffle/apps.py b/pinaxcon/raffle/apps.py new file mode 100644 index 00000000..784d169f --- /dev/null +++ b/pinaxcon/raffle/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig + + +class RaffleConfig(AppConfig): + name = "pinaxcon.raffle" + label = "pinaxcon_raffle" + verbose_name = "Pinaxcon Raffle" + admin_group_name = "Raffle admins" + + def ready(self): + import pinaxcon.raffle.signals + + def get_admin_group(self): + from django.contrib.auth.models import Group + + group, created = Group.objects.get_or_create(name=self.admin_group_name) + return group \ No newline at end of file diff --git a/pinaxcon/raffle/migrations/0001_initial.py b/pinaxcon/raffle/migrations/0001_initial.py new file mode 100644 index 00000000..34b24d1b --- /dev/null +++ b/pinaxcon/raffle/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-01-01 03:32 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import pinaxcon.raffle.mixins + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('registrasion', '0008_auto_20170930_1843'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Draw', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('drawn_time', models.DateTimeField(auto_now_add=True)), + ('drawn_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='DrawnTicket', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ticket', models.CharField(max_length=255)), + ('draw', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinaxcon_raffle.Draw')), + ('lineitem', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrasion.LineItem')), + ], + ), + migrations.CreateModel( + name='Prize', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255)), + ('order', models.PositiveIntegerField()), + ], + bases=(pinaxcon.raffle.mixins.PrizeMixin, models.Model), + ), + migrations.CreateModel( + name='PrizeAudit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(max_length=255)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('prize', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_events', to='pinaxcon_raffle.Prize')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-timestamp',), + }, + ), + migrations.CreateModel( + name='Raffle', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255)), + ('products', models.ManyToManyField(to='registrasion.Product')), + ], + bases=(pinaxcon.raffle.mixins.RaffleMixin, models.Model), + ), + migrations.AddField( + model_name='prize', + name='raffle', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prizes', to='pinaxcon_raffle.Raffle'), + ), + migrations.AddField( + model_name='prize', + name='winning_ticket', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='pinaxcon_raffle.DrawnTicket'), + ), + migrations.AddField( + model_name='drawnticket', + name='prize', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinaxcon_raffle.Prize'), + ), + migrations.AddField( + model_name='draw', + name='raffle', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='draws', to='pinaxcon_raffle.Raffle'), + ), + migrations.AlterUniqueTogether( + name='prize', + unique_together=set([('raffle', 'order')]), + ), + ] diff --git a/pinaxcon/raffle/migrations/0002_auto_20190102_1205.py b/pinaxcon/raffle/migrations/0002_auto_20190102_1205.py new file mode 100644 index 00000000..9bf8545f --- /dev/null +++ b/pinaxcon/raffle/migrations/0002_auto_20190102_1205.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2019-01-02 01:05 +from __future__ import unicode_literals + +from django.db import migrations + + +def get_admin_group_name(apps): + from pinaxcon.raffle.apps import RaffleConfig + return RaffleConfig.admin_group_name + + +def create_auth_group(apps, schema_editor): + Group = apps.get_model("auth", "Group") + Group.objects.get_or_create(name=get_admin_group_name(apps)) + + +def delete_auth_group(apps, schema_editor): + Group = apps.get_model("auth", "Group") + Group.objects.filter(name=get_admin_group_name(apps)).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinaxcon_raffle', '0001_initial'), + ('auth', '0001_initial') + ] + + operations = [ + migrations.RunPython(create_auth_group, delete_auth_group), + ] diff --git a/pinaxcon/raffle/migrations/__init__.py b/pinaxcon/raffle/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pinaxcon/raffle/mixins.py b/pinaxcon/raffle/mixins.py new file mode 100644 index 00000000..ce0f6c4c --- /dev/null +++ b/pinaxcon/raffle/mixins.py @@ -0,0 +1,56 @@ +from functools import partial + +from registrasion.models.commerce import Invoice, LineItem + + +def generate_ticket(prefix, length, num): + return "%d-%0*d" % (prefix, length, num) + + +def create_ticket_numbers(item): + quantity = item['quantity'] + length = len(str(quantity)) + ticket_func = partial(generate_ticket, item['id'], length) + return map(ticket_func, range(1, quantity+1)) + + +class RaffleMixin: + @property + def is_open(self): + prizes = self.prizes.all() + return len(prizes) and not all(p.locked for p in prizes) + + def draw(self, user): + self.draws.create(drawn_by=user) + + def get_tickets(self, user=None): + filters = { + 'invoice__status': Invoice.STATUS_PAID, + 'product__in': self.products.all() + } + + if user is not None: + filters['invoice__user'] = user + + for item in LineItem.objects.filter(**filters).values('id', 'quantity'): + yield (item['id'], list(create_ticket_numbers(item))) + + +class PrizeMixin: + @property + def locked(self): + return self._locked + + def unlock(self, user): + self.audit_events.create(user=user, reason="Unlocked") + self._locked = False + + def remove_winner(self, user): + reason = "Removed winning ticket: {}".format(self.winning_ticket.id) + self.audit_events.create(user=user, reason=reason) + self.winning_ticket = None + self.save(update_fields=('winning_ticket',)) + + + + diff --git a/pinaxcon/raffle/models.py b/pinaxcon/raffle/models.py new file mode 100644 index 00000000..bed3184d --- /dev/null +++ b/pinaxcon/raffle/models.py @@ -0,0 +1,85 @@ +from django.db import models + +from pinaxcon.raffle.mixins import PrizeMixin, RaffleMixin + + +class Raffle(RaffleMixin, models.Model): + """ + Stores a single Raffle object, related to one or many + :model:`pinaxcon_registrasion.Product`, which is usually a raffle ticket, + but can be set to tickets or other products for door prizes. + """ + description = models.CharField(max_length=255) + products = models.ManyToManyField('registrasion.Product') + + def __str__(self): + return self.description + + +class Prize(PrizeMixin, models.Model): + """ + Stores a Prize for a given :model:`pinaxcon_raffle.Raffle`. + + Once `winning_ticket` has been set to a :model:`pinaxcon_raffle.DrawnTicket` + object, no further changes are permitted unless the object is explicitely + unlocked. + """ + description = models.CharField(max_length=255) + raffle = models.ForeignKey('pinaxcon_raffle.Raffle', related_name='prizes') + order = models.PositiveIntegerField() + winning_ticket = models.OneToOneField( + 'pinaxcon_raffle.DrawnTicket', null=True, + blank=True, related_name='+', on_delete=models.PROTECT + ) + + class Meta: + unique_together = ('raffle', 'order') + + def __str__(self): + return f"{self.order}. Prize: {self.description}" + + +class PrizeAudit(models.Model): + """ + Stores an audit event for changes to a particular :model:`pinaxcon_raffle.Prize`. + """ + reason = models.CharField(max_length=255) + prize = models.ForeignKey('pinaxcon_raffle.Prize', related_name='audit_events') + + user = models.ForeignKey('auth.User') + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('-timestamp',) + + def __str__(self): + return self.reason + + +class Draw(models.Model): + """ + Stores a draw for a given :model:`pinaxcon_raffle.Raffle`, along with audit fields + for the creating :model:`auth.User` and the creation timestamp. + """ + raffle = models.ForeignKey('pinaxcon_raffle.Raffle', related_name='draws') + drawn_by = models.ForeignKey('auth.User') + drawn_time = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.raffle}: {self.drawn_time}" + + +class DrawnTicket(models.Model): + """ + Stores the result of a ticket draw, along with the corresponding + :model:`pinaxcon_raffle.Draw`, :model:`pinaxcon_raffle.Prize` and the + :model:`registrasion.commerce.LineItem` from which it was generated. + """ + ticket = models.CharField(max_length=255) + + draw = models.ForeignKey('pinaxcon_raffle.Draw') + prize = models.ForeignKey('pinaxcon_raffle.Prize') + lineitem = models.ForeignKey('registrasion.LineItem') + + def __str__(self): + return f"{self.ticket}: {self.draw.raffle}" \ No newline at end of file diff --git a/pinaxcon/raffle/signals.py b/pinaxcon/raffle/signals.py new file mode 100644 index 00000000..1efbce91 --- /dev/null +++ b/pinaxcon/raffle/signals.py @@ -0,0 +1,80 @@ +from itertools import chain +from random import sample + +from django.db import IntegrityError +from django.db.models.signals import post_save, pre_save, pre_delete, post_init +from django.dispatch import receiver +from pinaxcon.raffle.models import DrawnTicket, Raffle, Draw, Prize + + +# Much of the following could be handled by directly overriding the +# relevant model methods. However, since `.objects.delete()` bypasses +# a model's delete() method but not its pre_ and post_delete signals, +# using signals gives us slightly better coverage of edge cases. +# +# In order to avoid mixing the two approaches we make extensive use of +# signals. + + +@receiver(post_save, sender=Draw) +def draw_raffle_tickets(sender, instance, created, **kwargs): + """ + Draws tickets once a :model:`pinaxcon_raffle.Draw` instance + has been created and prizes are still available. + """ + if not created: + return + + raffle = instance.raffle + prizes = raffle.prizes.filter(winning_ticket__isnull=True) + tickets = list(chain(*(ticket[1] for ticket in raffle.get_tickets()))) + if not tickets: + return + + drawn_tickets = sample(tickets, len(prizes)) + + for prize, ticket in zip(prizes, drawn_tickets): + item_id = int(ticket.split('-')[0]) + + drawn_ticket = DrawnTicket.objects.create( + draw=instance, + prize=prize, + ticket=ticket, + lineitem_id=item_id, + ) + + prize.winning_ticket = drawn_ticket + prize.save(update_fields=('winning_ticket',)) + + +@receiver(post_init, sender=Prize) +def set_prize_lock(sender, instance, **kwargs): + """Locks :model:`pinaxcon_raffle.Prize` if a winner exists.""" + instance._locked = instance.winning_ticket is not None + + +@receiver(pre_save, sender=Prize) +def enforce_prize_lock(sender, instance, **kwargs): + """Denies updates to :model:`pinaxcon_raffle.Prize` if lock is in place.""" + if instance.locked: + raise IntegrityError("Updating a locked prize is not allowed.") + + +@receiver(pre_delete, sender=Prize) +def prevent_locked_prize_deletion(sender, instance, **kwargs): + """Denies deletion of :model:`pinaxcon_raffle.Prize` if lock is in place.""" + if instance.locked: + raise IntegrityError("Deleting a locked prize is not allowed.") + + +@receiver(pre_delete, sender=DrawnTicket) +def prevent_drawn_ticket_deletion(sender, instance, **kwargs): + """Protects :model:`pinaxcon_raffle.DrawnTicket` from deletion.""" + raise IntegrityError("Deleting a drawn ticket is not allowed.") + + +@receiver(pre_save, sender=DrawnTicket) +def prevent_drawn_ticket_update(sender, instance, **kwargs): + """Protects :model:`pinaxcon_raffle.DrawnTicket` from updates.""" + if getattr(instance, 'pk', None): + raise IntegrityError("Updating a drawn ticket is not allowed.") \ No newline at end of file diff --git a/pinaxcon/raffle/urls.py b/pinaxcon/raffle/urls.py new file mode 100644 index 00000000..6b4b2109 --- /dev/null +++ b/pinaxcon/raffle/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url +from pinaxcon.raffle import views + + +urlpatterns = [ + url(r'^tickets/', views.raffle_view), + url(r'^draw/(?P[0-9]+)/$', views.draw_raffle_ticket, name="raffle-draw"), + url(r'^draw/redraw/([0-9]+)/$', views.raffle_redraw, name="raffle-redraw"), + url(r'^draw/', views.draw_raffle_ticket, name="raffle-draw"), +] \ No newline at end of file diff --git a/pinaxcon/raffle/views.py b/pinaxcon/raffle/views.py new file mode 100644 index 00000000..b9248314 --- /dev/null +++ b/pinaxcon/raffle/views.py @@ -0,0 +1,45 @@ +from django.apps import apps +from django.contrib.auth.decorators import login_required, user_passes_test +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse +from django.views.decorators.http import require_POST + +from pinaxcon.raffle.models import Raffle, Prize + + +def _is_raffle_admin(user): + group = apps.get_app_config('pinaxcon_raffle').get_admin_group() + return group in user.groups.all() + + +@login_required +def raffle_view(request): + raffles = Raffle.objects.all() + for raffle in raffles: + raffle.tickets = list(raffle.get_tickets(user=request.user)) + + return render(request, 'raffle.html', {'raffles': raffles}) + + +@login_required +@user_passes_test(_is_raffle_admin) +def draw_raffle_ticket(request, raffle_id=None): + if request.method == 'POST' and raffle_id is not None: + Raffle.objects.get(id=raffle_id).draw(user=request.user) + return HttpResponseRedirect(reverse('raffle-draw')) + + raffles = Raffle.objects.prefetch_related('draws', 'prizes') + return render(request, 'raffle_draw.html', {'raffles': raffles}) + + +@login_required +@user_passes_test(_is_raffle_admin) +@require_POST +def raffle_redraw(request, redraw_ticket_id): + prize = Prize.objects.get(winning_ticket=redraw_ticket_id) + prize.unlock(user=request.user) + prize.remove_winner(user=request.user) + prize.raffle.draw(user=request.user) + return HttpResponseRedirect(reverse('raffle-draw')) + diff --git a/pinaxcon/settings.py b/pinaxcon/settings.py index cd29e20a..320dc099 100644 --- a/pinaxcon/settings.py +++ b/pinaxcon/settings.py @@ -240,6 +240,7 @@ INSTALLED_APPS = [ "pinaxcon", "pinaxcon.proposals", "pinaxcon.registrasion", + "pinaxcon.raffle", "jquery", "djangoformsetjs", diff --git a/pinaxcon/templates/raffle.html b/pinaxcon/templates/raffle.html new file mode 100644 index 00000000..5e1a4061 --- /dev/null +++ b/pinaxcon/templates/raffle.html @@ -0,0 +1,20 @@ +{% extends "registrasion/base.html" %} +{% load registrasion_tags %} +{% load lca2018_tags %} +{% load staticfiles %} + +{% block header_title %}{% conference_name %}{% endblock %} + +{% block proposals_body %} +

Raffle Tickets

+ +{% for raffle in raffles %} +{% if raffle.tickets %} +

{{ raffle }}

+ {% for id, numbers in raffle.tickets %} +

Ticket {{ id }}

+

{% for number in numbers %}{{ number }}{% if not forloop.last %}, {% endif %}{% endfor %}

+ {% endfor %} +{% endif %} +{% endfor %} +{% endblock %} diff --git a/pinaxcon/templates/raffle_draw.html b/pinaxcon/templates/raffle_draw.html new file mode 100644 index 00000000..28afc46d --- /dev/null +++ b/pinaxcon/templates/raffle_draw.html @@ -0,0 +1,56 @@ +{% extends "registrasion/base.html" %} +{% load registrasion_tags %} +{% load lca2018_tags %} +{% load staticfiles %} + +{% block header_title %}{% conference_name %}{% endblock %} + +{% block proposals_body %} +

Raffle Winners

+ +{% for raffle in raffles %} +

{{ raffle }}

+ +
+ {% for prize in raffle.prizes.all %} +
{{ prize }}
+
+ {% if prize.winning_ticket %} + {% with prize.winning_ticket as winner %} + {# this should be attendee name #} +

Winning ticket {{ winner.ticket }}, {{ winner.lineitem.invoice.user }}
+ Drawn by {{ winner.draw.drawn_by }}, {{ winner.draw.drawn_time}} +

+
+
+ {% csrf_token %} + {# This should have a `reason` field that can be passed through to the Audit log #} +

+ Re-draw {{ prize }} + +

+
+
+
+ {% endwith %} + {% else %} + Not drawn + {% endif %} +
+ {% endfor %} +
+ +{% if raffle.is_open %} +
+ {% csrf_token %} +

+ +

+
+
+{% endif %} + +{% if not forloop.last %}
{% endif %} +{% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/pinaxcon/urls.py b/pinaxcon/urls.py index 86566ec0..034a1dfb 100644 --- a/pinaxcon/urls.py +++ b/pinaxcon/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ url(r"^conference/", include("symposion.conference.urls")), url(r"^teams/", include("symposion.teams.urls")), + url(r'^raffle/', include("pinaxcon.raffle.urls")), # Required by registrasion url(r'^tickets/payments/', include('registripe.urls')), @@ -37,4 +38,4 @@ if settings.DEBUG: import debug_toolbar urlpatterns.insert(0, url(r'^__debug__/', include(debug_toolbar.urls))) -urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)# \ No newline at end of file