Add prototype for web-based raffle

This commit is contained in:
Tobias S 2019-01-02 01:44:57 +00:00
parent 68ea59f4d7
commit d1ff8d7253
15 changed files with 548 additions and 1 deletions

View file

@ -0,0 +1 @@
default_app_config = 'pinaxcon.raffle.apps.RaffleConfig'

50
pinaxcon/raffle/admin.py Normal file
View file

@ -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)

17
pinaxcon/raffle/apps.py Normal file
View file

@ -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

View file

@ -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')]),
),
]

View file

@ -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),
]

View file

56
pinaxcon/raffle/mixins.py Normal file
View file

@ -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',))

85
pinaxcon/raffle/models.py Normal file
View file

@ -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}"

View file

@ -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.")

10
pinaxcon/raffle/urls.py Normal file
View file

@ -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<raffle_id>[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"),
]

45
pinaxcon/raffle/views.py Normal file
View file

@ -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'))

View file

@ -240,6 +240,7 @@ INSTALLED_APPS = [
"pinaxcon",
"pinaxcon.proposals",
"pinaxcon.registrasion",
"pinaxcon.raffle",
"jquery",
"djangoformsetjs",

View file

@ -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 %}
<h1 class="mb-5">Raffle Tickets</h1>
{% for raffle in raffles %}
{% if raffle.tickets %}
<h2 class="mt-5">{{ raffle }}</h2>
{% for id, numbers in raffle.tickets %}
<h4 class="mt-3"><strong>Ticket {{ id }}</strong></h4>
<p>{% for number in numbers %}{{ number }}{% if not forloop.last %}, {% endif %}{% endfor %}</p>
{% endfor %}
{% endif %}
{% endfor %}
{% endblock %}

View file

@ -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 %}
<h1 class="mb-5">Raffle Winners</h1>
{% for raffle in raffles %}
<h2 class="mt-5">{{ raffle }}</h2>
<dl class="row my-4">
{% for prize in raffle.prizes.all %}
<dt class="col-sm-3 text-truncate">{{ prize }}</dt>
<dd class="col-sm-9">
{% if prize.winning_ticket %}
{% with prize.winning_ticket as winner %}
{# this should be attendee name #}
<p><strong>Winning ticket {{ winner.ticket }}, {{ winner.lineitem.invoice.user }}</strong><br />
Drawn by {{ winner.draw.drawn_by }}, {{ winner.draw.drawn_time}}
</p>
<div class="alert alert-danger">
<form method="POST" action="{% url 'raffle-redraw' winner.id %}">
{% csrf_token %}
{# This should have a `reason` field that can be passed through to the Audit log #}
<p>
Re-draw <em>{{ prize }}</em>
<button type="submit" class="btn btn-danger float-right">Re-draw</button>
</p>
<div class="clearfix"></div>
</form>
</div>
{% endwith %}
{% else %}
Not drawn
{% endif %}
</dd>
{% endfor %}
</dl>
{% if raffle.is_open %}
<form method="POST" action="{% url 'raffle-draw' raffle_id=raffle.id %}">
{% csrf_token %}
<p class="text-center">
<button type="submit" class="btn btn-success">Draw tickets</button>
</p>
<div class="clearfix"></div>
</form>
{% endif %}
{% if not forloop.last %}<hr>{% endif %}
{% endfor %}
{% endblock %}

View file

@ -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)#