├── rsvp ├── __init__.py ├── templates │ ├── rsvp │ │ ├── event_email.txt │ │ ├── event_thanks.html │ │ └── event_view.html │ └── base.html ├── urls.py ├── fixtures │ └── rsvp_testdata.yaml ├── admin.py ├── views.py ├── forms.py ├── tests.py └── models.py ├── CHANGELOG.txt ├── README.rst └── LICENSE.txt /rsvp/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Daniel Lindsley' 2 | __version__ = '1.1' 3 | -------------------------------------------------------------------------------- /rsvp/templates/rsvp/event_email.txt: -------------------------------------------------------------------------------- 1 | {{ event.email_message }} 2 | 3 | To RVSP to this invite, please visit http://{{ site.domain }}{{ event.get_absolute_url }}. -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | v1.1 2 | ==== 3 | 4 | * Refactored views for better reusability. 5 | * Refactored models for better reusability. 6 | * Fixed GuestInlines in admin. 7 | 8 | 9 | v1.0 10 | ==== 11 | 12 | * Initial release. -------------------------------------------------------------------------------- /rsvp/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | 4 | urlpatterns = patterns('rsvp.views', 5 | url(r'^event/(?P[A-Za-z0-9_-]+)/$', 'event_view', name='rsvp_event_view'), 6 | url(r'^event/(?P[A-Za-z0-9_-]+)/thanks/(?P\d+)/$', 'event_thanks', name='rsvp_event_thanks'), 7 | ) 8 | -------------------------------------------------------------------------------- /rsvp/templates/rsvp/event_thanks.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Thanks For RSVPing To {{ event.title }}{% endblock %} 4 | 5 | {% block content %} 6 |

Thanks For RSVPing To {{ event.title }}

7 | 8 |

9 | You've been placed on the 10 | "{{ guest.get_attending_status_display }}" 11 | list. 12 |

