Changeset - d1ff8d72533c
[Not reviewed]
pinaxcon/raffle/__init__.py
Show inline comments
 
new file 100644
 
default_app_config = 'pinaxcon.raffle.apps.RaffleConfig'
...
 
\ No newline at end of file
pinaxcon/raffle/admin.py
Show inline comments
 
new file 100644
 
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)
pinaxcon/raffle/apps.py
Show inline comments
 
new file 100644
 
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
pinaxcon/raffle/migrations/0001_initial.py
Show inline comments
 
new file 100644
 
# -*- 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')]),
 
        ),
 
    ]
pinaxcon/raffle/migrations/0002_auto_20190102_1205.py
Show inline comments
 
new file 100644
 
# -*- 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),
 
    ]
pinaxcon/raffle/migrations/__init__.py
Show inline comments
 
new file 100644
pinaxcon/raffle/mixins.py
Show inline comments
 
new file 100644
 
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',))
 

	
 

	
 

	
 

	
pinaxcon/raffle/models.py
Show inline comments
 
new file 100644
 
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
pinaxcon/raffle/signals.py
Show inline comments
 
new file 100644
 
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
pinaxcon/raffle/urls.py
Show inline comments
 
new file 100644
 
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"),
 
]
...
 
\ No newline at end of file
pinaxcon/raffle/views.py
Show inline comments
 
new file 100644
 
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'))
 

	
pinaxcon/settings.py
Show inline comments
...
 
@@ -240,6 +240,7 @@ INSTALLED_APPS = [
 
    "pinaxcon",
 
    "pinaxcon.proposals",
 
    "pinaxcon.registrasion",
 
    "pinaxcon.raffle",
 
    "jquery",
 
    "djangoformsetjs",
 

	
pinaxcon/templates/raffle.html
Show inline comments
 
new file 100644
 
{% 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 %}
pinaxcon/templates/raffle_draw.html
Show inline comments
 
new file 100644
 
{% 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 %}
...
 
\ No newline at end of file
pinaxcon/urls.py
Show inline comments
...
 
@@ -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
0 comments (0 inline, 0 general)