13 | {% endblock %} -------------------------------------------------------------------------------- /rsvp/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}{% endblock %} 6 | 7 | 8 | 9 |
10 | {% block content %} 11 | You probably shouldn't see this. 12 | {% endblock %} 13 |
14 | 15 | -------------------------------------------------------------------------------- /rsvp/fixtures/rsvp_testdata.yaml: -------------------------------------------------------------------------------- 1 | - model: rsvp.Event 2 | pk: 1 3 | fields: 4 | title: A Test Event 5 | slug: a-test-event 6 | description: What fun we will have. 7 | date_of_event: 2008-08-27 08:30:00 8 | email_subject: Come to my test event! 9 | email_message: We will have fun. 10 | 11 | - model: rsvp.Guest 12 | pk: 1 13 | fields: 14 | event: 1 15 | email: guest1@example.com 16 | name: Guest1 17 | attending_status: 'yes' 18 | number_of_guests: 0 19 | 20 | - model: rsvp.Guest 21 | pk: 2 22 | fields: 23 | event: 1 24 | email: guest2@example.com 25 | name: Guest2 26 | attending_status: no_rsvp 27 | 28 | - model: rsvp.Guest 29 | pk: 3 30 | fields: 31 | event: 1 32 | email: guest3@example.com 33 | name: Guest3 34 | attending_status: no_rsvp 35 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | django-rsvp: A simple RSVP app. 3 | =============================== 4 | 5 | ``django-rsvp`` is a simple RSVP application for use with Django. The intent 6 | is to be able to basic events, send guests invite e-mails and collect their 7 | response if they will be attending or not. 8 | 9 | 10 | Requirements 11 | ============ 12 | 13 | ``django-rsvp`` requires: 14 | 15 | * Python 2.3+ 16 | * Django 1.0+ 17 | 18 | The only potential dependency within Django is that ``django.contrib.sites`` 19 | is in ``INSTALLED_APPS`` in order to make the included e-mail template work. 20 | 21 | 22 | Installation 23 | ============ 24 | 25 | #. Either copy/symlink the ``rsvp`` app into your project or place it 26 | somewhere on your ``PYTHONPATH``. 27 | #. Add ``rvsp`` app to your ``INSTALLED_APPS``. 28 | #. Set the ``RSVP_FROM_EMAIL`` setting to an e-mail address you'd like 29 | invites to be sent from. 30 | #. Run ``./manage.py syncdb``. 31 | #. Add ``(r'^rsvp/', include('rsvp.urls')),`` to your 32 | ``urls.py``. 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008, Daniel Lindsley 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /rsvp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from rsvp.models import Event, Guest 3 | 4 | 5 | class GuestInline(admin.TabularInline): 6 | model = Guest 7 | extra = 3 8 | fields = ('email', 'name', 'attending_status', 'number_of_guests') 9 | 10 | 11 | class EventAdmin(admin.ModelAdmin): 12 | date_hiearchy = 'date_of_event' 13 | fieldsets = ( 14 | (None, { 15 | 'fields': ('title', 'slug', 'description', 'date_of_event'), 16 | }), 17 | ('Email', { 18 | 'fields': ('email_subject', 'email_message'), 19 | }), 20 | ('Event Details', { 21 | 'fields': ('hosted_by', 'street_address', 'city', 'state', 'zip_code', 'telephone'), 22 | 'classes': ('collapse',) 23 | }) 24 | ) 25 | inlines = [GuestInline] 26 | list_display = ('title', 'date_of_event') 27 | prepopulated_fields = {'slug': ('title',)} 28 | search_fields = ('title', 'description', 'hosted_by') 29 | 30 | 31 | class GuestAdmin(admin.ModelAdmin): 32 | fields = ('event', 'email', 'name', 'attending_status', 'number_of_guests', 'comment') 33 | list_display = ('email', 'name', 'attending_status', 'number_of_guests') 34 | list_filter = ('attending_status',) 35 | search_fields = ('email', 'name') 36 | 37 | 38 | admin.site.register(Event, EventAdmin) 39 | admin.site.register(Guest, GuestAdmin) 40 | -------------------------------------------------------------------------------- /rsvp/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render_to_response, get_object_or_404 2 | from django.http import HttpResponseRedirect, Http404 3 | from django.core.exceptions import ObjectDoesNotExist 4 | from django.core.urlresolvers import reverse 5 | from django.template import RequestContext 6 | from rsvp.models import Event 7 | from rsvp.forms import RSVPForm 8 | 9 | 10 | def event_view(request, slug, model_class=Event, form_class=RSVPForm, template_name='rsvp/event_view.html'): 11 | event = get_object_or_404(model_class, slug=slug) 12 | 13 | if request.POST: 14 | form = form_class(request.POST) 15 | 16 | if form.is_valid(): 17 | guest = form.save() 18 | return HttpResponseRedirect(reverse('rsvp_event_thanks', kwargs={'slug': slug, 'guest_id': guest.id})) 19 | else: 20 | form = form_class() 21 | 22 | return render_to_response(template_name, { 23 | 'event': event, 24 | 'form': form, 25 | }, context_instance=RequestContext(request)) 26 | 27 | 28 | def event_thanks(request, slug, guest_id, model_class=Event, template_name='rsvp/event_thanks.html'): 29 | event = get_object_or_404(model_class, slug=slug) 30 | 31 | try: 32 | guest = event.guests.get(pk=guest_id) 33 | except ObjectDoesNotExist: 34 | raise Http404 35 | 36 | return render_to_response(template_name, { 37 | 'event': event, 38 | 'guest': guest, 39 | }, context_instance=RequestContext(request)) -------------------------------------------------------------------------------- /rsvp/forms.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext as _ 2 | from django import forms 3 | from django.core.exceptions import ObjectDoesNotExist 4 | from rsvp.models import ATTENDING_CHOICES, Guest 5 | 6 | 7 | VISIBLE_ATTENDING_CHOICES = [choice for choice in ATTENDING_CHOICES if choice[0] != 'no_rsvp'] 8 | 9 | 10 | class RSVPForm(forms.Form): 11 | email = forms.EmailField() 12 | name = forms.CharField(max_length=128) 13 | attending = forms.ChoiceField(choices=VISIBLE_ATTENDING_CHOICES, initial='yes', widget=forms.RadioSelect) 14 | number_of_guests = forms.IntegerField(initial=0) 15 | comment = forms.CharField(max_length=255, required=False, widget=forms.Textarea) 16 | 17 | def __init__(self, *args, **kwargs): 18 | if 'guest_class' in kwargs: 19 | self.guest_class = kwargs['guest_class'] 20 | del(kwargs['guest_class']) 21 | else: 22 | self.guest_class = Guest 23 | super(RSVPForm, self).__init__(*args, **kwargs) 24 | 25 | def clean_email(self): 26 | try: 27 | guest = self.guest_class._default_manager.get(email=self.cleaned_data['email']) 28 | except ObjectDoesNotExist: 29 | raise forms.ValidationError(_('That e-mail is not on the guest list.'), code='not_on_list') 30 | 31 | if hasattr(guest, 'attending_status') and guest.attending_status != 'no_rsvp': 32 | raise forms.ValidationError(_('You have already provided RSVP information.'), code='already_rsvp') 33 | 34 | return self.cleaned_data['email'] 35 | 36 | def clean_number_of_guests(self): 37 | if self.cleaned_data['number_of_guests'] < 0: 38 | raise forms.ValidationError(_("The number of guests you're bringing can not be negative."), code='negative_guests') 39 | return self.cleaned_data['number_of_guests'] 40 | 41 | def save(self): 42 | guest = self.guest_class._default_manager.get(email=self.cleaned_data['email']) 43 | 44 | if self.cleaned_data['name']: 45 | guest.name = self.cleaned_data['name'] 46 | 47 | guest.attending_status = self.cleaned_data['attending'] 48 | guest.number_of_guests = self.cleaned_data['number_of_guests'] 49 | guest.comment = self.cleaned_data['comment'] 50 | guest.save() 51 | return guest 52 | -------------------------------------------------------------------------------- /rsvp/templates/rsvp/event_view.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Viewing Event {{ event.title }}{% endblock %} 4 | 5 | {% block content %} 6 |

{{ event.title }}

7 | 8 |

Date: {{ event.date_of_event|date:"F j, Y @ f a" }}

9 | 10 |

{{ event.description|linebreaksbr }}

11 | 12 |

Event Details

13 | 14 | {% if event.hosted_by %} 15 |

16 | Hosted By: 17 | {{ event.hosted_by }} 18 |

19 | {% endif %} 20 | 21 | {% if event.street_address %} 22 |

23 | Address:
24 | {{ event.street_address }}
25 | {{ event.city }}, {{ event.state }} {{ event.zip_code }}
26 | {{ event.telephone }} 27 |

28 | {% endif %} 29 | 30 |

Will You Be Attending?

31 | 32 |
{% csrf_token %} 33 | 34 | {{ form.as_table }} 35 | 36 | 37 | 40 | 41 |
  38 | 39 |
42 |
43 | 44 |

Guest List

45 | 46 | {% if event.guests_attending.count %} 47 |

Attending ({{ event.guests_attending.count }} guest{{ event.guests_attending.count|pluralize }})

48 | 49 |
    50 | {% for guest in event.guests_attending %} 51 |
  • 52 | {{ guest.name }} 53 | {% if guest.number_of_guests %} 54 | + {{ guest.number_of_guests }} 55 | {% endif %} 56 |
  • 57 | {% endfor %} 58 |
59 | {% endif %} 60 | 61 | {% if event.guests_not_attending.count %} 62 |

Not Attending ({{ event.guests_not_attending.count }} guest{{ event.guests_not_attending.count|pluralize }})

63 | 64 |
    65 | {% for guest in event.guests_not_attending %} 66 | {% if guest.name %} 67 |
  • 68 | {{ guest.name }} 69 |
  • 70 | {% endif %} 71 | {% endfor %} 72 |
73 | {% endif %} 74 | 75 | {% if event.guests_may_attend.count %} 76 |

May Attend ({{ event.guests_may_attend.count }} guest{{ event.guests_may_attend.count|pluralize }})

77 | 78 |
    79 | {% for guest in event.guests_may_attend %} 80 | {% if guest.name %} 81 |
  • 82 | {{ guest.name }} 83 |
  • 84 | {% endif %} 85 | {% endfor %} 86 |
87 | {% endif %} 88 | 89 | {% if event.guests_no_rsvp.count %} 90 |

Have Not RSVPed Yet ({{ event.guests_no_rsvp.count }} guest{{ event.guests_no_rsvp.count|pluralize }})

91 | 92 |
    93 | {% for guest in event.guests_no_rsvp %} 94 | {% if guest.name %} 95 |
  • 96 | {{ guest.name }} 97 |
  • 98 | {% endif %} 99 | {% endfor %} 100 |
101 | {% endif %} 102 | {% endblock %} -------------------------------------------------------------------------------- /rsvp/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | >>> from django.core.management import call_command 3 | >>> call_command('loaddata', 'rsvp_testdata.yaml') #doctest: +ELLIPSIS 4 | Installing yaml fixture 'rsvp_testdata' ... 5 | Installed 4 object(s) from 1 fixture(s) 6 | 7 | >>> from django.test import Client 8 | >>> c = Client() 9 | 10 | >>> from django.core import mail 11 | >>> from django.core.urlresolvers import reverse 12 | >>> from rsvp.models import Event, Guest 13 | 14 | # Test sending the e-mails. 15 | >>> mail.outbox 16 | [] 17 | >>> event = Event.objects.get(pk=1) 18 | >>> event 19 | 20 | >>> event.send_guest_emails() 21 | 2 22 | >>> len(mail.outbox) 23 | 2 24 | >>> mail.outbox[0].to 25 | [u'guest2@example.com'] 26 | >>> mail.outbox[0].subject 27 | u'Come to my test event!' 28 | >>> mail.outbox[0].body 29 | u'We will have fun.\\n\\nTo RVSP to this invite, please visit http://example.com/rsvp/event/a-test-event/.' 30 | 31 | # Re-empty the bin. 32 | >>> mail.outbox = [] 33 | 34 | >>> r = c.get(reverse('rsvp_event_view', args=['a-test-event'])) 35 | >>> r.status_code 36 | 200 37 | >>> r.context[-1]['event'] 38 | 39 | >>> r.context[-1]['event'].guests_attending() 40 | [] 41 | >>> r.context[-1]['event'].guests_no_rsvp() 42 | [, ] 43 | >>> type(r.context[-1]['form']) 44 | 45 | 46 | >>> r = c.post(reverse('rsvp_event_view', args=['a-test-event']), { 47 | ... 'email': 'guest99@example.com', 48 | ... 'name': 'Guest #99', 49 | ... 'attending': 'yes', 50 | ... 'number_of_guests': '0', 51 | ... 'comment': '', 52 | ... }) 53 | >>> r.status_code 54 | 200 55 | >>> r.context[-1]['form'].errors 56 | {'email': [u'That e-mail is not on the guest list.']} 57 | 58 | >>> r = c.post(reverse('rsvp_event_view', args=['a-test-event']), { 59 | ... 'email': 'guest1@example.com', 60 | ... 'name': 'Guest #1', 61 | ... 'attending': 'yes', 62 | ... 'number_of_guests': '0', 63 | ... 'comment': '', 64 | ... }) 65 | >>> r.status_code 66 | 200 67 | >>> r.context[-1]['form'].errors 68 | {'email': [u'You have already provided RSVP information.']} 69 | 70 | >>> r = c.post(reverse('rsvp_event_view', args=['a-test-event']), { 71 | ... 'email': 'guest2@example.com', 72 | ... 'name': 'Mr. Guest #2', 73 | ... 'attending': 'yes', 74 | ... 'number_of_guests': '-1', 75 | ... 'comment': '', 76 | ... }) 77 | >>> r.status_code 78 | 200 79 | >>> r.context[-1]['form'].errors 80 | {'number_of_guests': [u"The number of guests you're bringing can not be negative."]} 81 | 82 | >>> r = c.post(reverse('rsvp_event_view', args=['a-test-event']), { 83 | ... 'email': 'guest2@example.com', 84 | ... 'name': 'Mr. Guest #2', 85 | ... 'attending': 'yes', 86 | ... 'number_of_guests': '2', 87 | ... 'comment': 'Happy to come and bringing a dish!', 88 | ... }) 89 | >>> r.status_code 90 | 302 91 | >>> r['location'] 92 | 'http://testserver/rsvp/event/a-test-event/thanks/2/' 93 | 94 | >>> r = c.get(reverse('rsvp_event_view', args=['a-test-event'])) 95 | >>> r.status_code 96 | 200 97 | >>> r.context[-1]['event'] 98 | 99 | >>> r.context[-1]['event'].guests_attending() 100 | [, ] 101 | >>> r.context[-1]['event'].guests_no_rsvp() 102 | [] 103 | 104 | >>> r = c.post(reverse('rsvp_event_view', args=['a-test-event']), { 105 | ... 'email': 'guest2@example.com', 106 | ... 'name': 'Mr. Guest #2', 107 | ... 'attending': 'yes', 108 | ... 'number_of_guests': '2', 109 | ... 'comment': 'Happy to come and bringing a dish!', 110 | ... }) 111 | >>> r.status_code 112 | 200 113 | >>> r.context[-1]['form'].errors 114 | {'email': [u'You have already provided RSVP information.']} 115 | """ 116 | -------------------------------------------------------------------------------- /rsvp/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.db import models 3 | from django.db.models import permalink 4 | from django.core.mail import send_mass_mail 5 | from django.template import loader, Context 6 | from django.conf import settings 7 | from django.contrib.sites.models import Site 8 | 9 | 10 | ATTENDING_CHOICES = ( 11 | ('yes', 'Yes'), 12 | ('no', 'No'), 13 | ('maybe', 'Maybe'), 14 | ('no_rsvp', 'Hasn\'t RSVPed yet') 15 | ) 16 | 17 | 18 | class Event(models.Model): 19 | title = models.CharField(max_length=255) 20 | slug = models.SlugField() 21 | description = models.TextField() 22 | date_of_event = models.DateTimeField() 23 | verification_code = models.CharField(max_length=32, blank=True, default='', help_text='Present for future extension/guest verification.') 24 | email_subject = models.CharField(max_length=255, help_text='The subject line for the e-mail sent out to guests.') 25 | email_message = models.TextField(help_text='The body of the e-mail sent out to guests.') 26 | hosted_by = models.CharField(max_length=255, help_text='The name of the person/organization hosting the event.', blank=True, default='') 27 | street_address = models.CharField(max_length=255, help_text='The street address where the event is being held.', blank=True, default='') 28 | city = models.CharField(max_length=64, help_text='The city where the event is being held.', blank=True, default='') 29 | state = models.CharField(max_length=64, help_text='The state where the event is being held.', blank=True, default='') 30 | zip_code = models.CharField(max_length=10, help_text='The zip code where the event is being held.', blank=True, default='') 31 | telephone = models.CharField(max_length=20, blank=True, default='') 32 | created = models.DateTimeField(default=datetime.datetime.now) 33 | updated = models.DateTimeField(blank=True, null=True) 34 | 35 | def __unicode__(self): 36 | return self.title 37 | 38 | def save(self, *args, **kwargs): 39 | self.updated = datetime.datetime.now() 40 | super(Event, self).save(*args, **kwargs) 41 | 42 | def get_absolute_url(self): 43 | return ('rsvp_event_view', [self.slug]) 44 | get_absolute_url = permalink(get_absolute_url) 45 | 46 | def guests_attending(self): 47 | return self.guests.filter(attending_status='yes') 48 | 49 | def guests_not_attending(self): 50 | return self.guests.filter(attending_status='no') 51 | 52 | def guests_may_attend(self): 53 | return self.guests.filter(attending_status='maybe') 54 | 55 | def guests_no_rsvp(self): 56 | return self.guests.filter(attending_status='no_rsvp') 57 | 58 | def send_guest_emails(self): 59 | """ 60 | Sends an invite e-mail to all guest who have not RSVPed. 61 | 62 | Requires settings RSVP_FROM_EMAIL in your settings file. Returns a 63 | count of the number of guests e-mailed. 64 | """ 65 | mass_mail_data = [] 66 | from_email = getattr(settings, 'RSVP_FROM_EMAIL', '') 67 | 68 | for guest in self.guests_no_rsvp(): 69 | t = loader.get_template('rsvp/event_email.txt') 70 | c = Context({ 71 | 'event': self, 72 | 'site': Site.objects.get_current(), 73 | }) 74 | message = t.render(c) 75 | mass_mail_data.append([self.email_subject, message, from_email, [guest.email]]) 76 | 77 | send_mass_mail(mass_mail_data, fail_silently=True) 78 | return self.guests_no_rsvp().count() 79 | 80 | 81 | class Guest(models.Model): 82 | event = models.ForeignKey(Event, related_name='guests') 83 | email = models.EmailField() 84 | name = models.CharField(max_length=128, blank=True, default='') 85 | attending_status = models.CharField(max_length=32, choices=ATTENDING_CHOICES, default='no_rsvp') 86 | number_of_guests = models.SmallIntegerField(default=0) 87 | comment = models.CharField(max_length=255, blank=True, default='') 88 | created = models.DateTimeField(default=datetime.datetime.now) 89 | updated = models.DateTimeField(blank=True, null=True) 90 | 91 | def __unicode__(self): 92 | return u"%s - %s - %s" % (self.event.title, self.email, self.attending_status) 93 | 94 | class Meta: 95 | unique_together = ('event', 'email') 96 | 97 | def save(self, *args, **kwargs): 98 | self.updated = datetime.datetime.now() 99 | super(Guest, self).save(*args, **kwargs) 100 | --------------------------------------------------------------------------------