5 |
6 | Authors and Contributors of django-schedule:
7 |
8 | * Tony Hauber
9 | * Bartek Gorny
10 | * Alex Gaynor
11 | * Rock Howard
12 | * Alik Kurdyukow
13 | * Jannis Leidel
14 | * Yann Malet
15 | * James Pic
16 | * Skylar Saveland
17 | * ptoal
18 | * Wes Winham
19 |
20 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/_occurrence_in_list.html:
--------------------------------------------------------------------------------
1 | {% with event=occurrence.event %}
2 |
3 | {{ occurrence.html_time_description }}
4 | {% if event.status_message %}
5 | {{ event.status_message }}
6 | {% else %}
7 | {% if occurrence.status_message %}
8 | {{ occurrence.status_message }}
9 | {% endif %}
10 | {% endif %}
11 |
12 |
13 | {% endwith %}
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/calendar/calendar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ weeks.0.6.date|date:"F Y" }}
4 | {% include 'eventtools/calendar/_month_header.html' %}
5 |
6 |
7 | {% for week in weeks %}
8 |
9 | {% for day in week %}
10 | {% include 'eventtools/calendar/_day.html' %}
11 | {% endfor %}
12 |
13 | {% endfor %}
14 |
15 | {% if prev_month.href or next_month.href %}
16 |
17 | {% include 'eventtools/calendar/_month_nav.html' %}
18 |
19 | {% endif %}
20 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. glamkit-events documentation master file, created by Thomas Ashelford
2 | sphinx-quickstart on Fri Apr 2 16:27:30 2010.
3 |
4 | Welcome to GLAMkit Eventtools’ documentation!
5 | ==========================================
6 |
7 | GLAMkit Eventtools is an open-source event calendar application. It is part of the GLAMkit framework.
8 |
9 | .. rubric:: This is part of the GLAMkit Project. For more information, please visit http://glamkit.org.
10 |
11 | .. toctree::
12 | :maxdepth: 2
13 |
14 | install
15 | overview
16 | periods
17 | utils
18 | template_tags
19 | views
20 | models
21 | settings
22 |
--------------------------------------------------------------------------------
/eventtools/static/eventtools/js/admin.js:
--------------------------------------------------------------------------------
1 | (function($) {
2 |
3 | $(document).ready(function() {
4 | /*
5 |
6 | a workaround that allows inlines replace elements specified by a fieldset. Add something like this to your ModelAdmin fieldsets:
7 |
8 | ("OCCURRENCES_PLACEHOLDER", {
9 | 'fields': (),
10 | 'classes': ('occurrences-group',),
11 | }),
12 |
13 | where 'occurrences-group' is the id of the inline you want to replace it with.
14 |
15 | */
16 |
17 | $(".inline-group").each(function() {
18 | var $this = $(this);
19 | var id = $this.attr('id');
20 | $("fieldset."+id).replaceWith($this);
21 | });
22 |
23 | });
24 | })(jQuery);
--------------------------------------------------------------------------------
/docs/settings.rst:
--------------------------------------------------------------------------------
1 | .. _ref-settings:
2 |
3 | Settings
4 | ========
5 |
6 | .. _ref-settings-first-day-of-week:
7 |
8 | FIRST_DAY_OF_WEEK
9 | -----------------
10 |
11 | This setting determines which day of the week your calendar begins on if your locale doesn't already set it. Default is 0, which is Sunday.
12 |
13 | .. .. _ref-settings-show-cancelled-occurrences:
14 | ..
15 | .. SHOW_CANCELLED_OCCURRENCES
16 | .. --------------------------
17 | ..
18 | .. This setting controls the behaviour of :func:`Period.classify_occurence`. If True, then occurrences that have been cancelled will be displayed with a CSS class of cancelled, otherwise they won't appear at all.
19 | ..
20 | .. Defaults to False
21 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/calendar/calendars.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
16 |
17 |
18 |
<
19 |
20 |
>
21 |
22 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ==================
2 | Glamkit-eventtools
3 | ==================
4 |
5 | An event management application designed for the GLAM (Galleries, Libraries, Museums and Archives) sector. It is part of the `GLAMkit project `_.
6 |
7 | View a full list of `GLAMkit components `_.
8 |
9 | It is a fork of the popular django-schedule app.
10 |
11 | Features:
12 |
13 | * Events have several Occurrences. You define the non-essential fields.
14 | * Handles one-time and repeating Occurrences.
15 | * Can exclude particular times from repeating occurrences.
16 | * Ready to use, nice user interface
17 | * Flexible calendar template tags
18 |
19 | Please read the `documentation `_.
--------------------------------------------------------------------------------
/eventtools/templates/admin/eventtools/occurrence_list.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_list.html" %}
2 | {% load i18n %}
3 |
4 | {% block object-tools %}
5 | {% if has_add_permission %}
6 |
13 | {% endif %}
14 |
15 | {% if root_event %}
16 | {% blocktrans %}Showing all occurrences of {{ root_event }} and its descendants{% endblocktrans %}
17 | {% endif %}
18 | {% endblock %}
19 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/_occurrences_in_event.html:
--------------------------------------------------------------------------------
1 |
2 | {% regroup occurrences by start_date as day_list %}
3 | {% for day in day_list %}
4 | {{ day.grouper|date:"l, j F Y" }}
5 | {% for occurrence in day.list %}
6 |
7 | {{ occurrence.html_time_description }} :
8 | {% if occurrence.event != event %}
9 | {{ occurrence.event }}
10 | {% else %}
11 | {{ occurrence.event }}
12 | {% endif %}
13 |
14 | {% if occurrence.status_message %}({{ occurrence.status_message }}){% endif %}
15 | {% endfor %}
16 | {% endfor %}
17 |
18 |
--------------------------------------------------------------------------------
/eventtools/TODO.txt:
--------------------------------------------------------------------------------
1 | MODEL
2 | Reword 'parent' to be 'template'
3 |
4 | FRONT END
5 | Prettify templates a bit: Use http://cssgrid.net/ for more flexible layout?
6 | Tabs for today/tomorrow/this weekend/etc.
7 | List events only for a specific date (for institutions that have enough events)
8 | Resurrect iCal.
9 |
10 | ADMIN
11 | Tooltips for fields in inline admin forms: Django 1.4
12 |
13 | UNDER THE HOOD
14 | Improve performance
15 | Consistent api for dateranges/pprint_date.
16 | Put utils.domain into glamkit-convenient, or replace with another technique?
17 | Patch Django to provide instance to callable for default.
18 | PEP-8 compliance
19 |
20 | DOCUMENTATION
21 | Docstrings for public methods
22 | Write up in models.rst
23 |
24 | TEST
25 | Check coverage for public methods
26 | Add tests for templatetags, views
27 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/_pagination.html:
--------------------------------------------------------------------------------
1 | {% load get_string %}
2 |
3 |
4 | {% if pageinfo.has_previous %}
5 | « Earlier
6 | {% endif %}
7 |
8 |
9 | {% if pageinfo.paginator.num_pages > 1 %}
10 | Showing {{ pageinfo.start_index }}–{{ pageinfo.end_index }} of {% if pageinfo.paginator.count > 200 %}hundreds of{% else %}{{ pageinfo.paginator.count }}{% endif %} event{{ pageinfo.paginator.count|pluralize }}
11 | {% else %}
12 | Showing {{ pageinfo.paginator.count }} event{{ pageinfo.paginator.count|pluralize }}
13 | {% endif %}
14 |
15 |
16 | {% if pageinfo.has_next %}
17 | Later »
18 | {% endif %}
19 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/_base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% block title %}{% endblock %}
5 | {% block extrahead %}{% endblock %}
6 |
7 | {% block style %}
8 |
9 | {% endblock style %}
10 |
11 |
12 |
13 |
14 |
15 | {% block breadcrumbs %}
16 | Events home
17 | {% endblock %}
18 | {% block content %}
19 | {% endblock %}
20 | {% block scripts %}
21 | {# calendar scrolling. For projects already include jQuery, use http://cdn.jquerytools.org/1.2.5/tiny/jquery.tools.min.js #}
22 |
23 | {# shows session times when you click #}
24 |
25 | {% endblock scripts %}
26 |
27 |
--------------------------------------------------------------------------------
/eventtools/utils/datetimeify.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, date, time
2 |
3 | __all__ = ('datetimeify', 'dayify')
4 |
5 | MIN = "min"
6 | MAX = "max"
7 |
8 | def datetimeify(dt, tm=None, clamp=MIN):
9 | # pass in a date or a date and a time or a datetime, pass out a datetime.
10 | if isinstance(dt, datetime):
11 | if clamp == MAX and dt.time() == time.min:
12 | dt = datetime.combine(dt.date(), time.max)
13 | return dt
14 | if tm:
15 | return datetime.combine(dt, tm)
16 | if clamp.lower()==MAX:
17 | return datetime.combine(dt, time.max)
18 | return datetime.combine(dt, time.min)
19 |
20 | def dayify(d1, d2=None): #returns two datetimes that encompass the day or days given
21 | if isinstance(d1, datetime):
22 | d1 = d1.date()
23 | start = datetimeify(d1, clamp=MIN)
24 |
25 | if d2 is not None:
26 | if isinstance(d2, datetime):
27 | d2 = d2.date()
28 | end = datetimeify(d2, clamp=MAX)
29 | else:
30 | end = datetimeify(d1, clamp=MAX)
31 | return start, end
--------------------------------------------------------------------------------
/eventtools/settings.py:
--------------------------------------------------------------------------------
1 | # You can override these settings in Django.
2 | # Import with
3 | # from eventtools.conf import settings
4 | from django.conf import settings
5 |
6 | import calendar
7 | FIRST_DAY_OF_WEEK = calendar.MONDAY #you may prefer Saturday or Sunday.
8 | FIRST_DAY_OF_WEEKEND = calendar.SATURDAY #you may prefer Friday
9 | LAST_DAY_OF_WEEKEND = calendar.SUNDAY
10 |
11 | EVENT_GET_MAP = {
12 | 'startdate': 'startdate',
13 | 'enddate': 'enddate',
14 | }
15 |
16 | OCCURRENCES_PER_PAGE = 20
17 |
18 | ICAL_ROOT_URL = getattr(settings, 'ICS_ROOT_URL', 'http://www.example.com')
19 | ICAL_CALNAME = getattr(settings, 'SITE_NAME', 'Events list')
20 | ICAL_CALDESC = "Events listing" #e.g. "Events listing from mysite.com"
21 |
22 | from dateutil.relativedelta import relativedelta
23 | DEFAULT_GENERATOR_LIMIT = relativedelta(years=1) #months=6, etc
24 |
25 | OCCURRENCE_STATUS_CANCELLED = ('cancelled', 'Cancelled')
26 | OCCURRENCE_STATUS_FULLY_BOOKED = ('fully booked', 'Fully Booked')
27 |
28 | OCCURRENCE_STATUS_CHOICES = [
29 | OCCURRENCE_STATUS_CANCELLED,
30 | OCCURRENCE_STATUS_FULLY_BOOKED,
31 | ]
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | from setuptools import setup, find_packages
4 |
5 | setup(
6 | name='glamkit-eventtools',
7 | version='1.0.0a1',
8 | description='An event management app for Django.',
9 | author='Greg Turner',
10 | author_email='greg@interaction.net.au',
11 | url='http://github.com/glamkit/glamkit-eventtools',
12 | packages=find_packages(),
13 | include_package_data=True,
14 | zip_safe=False,
15 | classifiers=['Development Status :: 4 - Beta',
16 | 'Environment :: Web Environment',
17 | 'Framework :: Django',
18 | 'Intended Audience :: Developers',
19 | 'License :: OSI Approved :: BSD License',
20 | 'Operating System :: OS Independent',
21 | 'Programming Language :: Python',
22 | 'Topic :: Utilities'],
23 | install_requires=['setuptools', 'vobject==0.8.1c', 'python-dateutil==1.5', 'django-mptt>=0.5'],
24 | license='BSD',
25 | test_suite = "eventtools.tests",
26 | )
27 |
28 | # also requires libraries in REQUIREMENTS.txt
29 | # pip install -r REQUIREMENTS.txt
30 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/signage_on_date.html:
--------------------------------------------------------------------------------
1 | {% block content %}
2 |
3 | {% regroup occurrence_pool by start_date as day_list %}
4 |
5 |
6 |
7 | {% for day in day_list %}
8 |
9 | What's On{% if is_today %} Today{% endif %}: {{ day.grouper|date:"l j F" }}
10 |
11 | {% regroup day.list by html_time_description as occurrences_grouped %}
12 | {% for occurrence_group in occurrences_grouped %}
13 | {{ occurrence_group.grouper }}
14 |
15 | {% for occurrence in occurrence_group.list %}
16 |
17 |
18 | {% with occurrence.event as event %}
19 | {{ event.type }}{% if event.title %} – {{ event.title }}{% endif %}
20 | {{ event.subtitle }}
21 | {{ event.venue }}{% if event.venue.location %} – {{ event.venue.location }}{% endif %}
22 | {% endwith %}
23 |
24 |
25 | {% endfor %}
26 |
27 | {% endfor %}
28 |
29 |
30 | {% empty %}
31 | No events
32 | {% endfor %}
33 |
34 |
35 |
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/eventtools/tests/eventtools_testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from eventtools.models import EventModel, OccurrenceModel, GeneratorModel, ExclusionModel
3 | from django.conf import settings
4 |
5 | class ExampleEvent(EventModel):
6 | difference_from_parent = models.CharField(max_length=250, blank=True, null=True)
7 |
8 | def __unicode__(self):
9 | if self.difference_from_parent and self.parent:
10 | return u"%s (%s)" % (self.title, self.difference_from_parent)
11 | return self.title
12 |
13 | class EventMeta:
14 | fields_to_inherit = ['title',]
15 |
16 | class ExampleGenerator(GeneratorModel):
17 | event = models.ForeignKey(ExampleEvent, related_name="generators")
18 |
19 | class ExampleOccurrence(OccurrenceModel):
20 | generated_by = models.ForeignKey(ExampleGenerator, related_name="occurrences", blank=True, null=True)
21 | event = models.ForeignKey(ExampleEvent, related_name="occurrences")
22 |
23 | class ExampleExclusion(ExclusionModel):
24 | event = models.ForeignKey(ExampleEvent, related_name="exclusions")
25 |
26 | class ExampleTicket(models.Model):
27 | # used to test that an occurrence is unhooked rather than deleted.
28 | occurrence = models.ForeignKey(ExampleOccurrence, on_delete=models.PROTECT)
--------------------------------------------------------------------------------
/eventtools/utils/domain.py:
--------------------------------------------------------------------------------
1 | """
2 | From http://fragmentsofcode.wordpress.com/2009/02/24/django-fully-qualified-url/
3 |
4 | This is used by eventtools to get absolute URLs for ics files
5 |
6 | TODO: go into glamkit-convenient someday?
7 | """
8 |
9 | from django.conf import settings
10 |
11 | def current_site_url():
12 | """Returns fully qualified URL (no trailing slash) for the current site."""
13 | from django.contrib.sites.models import Site
14 | current_site = Site.objects.get_current()
15 | protocol = getattr(settings, 'SITE_PROTOCOL', 'http')
16 | port = getattr(settings, 'SITE_PORT', '')
17 | url = '%s://%s' % (protocol, current_site.domain)
18 | if port:
19 | url += ':%s' % port
20 | return url
21 |
22 | def django_root_url(fq=True):
23 | """Returns base URL (no trailing slash) for the current project.
24 |
25 | Setting fq parameter to a true value will prepend the base URL
26 | of the current site to create a fully qualified URL.
27 |
28 | The name django_root_url is used in favor of alternatives
29 | (such as project_url) because it corresponds to the mod_python
30 | PythonOption django.root setting used in Apache.
31 | """
32 | url = getattr(settings, 'DJANGO_URL_PATH', '')
33 | if fq:
34 | url = current_site_url() + url
35 | return url
--------------------------------------------------------------------------------
/eventtools/utils/managertype.py:
--------------------------------------------------------------------------------
1 | __author__ = 'gturner'
2 |
3 | def ManagerType(QSFN, supertype=type):
4 | """
5 | This metaclass generator injects proxies for given queryset functions into the manager.
6 |
7 | This allows the function f to be called from .objects.f() and .objects.filter().f()
8 |
9 | class QSFN(object):
10 | # define your queryset functions here
11 | def f(self):
12 | return self.filter(**kwargs)
13 | ...
14 |
15 | class MyQuerySet(models.query.QuerySet, QSFN):
16 | # trivial inheritance of the QS functions
17 | pass
18 |
19 | class MyManager(models.Manager):
20 | __metaclass__ = ManagerType(QSFN) # injects the QS functions
21 |
22 | def get_query_set(self):
23 | return MyQuerySet(self.model)
24 |
25 | class MyModel(models.Model):
26 | ...
27 | objects = MyManager()
28 |
29 | """
30 |
31 | #TODO: move to glamkit-convenient.
32 |
33 | class _MT(supertype):
34 | @staticmethod
35 | def _fproxy(name):
36 | def f(self, *args, **kwargs):
37 | return getattr(self.get_query_set(), name)(*args, **kwargs)
38 | return f
39 |
40 | def __init__(cls, *args):
41 | for fname in dir(QSFN):
42 | if not fname.startswith("_"):
43 | setattr(cls, fname, _MT._fproxy(fname))
44 | super(_MT, cls).__init__(*args)
45 | return _MT
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2008, Tony Hauber
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are
6 | met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above
11 | copyright notice, this list of conditions and the following
12 | disclaimer in the documentation and/or other materials provided
13 | with the distribution.
14 | * Neither the name of the author nor the names of other
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/eventtools/models/exclusion.py:
--------------------------------------------------------------------------------
1 | # (We thought of calling it Exceptions, but Python has them)
2 |
3 | from django.db import models
4 | from django.utils.timezone import localtime
5 | from django.utils.translation import ugettext, ugettext_lazy as _
6 |
7 | class ExclusionModel(models.Model):
8 | """
9 | Represents the time of an occurrence which is not to be generated for a given event.
10 |
11 | Implementing subclasses should define an 'event' ForeignKey to an EventModel
12 | subclass. The related_name for the ForeignKey should be 'exclusions'.
13 |
14 | event = models.ForeignKey(SomeEvent, related_name="exclusions")
15 | """
16 | start = models.DateTimeField(db_index=True, verbose_name=_('start'))
17 |
18 | class Meta:
19 | abstract = True
20 | ordering = ('start',)
21 | verbose_name = _("repeating occurrence exclusion")
22 | verbose_name_plural = _("repeating occurrence exclusions")
23 | unique_together = ('event', 'start')
24 |
25 | def __unicode__(self):
26 | return "%s starting on %s is excluded" \
27 | % (self.event, localtime(self.start))
28 |
29 | def save(self, *args, **kwargs):
30 | """
31 | When an exclusion is saved, any generated occurrences that match should
32 | be unhooked.
33 | """
34 | r = super(ExclusionModel, self).save(*args, **kwargs)
35 |
36 | clashing = self.event.occurrences.filter(start = self.start, generated_by__isnull=False)
37 | for c in clashing:
38 | c.generated_by = None
39 | c.save()
40 |
41 | return r
42 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/occurrence_list.html:
--------------------------------------------------------------------------------
1 | {% extends "eventtools/_base.html" %}
2 | {% load calendar %}
3 |
4 | {% block extrahead %}
5 |
6 | {% endblock %}
7 |
8 | {% block content %}
9 |
10 | {% comment %}
11 | download .ics file
12 | add to iCal/Outlook
13 | add to Google calendar
14 |
15 | {% endcomment %}
16 |
17 | {% if occurrence_page %}
18 | {% regroup occurrence_page by start_date as day_list %}
19 | {% else %}
20 | {% regroup occurrence_pool by start_date as day_list %}
21 | {% endif %}
22 |
23 | {% nav_calendar day occurrence_qs %}{# shows a global nav calendar where dates in occurrence_qs are highlighted #}
24 |
25 |
26 | {% for day in day_list %}
27 |
28 | {{ day.grouper|date:"l, j F Y" }}
29 |
30 | {% for occurrence in day.list %}
31 |
32 | {% include "eventtools/_occurrence_in_list.html" %}
33 |
34 | {% endfor %}
35 |
36 |
37 | {% empty %}
38 | Sorry, no events were found
39 | {% endfor %}
40 |
41 |
42 | {% if occurrence_page %}
43 |
48 | {% endif %}
49 |
50 | {% endblock %}
--------------------------------------------------------------------------------
/eventtools/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import datetime
3 | from south.db import db
4 | from south.v2 import SchemaMigration
5 | from django.db import models
6 |
7 | class Migration(SchemaMigration):
8 |
9 | def forwards(self, orm):
10 |
11 | # Adding model 'Rule'
12 | db.create_table('eventtools_rule', (
13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
14 | ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
15 | ('common', self.gf('django.db.models.fields.BooleanField')(default=False)),
16 | ('frequency', self.gf('django.db.models.fields.CharField')(max_length=10, blank=True)),
17 | ('params', self.gf('django.db.models.fields.TextField')(blank=True)),
18 | ('complex_rule', self.gf('django.db.models.fields.TextField')(blank=True)),
19 | ))
20 | db.send_create_signal('eventtools', ['Rule'])
21 |
22 |
23 | def backwards(self, orm):
24 |
25 | # Deleting model 'Rule'
26 | db.delete_table('eventtools_rule')
27 |
28 |
29 | models = {
30 | 'eventtools.rule': {
31 | 'Meta': {'ordering': "('-common', 'name')", 'object_name': 'Rule'},
32 | 'common': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
33 | 'complex_rule': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
34 | 'frequency': ('django.db.models.fields.CharField', [], {'max_length': '10', 'blank': 'True'}),
35 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
36 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
37 | 'params': ('django.db.models.fields.TextField', [], {'blank': 'True'})
38 | }
39 | }
40 |
41 | complete_apps = ['eventtools']
42 |
--------------------------------------------------------------------------------
/eventtools/filters.py:
--------------------------------------------------------------------------------
1 | # Needs Django 1.4
2 | from django.utils.translation import ugettext_lazy as _
3 | from django.contrib.admin import SimpleListFilter
4 | from django.db.models import F
5 |
6 | class IsGeneratedListFilter(SimpleListFilter):
7 | # Human-readable title which will be displayed in the
8 | # right admin sidebar just above the filter options.
9 | title = _('type')
10 |
11 | # Parameter for the filter that will be used in the URL query.
12 | parameter_name = 'method'
13 |
14 | def lookups(self, request, model_admin):
15 | """
16 | Returns a list of tuples. The first element in each
17 | tuple is the coded value for the option that will
18 | appear in the URL query. The second element is the
19 | human-readable name for the option that will appear
20 | in the right sidebar.
21 | """
22 | return (
23 | ('generated_self', _('Generated in same event')),
24 | ('generated_ancestor', _('Generated in ancestor event')),
25 | ('generated', _('Generated anywhere')),
26 | ('one-off', _('One-off')),
27 | )
28 |
29 | def queryset(self, request, queryset):
30 | """
31 | Returns the filtered queryset based on the value
32 | provided in the query string and retrievable via
33 | `self.value()`.
34 | """
35 | # Compare the requested value (either '80s' or 'other')
36 | # to decide how to filter the queryset.
37 |
38 |
39 | if self.value() == 'generated_self':
40 | return queryset.filter(generated_by__event=F('event'))
41 | if self.value() == 'generated_ancestor':
42 | return queryset.filter(generated_by__isnull=False).exclude(generated_by__event=F('event'))
43 | if self.value() == 'generated':
44 | return queryset.filter(generated_by__isnull=False)
45 | if self.value() == 'one-off':
46 | return queryset.filter(generated_by__isnull=True)
47 |
--------------------------------------------------------------------------------
/eventtools/static/eventtools/css/events.css:
--------------------------------------------------------------------------------
1 | table.calendar tr {
2 | height: 30px;
3 | line-height: 30px;
4 | }
5 | table.calendar td {
6 | text-align: center;
7 | width: 30px;
8 | height: 30px;
9 | }
10 |
11 | table.calendar td span {
12 | width: 30px;
13 | height: 30px;
14 | display: block;
15 | vertical-align: center;
16 | }
17 |
18 | table.calendar td a {
19 | display: block;
20 | width: 30px;
21 | height: 30px;
22 | }
23 |
24 | table.calendar .today {
25 | border: 1px solid #888;
26 | }
27 |
28 | table.calendar .clicked {
29 | background-color: #607890 !important;
30 | color: white;
31 | }
32 |
33 |
34 | table.calendar .saturday,
35 | table.calendar .sunday {
36 | background-color: #eee;
37 | }
38 |
39 | table.calendar .highlight {
40 | background-color: #ccc;
41 | }
42 |
43 | table.calendar .selected {
44 | font-weight: bold;
45 | }
46 |
47 | table.calendar .last_month,
48 | table.calendar .next_month {
49 | opacity: 0.3;
50 | }
51 |
52 |
53 |
54 | .calendarlist table.calendar .last_month span,
55 | .calendarlist table.calendar .next_month span {
56 | visibility: hidden;
57 | }
58 |
59 |
60 | /*
61 | root element for the scrollable.
62 | when scrolling occurs this element stays still.
63 | */
64 | .calendarlist {
65 | width: 210px;
66 | }
67 |
68 | .calendarlist .scrollable {
69 |
70 | /* required settings */
71 | position:relative;
72 | overflow:hidden;
73 | width: 210px;
74 | height:240px;
75 | }
76 |
77 | /*
78 | root element for scrollable items. Must be absolutely positioned
79 | and it should have a extremely large width to accommodate scrollable items.
80 | it's enough that you set width and height for the root element and
81 | not for this element.
82 | */
83 | .calendarlist .scrollable .items {
84 | /* this cannot be too large */
85 | width:20000em;
86 | position:absolute;
87 | }
88 |
89 | /*
90 | a single item. must be floated in horizontal scrolling.
91 | typically, this element is the one that *you* will style
92 | the most.
93 | */
94 | .calendarlist .items div {
95 | float:left;
96 | margin-right: 10px;
97 | }
--------------------------------------------------------------------------------
/eventtools/static/eventtools/js/events.js:
--------------------------------------------------------------------------------
1 | (function($) {
2 | $(document).ready(function() {
3 |
4 | // make calendars scrollable
5 | var $el = $(".calendarlist .scrollable")
6 | $el.scrollable();
7 |
8 | var api = $el.data("scrollable");
9 |
10 | var month_str;
11 | // if there is a selected day, scroll to it
12 | var date = $(".calendar td.highlight.selected").attr('data');
13 | if (date) {
14 | month_str = date.substr(0, 7);
15 | } else {
16 | // elif the current month is in the list of scrollable items, scroll to it.
17 | var today = new Date()
18 | function pad(n){return n<10 ? '0'+n : n}
19 | month_str = today.getUTCFullYear()+'-'
20 | + pad(today.getUTCMonth()+1);
21 | }
22 |
23 | if (month_str) {
24 | var offset = 0;
25 | api.getItems().each(function() {
26 | var $this = $(this);
27 | if ($this.attr("data") == month_str) {
28 | api.move(offset, 0);
29 | return false;
30 | }
31 | offset += 1;
32 | });
33 | };
34 |
35 | var days_count = $("#sessions dt").size();
36 |
37 | if (days_count > 1) {
38 | //Hide sessions
39 | $("#sessions dt").hide();
40 | $("#sessions dd").hide();
41 |
42 | //inject an info/results box
43 | $("#sessions").prepend("Click on calendar to see session times
");
44 |
45 | // Make highlighted dates look clickable
46 | $(".calendar td.highlight").css("cursor", "pointer");
47 |
48 | var highlight_click = function(event) {
49 | var $this = $(this);
50 | $(".calendar td.highlight").removeClass("clicked");
51 | $this.addClass("clicked");
52 | $("#sessions .help").hide();
53 | $("#sessions dt").hide();
54 | $("#sessions dd").hide();
55 | // show only the sessions with the data
56 | $("#sessions [data=\""+$this.attr('data')+"\"]").fadeIn(400);
57 |
58 | };
59 | // Show session data when we click on a date
60 | $(".calendar td.highlight").click(highlight_click);
61 |
62 | // By default, highlight the initially selected date
63 | $(".calendar td.highlight.selected").each(highlight_click);
64 |
65 | } // endif
66 | });
67 |
68 | })(jQuery);
--------------------------------------------------------------------------------
/CHANGES.txt:
--------------------------------------------------------------------------------
1 | v0.5.0, 2010-06-22 -- Initial release.
2 | v0.5.1, 2010-06-22 -- Fixed setup.py bug.
3 | v0.9.0, 2010-09-26 -- Refactored to have more consistent treatment of dateranges.
4 | Occurrence.start and Occurrence.end methods are deprecated;
5 | instead use Occurrence.timespan.start etc.
6 |
7 | -------------------------------------------------------------------------------
8 |
9 | 2011-09-06 -- Major backwards incompatibility:
10 |
11 | This revision contains a breaking change in the Occurrence and Generator models, to use start + duration, rather than
12 | start + end, and to have consistency between their APIs.
13 |
14 | The Generator model repeat_until is now a date, rather than a datetime, for simplicity (only one occurrence per day is
15 | generated).
16 |
17 | See UPGRADING.txt for sample code for migrations.
18 |
19 |
20 | -------------------------------------------------------------------------------
21 |
22 | 2011-03-23 -- Possible backwards incompatibility:
23 |
24 | [EDIT 2011-09-06 - later changes now render this validation check obsolete]
25 |
26 | This revision introduces a validation check to ensure events marked as daily do
27 | not span more than 24 hours. If such events exist in your database, the
28 | following code should be executed from the Django shell BEFORE upgrading
29 | eventtools to fix the newly invalid occurrences (note that it may take quite a
30 | while to execute):
31 |
32 | from datetime import timedelta
33 | from events.models import Generator # Or wherever your subclass of GeneratorModel lives
34 | for generator in [g for g in Generator.objects.filter(rule__frequency='DAILY') if g.event_end - g.event_start > timedelta(1)]:
35 | if not generator.repeat_until:
36 | generator.repeat_until = generator.event_end
37 | generator.event_end = generator.event_end.replace(*generator.event_start.timetuple()[:3])
38 | generator.save()
39 |
40 | # Review the occurrences to be deleted with caution before executing this
41 | len([o.delete() for o in Occurrence.objects.filter(generator__rule__frequency='DAILY') if o.start.date() != o.end.date()])
42 |
43 | -------------------------------------------------------------------------------
--------------------------------------------------------------------------------
/eventtools/tests/_inject_app.py:
--------------------------------------------------------------------------------
1 | import shlex
2 | import subprocess
3 | from random import randint
4 |
5 | from django.db.models.loading import load_app
6 | from django.conf import settings
7 | from django.core.management import call_command
8 | from django.template.loaders import app_directories
9 | from django.template import loader
10 | from django.test import TestCase
11 |
12 | from _fixture import fixture
13 |
14 | APP_NAME = 'eventtools.tests.eventtools_testapp'
15 |
16 | class TestCaseWithApp(TestCase):
17 |
18 | """Make sure to call super(..).setUp and tearDown on subclasses"""
19 |
20 | def setUp(self):
21 | self.__class__.__module__ = self.__class__.__name__
22 |
23 | self.old_INSTALLED_APPS = settings.INSTALLED_APPS
24 | if isinstance(settings.INSTALLED_APPS, tuple):
25 | settings.INSTALLED_APPS += (APP_NAME,)
26 | else:
27 | settings.INSTALLED_APPS += [APP_NAME]
28 | self._old_root_urlconf = settings.ROOT_URLCONF
29 | settings.ROOT_URLCONF = '%s.urls' % APP_NAME
30 | load_app(APP_NAME)
31 | call_command('flush', verbosity=0, interactive=False)
32 | call_command('syncdb', verbosity=0, interactive=False)
33 | self.ae = self.assertEqual
34 | self._old_template_loaders = settings.TEMPLATE_LOADERS
35 | loaders = list(settings.TEMPLATE_LOADERS)
36 | try:
37 | loaders.remove('django.template.loaders.filesystem.Loader')
38 | settings.TEMPLATE_LOADERS = loaders
39 | self._refresh_cache()
40 | except ValueError:
41 | pass
42 |
43 | def tearDown(self):
44 | settings.INSTALLED_APPS = self.old_INSTALLED_APPS
45 | settings.ROOT_URLCONF = self._old_root_urlconf
46 | settings.TEMPLATE_LOADERS = self._old_template_loaders
47 | self._refresh_cache()
48 |
49 | def _refresh_cache(self):
50 | reload(app_directories)
51 | loader.template_source_loaders = None
52 |
53 | def open_string_in_browser(self, s):
54 | filename = "/tmp/%s.html" % randint(1, 100)
55 | f = open(filename, "w")
56 | f.write(s)
57 | f.close()
58 | subprocess.call(shlex.split("google-chrome %s" % filename))
--------------------------------------------------------------------------------
/eventtools/fixtures/initial_data.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "pk": 1,
4 | "model": "eventtools.rule",
5 | "fields": {
6 | "complex_rule": "",
7 | "frequency": "DAILY",
8 | "params": "",
9 | "name": "Every day",
10 | "common": true
11 | }
12 | },
13 | {
14 | "pk": 7,
15 | "model": "eventtools.rule",
16 | "fields": {
17 | "complex_rule": "RRULE:FREQ=MONTHLY;BYDAY=%nthday%",
18 | "frequency": "",
19 | "params": "",
20 | "name": "Every month: same calendar position",
21 | "common": true
22 | }
23 | },
24 | {
25 | "pk": 6,
26 | "model": "eventtools.rule",
27 | "fields": {
28 | "complex_rule": "FREQ=MONTHLY;BYMONTHDAY=%day%,-1;BYSETPOS=1",
29 | "frequency": "MONTHLY",
30 | "params": "",
31 | "name": "Every month: same date",
32 | "common": true
33 | }
34 | },
35 | {
36 | "pk": 4,
37 | "model": "eventtools.rule",
38 | "fields": {
39 | "complex_rule": "",
40 | "frequency": "WEEKLY",
41 | "params": "",
42 | "name": "Every week",
43 | "common": true
44 | }
45 | },
46 | {
47 | "pk": 5,
48 | "model": "eventtools.rule",
49 | "fields": {
50 | "complex_rule": "RRULE:FREQ=WEEKLY;INTERVAL=2",
51 | "frequency": "",
52 | "params": "",
53 | "name": "Every fortnight",
54 | "common": false
55 | }
56 | },
57 | {
58 | "pk": 8,
59 | "model": "eventtools.rule",
60 | "fields": {
61 | "complex_rule": "RRULE:FREQ=MONTHLY;BYDAY=%-nthday%",
62 | "frequency": "",
63 | "params": "",
64 | "name": "Every month: same calendar position from end",
65 | "common": false
66 | }
67 | },
68 | {
69 | "pk": 2,
70 | "model": "eventtools.rule",
71 | "fields": {
72 | "complex_rule": "",
73 | "frequency": "WEEKLY",
74 | "params": "byweekday:0,1,2,3,4",
75 | "name": "Every weekday",
76 | "common": false
77 | }
78 | },
79 | {
80 | "pk": 3,
81 | "model": "eventtools.rule",
82 | "fields": {
83 | "complex_rule": "",
84 | "frequency": "WEEKLY",
85 | "params": "byweekday:5,6",
86 | "name": "Every weekend day",
87 | "common": false
88 | }
89 | }
90 | ]
--------------------------------------------------------------------------------
/eventtools/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.http import HttpResponseRedirect
3 |
4 | FORMAT_CHOICES = [
5 | ('webcal', 'iCal/Outlook'),
6 | ('google', 'Google Calendar'),
7 | ('ics', '.ics file'),
8 | ]
9 |
10 | class OccurrenceChoiceField(forms.ModelChoiceField):
11 | def label_from_instance(self, obj):
12 | return obj.html_timespan()
13 |
14 |
15 | class ExportICalForm(forms.Form):
16 | """
17 | Form allows user to choose which occurrence (or all), and which format.
18 | """
19 |
20 | event = forms.ModelChoiceField(
21 | queryset=None,
22 | widget=forms.HiddenInput,
23 | required=True,
24 | ) #needed in case no (all) occurrence is selected.
25 | occurrence = OccurrenceChoiceField(
26 | queryset=None,
27 | empty_label="Save all",
28 | required=False,
29 | widget=forms.Select(attrs={'size':10}),
30 | )
31 | format = forms.ChoiceField(
32 | choices=FORMAT_CHOICES,
33 | required=True,
34 | widget=forms.RadioSelect,
35 | initial="webcal",
36 | )
37 |
38 | def __init__(self, event, *args, **kwargs):
39 | self.base_fields['event'].queryset = type(event).objects.filter(id=event.id)
40 | self.base_fields['event'].initial = event.id
41 | self.base_fields['occurrence'].queryset = event.occurrences.forthcoming()
42 |
43 | super(ExportICalForm, self).__init__(*args, **kwargs)
44 |
45 |
46 | def to_ical(self):
47 | format = self.cleaned_data['format']
48 | occurrence = self.cleaned_data['occurrence']
49 |
50 | if occurrence:
51 | if format == 'webcal':
52 | return HttpResponseRedirect(occurrence.webcal_url())
53 | if format == 'ics':
54 | return HttpResponseRedirect(occurrence.ics_url())
55 | if format == 'google':
56 | return HttpResponseRedirect(occurrence.gcal_url())
57 | else:
58 | event = self.cleaned_data['event']
59 | if format == 'webcal':
60 | return HttpResponseRedirect(event.webcal_url())
61 | if format == 'ics':
62 | return HttpResponseRedirect(event.ics_url())
63 | if format == 'google':
64 | return HttpResponseRedirect(event.gcal_url())
65 |
66 |
67 | # Download .ics file
68 | # Add to iCal/Outlook
69 |
--------------------------------------------------------------------------------
/eventtools/utils/inheritingdefault.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | from types import NoneType
3 | from django.db.models.fields import NOT_PROVIDED
4 | from django.utils.encoding import force_unicode
5 | from django.db import connection
6 |
7 | class ModelInstanceAwareDefault():
8 | """
9 | This callable class provides model instance awareness in order to generate a
10 | default. It uses 9th level voodoo, so may break if Django changes much.
11 | Probably much better to patch django to send the model instance and field
12 | into the callable. Could be expanded to be general.
13 | """
14 | def __init__(self, attr, old_default=None):
15 | self.attr = attr
16 | self.old_default = old_default
17 |
18 | def has_old_default(self):
19 | "Returns a boolean of whether this field has a default value."
20 | return self.old_default is not NOT_PROVIDED
21 |
22 | def get_old_default(self, field):
23 | "Returns the default value for this field."
24 | if self.has_old_default():
25 | if callable(self.old_default):
26 | return self.old_default()
27 | return force_unicode(self.old_default, strings_only=True)
28 | if hasattr(field, 'empty_strings_allowed'):
29 | if not field.empty_strings_allowed or (
30 | field.null and not \
31 | connection.features.interprets_empty_strings_as_nulls
32 | ):
33 | return None
34 | return ""
35 |
36 |
37 | def __call__(self):
38 | # it would be so awesome if django passed the field/instance in question
39 | # to the default callable. Since it doesn't, let's grab it with voodoo.
40 | frame = inspect.currentframe().f_back
41 | field = frame.f_locals.get('self', None)
42 | parent = None
43 | # calling if field: on a forms.BoundField in the end calls data() again
44 | # and leads to a recursion error, using type(field) avoids this.
45 | if type(field) is not NoneType:
46 | frame = frame.f_back
47 | else:
48 | frame = None
49 | while frame is not None:
50 | if frame.f_locals.has_key('kwargs'):
51 | modelbasekwargs = frame.f_locals['kwargs']
52 | if modelbasekwargs.has_key('parent'):
53 | parent = modelbasekwargs['parent']
54 | break
55 | frame = frame.f_back
56 |
57 | if parent is not None:
58 | return getattr(parent, field.attname, self.get_old_default(field))
59 | return self.get_old_default(field)
60 |
--------------------------------------------------------------------------------
/eventtools/utils/viewutils.py:
--------------------------------------------------------------------------------
1 | from django.core.paginator import Paginator, EmptyPage, InvalidPage
2 | from django.http import HttpResponse
3 | from eventtools.conf import settings
4 | from datetime import date
5 | from dateutil import parser as dateparser
6 | from vobject import iCalendar
7 |
8 |
9 | def paginate(request, pool):
10 | paginator = Paginator(pool, settings.OCCURRENCES_PER_PAGE)
11 |
12 | # Make sure page request is an int. If not, deliver first page.
13 | try:
14 | page = int(request.GET.get('page', '1'))
15 | except ValueError:
16 | page = 1
17 |
18 | # If page request (9999) is out of range, deliver last page of results.
19 | try:
20 | pageinfo = paginator.page(page)
21 | except (EmptyPage, InvalidPage):
22 | pageinfo = paginator.page(paginator.num_pages)
23 |
24 | return pageinfo
25 |
26 | def parse_GET_date(GET={}):
27 | mapped_GET = {}
28 | for k, v in GET.iteritems():
29 | mapped_GET[settings.EVENT_GET_MAP.get(k, k)] = v
30 |
31 | fr = mapped_GET.get('startdate', None)
32 | to = mapped_GET.get('enddate', None)
33 |
34 | if fr is not None:
35 | try:
36 | fr = dateparser.parse(fr).date()
37 | except ValueError:
38 | fr = None
39 | if to is not None:
40 | try:
41 | to = dateparser.parse(to).date()
42 | except ValueError:
43 | to = None
44 |
45 | if fr is None and to is None:
46 | fr = date.today()
47 |
48 | return fr, to
49 |
50 | def response_as_ical(request, occurrences):
51 |
52 | ical = iCalendar()
53 |
54 | cal_name = settings.ICAL_CALNAME
55 | # If multiple occurrences with one event, name the calendar after the event
56 | if hasattr(occurrences, '__iter__'):
57 | events = list(set([o.event for o in occurrences]))
58 | if len(events) == 1:
59 | cal_name = unicode(events[0])
60 | # If a single occurrence with an event
61 | elif getattr(occurrences, 'event', None):
62 | cal_name = unicode(occurrences.event)
63 |
64 | ical.add('X-WR-CALNAME').value = cal_name
65 | ical.add('X-WR-CALDESC').value = settings.ICAL_CALDESC
66 | ical.add('method').value = 'PUBLISH' # IE/Outlook needs this
67 |
68 | if hasattr(occurrences, '__iter__'):
69 | for occ in occurrences:
70 | ical = occ.as_icalendar(ical, request)
71 | else:
72 | ical = occurrences.as_icalendar(ical, request)
73 |
74 | icalstream = ical.serialize()
75 | response = HttpResponse(icalstream, mimetype='text/calendar')
76 | response['Filename'] = 'events.ics' # IE needs this
77 | response['Content-Disposition'] = 'attachment; filename=events.ics'
78 | return response
79 |
--------------------------------------------------------------------------------
/eventtools/utils/diff.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # pinched from django-moderation.
3 | # modified to include rather than exclude, fields
4 | import re
5 | import difflib
6 |
7 |
8 | def get_changes_between_models(model1, model2, include=[]):
9 | from django.db.models import fields
10 | changes = {}
11 | for field_name in include:
12 | field = type(model1)._meta.get_field(field_name)
13 | value2 = unicode(getattr(model2, field_name))
14 | value1 = unicode(getattr(model1, field_name))
15 | if value1 != value2:
16 | changes[field.verbose_name] = (value1, value2)
17 | return changes
18 |
19 |
20 | def get_diff(a, b):
21 | out = []
22 | sequence_matcher = difflib.SequenceMatcher(None, a, b)
23 | for opcode in sequence_matcher.get_opcodes():
24 |
25 | operation, start_a, end_a, start_b, end_b = opcode
26 |
27 | deleted = ''.join(a[start_a:end_a])
28 | inserted = ''.join(b[start_b:end_b])
29 |
30 | if operation == "replace":
31 | out.append('%s'\
32 | '%s ' % (deleted,
33 | inserted))
34 | elif operation == "delete":
35 | out.append('%s' % deleted)
36 | elif operation == "insert":
37 | out.append('%s ' % inserted)
38 | elif operation == "equal":
39 | out.append(inserted)
40 |
41 | return out
42 |
43 |
44 | def html_diff(a, b):
45 | """Takes in strings a and b and returns a human-readable HTML diff."""
46 |
47 | a, b = html_to_list(a), html_to_list(b)
48 | diff = get_diff(a, b)
49 |
50 | return u"".join(diff)
51 |
52 |
53 | def html_to_list(html):
54 | pattern = re.compile(r'&.*?;|(?:<[^<]*?>)|'\
55 | '(?:\w[\w-]*[ ]*)|(?:<[^<]*?>)|'\
56 | '(?:\s*[,\.\?]*)', re.UNICODE)
57 |
58 | return [''.join(element) for element in filter(None,
59 | pattern.findall(html))]
60 |
61 |
62 | def generate_diff(instance1, instance2, include=[]):
63 | from django.db.models import fields
64 |
65 | changes = get_changes_between_models(instance1, instance2, include)
66 |
67 | fields_diff = []
68 |
69 | for field_name in include:
70 | field = type(instance1)._meta.get_field(field_name)
71 | field_changes = changes.get(field.verbose_name, None)
72 | if field_changes:
73 | change1, change2 = field_changes
74 | if change1 != change2:
75 | diff = {'verbose_name': field.verbose_name, 'diff': html_diff(change1, change2)}
76 | fields_diff.append(diff)
77 | return fields_diff
78 |
--------------------------------------------------------------------------------
/eventtools/models/xseason.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 | from django.db import models
3 | from eventtools.utils.pprint_timespan \
4 | import pprint_datetime_span, pprint_date_span
5 | from django.core.exceptions import ValidationError
6 | from django.utils.translation import ugettext, ugettext_lazy as _
7 |
8 | class SeasonQSFN(object):
9 | def current_on(self, date):
10 | return self.filter(start__lte=date, end__gte=date)
11 |
12 | def forthcoming_on(self, date):
13 | return self.filter(start__gt=date)
14 |
15 | def previous_on(self, date):
16 | return self.filter(end__lt=date)
17 |
18 | class SeasonQuerySet(models.query.QuerySet, SeasonQSFN):
19 | pass #all the goodness is inherited from SeasonQSFN
20 |
21 | class SeasonManagerType(type):
22 | """
23 | Injects proxies for all the queryset's functions into the Manager
24 | """
25 | @staticmethod
26 | def _fproxy(name):
27 | def f(self, *args, **kwargs):
28 | return getattr(self.get_query_set(), name)(*args, **kwargs)
29 | return f
30 |
31 | def __init__(cls, *args):
32 | for fname in dir(SeasonQSFN):
33 | if not fname.startswith("_"):
34 | setattr(cls, fname, SeasonManagerType._fproxy(fname))
35 | super(SeasonManagerType, cls).__init__(*args)
36 |
37 | class SeasonManager(models.Manager):
38 | __metaclass__ = SeasonManagerType
39 |
40 | def get_query_set(self):
41 | return SeasonQuerySet(self.model)
42 |
43 |
44 | class XSeasonModel(models.Model):
45 | """
46 | Describes an entity which takes place between start and end dates. For
47 | example, a festival or exhibition.
48 |
49 | The fields are optional - both omitted means 'ongoing'.
50 | """
51 |
52 | start = models.DateField(null=True, blank=True, verbose_name=_('start'))
53 | end = models.DateField(null=True, blank=True, verbose_name=_('end'))
54 |
55 | objects = SeasonManager()
56 |
57 | class Meta:
58 | abstract = True
59 |
60 | def clean(self):
61 | if (self.start is not None and self.end is None) or \
62 | (self.end is not None and self.start is None):
63 | raise ValidationError('Start and End must both be provided, or blank')
64 |
65 | if self.start > self.end:
66 | raise ValidationError('Start must be earlier than End')
67 |
68 | def season(self):
69 | """
70 | Returns a string describing the first and last dates of this event.
71 | """
72 | if self.start and self.end:
73 | first = self.start
74 | last = self.end
75 |
76 | return pprint_date_span(first, last)
77 |
78 | return None
79 |
80 | def __unicode__(self):
81 | return self.season()
82 |
83 | def is_finished(self):
84 | return self.end < date.today()
85 |
--------------------------------------------------------------------------------
/eventtools/templates/admin/eventtools/event.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 |
3 | {# django include/ssi tags don't allow block definition. This is exactly identical to the bottom of feincmsevent.html #}
4 | {% load i18n %}
5 | {% load mptt_tags %}
6 |
7 | {% block extrahead %}
8 | {{ block.super }}
9 |
10 | {% endblock %}
11 |
12 | {% block extrastyle %}
13 | {{ block.super }}
14 |
51 | {% endblock %}
52 |
53 |
54 |
55 | {% block object-tools-items %}
56 | {{ block.super }}
57 | {% if original and object.id %}
58 | {% trans "Create a variation of this event" %}
59 | {% trans "View child occurrences" %} ({{ object.occurrences_in_listing.count }})
60 | {% endif %}
61 | {% endblock %}
62 |
63 | {% block object-tools %}
64 | {{ block.super }}
65 | {% if object.id %}
66 | {% drilldown_tree_for_node object as drilldown %}
67 | {% if drilldown %}
68 |
69 | {% trans "Variation family" %}
70 | {% for node,structure in drilldown|tree_info %}
71 | {% if structure.new_level %}{% else %} {% endif %}
72 | {% ifequal node object %}
73 | {{ node }}
74 | {% else %}
75 | {{ node }}
76 | {% endifequal %}
77 | {% for level in structure.closed_levels %} {% endfor %}
78 | {% endfor %}
79 |
80 | {% if fields_diff %}
81 | Changes in inherited fields
82 | {% for field in fields_diff %}
83 |
89 | {% endfor %}
90 | {% endif %}
91 |
92 | {% endif %}
93 | {% endif %}
94 | {% endblock %}
95 |
96 |
97 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/docs/install.rst:
--------------------------------------------------------------------------------
1 | .. _ref-install:
2 |
3 | ================
4 | Getting The Code
5 | ================
6 |
7 | The project is available through `Github `_.
8 |
9 | .. _ref-configure:
10 |
11 | =============
12 | Configuration
13 | =============
14 |
15 | Installation
16 | ------------
17 |
18 | 0. Download the code; put it into your project's directory or run ``python setup.py install`` to install to your envirnoment.
19 |
20 | 1. Install the requirements (using pip).
21 |
22 | pip install -e REQUIREMENTS.txt
23 |
24 | 2. Create an `events` app, where you will define what Events look like for your project.
25 |
26 | ./manage.py startapp events
27 |
28 | The app doesn't have to be called `events`, but it will make the rest of these
29 | instructions easier to follow.
30 |
31 | Settings.py
32 | -----------
33 |
34 | 3. List the required applications in the ``INSTALLED_APPS`` portion of your settings
35 | file. Your settings file might look something like::
36 |
37 | INSTALLED_APPS = (
38 | # ...
39 | 'mptt'
40 | 'eventtools',
41 | 'events', # the name of your app.
42 | )
43 |
44 | 4. Install the pagination middleware. Your settings file might look something
45 | like::
46 |
47 | MIDDLEWARE_CLASSES = (
48 | # ...
49 | 'pagination.middleware.PaginationMiddleware',
50 | )
51 |
52 | Models Definition
53 | -----------------
54 |
55 | 5. Define models in your new app. We suggest calling the Event model 'Event'
56 | to easily use the provided templates. In ``events/models.py``:
57 |
58 | from django.db import models
59 | from eventtools.models import EventModel, OccurrenceModel, GeneratorModel #, ExclusionModel
60 |
61 | class Event(EventModel):
62 | teaser = models.TextField(blank=True)
63 | image = models.ImageField(upload_to="events_uploads", blank=True)
64 | #etc
65 |
66 | class Generator(GeneratorModel):
67 | event = models.ForeignKey(Event, related_name="generators")
68 |
69 | class Occurrence(OccurrenceModel):
70 | event = models.ForeignKey(Event, related_name="occurrences")
71 | generated_by = models.ForeignKey(Generator, blank=True, null=True, related_name="occurrences")
72 |
73 | class Exclusion(ExclusionModel):
74 | event = models.ForeignKey(Event, related_name="exclusions")
75 |
76 | Admin
77 | -----
78 |
79 | 6. Set up admin. In ``events/admin.py``:
80 |
81 | from django.contrib import admin
82 | from eventtools.admin import EventAdmin, OccurrenceAdmin
83 | from .models import Event, Occurrence
84 |
85 | admin.site.register(Event, EventAdmin(Event), show_exclusions=True)
86 | admin.site.register(Occurrence, OccurrenceAdmin(Occurrence))
87 |
88 | Views and URLs
89 | --------------
90 |
91 | 7. Set up view URLs. In ``events/urls.py``
92 |
93 | from django.conf.urls.defaults import *
94 | from eventtools.views import EventViews
95 | from .models import Event
96 |
97 | views = EventViews(event_qs=Event.eventobjects.all())
98 |
99 | urlpatterns = patterns('',
100 | url(r'^', include(views.urls)),
101 | )
102 |
103 | 8. In your main ``urls.py``:
104 |
105 | urlpatterns += patterns('',
106 | url(r'^events/', include('events.urls')),
107 | )
108 |
109 | Nearly there
110 | ------------
111 |
112 | 8. syncdb/migrate, then collectstatic
113 |
114 | 9. try it! Visit http://yourserver/events/
--------------------------------------------------------------------------------
/eventtools/templates/admin/eventtools/feincmsevent.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/feincms/item_editor.html" %}
2 |
3 | {% block feincms_jquery_ui %}
4 | {% if FEINCMS_ADMIN_MEDIA_HOTLINKING %}
5 |
6 |
7 |
8 | {% else %}
9 |
10 |
11 |
12 | {% endif %}
13 | {% endblock %}
14 |
15 | {# django include/ssi tags don't allow block definition. This is exactly identical to the bottom of event.html #}
16 | {% load i18n %}
17 | {% load mptt_tags %}
18 |
19 | {% block extrahead %}
20 | {{ block.super }}
21 |
22 | {% endblock %}
23 |
24 | {% block extrastyle %}
25 | {{ block.super }}
26 |
27 |
63 | {% endblock %}
64 |
65 | {% block object-tools-items %}
66 | {{ block.super }}
67 | {% if original and object.id %}
68 | {% trans "Create a variation of this event" %}
69 | {% trans "View child occurrences" %} ({{ object.occurrences_in_listing.count }})
70 | {% endif %}
71 | {% endblock %}
72 |
73 | {% block object-tools %}
74 | {{ block.super }}
75 | {% if object.id %}
76 | {% drilldown_tree_for_node object as drilldown %}
77 | {% if drilldown %}
78 |
79 | {% trans "Variation family" %}
80 | {% for node,structure in drilldown|tree_info %}
81 | {% if structure.new_level %}{% else %} {% endif %}
82 | {% ifequal node object %}
83 | {{ node }}
84 | {% else %}
85 | {{ node }}
86 | {% endifequal %}
87 | {% for level in structure.closed_levels %} {% endfor %}
88 | {% endfor %}
89 |
90 | {% if fields_diff %}
91 | Changes in inherited fields
92 | {% for field in fields_diff %}
93 |
99 | {% endfor %}
100 | {% endif %}
101 |
102 | {% endif %}
103 | {% endif %}
104 | {% endblock %}
105 |
--------------------------------------------------------------------------------
/eventtools/templates/eventtools/event.html:
--------------------------------------------------------------------------------
1 | {% extends "eventtools/_base.html" %}
2 | {% load calendar %}
3 |
4 | {% block title %}{{ event.title }} :: {{ block.super }}{% endblock %}
5 |
6 | {% block content %}
7 |
8 | {% with listed_under=event.listed_under %}
9 | {{ event.title }}
10 | {% if perms.events.can_change_event %}edit
{% endif %}
11 |
12 | {% if occurrence %}
13 | {{ occurrence.start_date|date:"l, j F Y" }}, {{ occurrence.html_time_description }}{% if occurrence.status_message %} ({{ occurrence.status_message }}){% endif %} - view all sessions
14 | {% else %}
15 | {% if listed_under.unavailable_status_message %}
16 |
17 | {{ listed_under.unavailable_status_message }}
18 |
19 | {% endif %}
20 | {% endif %}
21 |
22 | {% if listed_under != event %}
23 | {{ event.title }} {% if event.is_finished %}was{% else %}is{% endif %} one of the {{ listed_under.title }} sessions.
24 | {% endif %}
25 |
26 | {% if event.sessions %}When: {{ event.sessions|linebreaksbr }}
{% endif %}
27 |
28 |
{% if occurrence %}Other sessions{% else %}Sessions{% endif %}
29 |
30 |
31 | {% nav_calendars event.occurrences_in_listing occurrence %}
32 |
33 | {% with event.occurrences_in_listing.all as occurrences %}
34 | {% if occurrences %}
35 |
36 | {% include "eventtools/_occurrences_in_event.html" %}
37 |
38 | {% endif %}
39 | {% endwith %}
40 |
41 | {# Variation sessions #}
42 | {% with vo=event.variation_occurrences.available.forthcoming %}
43 | {% if vo.count %}
44 | Special sessions
45 | {% include "eventtools/_occurrences_in_event.html" with occurrences=vo %}
46 | {% endif %}
47 | {% endwith %}
48 |
49 |
50 | {# Out-of-the-ordinary statuses #}
51 |
52 | {% comment %}
53 | IF the list of unavailable occurrences is longer than the list of available occurrences.
54 | if there are any fully booked occurrences, we say it's booking fast, and
55 | list available occurrences, if any
56 | list cancelled occurrences, if any
57 | else, we say the following are STILL available:
58 | list available occurrences, if any
59 | ELSE
60 | We want to display a list of unavailable occurrences, if any.
61 | {% endcomment %}
62 |
63 | {% with avail_count=event.available_occurrences.forthcoming.count unavail_count=event.unavailable_occurrences.forthcoming.count %}
64 | {% if unavail_count > avail_count %}
65 | {% if event.fully_booked_occurrences.forthcoming.count %}
66 | {% if avail_count %}
67 | {{ event.title }} is booking fast - the following {{ avail_count|pluralize:"session is, sessions are" }} still available
68 | {% include "eventtools/_occurrences_in_event.html" with occurrences=event.available_occurrences.forthcoming %}
69 | {% endif %}
70 | {% with co=event.cancelled_occurrences.forthcoming %}
71 | {% if co.count %}
72 | Note: the following {{ co.count|pluralize:"session is, sessions are" }} cancelled
73 | {% include "eventtools/_occurrences_in_event.html" with occurrences=co %}
74 | {% endif %}
75 | {% endwith %}
76 | {% else %}
77 | {% if avail_count %}
78 | The following {{ avail_count|pluralize:"session is, sessions are" }} still available
79 | {% include "eventtools/_occurrences_in_event.html" with occurrences=event.available_occurrences.forthcoming %}
80 | {% endif %}
81 | {% endif %}
82 | {% else %}
83 | {% if unavail_count %}
84 | The following {{ unavail_count|pluralize:"session is, sessions are" }} not available
85 | {% include "eventtools/_occurrences_in_event.html" with occurrences=event.unavailable_occurrences.forthcoming %}
86 | {% endif %}
87 | {% endif %}
88 | {% endwith %}
89 |
90 | {% endwith %}
91 | {% endblock %}
--------------------------------------------------------------------------------
/eventtools/utils/dateranges.py:
--------------------------------------------------------------------------------
1 | from datetime import *
2 | from dateutil.relativedelta import *
3 | from eventtools.conf import settings
4 | import calendar
5 |
6 | WEEKDAY_MAP = {
7 | calendar.MONDAY: MO,
8 | calendar.TUESDAY: TU,
9 | calendar.WEDNESDAY: WE,
10 | calendar.THURSDAY: TH,
11 | calendar.FRIDAY: FR,
12 | calendar.SATURDAY: SA,
13 | calendar.SUNDAY: SU,
14 | }
15 |
16 | def _weekday_fn(wk):
17 | return WEEKDAY_MAP.get(wk, wk)
18 |
19 | FIRST_DAY_OF_WEEK = _weekday_fn(settings.FIRST_DAY_OF_WEEK)
20 | FIRST_DAY_OF_WEEKEND = _weekday_fn(settings.FIRST_DAY_OF_WEEKEND)
21 | LAST_DAY_OF_WEEKEND = _weekday_fn(settings.LAST_DAY_OF_WEEKEND)
22 |
23 | class XDateRange(object):
24 | """
25 | Embryo class to replace xdaterange below.
26 |
27 | For now this is only used in calendar sets (which uses the 'in' method)
28 | """
29 | def __init__(self, start, end):
30 | self.start = start
31 | self.end = end
32 | self.delta = end - start
33 |
34 | def __contains__(self, item):
35 | if self.start is not None:
36 | after_start = item >= self.start
37 | else:
38 | after_start = True
39 | if self.end is not None:
40 | before_end = item <= self.end
41 | else:
42 | before_end = True
43 | return after_start and before_end
44 |
45 | def __unicode__(self):
46 | if self.delta:
47 | return '%s - %s' % (
48 | self.start.strftime('%d %b %Y'),
49 | self.end.strftime('%d %b %Y'),
50 | )
51 | return self.start.strftime('%d %b %Y')
52 |
53 | def later(self):
54 | return XDateRange(self.end + timedelta(1), self.end + self.delta + timedelta(1))
55 |
56 | def earlier(self):
57 | return XDateRange(self.start - self.delta - timedelta(1), self.start - timedelta(1))
58 |
59 |
60 | class DateTester(object):
61 | """
62 | A class that takes a set of occurrences. Then you can test dates with it to
63 | see if the date is in that set.
64 |
65 | if date.today() in date_tester_object:
66 | ...
67 |
68 | """
69 | def __init__(self, occurrence_qs):
70 | self.occurrence_qs = occurrence_qs
71 |
72 | def __contains__(self, d):
73 | occs = self.occurrence_qs.starts_on(d)
74 | return occs
75 |
76 |
77 |
78 | def xdaterange(d1, d2):
79 | delta_range = range((d2-d1).days)
80 | for td in delta_range:
81 | yield d1 + timedelta(td)
82 |
83 | def daterange(d1, d2):
84 | return list(xdaterange(d1, d2))
85 |
86 | def dates_for_week_of(d):
87 | d1 = d + relativedelta(weekday = FIRST_DAY_OF_WEEK(-1))
88 | d2 = d1 + timedelta(7)
89 | return d1, d2
90 |
91 | def dates_in_week_of(d):
92 | return daterange(*dates_for_week_of(d))
93 |
94 | def dates_for_weekend_of(d):
95 | d1 = d + relativedelta(weekday = FIRST_DAY_OF_WEEKEND(+1))
96 | d2 = d1 + relativedelta(weekday = LAST_DAY_OF_WEEKEND(+1))
97 | return d1, d2
98 |
99 | def dates_in_weekend_of(d):
100 | return daterange(*dates_for_weekend_of(d))
101 |
102 | def dates_for_fortnight_of(d): #fortnights overlap
103 | d1 = d + relativedelta(weekday = FIRST_DAY_OF_WEEK(-1))
104 | d2 = d1 + timedelta(14)
105 | return d1, d2
106 |
107 | def dates_in_fortnight_of(d):
108 | return daterange(*dates_for_fortnight_of(d))
109 |
110 | def dates_for_month_of(d):
111 | d1 = d + relativedelta(day=1) #looks like a bug; isn't.
112 | d2 = d1 + relativedelta(months=+1, days=-1)
113 | return d1, d2
114 |
115 | def dates_in_month_of(d):
116 | return daterange(*dates_for_month_of(d))
117 |
118 | def dates_for_year_of(d):
119 | d1 = date(d.year, 1, 1)
120 | d2 = date(d.year, 12, 31)
121 | return d1, d2
122 |
123 | def dates_in_year_of(d):
124 | return daterange(*dates_for_year_of(d))
125 |
126 | def is_weekend(d):
127 | if type(d) in [date, datetime]:
128 | d = d.weekday()
129 | if type(d) == type(MO):
130 | d = d.weekday
131 | if FIRST_DAY_OF_WEEKEND <= LAST_DAY_OF_WEEKEND:
132 | return (FIRST_DAY_OF_WEEKEND.weekday <= d <= LAST_DAY_OF_WEEKEND.weekday)
133 | else:
134 | return (d >= FIRST_DAY_OF_WEEKEND.weekday) or (d <= LAST_DAY_OF_WEEKEND.weekday)
135 |
136 | def is_weekday(d):
137 | return not is_weekend(d)
--------------------------------------------------------------------------------
/eventtools/models/rule.py:
--------------------------------------------------------------------------------
1 | import calendar
2 | from django.db import models
3 | from django.utils.translation import ugettext, ugettext_lazy as _
4 | from dateutil import rrule
5 | from dateutil.relativedelta import weekdays
6 |
7 | freqs = (
8 | ("YEARLY", _("Yearly")),
9 | ("MONTHLY", _("Monthly")),
10 | ("WEEKLY", _("Weekly")),
11 | ("DAILY", _("Daily")),
12 | )
13 |
14 | class Rule(models.Model):
15 | """
16 | This defines a rule by which an occurrence will repeat. Parameters
17 | correspond to the rrule in the dateutil documentation.
18 |
19 | * name - the human friendly name of this kind of repetition.
20 | * frequency - the base repetition period
21 | * param - extra params required to define this type of repetition. The params
22 | should follow this format:
23 |
24 | param = [rruleparam:value;]*
25 | rruleparam = see list below
26 | value = int[,int]*
27 |
28 | The options are: (documentation for these can be found at
29 | http://labix.org/python-dateutil#head-470fa22b2db72000d7abe698a5783a46b0731b57)
30 | ** count
31 | ** bysetpos
32 | ** bymonth
33 | ** bymonthday
34 | ** byyearday
35 | ** byweekno
36 | ** byweekday
37 | ** byhour
38 | ** byminute
39 | ** bysecond
40 | ** byeaster
41 | """
42 | name = models.CharField(
43 | _("name"), max_length=100,
44 | help_text=_("a short friendly name for this repetition.")
45 | )
46 | common = models.BooleanField(
47 | help_text=_("common rules appear at the top of the list.")
48 | )
49 | frequency = models.CharField(
50 | _("frequency"), choices=freqs, max_length=10, blank=True,
51 | help_text=_("the base repetition period.")
52 | )
53 | params = models.TextField(
54 | _("inclusion parameters"), blank=True,
55 | help_text=_("extra params required to define this type of repetition.")
56 | )
57 | complex_rule = models.TextField(
58 | _("complex rules"), help_text=_("overrides all other settings."),
59 | blank=True
60 | )
61 |
62 | class Meta:
63 | verbose_name = _('repetition rule')
64 | verbose_name_plural = _('repetition rules')
65 | ordering = ('-common', 'name')
66 | app_label = "eventtools"
67 |
68 | def get_params(self):
69 | """
70 | >>> rule = Rule(params = "count:1;bysecond:1;byminute:1,2,4,5")
71 | >>> rule.get_params()
72 | {'count': 1, 'byminute': [1, 2, 4, 5], 'bysecond': 1}
73 | """
74 | params = self.params
75 | if params is None:
76 | return {}
77 | params = params.split(';')
78 | param_dict = []
79 | for param in params:
80 | param = param.split(':')
81 | if len(param) == 2:
82 | param = (str(param[0]), [int(p) for p in param[1].split(',')])
83 | if len(param[1]) == 1:
84 | param = (param[0], param[1][0])
85 | param_dict.append(param)
86 | return dict(param_dict)
87 |
88 | def __unicode__(self):
89 | """Human readable string for Rule"""
90 | return self.name or unicode(self.frequency).lower()
91 |
92 | def get_rrule(self, dtstart):
93 | if self.complex_rule:
94 | d = dtstart.date()
95 | weekday = weekdays[d.weekday()]
96 | n = 1 + (d.day-1)/7
97 |
98 | start_day, days_in_month = calendar.monthrange(d.year, d.month)
99 | days_from_end = days_in_month - d.day
100 |
101 | minus_n = -1 - (days_from_end / 7)
102 | cr = self.complex_rule \
103 | .replace("%date%", dtstart.strftime("%Y%m%d")) \
104 | .replace("%day%", dtstart.strftime("%d")) \
105 | .replace("%month%", dtstart.strftime("%m")) \
106 | .replace("%year%", dtstart.strftime("%Y")) \
107 | .replace("%time%", dtstart.strftime("%H%M%S")) \
108 | .replace("%datetime%", dtstart.strftime("%Y%m%dT%H%M%S")) \
109 | .replace("%nthday%", "%s%s" % (n, weekday)) \
110 | .replace("%-nthday%", "%s%s" % (minus_n, weekday))
111 | try:
112 | return rrule.rrulestr(str(cr), dtstart=dtstart)
113 | except ValueError: # eg. unsupported property
114 | pass
115 | params = self.get_params()
116 | frequency = 'rrule.%s' % self.frequency
117 | simple_rule = rrule.rrule(eval(frequency), dtstart=dtstart, **params)
118 | rs = rrule.rruleset()
119 | rs.rrule(simple_rule)
120 | return rs
121 |
--------------------------------------------------------------------------------
/eventtools/tests/models/tree.py:
--------------------------------------------------------------------------------
1 | __author__ = 'gturner'
2 | from eventtools.tests._inject_app import TestCaseWithApp as AppTestCase
3 | from eventtools.tests.eventtools_testapp.models import *
4 | from eventtools.models import Rule
5 | import datetime
6 |
7 | class TestEventTree(AppTestCase):
8 |
9 | def setUp(self):
10 | super(TestEventTree, self).setUp()
11 |
12 | #SCENARIO 1: Variation
13 | #there is a daily tour on for 30 days in January, run by Anna, which is listed as an event.
14 | self.tour = ExampleEvent.tree.create(title="Daily Tour")
15 | daily = Rule.objects.create(frequency = "DAILY")
16 | self.tour_generator = ExampleGenerator.objects.create(event=self.tour, start=datetime.datetime(2011,1,1,10,0), _duration=60, rule=daily, repeat_until=datetime.date(2011,1,30))
17 |
18 | #when Anna is on holiday on the first day in May, Glen does the daily tour. These are not separately listed.
19 | self.glen_tour = ExampleEvent.tree.create(parent=self.tour, title="Glen's Daily Tour")
20 | occs = self.tour.occurrences.all()[6:10]
21 | for occ in occs:
22 | occ.event = self.glen_tour
23 | occ.save()
24 |
25 | #SCENARIO 2: Template/instance
26 | #there is a template for artist talks. Should not be listed as an event.
27 | self.talks = ExampleEvent.tree.create(title="Artist Talks")
28 |
29 | #one example is a talk by John Smith. Listed as an event.
30 | self.talk1 = ExampleEvent.tree.create(parent=self.talks, title="Artist Talk: John Smith")
31 | ExampleOccurrence.objects.create(event=self.talk1, start=datetime.datetime(2011,8,28, 19,0), _duration=30)
32 | ExampleOccurrence.objects.create(event=self.talk1, start=datetime.datetime(2011,8,29, 19,0), _duration=30)
33 |
34 | #another example is a talk by Jane Doe. Listed as an event.
35 | self.talk2 = ExampleEvent.tree.create(parent=self.talks, title="Artist Talk: Jane Doe")
36 | ExampleOccurrence.objects.create(event=self.talk2, start=datetime.datetime(2011,8,30, 19,0), _duration=30)
37 |
38 | #One of Jane's talks is with her husband Barry.
39 | self.talk2a = ExampleEvent.tree.create(parent=self.talk2, title="Artist Talk: Jane and Barry Doe")
40 | ExampleOccurrence.objects.create(event=self.talk2a, start=datetime.datetime(2011,8,31, 19,0), _duration=30)
41 |
42 | #have to reload stuff so that the mptt-inserted lft,rght values are given.
43 | self.talks = self.talks.reload()
44 | self.talk1 = self.talk1.reload()
45 | self.talk2 = self.talk2.reload()
46 | self.talk2a = self.talk2a.reload()
47 |
48 |
49 | def test_queries(self):
50 | #the private listing should show all events
51 | self.ae(ExampleEvent.tree.count(), 6)
52 |
53 | #the public events listing should only show the daily tour event, and the two artist talks.
54 | qs = ExampleEvent.eventobjects.in_listings()
55 | self.ae(qs.count(), 3)
56 | self.ae(set(list(qs.filter())), set([self.talk1, self.talk2, self.tour]))
57 |
58 | #the 'direct' occurrences of an event are default and direct
59 | self.ae(self.tour.occurrences.count(), 26)
60 | self.ae(self.glen_tour.occurrences.count(), 4)
61 | self.ae(self.talks.occurrences.count(), 0)
62 | self.ae(self.talk1.occurrences.count(), 2)
63 | self.ae(self.talk2.occurrences.count(), 1)
64 | self.ae(self.talk2a.occurrences.count(), 1)
65 |
66 | #the 'listing' occurrences are the occurrences of and event and those of its children.
67 | self.ae(self.tour.occurrences_in_listing().count(), 30)
68 | self.ae(self.glen_tour.occurrences_in_listing().count(), 4)
69 | self.ae(self.talks.occurrences_in_listing().count(), 4)
70 | self.ae(self.talk1.occurrences_in_listing().count(), 2)
71 | self.ae(self.talk2.occurrences_in_listing().count(), 2)
72 | self.ae(self.talk2a.occurrences_in_listing().count(), 1)
73 |
74 | def test_methods(self):
75 | #an event knows the event it is listed under
76 | self.ae(self.tour.listed_under(), self.tour)
77 | self.ae(self.glen_tour.listed_under(), self.tour)
78 | self.ae(self.talks.listed_under(), None) #isn't listed
79 | self.ae(self.talk1.listed_under(), self.talk1)
80 | self.ae(self.talk2.listed_under(), self.talk2)
81 | self.ae(self.talk2a.listed_under(), self.talk2)
82 |
83 | def test_generation(self):
84 | # updating the generator for an event should not cause the regenerated Occurrences to be reassigned to that event.
85 | # the occurrences should be updated though, since they are still attached to the generator
86 | self.tour_generator.start=datetime.datetime(2011,1,1,10,30)
87 | self.tour_generator.save()
88 |
89 | self.ae(self.tour.occurrences.count(), 26)
90 | self.ae(self.glen_tour.occurrences.count(), 4)
91 |
92 | [self.ae(o.start.time(), datetime.time(10,30)) for o in self.tour.occurrences.all()]
93 | [self.ae(o.start.time(), datetime.time(10,30)) for o in self.glen_tour.occurrences.all()]
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 |
15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest
16 |
17 | help:
18 | @echo "Please use \`make ' where is one of"
19 | @echo " html to make standalone HTML files"
20 | @echo " dirhtml to make HTML files named index.html in directories"
21 | @echo " singlehtml to make a single large HTML file"
22 | @echo " pickle to make pickle files"
23 | @echo " json to make JSON files"
24 | @echo " htmlhelp to make HTML files and a HTML help project"
25 | @echo " qthelp to make HTML files and a qthelp project"
26 | @echo " devhelp to make HTML files and a Devhelp project"
27 | @echo " epub to make an epub"
28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
29 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
30 | @echo " text to make text files"
31 | @echo " man to make manual pages"
32 | @echo " changes to make an overview of all changed/added/deprecated items"
33 | @echo " linkcheck to check all external links for integrity"
34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
35 |
36 | clean:
37 | -rm -rf $(BUILDDIR)/*
38 |
39 | html:
40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
41 | @echo
42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
43 |
44 | dirhtml:
45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
46 | @echo
47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
48 |
49 | singlehtml:
50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
51 | @echo
52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
53 |
54 | pickle:
55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
56 | @echo
57 | @echo "Build finished; now you can process the pickle files."
58 |
59 | json:
60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
61 | @echo
62 | @echo "Build finished; now you can process the JSON files."
63 |
64 | htmlhelp:
65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
66 | @echo
67 | @echo "Build finished; now you can run HTML Help Workshop with the" \
68 | ".hhp project file in $(BUILDDIR)/htmlhelp."
69 |
70 | qthelp:
71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
72 | @echo
73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/GLAMkitSmartlinks.qhcp"
76 | @echo "To view the help file:"
77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/GLAMkitSmartlinks.qhc"
78 |
79 | devhelp:
80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
81 | @echo
82 | @echo "Build finished."
83 | @echo "To view the help file:"
84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/GLAMkitSmartlinks"
85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/GLAMkitSmartlinks"
86 | @echo "# devhelp"
87 |
88 | epub:
89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
90 | @echo
91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
92 |
93 | latex:
94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
95 | @echo
96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
98 | "(use \`make latexpdf' here to do that automatically)."
99 |
100 | latexpdf:
101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
102 | @echo "Running LaTeX files through pdflatex..."
103 | make -C $(BUILDDIR)/latex all-pdf
104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
105 |
106 | text:
107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
108 | @echo
109 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
110 |
111 | man:
112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
113 | @echo
114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
115 |
116 | changes:
117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
118 | @echo
119 | @echo "The overview file is in $(BUILDDIR)/changes."
120 |
121 | linkcheck:
122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
123 | @echo
124 | @echo "Link check complete; look for any errors in the above output " \
125 | "or in $(BUILDDIR)/linkcheck/output.txt."
126 |
127 | doctest:
128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
129 | @echo "Testing of doctests in the sources finished, look at the " \
130 | "results in $(BUILDDIR)/doctest/output.txt."
131 |
--------------------------------------------------------------------------------
/docs/overview.rst:
--------------------------------------------------------------------------------
1 | =======================
2 | GLAMkit-events Overview
3 | =======================
4 |
5 | Different institutions have event calendars of differing complexity. GLAMkit-events attempts to cover all the possible scenarios. Before developing with GLAMkit-events, you should spend some time determining what sort of events structure you need to model.
6 |
7 |
8 | Events, Occurrences and OccurrenceGenerators
9 | --------------------------------------------
10 |
11 | GLAMkit-events draws a distinction between **events**, and **occurrences** of those events. **Events** contain all the information *except* for the times and dates. **Events** know where, why and how things happen, but not when. **Occurrences** contain all the *when* information. By combining the two, you can specify individual occurrences of an event.
12 |
13 | Many institutions have repeating events, ie Occurrences that happen at the same time every day, or week, or according to some other rule. These Occurrences are created with a **Generator**. The best way to grasp this is with an example:
14 |
15 | Imagine a museum that has a tour for the blind every Sunday at 2pm. The tour always starts at the same place, costs the same amount etc. The only thing that changes is the date. You can define an event model which has field for storing all the non-time information. You can use a Generator to specify that the tour starts next Sunday at 2pm and repeats every week after. When you save the Generator, it generates an Occurrence instance for each specific instance of the tour.
16 |
17 | This separation into three models allows us to do some very cool things:
18 |
19 | * we can specify complex repetition rules (eg. every Sunday at 2pm, unless it happens to be Easter Sunday, or Christmas day);
20 | * we can attach multiple Generators to the same event (eg. the same tour might also happen at 11am every weekday, except during December and January);
21 | * we can specify an end date for these repetition rules, or have them repeat infinitely (although since we can't store an infinite number of occurrences, we only generate a year into the futue. This is a setting which can be changed);
22 |
23 | Event variations
24 | ----------------
25 |
26 | Organisations which organise events are familiar with the notion of some events being special one-off variations of other events. For example, a monthly series of film screenings may have the same overall information, but different films each month. Or a film that shows every night in a month might have a directors' talk one night.
27 |
28 | (Note: it might be tempting to use the tree arrangement for 'parent events' e.g. Festivals, and events which are part of the festival. In our experience, events and their 'parents' are rarely in a strict tree arrangement, so we use another many-to-many relation between a model which represents Events, and a model which represents parent events, or event series. Depending on your arrangement, an umbrella event may be another Event, or another model entirely.)
29 |
30 | In Eventtools, Event variations are modelled by arranging events in a tree, with 'template' events (with no occurrences) higher in the tree, and 'actual' events (with occurrences) lower in the tree.
31 |
32 | An example arrangement might look like this:
33 |
34 | Screening
35 | |---Outdoor Screening
36 | |---Mad Max
37 | |---Mad Max II
38 | |---Red Curtain
39 | |---Moulin Rouge
40 | |---Strictly Ballroom
41 | |---Romeo and Juliet
42 | |---Romeo and Juliet with Director's talk
43 |
44 | Variation events can automatically inherit some attributes from template events.
45 |
46 | To define inherited fields, declare an EventMeta class in your Event model:
47 |
48 | class Event(EventModel):
49 | ...
50 |
51 | class EventMeta:
52 | fields_to_inherit = ('description', 'price', 'booking_info')
53 | ...
54 |
55 | This results in the following:
56 |
57 | * Changes to the parent model 'cascade' to child models, unless the child model already has a different value.
58 | * When you view an event, it shows the 'diff' of the child event from its parent
59 | * When you create a child event by clicking 'create child event', the values in the admin form are pre-populated.
60 |
61 |
62 | Exclusions
63 | ----------
64 |
65 | An Exclusion is a way to prevent an Occurrence from being created by a Generator. You might want to do this if there is a one-off exclusion to a repeating occurrence.
66 |
67 | For example, if a film is on every night for a month, but on one night there is a director's talk, then the Event arrangement is:
68 |
69 | Film <-- has an Occurrence Generator that repeats daily for a month
70 | |---Film with director's talk <-- has a one-off Occurrence
71 |
72 | This will result in two occurrences on the night of the director's talk, one for the Film, and one for the Film with director's talk. In this case, you'd add an Exclusion for the Film on that night.
73 |
74 | If an Occurrence that should be excluded has already been generated, it is not deleted, because there may be other information (e.g. ticket sales) attached. Instead, it is converted into a 'manual' occurrence, so the events administrator can decide whether to delete or change the occurrence.
--------------------------------------------------------------------------------
/eventtools/tests/models/occurrence.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8“ -*-
2 | from django.db import IntegrityError
3 | from django.test import TestCase
4 | from eventtools.tests._fixture import fixture
5 | from eventtools.tests._inject_app import TestCaseWithApp as AppTestCase
6 | from eventtools.tests.eventtools_testapp.models import *
7 | from datetime import date, time, datetime, timedelta
8 | from eventtools.utils import datetimeify
9 |
10 | class TestOccurrences(AppTestCase):
11 | """
12 | Occurrences must have a start datetime and end datetime. (We might have to make a widget to support entry of all-day events).
13 |
14 | If start.time is 'ommitted', it is set to time.min.
15 | If end is omitted, then:
16 | end.date = start.date, then apply rule below for time.
17 |
18 | If end.time is 'ommitted' it is set to start.time, unless start.time is time.min in which case end.time is set to time.max.
19 |
20 | If an occurrence's times are min and max, then it is an all-day event.
21 |
22 | End datetime must be >= start datetime.
23 | """
24 | def setUp(self):
25 | super(TestOccurrences, self).setUp()
26 | fixture(self)
27 |
28 | def test_occurrence_create(self):
29 | e = ExampleEvent.eventobjects.create(title="event with occurrences")
30 |
31 | d1 = date(2010,1,1)
32 | d2 = date(2010,1,2)
33 | d1min = datetimeify(d1, clamp='min')
34 | d2min = datetimeify(d2, clamp='min')
35 | t1 = time(9,00)
36 | t2 = time(10,00)
37 | dt1 = datetime.combine(d1, t1)
38 | dt2 = datetime.combine(d2, t2)
39 |
40 | #datetimes
41 | o = e.occurrences.create(start=dt1, _duration=24*60+60)
42 | self.ae(o.start, dt1)
43 | self.ae(o.end(), dt2)
44 | o.delete()
45 |
46 | o = e.occurrences.create(start=dt1)
47 | self.ae(o.start, dt1)
48 | self.ae(o.end(), dt1)
49 | o.delete()
50 |
51 | o = e.occurrences.create(start=d1min)
52 | self.ae(o.start, d1min)
53 | self.ae(o.end(), d1min)
54 | o.delete()
55 |
56 | #missing start date
57 | self.assertRaises(Exception, e.occurrences.create, **{'_duration': 60})
58 |
59 | #invalid start value
60 | self.assertRaises(Exception, e.occurrences.create, **{'start':t1})
61 | self.assertRaises(Exception, e.occurrences.create, **{'start':t1, '_duration':60})
62 |
63 | def test_occurrence_duration(self):
64 | e = ExampleEvent.eventobjects.create(title="event with occurrences")
65 | d1 = date(2010,1,1)
66 |
67 | # Occurrences with no duration have duration 0
68 | o = e.occurrences.create(start=d1)
69 | self.ae(o._duration, None)
70 | self.ae(o.duration, timedelta(0))
71 | o._duration = 0
72 | self.ae(o.duration, timedelta(0))
73 |
74 | # Occurrences with a given _duration in minutes have a corresponding timedelta duration property
75 | o._duration = 60
76 | self.ae(o.duration, timedelta(seconds=60*60))
77 |
78 | # - even if it's more than a day
79 | o._duration = 60 * 25
80 | self.ae(o.duration, timedelta(days=1, seconds=60*60))
81 |
82 | # Can set duration property with a timedelta
83 | o.duration = timedelta(days=1, seconds=60*60)
84 | self.ae(o._duration, 25 * 60)
85 | self.ae(o.duration, timedelta(days=1, seconds=60*60))
86 |
87 | # Can set duration property with a literal
88 | o.duration = 25*60
89 | self.ae(o._duration, 25 * 60)
90 | self.ae(o.duration, timedelta(days=1, seconds=60*60))
91 |
92 | # Can't have <0 duration
93 | self.assertRaises(IntegrityError, e.occurrences.create, **{'_duration': -60})
94 |
95 |
96 |
97 |
98 | def test_timespan_properties(self):
99 | """
100 | Occurrences have a robot description.
101 |
102 | Occurrences that are currently taking place return true for now_on.
103 |
104 | Occurrences that finish in the past return True for is_finished.
105 |
106 | We can find out how long we have to wait until an occurrence starts.
107 | We can find out how long it has been since an occurrence finished.
108 | """
109 | e = ExampleEvent.eventobjects.create(title="event with occurrences")
110 |
111 | now = datetime.now()
112 | earlier = now - timedelta(seconds=600)
113 |
114 | d1 = date(2010,1,1)
115 | t1 = time(9,00)
116 | dt1 = datetime.combine(d1, t1)
117 |
118 | o = e.occurrences.create(start=dt1, _duration=25*60)
119 | o2 = e.occurrences.create(start=earlier, _duration = 20)
120 |
121 | self.ae(o.duration, timedelta(days=1, seconds=3600))
122 | self.ae(o.timespan_description(), "1 January 2010, 9am until 10am on 2 January 2010")
123 |
124 | self.ae(o.is_finished(), True)
125 | self.ae(o.is_started(), True)
126 | self.ae(o.now_on(), False)
127 | self.ae(o2.is_finished(), False)
128 | self.ae(o2.is_started(), True)
129 | self.ae(o2.now_on(), True)
130 |
131 | self.assertTrue(o.time_to_go() < timedelta(0))
132 | self.ae(o2.time_to_go(), timedelta(0))
133 |
134 | """
135 | TODO
136 |
137 | Occurrences know if they are the opening or closing occurrences for their event.
138 |
139 | You can filter an Occurrence queryset to show only those occurrences that are opening or closing.
140 |
141 | The custom admin occurrence view lists the occurrences of an event and all its children. Each occurrence shows which event it is linked to.
142 |
143 | The custom admin view can be used to assign a different event to an occurrence. The drop-down list only shows the given event and its children.
144 |
145 |
146 | Warning
147 | The “delete selected objects” action uses QuerySet.delete() for efficiency reasons, which has an important caveat: your model’s delete() method will not be called.
148 |
149 | If you wish to override this behavior, simply write a custom action which accomplishes deletion in your preferred manner – for example, by calling Model.delete() for each of the selected items.
150 |
151 | For more background on bulk deletion, see the documentation on object deletion.
152 | """
153 |
--------------------------------------------------------------------------------
/eventtools/tests/models/exclusion.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8“ -*-
2 | from django.test import TestCase
3 | from eventtools.tests._inject_app import TestCaseWithApp as AppTestCase
4 | from eventtools.tests.eventtools_testapp.models import *
5 | from datetime import date, time, datetime, timedelta
6 | from eventtools.tests._fixture import generator_fixture
7 | from eventtools.tests._fixture import fixture
8 | from django.core.urlresolvers import reverse
9 | from eventtools.models import Rule
10 | from django.core.exceptions import ValidationError
11 | from django.db import IntegrityError
12 |
13 | class TestExclusions(AppTestCase):
14 |
15 | def setUp(self):
16 | super(TestExclusions, self).setUp()
17 | fixture(self)
18 |
19 | def test_generation_then_exclusion(self):
20 | """
21 | If an Exclusion is saved, then:
22 |
23 | * Generated Occurrences that should be excluded are converted to one-off.
24 | * Re-generating from generators will not re-generate that occurrence.
25 | """
26 |
27 | generator_fixture(self)
28 |
29 | clashingtime = datetime(2010,1,8,10,30)
30 |
31 | # Check we're starting the occurrence with a generator
32 | self.existing_occurrence = self.bin_night.occurrences.get(start = clashingtime)
33 | self.existing_occurrence_id = self.existing_occurrence.id
34 | self.assertTrue(self.existing_occurrence.generated_by is not None)
35 |
36 | #exclude the second occurrence of the weekly_ and endless_generators.
37 | self.exclusion = self.bin_night.exclusions.create(
38 | start = clashingtime
39 | )
40 |
41 | # Assert that the clashing occurrence has the same ID, but
42 | # now has no generator (ie is one-off)
43 | self.existing_occurrence = self.bin_night.occurrences.get(start = clashingtime)
44 | self.ae(self.existing_occurrence_id, self.existing_occurrence.id)
45 | self.assertTrue(self.existing_occurrence.generated_by is None)
46 |
47 | # delete the clashing occurrence
48 | self.existing_occurrence.delete()
49 |
50 | # Let's re-save the generators
51 | self.weekly_generator.save()
52 | self.dupe_weekly_generator.save()
53 |
54 | # no excluded occurrence is (re)generated
55 | self.ae(self.bin_night.occurrences.filter(start = clashingtime).count(), 0)
56 |
57 | def test_clash(self):
58 | """
59 | If we create a one-off occurrence that clashes
60 | * event + start-time is unique, so it must be added as an exception
61 | first.
62 | * the one-off occurrence shouldn't be generated.
63 | """
64 |
65 | generator_fixture(self)
66 |
67 | # Check there is an auto occurrence
68 | clashingtime = datetime(2010,1,8,10,30)
69 | auto_occs = self.bin_night.occurrences.filter(start = clashingtime)
70 | self.ae(auto_occs.count(), 1)
71 | self.assertTrue(auto_occs[0].generated_by is not None)
72 |
73 | # we can't add another occurrence with a clashing start time.
74 | self.assertRaises(
75 | IntegrityError,
76 | self.bin_night.occurrences.create,
77 | start = clashingtime
78 | )
79 |
80 | # let's add the Exclusions
81 | self.exclusion = self.bin_night.exclusions.create(
82 | start = clashingtime
83 | )
84 |
85 | # now we should have a manual occurrence
86 | oneoff_occ = self.bin_night.occurrences.get(start = clashingtime)
87 | self.assertTrue(oneoff_occ.generated_by is None)
88 |
89 | # let's delete it:
90 | oneoff_occ.delete()
91 |
92 | # and now it's OK to create a one-off one:
93 | self.bin_night.occurrences.create(start=clashingtime)
94 |
95 | # and if we remove the Exclusion, the generators don't try to generate
96 | # anything clashing with the one-off occurrence
97 | self.exclusion.delete()
98 |
99 | self.weekly_generator.save()
100 | self.endless_generator.save()
101 |
102 | oneoff_occs = self.bin_night.occurrences.filter(start = clashingtime)
103 | self.ae(oneoff_occs.count(), 1)
104 | self.assertTrue(oneoff_occs[0].generated_by is None)
105 |
106 | def test_timeshift_into_exclusion(self):
107 | """
108 | If a generator is modified such that occurrences are timeshifted such
109 | that an occurrence matches an exclusion, then the occurrence should
110 | be deleted (or unhooked).
111 | """
112 | event = ExampleEvent.objects.create(title="Curator's Talk", slug="curators-talk-1")
113 | # is on every week for a year
114 | weekly = Rule.objects.create(frequency = "WEEKLY")
115 | generator = event.generators.create(start=datetime(2010,1,1, 9,00), _duration=60, rule=weekly, repeat_until=date(2010,12,31))
116 |
117 | # now I buy a ticket to the first occurrence
118 | ticket = ExampleTicket.objects.create(occurrence=generator.occurrences.all()[0])
119 |
120 | #here is an exclusion (to clash with the ticketed occurrence)
121 | clashingtime = datetime(2010,1,1,9,05)
122 | self.exclusion = event.exclusions.create(start = clashingtime)
123 | #and another to clash with an unticketed occurrence
124 | clashingtime2 = datetime(2010,1,8,9,05)
125 | self.exclusion = event.exclusions.create(start = clashingtime2)
126 |
127 | self.ae(event.occurrences.count(), 53)
128 |
129 | #update start time of generator 5 mins
130 | generator.start=datetime(2010,1,1,9,05)
131 | generator.save()
132 |
133 | # the first clashing occurrence should still exist, as there are tickets attached
134 | self.ae(event.occurrences.filter(start = clashingtime).count(), 1)
135 | self.ae(event.occurrences.get(start = clashingtime).generated_by, None)
136 |
137 | # the second clashing occurrence should no longer exist
138 | self.ae(event.occurrences.filter(start = clashingtime2).count(), 0)
139 |
140 | # overall, there is one less occurrence
141 | self.ae(event.occurrences.count(), 52)
--------------------------------------------------------------------------------
/UPGRADING.txt:
--------------------------------------------------------------------------------
1 | 2 September 2011:
2 |
3 | This revision contains a breaking change in the Occurrence and Generator models, to use start + duration, rather than start + end, and to have consistency between their APIs.
4 |
5 | The Generator model repeat_until is now a date, rather than a datetime, for simplicity (only one occurrence per day is generated).
6 |
7 | To migrate, using South:
8 |
9 | 1) Create a migration representing your app's current state, if you haven't already.
10 | 2) Update event tools to a current version.
11 | 3) Create a manual migration which adds the _duration fields to Occurrence and Generator (see sample code)
12 |
13 | def forwards(self, orm):
14 |
15 | # Adding field 'Occurrence.duration'
16 | db.add_column('events_occurrence', '_duration', self.gf('django.db.models.fields.PositiveIntegerField')(null=True, blank=True), keep_default=False)
17 | # Copy info over
18 | for o in orm['events.occurrence'].objects.all():
19 | if o.end and o.end != o.start:
20 | td = o.end - o.start
21 | secs = td.days * 24 * 60 * 60 + td.seconds
22 | o._duration = secs/60
23 | o.save()
24 |
25 | # Adding field 'Generator._duration'
26 | db.add_column('events_generator', '_duration', self.gf('django.db.models.fields.PositiveIntegerField')(null=True, blank=True), keep_default=False)
27 | # Copy info over
28 | for g in orm['events.generator'].objects.all():
29 | if g.event_end and g.event_end != g.event_start:
30 | td = g.event_end - g.event_start
31 | secs = td.days * 24 * 60 * 60 + td.seconds
32 | g._duration = secs/60
33 | g.save()
34 |
35 | def backwards(self, orm):
36 |
37 | # Deleting field 'Occurrence.duration'
38 | db.delete_column('events_occurrence', '_duration')
39 |
40 | # Deleting field 'Generator._duration'
41 | db.delete_column('events_generator', '_duration')
42 |
43 |
44 | 4) Create a manual migration which removes the *end fields
45 |
46 | def forwards(self, orm):
47 |
48 | # Deleting field 'Occurrence.end'
49 | db.delete_column('events_occurrence', 'end')
50 |
51 | # Deleting field 'Generator.event_end'
52 | db.delete_column('events_generator', 'event_end')
53 |
54 | def backwards(self, orm):
55 |
56 | # Adding field 'Occurrence.end'
57 | db.add_column('events_occurrence', 'end', self.gf('django.db.models.fields.DateTimeField')(default='', blank=True, db_index=True), keep_default=False)
58 |
59 | for o in orm['events.occurrence'].objects.all():
60 | if o._duration:
61 | o.end = o.start + datetime.timedelta(seconds = o._duration * 60)
62 | else:
63 | o.end = o.start
64 | o.save()
65 |
66 | # Adding field 'Generator.event_end'
67 | db.add_column('events_generator', 'event_end', self.gf('django.db.models.fields.DateTimeField')(default='', blank=True, db_index=True), keep_default=False)
68 |
69 | for o in orm['events.generator'].objects.all():
70 | if o._duration:
71 | o.event_end = o.event_start + datetime.timedelta(seconds = o._duration * 60)
72 | else:
73 | o.event_end = o.event_start
74 | o.save()
75 |
76 |
77 | 5) Create a manual migration which renames event_start to start in Generator
78 |
79 | def forwards(self, orm):
80 |
81 | # Renaming field 'Generator.event_start'
82 | db.rename_column('events_generator', 'event_start', 'start')
83 |
84 | def backwards(self, orm):
85 |
86 | # Renaming field 'Generator.start'
87 | db.rename_column('events_generator', 'start', 'event_start')
88 |
89 |
90 | 6) Create a manual migration which adds repeat_until_date to Generator, and populates the values
91 |
92 | def forwards(self, orm):
93 |
94 | # Adding field 'Generator.repeat_until_date'
95 | db.add_column('events_generator', 'repeat_until_date', self.gf('django.db.models.fields.DateField')(null=True, blank=True), keep_default=False)
96 |
97 | for o in orm['events.generator'].objects.all():
98 | if o.repeat_until:
99 | o.repeat_until_date = o.repeat_until.date()
100 | o.save()
101 |
102 | def backwards(self, orm):
103 |
104 | # Deleting field 'Generator.repeat_until_date'
105 | db.delete_column('events_generator', 'repeat_until_date')
106 |
107 |
108 | 7) Create a manual migration which removes repeat_until (the datetime), and renames repeat_until_date to repeat_until.
109 |
110 | def forwards(self, orm):
111 |
112 | # Deleting field 'Generator.repeat_until'
113 | db.delete_column('events_generator', 'repeat_until')
114 | db.rename_column('events_generator', 'repeat_until_date', 'repeat_until')
115 |
116 | def backwards(self, orm):
117 |
118 | # Adding field 'Generator.repeat_until'
119 | db.rename_column('events_generator', 'repeat_until', 'repeat_until_date')
120 | db.add_column('events_generator', 'repeat_until', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), keep_default=False)
121 |
122 | for o in orm['events.generator'].objects.all():
123 | if o.repeat_until_date:
124 | o.repeat_until = datetime.datetime.combine(o.repeat_until_date, datetime.time.max)
125 | o.save()
126 |
127 |
128 | 8) ./manage.py schemamigration youreventsapp --auto should pick up any other changes (fields which are now required, etc.). However, the GeneratorModel rule field is now required. In EventTools 1, Generators without a rule were 'one-off' events, which should now be stored as separate Occurrences. (If you do a migration like this, update this document with sample code!)
129 |
130 |
131 | # === Snippet to migrate exclusions from generators ===
132 | from dateutil import parser
133 |
134 | class Migration(SchemaMigration):
135 |
136 | def forwards(self, orm):
137 | for generator in orm['events.generator'].objects.filter(exceptions__isnull=False):
138 | for exc in generator.exceptions.keys():
139 | dt = parser.parse(exc)
140 | orm['events.exclusion'].objects.create(event=generator.event, start=dt)
141 |
--------------------------------------------------------------------------------
/eventtools/views.py:
--------------------------------------------------------------------------------
1 | from dateutil.relativedelta import relativedelta
2 |
3 | from django.conf.urls.defaults import *
4 | from django.core.paginator import Paginator, EmptyPage, InvalidPage
5 | from django.shortcuts import get_object_or_404, render_to_response
6 | from django.template.context import RequestContext
7 | from django.utils.safestring import mark_safe
8 |
9 | from eventtools.conf import settings
10 | from eventtools.utils.pprint_timespan import humanized_date_range
11 | from eventtools.utils.viewutils import paginate, response_as_ical, parse_GET_date
12 |
13 | import datetime
14 |
15 |
16 | class EventViews(object):
17 |
18 | # Have currently disabled icals.
19 |
20 | """
21 | use Event.eventobjects.all() for event_qs.
22 |
23 | It will get filtered to .in_listings() where appropriate.
24 | """
25 |
26 | def __init__(self, event_qs, occurrence_qs=None):
27 | self.event_qs = event_qs
28 |
29 | if occurrence_qs is None:
30 | occurrence_qs = self.event_qs.occurrences()
31 | self.occurrence_qs = occurrence_qs
32 |
33 | @property
34 | def urls(self):
35 | from django.conf.urls.defaults import patterns, url
36 |
37 | return (
38 | patterns('',
39 | url(r'^$', self.index, name='index'),
40 | url(r'^signage/$', self.signage, name='signage'),
41 | url(r'^signage/(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})/$',
42 | self.signage_on_date, name='signage_on_date'),
43 | url(r'^(?P\d{4})/(?P\d{1,2})/(?P\d{1,2})/$', self.on_date, name='on_date'),
44 | url(r'^(?P[-\w]+)/$', self.event, name='event'),
45 | url(r'^(?P[-\w]+)/(?P[\d]+)/$', self.occurrence, name='occurrence'),
46 |
47 | # iCal
48 | url(r'^(?P[-\w]+)/ical\.ics$', self.event_ical, name='event_ical'),
49 | url(r'^(?P[-\w]+)/(?P\d+)/ical\.ics$',
50 | self.occurrence_ical, name='occurrence_ical'),
51 | url(r'^ical\.ics$', self.occurrence_list_ical, name='occurrence_list_ical'),
52 | ),
53 | "events", # application namespace
54 | "events", # instance namespace
55 | )
56 |
57 | def event(self, request, event_slug):
58 | event = get_object_or_404(self.event_qs, slug=event_slug)
59 | context = RequestContext(request)
60 | context['event'] = event
61 |
62 | return render_to_response('eventtools/event.html', context)
63 |
64 | def event_ical(self, request, event_slug):
65 | """
66 | Returns all of an Event's occurrences as an iCal file
67 | """
68 | event = get_object_or_404(self.event_qs, slug=event_slug)
69 | return response_as_ical(request, event.occurrences.all())
70 |
71 | def occurrence(self, request, event_slug, occurrence_pk):
72 | """
73 | Returns a page similar to eventtools/event.html, but for a specific occurrence.
74 |
75 | event_slug is ignored, since occurrences may move from event to sub-event, and
76 | it would be nice if URLs continued to work.
77 | """
78 |
79 | occurrence = get_object_or_404(self.occurrence_qs, pk=occurrence_pk)
80 | event = occurrence.event
81 | context = RequestContext(request)
82 | context['occurrence'] = occurrence
83 | context['event'] = event
84 |
85 | return render_to_response('eventtools/event.html', context)
86 |
87 | def occurrence_ical(self, request, event_slug, occurrence_pk):
88 | """
89 | Returns a single Occurrence as an iCal file
90 | """
91 | occurrence = get_object_or_404(self.occurrence_qs, pk=occurrence_pk)
92 | return response_as_ical(request, occurrence)
93 |
94 | #occurrence_list
95 | def _occurrence_list_context(self, request, qs):
96 | fr, to = parse_GET_date(request.GET)
97 |
98 | if to is None:
99 | occurrence_pool = qs.after(fr)
100 | else:
101 | occurrence_pool = qs.between(fr, to)
102 |
103 | pageinfo = paginate(request, occurrence_pool)
104 |
105 | return {
106 | 'bounded': False,
107 | 'pageinfo': pageinfo,
108 | 'occurrence_pool': occurrence_pool,
109 | 'occurrence_page': pageinfo.object_list,
110 | 'day': fr,
111 | 'occurrence_qs': qs,
112 | }
113 |
114 |
115 | def occurrence_list(self, request): #probably want to override this for doing more filtering.
116 | template = 'eventtools/occurrence_list.html'
117 | context = RequestContext(request)
118 | context.update(self._occurrence_list_context(request, self.occurrence_qs))
119 | return render_to_response(template, context)
120 |
121 | def occurrence_list_ical(self, request):
122 | """
123 | Returns an iCal file containing all occurrences returned from `self._occurrence_list`
124 | """
125 | occurrences = self._occurrence_list_context(request, self.occurrence_qs)['occurrence_pool']
126 | return response_as_ical(request, occurrences)
127 |
128 | def on_date(self, request, year, month, day):
129 | template = 'eventtools/occurrence_list.html'
130 | day = datetime.date(int(year), int(month), int(day))
131 | event_pool = self.occurrence_qs.starts_on(day)
132 |
133 | context = RequestContext(request)
134 | context['occurrence_pool'] = event_pool
135 | context['day'] = day
136 | context['occurrence_qs'] = self.occurrence_qs
137 | return render_to_response(template, context)
138 |
139 | def today(self, request):
140 | today = datetime.date.today()
141 | return self.on_date(request, today.year, today.month, today.day)
142 |
143 | def signage(self, request):
144 | """
145 | Render a signage view of events that occur today.
146 | """
147 | today = datetime.date.today()
148 | return self.signage_on_date(request, today.year, today.month, today.day)
149 |
150 | def signage_on_date(self, request, year, month, day):
151 | """
152 | Render a signage view of events that occur on a given day.
153 | """
154 | template = 'eventtools/signage_on_date.html'
155 | dt = datetime.date(int(year), int(month), int(day))
156 | today = datetime.date.today()
157 | occurrences = self.occurrence_qs.starts_on(dt)
158 |
159 | context = RequestContext(request)
160 | context['occurrence_pool'] = occurrences
161 | context['day'] = dt
162 | context['is_today'] = dt == today
163 | return render_to_response(template, context)
164 |
165 | def index(self, request):
166 | # In your subclass, you may prefer:
167 | # return self.today(request)
168 | return self.occurrence_list(request)
169 |
170 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # GLAMkit Smartlinks documentation build configuration file, created by
4 | # sphinx-quickstart on Fri Oct 8 16:20:55 2010.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys, os
15 |
16 | # If extensions (or modules to document with autodoc) are in another directory,
17 | # add these directories to sys.path here. If the directory is relative to the
18 | # documentation root, use os.path.abspath to make it absolute, like shown here.
19 | #sys.path.insert(0, os.path.abspath('.'))
20 |
21 | # -- General configuration -----------------------------------------------------
22 |
23 | # If your documentation needs a minimal Sphinx version, state it here.
24 | #needs_sphinx = '1.0'
25 |
26 | # Add any Sphinx extension module names here, as strings. They can be extensions
27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
28 | extensions = []
29 |
30 | # Add any paths that contain templates here, relative to this directory.
31 | templates_path = ['_templates']
32 |
33 | # The suffix of source filenames.
34 | source_suffix = '.rst'
35 |
36 | # The encoding of source files.
37 | #source_encoding = 'utf-8-sig'
38 |
39 | # The master toctree document.
40 | master_doc = 'index'
41 |
42 | # General information about the project.
43 | project = u'GLAMkit Event tools'
44 | copyright = u'2010, The GLAMkit Association'
45 |
46 | # The version info for the project you're documenting, acts as replacement for
47 | # |version| and |release|, also used in various other places throughout the
48 | # built documents.
49 | #
50 | # The short X.Y version.
51 | version = '0.5.5'
52 | # The full version, including alpha/beta/rc tags.
53 | release = '0.5.5'
54 |
55 | # The language for content autogenerated by Sphinx. Refer to documentation
56 | # for a list of supported languages.
57 | #language = None
58 |
59 | # There are two options for replacing |today|: either, you set today to some
60 | # non-false value, then it is used:
61 | #today = ''
62 | # Else, today_fmt is used as the format for a strftime call.
63 | #today_fmt = '%B %d, %Y'
64 |
65 | # List of patterns, relative to source directory, that match files and
66 | # directories to ignore when looking for source files.
67 | exclude_patterns = ['_build']
68 |
69 | # The reST default role (used for this markup: `text`) to use for all documents.
70 | #default_role = None
71 |
72 | # If true, '()' will be appended to :func: etc. cross-reference text.
73 | #add_function_parentheses = True
74 |
75 | # If true, the current module name will be prepended to all description
76 | # unit titles (such as .. function::).
77 | #add_module_names = True
78 |
79 | # If true, sectionauthor and moduleauthor directives will be shown in the
80 | # output. They are ignored by default.
81 | #show_authors = False
82 |
83 | # The name of the Pygments (syntax highlighting) style to use.
84 | pygments_style = 'sphinx'
85 |
86 | # A list of ignored prefixes for module index sorting.
87 | #modindex_common_prefix = []
88 |
89 |
90 | # -- Options for HTML output ---------------------------------------------------
91 |
92 | # The theme to use for HTML and HTML Help pages. See the documentation for
93 | # a list of builtin themes.
94 | html_theme = 'nature'
95 |
96 | # Theme options are theme-specific and customize the look and feel of a theme
97 | # further. For a list of options available for each theme, see the
98 | # documentation.
99 | #html_theme_options = {}
100 |
101 | # Add any paths that contain custom themes here, relative to this directory.
102 | #html_theme_path = []
103 |
104 | # The name for this set of Sphinx documents. If None, it defaults to
105 | # " v documentation".
106 | #html_title = None
107 |
108 | # A shorter title for the navigation bar. Default is the same as html_title.
109 | #html_short_title = None
110 |
111 | # The name of an image file (relative to this directory) to place at the top
112 | # of the sidebar.
113 | #html_logo = None
114 |
115 | # The name of an image file (within the static path) to use as favicon of the
116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
117 | # pixels large.
118 | #html_favicon = None
119 |
120 | # Add any paths that contain custom static files (such as style sheets) here,
121 | # relative to this directory. They are copied after the builtin static files,
122 | # so a file named "default.css" will overwrite the builtin "default.css".
123 | html_static_path = ['_static']
124 |
125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
126 | # using the given strftime format.
127 | #html_last_updated_fmt = '%b %d, %Y'
128 |
129 | # If true, SmartyPants will be used to convert quotes and dashes to
130 | # typographically correct entities.
131 | #html_use_smartypants = True
132 |
133 | # Custom sidebar templates, maps document names to template names.
134 | #html_sidebars = {}
135 |
136 | # Additional templates that should be rendered to pages, maps page names to
137 | # template names.
138 | #html_additional_pages = {}
139 |
140 | # If false, no module index is generated.
141 | #html_domain_indices = True
142 |
143 | # If false, no index is generated.
144 | #html_use_index = True
145 |
146 | # If true, the index is split into individual pages for each letter.
147 | #html_split_index = False
148 |
149 | # If true, links to the reST sources are added to the pages.
150 | #html_show_sourcelink = True
151 |
152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
153 | #html_show_sphinx = True
154 |
155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
156 | #html_show_copyright = True
157 |
158 | # If true, an OpenSearch description file will be output, and all pages will
159 | # contain a tag referring to it. The value of this option must be the
160 | # base URL from which the finished HTML is served.
161 | #html_use_opensearch = ''
162 |
163 | # This is the file name suffix for HTML files (e.g. ".xhtml").
164 | #html_file_suffix = None
165 |
166 | # Output file base name for HTML help builder.
167 | htmlhelp_basename = 'GLAMkitSmartlinksdoc'
168 |
169 |
170 | # -- Options for LaTeX output --------------------------------------------------
171 |
172 | # The paper size ('letter' or 'a4').
173 | #latex_paper_size = 'letter'
174 |
175 | # The font size ('10pt', '11pt' or '12pt').
176 | #latex_font_size = '10pt'
177 |
178 | # Grouping the document tree into LaTeX files. List of tuples
179 | # (source start file, target name, title, author, documentclass [howto/manual]).
180 | latex_documents = [
181 | ('index', 'GLAMkitSmartlinks.tex', u'GLAMkit Smartlinks Documentation',
182 | u'The GLAMkit Association', 'manual'),
183 | ]
184 |
185 | # The name of an image file (relative to this directory) to place at the top of
186 | # the title page.
187 | #latex_logo = None
188 |
189 | # For "manual" documents, if this is true, then toplevel headings are parts,
190 | # not chapters.
191 | #latex_use_parts = False
192 |
193 | # If true, show page references after internal links.
194 | #latex_show_pagerefs = False
195 |
196 | # If true, show URL addresses after external links.
197 | #latex_show_urls = False
198 |
199 | # Additional stuff for the LaTeX preamble.
200 | #latex_preamble = ''
201 |
202 | # Documents to append as an appendix to all manuals.
203 | #latex_appendices = []
204 |
205 | # If false, no module index is generated.
206 | #latex_domain_indices = True
207 |
208 |
209 | # -- Options for manual page output --------------------------------------------
210 |
211 | # One entry per manual page. List of tuples
212 | # (source start file, name, description, authors, manual section).
213 | man_pages = [
214 | ('index', 'glamkitsmartlinks', u'GLAMkit Smartlinks Documentation',
215 | [u'The GLAMkit Association'], 1)
216 | ]
217 |
--------------------------------------------------------------------------------
/eventtools/models/xtimespan.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.db import models
4 | from django.utils.translation import ugettext as _
5 | from eventtools.utils import datetimeify
6 | from eventtools.utils.datetimeify import dayify
7 | from eventtools.utils.managertype import ManagerType
8 | from eventtools.utils.pprint_timespan import pprint_datetime_span, pprint_time_span
9 | from django.utils.safestring import mark_safe
10 | from django.utils.timezone import now, localtime, make_aware, \
11 | get_default_timezone, is_naive
12 |
13 | class XTimespanQSFN(object):
14 | """
15 | All the query functions are defined here, so they can be easily introspected
16 | and injected by the OccurrenceManagerType metaclass.
17 | """
18 |
19 | def starts_before(self, date):
20 | end = datetimeify(date, clamp="max")
21 | if is_naive(end):
22 | end = make_aware(end, get_default_timezone())
23 | return self.filter(start__lte=end)
24 | def starts_after(self, date):
25 | start = datetimeify(date, clamp="min")
26 | if is_naive(start):
27 | start = make_aware(start, get_default_timezone())
28 | return self.filter(start__gte=start)
29 | def starts_between(self, d1, d2):
30 | """
31 | returns the occurrences that start in a given date/datetime range.
32 | """
33 | return self.starts_after(d1).starts_before(d2)
34 |
35 | def starts_on(self, day):
36 | d1, d2 = dayify(day)
37 | return self.starts_between(d1, d2)
38 |
39 | #defaults - implementers may wish to override with other kinds of queries
40 | before = starts_before
41 | after = starts_after
42 | between = starts_between
43 | on = starts_on
44 |
45 | #misc queries (note they assume starts_)
46 | def forthcoming(self):
47 | return self.starts_after(now())
48 |
49 | def recent(self):
50 | return self.starts_before(now())
51 |
52 | class XTimespanQuerySet(models.query.QuerySet, XTimespanQSFN):
53 | pass #all the goodness is inherited from XTimespanQSFN
54 |
55 | class XTimespanManager(models.Manager):
56 | __metaclass__ = ManagerType(XTimespanQSFN)
57 |
58 | def get_query_set(self):
59 | return XTimespanQuerySet(self.model)
60 |
61 |
62 | class XTimespanModel(models.Model):
63 | start = models.DateTimeField(db_index=True, verbose_name=_('start'))
64 | _duration = models.PositiveIntegerField(_("duration (mins)"), blank=True, null=True, help_text=_("to create 'all day' events, set start time to 00:00 and leave duration blank"))
65 |
66 | objects = XTimespanManager()
67 |
68 | class Meta:
69 | abstract = True
70 | ordering = ('start', )
71 |
72 | def get_duration(self):
73 | """
74 | _duration is a value in minutes. The duration property returns a
75 | timedelta representing this.
76 | """
77 | if self._duration:
78 | return datetime.timedelta(seconds = self._duration * 60)
79 | else:
80 | return datetime.timedelta(0)
81 |
82 | def set_duration(self, v):
83 | """
84 | Pass in a timedelta to convert to minutes; pass in something else to set directly.
85 | """
86 | if isinstance(v, datetime.timedelta):
87 | self._duration = v.days * 24 * 60 + v.seconds / 60
88 | else:
89 | self._duration = v
90 |
91 | duration = property(get_duration, set_duration)
92 |
93 | def duration_string(self):
94 | """
95 | Prints out the duration in plain-ish English.
96 | *cough* internationalisation *cough*
97 | """
98 | if self.all_day():
99 | return u"all day"
100 | d = self.duration
101 | result = []
102 | if d.days:
103 | plural = "" if d.days == 1 else "s"
104 | result.append("%s day%s" % (d.days, plural))
105 | if d.seconds:
106 | num_hours = d.seconds / 3600
107 | remaining_seconds = d.seconds - (3600 * num_hours)
108 |
109 | if num_hours:
110 | plural = "" if num_hours == 1 else "s"
111 | result.append("%s hour%s" % (num_hours, plural))
112 |
113 | num_minutes = remaining_seconds / 60
114 | if num_minutes:
115 | plural = "" if num_minutes == 1 else "s"
116 | result.append("%s min%s" % (num_minutes, plural))
117 |
118 | return " ".join(result)
119 |
120 | def end(self):
121 | return self.start + self.duration
122 |
123 | def all_day(self):
124 | """
125 | WARNING: the implementation of 'all day' may change, for example by
126 | making it a BooleanField. If this is important to you, define it
127 | yourself.
128 |
129 | By default, an event is 'all day' if the start time is time.min
130 | (ie midnight) and the duration is not provided.
131 |
132 | 'All day' is distinguished from events that last 24 hours, because
133 | there is a reader assumption that opening hours are taken into account.
134 |
135 | Implementers may prefer their own definition, maybe adding a
136 | BooleanField that overrides the given times.
137 | """
138 | return localtime(self.start).time() == datetime.time.min and not self._duration
139 |
140 | def timespan_description(self, html=False):
141 | start = localtime(self.start)
142 | end = localtime(self.end())
143 | if html:
144 | return mark_safe(pprint_datetime_span(start, end,
145 | infer_all_day=False,
146 | space=" ",
147 | date_range_str="–",
148 | time_range_str="–",
149 | separator=":",
150 | grand_range_str=" – ",
151 | ))
152 | return mark_safe(pprint_datetime_span(start, end, infer_all_day=False))
153 |
154 | def html_timespan(self):
155 | return self.timespan_description(html=True)
156 |
157 | def time_description(self, html=False, *args, **kwargs):
158 | start = localtime(self.start)
159 | end = localtime(self.end())
160 | if self.all_day():
161 | return mark_safe(_("all day"))
162 |
163 | t1 = start.time()
164 | if start.date() == end.date():
165 | t2 = end.time()
166 | else:
167 | t2 = t1
168 |
169 | if html:
170 | return mark_safe(pprint_time_span(t1, t2, range_str="–", *args, **kwargs))
171 | return pprint_time_span(t1, t2, *args, **kwargs)
172 |
173 | def html_time_description(self):
174 | return self.time_description(html=True)
175 |
176 | def is_finished(self):
177 | return self.end() < now()
178 |
179 | def is_started(self):
180 | return self.start < now()
181 |
182 | def now_on(self):
183 | return self.is_started() and not self.is_finished()
184 |
185 | def time_to_go(self):
186 | """
187 | If self is in future, return + timedelta.
188 | If self is in past, return - timedelta.
189 | If self is now on, return timedelta(0)
190 | """
191 | if not self.is_started():
192 | return self.start - now()
193 | if self.is_finished():
194 | return self.end() - now()
195 | return datetime.timedelta(0)
196 |
197 | def start_date(self):
198 | """Used for regrouping in template"""
199 | return self.start.date()
200 |
201 | def humanised_day(self):
202 | if self.start.date() == now().date():
203 | return _("Today")
204 | elif self.start.date() == now().date() + datetime.timedelta(days=1):
205 | return _("Tomorrow")
206 | elif self.start.date() == now().date() - datetime.timedelta(days=1):
207 | return _("Yesterday")
208 | return self.start.strftime("%A, %d %B %Y")
209 |
210 | """
211 | TODO:
212 |
213 | timespan +/ timedelta = new timespan
214 | """
215 |
--------------------------------------------------------------------------------
/eventtools/tests/_fixture.py:
--------------------------------------------------------------------------------
1 | from dateutil.relativedelta import *
2 | from eventtools.models import Rule
3 | from eventtools_testapp.models import *
4 | from eventtools.utils.dateranges import *
5 | from datetime import datetime, date, timedelta
6 |
7 | def fixture(obj):
8 | #some simple events
9 | obj.talk = ExampleEvent.eventobjects.create(title="Curator's Talk", slug="curators-talk")
10 | obj.performance = ExampleEvent.eventobjects.create(title="A performance", slug="performance")
11 |
12 | #some useful dates
13 | obj.day1 = date(2010,10,10)
14 | obj.day2 = obj.day1+timedelta(1)
15 |
16 | #some simple occurrences
17 | obj.talk_morning = ExampleOccurrence.objects.create(event=obj.talk, start=datetime(2010,10,10,10,00))
18 | obj.talk_afternoon = ExampleOccurrence.objects.create(event=obj.talk, start=datetime(2010,10,10,14,00))
19 | obj.talk_tomorrow_morning_cancelled = ExampleOccurrence.objects.create(event=obj.talk, start=datetime(2010,10,11,10,00), status='cancelled')
20 |
21 | obj.performance_evening = ExampleOccurrence.objects.create(event=obj.performance, start=datetime(2010,10,10,20,00))
22 | obj.performance_tomorrow = ExampleOccurrence.objects.create(event=obj.performance, start=datetime(2010,10,11,20,00))
23 | obj.performance_day_after_tomorrow = ExampleOccurrence.objects.create(event=obj.performance, start=datetime(2010,10,12,20,00))
24 |
25 | #an event with many occurrences
26 | # deleting the 2nd jan, because we want to test it isn't displayed
27 | obj.daily_tour = ExampleEvent.eventobjects.create(title="Daily Tour", slug="daily-tour")
28 | for day in range(50):
29 | if day !=1: #2nd of month.
30 | d = date(2010,1,1) + timedelta(day)
31 | obj.daily_tour.occurrences.create(start=d)
32 |
33 |
34 | obj.weekly_talk = ExampleEvent.eventobjects.create(title="Weekly Talk", slug="weekly-talk")
35 | for day in range(50):
36 | d = date(2010,1,1) + timedelta(day*7)
37 | obj.weekly_talk.occurrences.create(start=datetime.combine(d, time(10,00)), _duration=240)
38 |
39 |
40 | #an event with some variations
41 | obj.film = ExampleEvent.eventobjects.create(title="Film Night", slug="film-night")
42 | obj.film_with_popcorn = ExampleEvent.eventobjects.create(parent=obj.film, title="Film Night", slug="film-night-2", difference_from_parent="free popcorn")
43 | obj.film_with_talk = ExampleEvent.eventobjects.create(parent=obj.film, title="Film Night", slug="film-night-talk", difference_from_parent="director's talk")
44 | obj.film_with_talk_and_popcorn = ExampleEvent.eventobjects.create(parent=obj.film_with_talk, title="Film Night", slug="film-with-talk-and-popcorn", difference_from_parent="popcorn and director's talk")
45 |
46 | # obj.film_with_popcorn.move_to(obj.film, position='first-child')
47 | # obj.film_with_talk.move_to(obj.film, position='first-child')
48 | # obj.film_with_talk_and_popcorn.move_to(obj.film_with_talk, position='first-child')
49 | # the mptt gotcha. reload the parents
50 | reload_films(obj)
51 |
52 | obj.film_occ = obj.film.occurrences.create(start=datetime(2010,10,10,18,30))
53 | obj.film_occ.save()
54 | obj.film_with_popcorn_occ = obj.film_with_popcorn.occurrences.create(start=datetime(2010,10,11,18,30))
55 | obj.film_with_talk_occ = obj.film_with_talk.occurrences.create(start=datetime(2010,10,12,18,30))
56 | obj.film_with_talk_and_popcorn_occ = obj.film_with_talk_and_popcorn.occurrences.create(start=datetime(2010,10,13,18,30))
57 |
58 | def generator_fixture(obj):
59 | #TestEvents with generators (separate models to test well)
60 | obj.weekly = Rule.objects.create(frequency = "WEEKLY")
61 | obj.daily = Rule.objects.create(frequency = "DAILY")
62 | obj.yearly = Rule.objects.create(frequency = "YEARLY")
63 | obj.bin_night = ExampleEvent.eventobjects.create(title='Bin Night')
64 |
65 | obj.weekly_generator = obj.bin_night.generators.create(start=datetime(2010,1,8,10,30), _duration=60, rule=obj.weekly, repeat_until=date(2010,2,5))
66 | #this should create 0 occurrences, since it is a duplicate of weekly.
67 | obj.dupe_weekly_generator = obj.bin_night.generators.create(start=datetime(2010,1,8,10,30), _duration=60, rule=obj.weekly, repeat_until=date(2010,2,5))
68 |
69 | obj.endless_generator = obj.bin_night.generators.create(start=datetime(2010,1,2,10,30), _duration=60, rule=obj.weekly)
70 |
71 | obj.all_day_generator = obj.bin_night.generators.create(start=datetime(2010,1,4,0,0), rule=obj.weekly, repeat_until=date(2010,1,25))
72 |
73 | def reload_films(obj):
74 | obj.film = obj.film.reload()
75 | obj.film_with_popcorn = obj.film_with_popcorn.reload()
76 | obj.film_with_talk = obj.film_with_talk.reload()
77 | obj.film_with_talk_and_popcorn = obj.film_with_talk_and_popcorn.reload()
78 |
79 |
80 | def bigfixture(obj):
81 | # have to create some more events since we are working from 'today'.
82 | obj.pe = ExampleEvent.eventobjects.create(title="proliferating event")
83 |
84 | obj.todaynow = datetime.now()
85 |
86 | obj.today = date.today()
87 | obj.tomorrow = obj.today + timedelta(1)
88 | obj.yesterday = obj.today - timedelta(1)
89 |
90 | obj.this_week = dates_in_week_of(obj.today)
91 | obj.last_week = dates_in_week_of(obj.today-timedelta(7))
92 | obj.next_week = dates_in_week_of(obj.today+timedelta(7))
93 |
94 | obj.this_weekend = dates_in_weekend_of(obj.today)
95 | obj.last_weekend = dates_in_weekend_of(obj.today-timedelta(7))
96 | obj.next_weekend = dates_in_weekend_of(obj.today+timedelta(7))
97 |
98 | obj.this_fortnight = dates_in_fortnight_of(obj.today)
99 | obj.last_fortnight = dates_in_fortnight_of(obj.today-timedelta(14))
100 | obj.next_fortnight = dates_in_fortnight_of(obj.today+timedelta(14))
101 |
102 | obj.this_month = dates_in_month_of(obj.today)
103 | obj.last_month = dates_in_month_of(obj.today+relativedelta(months=-1))
104 | obj.next_month = dates_in_month_of(obj.today+relativedelta(months=+1))
105 |
106 | obj.this_year = dates_in_year_of(obj.today)
107 | obj.last_year = dates_in_year_of(obj.today+relativedelta(years=-1))
108 | obj.next_year = dates_in_year_of(obj.today+relativedelta(years=+1))
109 |
110 | obj.now = datetime.now().time()
111 | obj.hence1 = (datetime.now() + timedelta(seconds=600)).time()
112 | obj.hence2 = (datetime.now() + timedelta(seconds=1200)).time()
113 | obj.earlier1 = (datetime.now() - timedelta(seconds=600)).time()
114 | obj.earlier2 = (datetime.now() - timedelta(seconds=1200)).time()
115 |
116 | #on each of the given days, we'll create 5 occurrences:
117 | # all day
118 | # earlier
119 | # hence
120 | # current
121 | # multiday
122 |
123 | present_days = \
124 | obj.this_week + \
125 | obj.this_weekend + \
126 | obj.this_fortnight + \
127 | obj.this_month + \
128 | obj.this_year + \
129 | [obj.today]
130 |
131 | past_days = \
132 | obj.last_week + \
133 | obj.last_weekend + \
134 | obj.last_fortnight + \
135 | obj.last_month + \
136 | obj.last_year + \
137 | [obj.yesterday]
138 |
139 | future_days = \
140 | obj.next_week + \
141 | obj.next_weekend + \
142 | obj.next_fortnight + \
143 | obj.next_month + \
144 | obj.next_year + \
145 | [obj.tomorrow]
146 |
147 | for day in present_days + past_days + future_days:
148 | #all day
149 | obj.pe.occurrences.create(start=day)
150 | # earlier
151 | obj.pe.occurrences.create(start=datetime.combine(day, obj.earlier2), end=datetime.combine(day, obj.earlier1))
152 | # later
153 | obj.pe.occurrences.create(start=datetime.combine(day, obj.hence1), end=datetime.combine(day, obj.hence2))
154 | # now-ish
155 | obj.pe.occurrences.create(start=datetime.combine(day, obj.earlier1), end=datetime.combine(day, obj.hence1))
156 | # multiday
157 | obj.pe.occurrences.create(start=datetime.combine(day, obj.earlier1), end=datetime.combine(day+timedelta(1), obj.hence1))
--------------------------------------------------------------------------------
/eventtools/locale/fr_FR/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2012-07-17 13:13+0200\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 |
20 | #: admin.py:51
21 | msgid "Delete occurrences (and prevent recreation by a repeating occurrence)"
22 | msgstr "Supprimer les occurrences (et empêcher leur recréation par le biais d'une occurrence répétée)"
23 |
24 | #: admin.py:55
25 | msgid "Delete occurrences (but allow recreation by a repeating occurrence)"
26 | msgstr "Supprimer les occurrences (mais autoriser la recréation par le biais d'un occurrence répétée)"
27 |
28 | #: admin.py:63
29 | msgid ""
30 | "Make occurrences one-off (and prevent recreation by a repeating occurrence)"
31 | msgstr "Rendre les occurrences "
32 |
33 | #: admin.py:67
34 | msgid "Make occurrences cancelled"
35 | msgstr "Donner le status 'annulé' aux aoccurrences"
36 |
37 | #: admin.py:71
38 | msgid "Make occurrences fully booked"
39 | msgstr "Donner les statut 'complet' aux occurrences"
40 |
41 | #: admin.py:75
42 | msgid "Clear booked/cancelled status"
43 | msgstr "Supprimer les statuts 'annulé' ou 'complet'"
44 |
45 | #: admin.py:286
46 | msgid ""
47 | "title (items in bold will be listed; other items are templates or variations)"
48 | msgstr ""
49 | "titre (les items en gras seront listés; les autres items sont des gabarits "
50 | "ou des variations)"
51 |
52 | #: admin.py:303
53 | #, fuzzy
54 | msgid "No occurrences yet"
55 | msgstr "occurrences répétées"
56 |
57 | #: admin.py:312
58 | #, fuzzy
59 | msgid "Edit Occurrences"
60 | msgstr "occurrences répétées"
61 |
62 | #: filters.py:9
63 | msgid "type"
64 | msgstr "type"
65 |
66 | #: filters.py:23
67 | msgid "Generated in same event"
68 | msgstr "Généré dans le même évènement"
69 |
70 | #: filters.py:24
71 | msgid "Generated in ancestor event"
72 | msgstr "Généré dans un évènement parent"
73 |
74 | #: filters.py:25
75 | msgid "Generated anywhere"
76 | msgstr "Généré n'importe où"
77 |
78 | #: filters.py:26
79 | msgid "One-off"
80 | msgstr "Unique"
81 |
82 | #: models/event.py:212
83 | msgid "parent"
84 | msgstr "parent"
85 |
86 | #: models/event.py:213
87 | msgid ""
88 | "Which event is this event derived from. Use the 'create a variation' on the "
89 | "parent event to inherit the parent's information."
90 | msgstr ""
91 | "L'évènement duquel cet évènement est dérivé. Utilisez la fonctionnalité "
92 | "'créer une variation' sur l'évènement parent pour hériter de ses informations"
93 |
94 | #: models/event.py:215
95 | msgid "title"
96 | msgstr "titre"
97 |
98 | #: models/event.py:216
99 | msgid "URL name"
100 | msgstr "Permalien"
101 |
102 | #: models/event.py:216
103 | msgid ""
104 | "This is used in the event's URL, and should be unique and unchanging."
105 | msgstr ""
106 | "Ceci est utilisé pour construire l'URL de l'évènement, et doit être unique "
107 | "et constant"
108 |
109 | #: models/event.py:218
110 | msgid "season"
111 | msgstr "saison"
112 |
113 | #: models/event.py:219
114 | msgid ""
115 | "a summary description of when this event is on (e.g. 24 August - 12 "
116 | "September 2012). One will be generated from the occurrences if not "
117 | "provided)"
118 | msgstr ""
119 | "Un résumé de quand cet évènement a lieu (ex. 24 août - 12 septembre 2012). "
120 | "Généré automatiquement si le champ n'est pas rempli"
121 |
122 | #: models/event.py:223
123 | msgid "sessions"
124 | msgstr "sessions"
125 |
126 | #: models/event.py:224
127 | msgid ""
128 | "a detailed description of when sessions are (e.g. 'Tuesdays and "
129 | "Thursdays throughout February, at 10:30am')"
130 | msgstr ""
131 | "une description détaillée de quand les sessions ont lieu (ex. 'Les mardis et "
132 | "jeudis de février, à 10h30'"
133 |
134 | #: models/exclusion.py:15 models/xseason.py:52 models/xtimespan.py:57
135 | msgid "start"
136 | msgstr "début"
137 |
138 | #: models/exclusion.py:20
139 | msgid "repeating occurrence exclusion"
140 | msgstr "exclusion d'occurrences répétées"
141 |
142 | #: models/exclusion.py:21
143 | msgid "repeating occurrence exclusions"
144 | msgstr "exclusions d'occurrences répétées"
145 |
146 | #: models/generator.py:38
147 | msgid "rule"
148 | msgstr "règle"
149 |
150 | #: models/generator.py:41
151 | msgid "repeat until"
152 | msgstr "répéter jusqu'au"
153 |
154 | #: models/generator.py:42
155 | msgid ""
156 | "Occurrences will repeat up to and including this date. If ommitted, the next "
157 | "year's worth of occurrences will be created."
158 | msgstr ""
159 | "Les occurrences se répèterons jusqu'à cette date incluse. En cas "
160 | "d'ommission, des événements seront créés sur un an."
161 |
162 | #: models/generator.py:50
163 | msgid "repeating occurrence"
164 | msgstr "occurrence répétée"
165 |
166 | #: models/generator.py:51
167 | msgid "repeating occurrences"
168 | msgstr "occurrences répétées"
169 |
170 | #: models/occurrence.py:83
171 | msgid "status"
172 | msgstr "status"
173 |
174 | #: models/rule.py:8
175 | msgid "Yearly"
176 | msgstr "Annuel"
177 |
178 | #: models/rule.py:9
179 | msgid "Monthly"
180 | msgstr "Mensuel"
181 |
182 | #: models/rule.py:10
183 | msgid "Weekly"
184 | msgstr "Hebdomadaire"
185 |
186 | #: models/rule.py:11
187 | msgid "Daily"
188 | msgstr "Journalier"
189 |
190 | #: models/rule.py:43
191 | msgid "name"
192 | msgstr "nom"
193 |
194 | #: models/rule.py:44
195 | msgid "a short friendly name for this repetition."
196 | msgstr "un nom court convivial pour cette répétition."
197 |
198 | #: models/rule.py:47
199 | msgid "common rules appear at the top of the list."
200 | msgstr "les règles communes apparaisent en haut de la liste."
201 |
202 | #: models/rule.py:50
203 | msgid "frequency"
204 | msgstr "fréquence"
205 |
206 | #: models/rule.py:51
207 | msgid "the base repetition period."
208 | msgstr "la période de répétition de base."
209 |
210 | #: models/rule.py:54
211 | msgid "inclusion parameters"
212 | msgstr "paramètres d'inclusion"
213 |
214 | #: models/rule.py:55
215 | msgid "extra params required to define this type of repetition."
216 | msgstr ""
217 | "paramètres supplémentaires nécéssaires pour définire ce type de répétition."
218 |
219 | #: models/rule.py:58
220 | msgid "complex rules"
221 | msgstr "règles complexes"
222 |
223 | #: models/rule.py:58
224 | msgid "overrides all other settings."
225 | msgstr "outrepasse tous les autres paramètres"
226 |
227 | #: models/rule.py:63
228 | msgid "repetition rule"
229 | msgstr "règle de répétition"
230 |
231 | #: models/rule.py:64
232 | msgid "repetition rules"
233 | msgstr "règles de répétition"
234 |
235 | #: models/xseason.py:53
236 | msgid "end"
237 | msgstr "fin"
238 |
239 | #: models/xtimespan.py:58
240 | msgid "duration (mins)"
241 | msgstr "durée (minutes)"
242 |
243 | #: models/xtimespan.py:58
244 | msgid ""
245 | "to create 'all day' events, set start time to 00:00 and leave duration blank"
246 | msgstr ""
247 | "pour créer des évènements sur toute la journée, régler l'heure de début sur "
248 | "00:00 et ne pas spécifier de durée"
249 |
250 | #: models/xtimespan.py:151
251 | msgid "all day"
252 | msgstr "toute la journée"
253 |
254 | #: models/xtimespan.py:193
255 | msgid "Today"
256 | msgstr "Aujourd'hui"
257 |
258 | #: models/xtimespan.py:195
259 | msgid "Tomorrow"
260 | msgstr "Demain"
261 |
262 | #: models/xtimespan.py:197
263 | msgid "Yesterday"
264 | msgstr "Hier"
265 |
266 | #: templates/admin/eventtools/event.html:58
267 | #: templates/admin/eventtools/feincmsevent.html:68
268 | msgid "Create a variation of this event"
269 | msgstr "Créer une variation de cet évènement"
270 |
271 | #: templates/admin/eventtools/event.html:59
272 | #: templates/admin/eventtools/feincmsevent.html:69
273 | msgid "View child occurrences"
274 | msgstr "Voir les occurrences répétées"
275 |
276 | #: templates/admin/eventtools/event.html:69
277 | #: templates/admin/eventtools/feincmsevent.html:79
278 | msgid "Variation family"
279 | msgstr "Variation de la famille"
280 |
281 | #: templates/admin/eventtools/occurrence_list.html:9
282 | #, python-format
283 | msgid "Add %(name)s"
284 | msgstr "Ajouter %(name)s"
285 |
286 | #: templates/admin/eventtools/occurrence_list.html:16
287 | #, python-format
288 | msgid ""
289 | "Showing all occurrences of "
290 | "%(root_event)s and its descendants"
291 | msgstr ""
292 | "Montrer toutes les occurrences de "
293 | "%(root_event)s et ses enfants"
294 |
--------------------------------------------------------------------------------
/eventtools/templatetags/calendar.py:
--------------------------------------------------------------------------------
1 | import sys, imp
2 |
3 | pycal = sys.modules.get('calendar')
4 | if not pycal:
5 | pycal = imp.load_module('calendar',*imp.find_module('calendar'))
6 |
7 | import datetime
8 | from dateutil.relativedelta import *
9 | from django import template
10 | from django.template.context import RequestContext
11 | from django.template import TemplateSyntaxError
12 | from django.core.urlresolvers import reverse
13 |
14 | from eventtools.conf import settings as eventtools_settings
15 | from eventtools.models import EventModel, OccurrenceModel
16 |
17 | register = template.Library()
18 |
19 | def DATE_HREF_FACTORY(test_dates=True, dates=[]):
20 | """
21 | If test_dates is True, then URLs will only be returned if the day is in the
22 | dates iterable.
23 |
24 | If test_dates is False, URLs are always returned.
25 | """
26 | def f(day):
27 | """
28 | Given a day, return a URL to navigate to.
29 | """
30 | if (test_dates and day in dates) or (not test_dates):
31 | return reverse('events:on_date', args=(
32 | day.year,
33 | day.month,
34 | day.day,
35 | ))
36 | return None
37 | return f
38 |
39 | def DATE_CLASS_HIGHLIGHT_FACTORY(dates, selected_day):
40 | def f(day):
41 | r = set()
42 | if day == selected_day:
43 | r.add('selected')
44 | if day in dates:
45 | r.add('highlight')
46 | return r
47 | return f
48 |
49 | class DecoratedDate(object):
50 | """
51 | A wrapper for date that has some css classes and a link, to use in rendering
52 | that date in a calendar.
53 | """
54 | def __init__(self, date, href=None, classes=[], data=""):
55 | self.date = date
56 | self.href = href
57 | self.classes = classes
58 | self.data = data
59 |
60 | def __unicode__(self):
61 | if self.href:
62 | return "%s (%s)" % (self.date, self.href)
63 | return unicode(self.date)
64 |
65 | def calendar(
66 | context, day=None,
67 | date_class_fn=None,
68 | date_href_fn=None,
69 | month_href_fn=None,
70 | ):
71 | """
72 | Creates an html calendar displaying one month, where each day has a link and
73 | various classes, followed by links to the previous and next months.
74 |
75 | Arguments:
76 |
77 | context: context from the parent template
78 | day: a date or occurrence defining the month to be displayed
79 | (if it isn't given, today is assumed).
80 | date_class_fn: a function that returns an iterable of CSS classes,
81 | given a date.
82 | date_href_fn: a function that returns the url for a date, given a date
83 | month_href_fn: a function that returns the url for a date, given a date
84 | (which will be the first day of the next and previous
85 | months)
86 |
87 |
88 | Automatic attributes:
89 |
90 | Every day is given the 'data' attribute of the date in ISO form.
91 |
92 | The class 'today' is given to today's date.
93 |
94 | Every day is given the class of the day of the week 'monday' 'tuesday',
95 | etc.
96 |
97 | Leading and trailing days are given the classes 'last_month' and
98 | 'next_month' respectively.
99 |
100 | """
101 |
102 | if date_class_fn is None:
103 | date_class_fn = lambda x: set()
104 |
105 | if date_href_fn is None:
106 | date_href_fn = lambda x: None
107 |
108 | if month_href_fn is None:
109 | month_href_fn = lambda x: None
110 |
111 | today = datetime.date.today()
112 |
113 | if day is None:
114 | day = today
115 | else:
116 | try:
117 | day = day[0]
118 | except TypeError:
119 | pass
120 |
121 | if isinstance(day, OccurrenceModel):
122 | day = day.start.date()
123 |
124 | cal = pycal.Calendar(eventtools_settings.FIRST_DAY_OF_WEEK)
125 | # cal is a list of the weeks in the month of the year as full weeks.
126 | # Weeks are lists of seven dates
127 | weeks = cal.monthdatescalendar(day.year, day.month)
128 |
129 | # Transform into decorated dates
130 | decorated_weeks = []
131 | for week in weeks:
132 | decorated_week = []
133 | for wday in week:
134 | classes = set(date_class_fn(wday))
135 | if wday == today:
136 | classes.add('today')
137 | if wday.month != day.month:
138 | if wday < day:
139 | classes.add('last_month')
140 | if wday > day:
141 | classes.add('next_month')
142 | #day of the week class
143 | classes.add(wday.strftime('%A').lower())
144 | #ISO class
145 | data = wday.isoformat()
146 |
147 | decorated_week.append(
148 | DecoratedDate(
149 | date=wday, href=date_href_fn(wday), classes=classes, data=data,
150 | )
151 | )
152 | decorated_weeks.append(decorated_week)
153 |
154 | prev = day+relativedelta(months=-1)
155 | prev_date = datetime.date(prev.year, prev.month, 1)
156 | decorated_prev_date = DecoratedDate(
157 | date=prev_date, href=month_href_fn(prev_date)
158 | )
159 |
160 | next = day+relativedelta(months=+1)
161 | next_date = datetime.date(next.year, next.month, 1)
162 | decorated_next_date = DecoratedDate(
163 | date=next_date, href=month_href_fn(next_date)
164 | )
165 |
166 |
167 | context.update({
168 | 'weeks': decorated_weeks,
169 | 'prev_month': decorated_prev_date,
170 | 'next_month': decorated_next_date,
171 | })
172 |
173 | return context
174 |
175 |
176 | def nav_calendar(
177 | context, date=None, occurrence_qs=[],
178 | date_href_fn=None,
179 | month_href_fn=None,
180 | date_class_fn=None,
181 | ):
182 | """
183 | Renders a nav calendar for a date, and an optional occurrence_qs.
184 | Dates in the occurrence_qs are given the class 'highlight'.
185 | """
186 |
187 | #TODO: allow dates, not just occurrence_qs
188 | if occurrence_qs:
189 | occurrence_days = [o.start.date() for o in occurrence_qs]
190 | else:
191 | occurrence_days = []
192 |
193 | if date_href_fn is None:
194 | date_href_fn = DATE_HREF_FACTORY(dates=occurrence_days)
195 |
196 | if month_href_fn is None:
197 | month_href_fn = DATE_HREF_FACTORY(test_dates = False)
198 |
199 | if date_class_fn is None:
200 | date_class_fn = DATE_CLASS_HIGHLIGHT_FACTORY(dates=occurrence_days, selected_day = date)
201 |
202 | return calendar(
203 | context, day=date,
204 | date_href_fn=date_href_fn,
205 | date_class_fn=date_class_fn,
206 | month_href_fn=month_href_fn,
207 | )
208 |
209 | def nav_calendars(
210 | context, occurrence_qs=[], selected_occurrence=None,
211 | date_href_fn=None,
212 | date_class_fn=None,
213 | ):
214 | """
215 | Renders several calendars, so as to encompass all dates in occurrence_qs.
216 | These will be folded up into a usable widget with javascript.
217 | """
218 |
219 | #TODO: allow dates, not just occurrence_qs
220 | if date_class_fn is None and occurrence_qs:
221 | occurrence_days = [o.start.date() for o in occurrence_qs]
222 | if selected_occurrence:
223 | date_class_fn = DATE_CLASS_HIGHLIGHT_FACTORY(occurrence_days, selected_occurrence.start.date())
224 | else:
225 | date_class_fn = DATE_CLASS_HIGHLIGHT_FACTORY(occurrence_days, None)
226 |
227 |
228 | calendars = []
229 | if occurrence_qs.count() > 0:
230 | first_date = occurrence_qs[0].start.date()
231 | last_date = occurrence_qs.reverse()[0].start.date()
232 | else:
233 | first_date = last_date = datetime.date.today()
234 | first_month = datetime.date(first_date.year, first_date.month, 1)
235 | month = first_month
236 |
237 | while month <= last_date:
238 | calendars.append(
239 | calendar(
240 | {}, day=month,
241 | date_href_fn=date_href_fn,
242 | date_class_fn=date_class_fn,
243 | )
244 | )
245 | month += relativedelta(months=+1)
246 |
247 |
248 | context.update({
249 | 'calendars': calendars
250 | })
251 | return context
252 |
253 | register.inclusion_tag("eventtools/calendar/calendar.html", takes_context=True)(calendar)
254 | register.inclusion_tag("eventtools/calendar/calendar.html", takes_context=True)(nav_calendar)
255 | register.inclusion_tag("eventtools/calendar/calendars.html", takes_context=True)(nav_calendars)
--------------------------------------------------------------------------------
/eventtools/models/occurrence.py:
--------------------------------------------------------------------------------
1 | from vobject.icalendar import utc
2 |
3 | from django.db import models
4 | from django.conf import settings
5 | from django.core.exceptions import ValidationError
6 | from django.utils.safestring import mark_safe
7 | from django.core.urlresolvers import reverse
8 | from django.db.models import signals
9 | from django.db.models.base import ModelBase
10 | from django.template.defaultfilters import urlencode
11 | from django.utils.dateformat import format
12 | from django.utils.timezone import make_aware, localtime
13 | from django.utils.translation import ugettext as _
14 | from eventtools.models.xtimespan import XTimespanModel, XTimespanQSFN, XTimespanQuerySet, XTimespanManager
15 | from eventtools.conf import settings
16 |
17 | from eventtools.utils import datetimeify, dayify
18 | from eventtools.utils.managertype import ManagerType
19 |
20 | import datetime
21 | from dateutil.tz import gettz
22 |
23 |
24 |
25 |
26 | """
27 | eventtools.utils.dateranges has some handy functions for generating parameters for a query:
28 |
29 | e.g.
30 | from eventtools.utils import dateranges
31 | dateranges.dates_for_week_of(day) # a tuple
32 | dateranges.dates_in_week_of(day) # a generator
33 |
34 | """
35 |
36 | class OccurrenceQSFN(XTimespanQSFN):
37 | """
38 | All the query functions are defined here, so they can be easily introspected
39 | and injected by the OccurrenceManagerType metaclass.
40 | """
41 |
42 | def events(self):
43 | """
44 | Return a queryset corresponding to the events matched by these
45 | occurrences.
46 | """
47 | event_ids = self.values_list('event_id', flat=True).distinct()
48 | return self.model.EventModel()._event_manager.filter(id__in=event_ids)
49 |
50 | def available(self):
51 | return self.filter(status__in=("", None))
52 |
53 | def unavailable(self):
54 | return self.exclude(status="").exclude(status=None)
55 |
56 | def fully_booked(self):
57 | return self.filter(status=settings.OCCURRENCE_STATUS_FULLY_BOOKED[0])
58 |
59 | def cancelled(self):
60 | return self.filter(status=settings.OCCURRENCE_STATUS_CANCELLED[0])
61 |
62 | class OccurrenceQuerySet(XTimespanQuerySet, OccurrenceQSFN):
63 | pass #all the goodness is inherited from OccurrenceQuerySetFN
64 |
65 | class OccurrenceManager(XTimespanManager):
66 | __metaclass__ = ManagerType(OccurrenceQSFN, supertype=XTimespanManager.__metaclass__,)
67 |
68 | def get_query_set(self):
69 | return OccurrenceQuerySet(self.model)
70 |
71 | class OccurrenceModel(XTimespanModel):
72 | """
73 | An abstract model for an event occurrence.
74 |
75 | Implementing subclasses should define an 'event' ForeignKey to an
76 | EventModel subclass. The related_name for the ForeignKey should be
77 | 'occurrences'.
78 |
79 | Implementing subclasses should define a 'generated_by' ForeignKey to a
80 | GeneratorModel subclass. The related_name for the ForeignKey should be
81 | 'occurrences'. In almost all situations, this FK should be optional.
82 |
83 | event = models.Foreignkey(SomeEvent, related_name="occurrences")
84 | generated_by = models.ForeignKey(ExampleGenerator, related_name="occurrences", blank=True, null=True)
85 | """
86 |
87 | status = models.CharField(max_length=20, blank=True, verbose_name=_('status'), choices=settings.OCCURRENCE_STATUS_CHOICES)
88 |
89 | objects = OccurrenceManager()
90 |
91 | class Meta:
92 | abstract = True
93 | ordering = ('start', 'event',)
94 | unique_together = ('start', 'event',)
95 |
96 | def __unicode__(self):
97 | return u"%s: %s" % (self.event, self.timespan_description())
98 |
99 | def get_absolute_url(self):
100 | return reverse('events:occurrence', kwargs={'event_slug': self.event.slug, 'occurrence_pk': self.pk })
101 |
102 | @classmethod
103 | def EventModel(cls):
104 | return cls._meta.get_field('event').rel.to
105 |
106 | def is_exclusion(self):
107 | qs = self.event.exclusions.filter(start=self.start)
108 | if qs.count():
109 | return True
110 | return False
111 |
112 | def delete(self, *args, **kwargs):
113 | try:
114 | r = super(OccurrenceModel, self).delete(*args, **kwargs)
115 | except models.ProtectedError: #can't delete as there is an FK to me. Make one-off..
116 | self.generated_by = None
117 | self.save()
118 |
119 | def is_cancelled(self):
120 | return self.status == settings.OCCURRENCE_STATUS_CANCELLED[0]
121 |
122 | def is_fully_booked(self):
123 | return self.status == settings.OCCURRENCE_STATUS_FULLY_BOOKED[0]
124 |
125 | def status_message(self):
126 | if self.is_cancelled():
127 | if self.is_finished():
128 | iswas = "was"
129 | else:
130 | iswas = "is"
131 | return "This session %s cancelled." % iswas
132 |
133 | if self.is_finished():
134 | return "This session has finished."
135 |
136 | if self.is_fully_booked():
137 | return "This session is fully booked."
138 |
139 | return None
140 |
141 | def _resolve_attr(self, attr):
142 | v = getattr(self, attr, None)
143 | if v is not None:
144 | if callable(v):
145 | v = v()
146 | return v
147 |
148 | def ical_summary(self):
149 | return unicode(self.event)
150 |
151 | def ical_description(self):
152 | """
153 | Try to gracefully fall back through various conventions of descriptive fields
154 | """
155 | if hasattr(self.event, 'mobile_description') and unicode(self.event.mobile_description):
156 | return unicode(self.event.mobile_description)
157 | elif hasattr(self.event, 'teaser'):
158 | if hasattr(self.event.teaser, 'raw'):
159 | return unicode(self.event.teaser.raw)
160 | else:
161 | return unicode(self.event.teaser)
162 |
163 | def as_icalendar(self,
164 | ical,
165 | request,
166 | summary_attr='ical_summary',
167 | description_attr='ical_description',
168 | url_attr='get_absolute_url',
169 | location_attr='venue_description',
170 | latitude_attr='latitude',
171 | longitude_attr='longitude',
172 | cancelled_attr='is_cancelled',
173 | ):
174 | """
175 | Returns the occurrence as an iCalendar object.
176 |
177 | Pass in an iCalendar, and this function will add `self` to it, otherwise it will create a new iCalendar named `calname` described `caldesc`.
178 |
179 | The property parameters passed indicate properties of an Event that return the info to be shown in the ical.
180 |
181 | location_property is the string describing the location/venue.
182 |
183 | Props to Martin de Wulf, Andrew Turner, Derek Willis
184 | http://www.multitasked.net/2010/jun/16/exporting-schedule-django-application-google-calen/
185 |
186 |
187 | """
188 | vevent = ical.add('vevent')
189 |
190 | start = localtime(self.start)
191 | end = localtime(self.end())
192 |
193 | if self.all_day():
194 | vevent.add('dtstart').value = start.date()
195 | vevent.add('dtend').value = end.date()
196 | else:
197 | # Add the timezone specified in the project settings to the event start
198 | # and end datetimes, if they don't have a timezone already
199 | if not start.tzinfo and not end.tzinfo \
200 | and getattr(settings, 'TIME_ZONE', None):
201 | # Since Google Calendar (and probably others) can't handle timezone
202 | # declarations inside ICS files, convert to UTC before adding.
203 | start = start.astimezone(utc)
204 | end = end.astimezone(utc)
205 | vevent.add('dtstart').value = start
206 | vevent.add('dtend').value = end
207 |
208 | cancelled = self._resolve_attr(cancelled_attr)
209 | if cancelled:
210 | vevent.add('method').value = 'CANCEL'
211 | vevent.add('status').value = 'CANCELLED'
212 |
213 | summary = self._resolve_attr(summary_attr)
214 | if summary:
215 | vevent.add('summary').value = summary
216 |
217 | description = self._resolve_attr(description_attr)
218 | if description:
219 | vevent.add('description').value = description
220 |
221 | url = self._resolve_attr(url_attr)
222 | if url:
223 | domain = "".join(('http', ('', 's')[request.is_secure()], '://', request.get_host()))
224 | vevent.add('url').value = "%s%s" % (domain, url)
225 |
226 | location = self._resolve_attr(location_attr)
227 | if location:
228 | vevent.add('location').value = location
229 |
230 | lat = self._resolve_attr(latitude_attr)
231 | lon = self._resolve_attr(longitude_attr)
232 | if lat and lon:
233 | vevent.add('geo').value = "%s;%s" % (lon, lat)
234 |
235 | return ical
236 |
237 | def ical_url(self):
238 | # Needs to be fully-qualified (for sending to calendar apps)
239 | return settings.ICAL_ROOT_URL + reverse("events:occurrence_ical", args=[self.event.slug, self.pk])
240 |
241 | def webcal_url(self):
242 | return self.ical_url().replace("http://", "webcal://").replace("https://", "webcal://")
243 |
244 | def gcal_url(self):
245 | return "http://www.google.com/calendar/render?cid=%s" % urlencode(self.ical_url())
246 |
--------------------------------------------------------------------------------
/eventtools/tests/models/event.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from eventtools.tests._inject_app import TestCaseWithApp as AppTestCase
3 | from eventtools.tests.eventtools_testapp.models import *
4 | from eventtools.tests._fixture import fixture
5 | from datetime import date, time, datetime, timedelta
6 | from eventtools.tests._fixture import bigfixture, reload_films
7 | from eventtools.utils import dateranges
8 |
9 | class TestEvents(AppTestCase):
10 |
11 | def setUp(self):
12 | super(TestEvents, self).setUp()
13 | fixture(self)
14 |
15 | def test_creation(self):
16 |
17 | """
18 | When you create an TestEventModel,
19 | you need to create an ExampleOccurrence class with a field 'event' that FKs to event.
20 |
21 | TestOccurrences are sorted by start (then end) by default.
22 |
23 | """
24 | self.assertTrue(hasattr(ExampleEvent, 'occurrences'))
25 | self.assertTrue(hasattr(ExampleOccurrence, 'event'))
26 |
27 | #test sorting
28 | occs = ExampleOccurrence.objects.all()
29 | x = occs[0].start
30 | for o in occs:
31 | self.assertTrue(o.start >= x)
32 | x= o.start
33 |
34 | def test_occurrence_relation(self):
35 | """
36 | You can query the occurrences for a single event by date(datetime) range etc.
37 | e.occurrences.filter(status='cancelled')
38 | e.occurrences.all().between(dt1, dt2)
39 | """
40 | talks = self.talk.occurrences.all()
41 | self.ae(len(talks), 3)
42 |
43 | talks = self.talk.occurrences.filter(status='cancelled')
44 | self.ae(len(talks), 1)
45 |
46 | #day range
47 | talks = self.talk.occurrences.between(self.day1, self.day2)
48 | self.ae(len(talks), 3)
49 |
50 | #before and after
51 | talks = self.talk.occurrences.before(self.day1)
52 | self.ae(len(talks), 2)
53 | talks = self.talk.occurrences.after(self.day2)
54 | self.ae(len(talks), 1)
55 |
56 | #one day is allowed
57 | talks1 = self.talk.occurrences.on(self.day1)
58 | self.ae(len(talks1), 2)
59 | # and it's the same as passing the same day into the range.
60 | talks2 = self.talk.occurrences.between(self.day1, self.day1)
61 | self.ae(list(talks1), list(talks2))
62 |
63 | #combining queries
64 | talks = self.talk.occurrences.filter(status='cancelled').between(self.day1, self.day2)
65 | self.ae(len(talks), 1)
66 |
67 | # hour range
68 | morningstart = datetime.combine(self.day1, time.min)
69 | morningend = datetime.combine(self.day1, time(12,00))
70 | talks = self.talk.occurrences.between(morningstart, morningend)
71 | self.ae(len(talks), 1)
72 |
73 | def test_occurrences_from_events(self):
74 | """
75 | You can query occurrences for an event queryset, including by date range etc.
76 |
77 | TODO: start queries are covered in tests; ends and entirely queries are not.
78 |
79 | You can get the opening and closing occurrence for an event:
80 |
81 | """
82 | all_occs = ExampleEvent.eventobjects.occurrences()
83 | self.ae(list(all_occs), list(ExampleOccurrence.objects.all()))
84 |
85 | #opening and closing
86 | self.ae(self.performance.opening_occurrence(), self.performance_evening)
87 | self.ae(self.performance.closing_occurrence(), self.performance_day_after_tomorrow)
88 |
89 | # The bigfixture takes ages.
90 | # def test_advanced_queries(self):
91 | # """
92 | # There are shortcut occurrence queries, which define date range relative to the current day.
93 | #
94 | # Weeks can start on sunday (6), monday (0), etc. Weekends can be any set of days (some sites include fridays). These are defined in settings.
95 | # """
96 | #
97 | # #create a huge fixture of occurrences for event self.pe
98 | # bigfixture(self)
99 | #
100 | # num_per_day = 5 #how many events we generate each day
101 | #
102 | # peo = self.pe.occurrences
103 | #
104 | # #forthcoming and recent
105 | # forthcoming = peo.forthcoming()
106 | # recent = peo.recent()
107 | #
108 | # # self.ae(recent.count(), 4696)
109 | # # self.ae(forthcoming.count(), 1514)
110 | #
111 | # dtnow = datetime.now()
112 | #
113 | # for o in forthcoming:
114 | # self.assertTrue(o.start > dtnow)
115 | #
116 | # for o in recent:
117 | # self.assertTrue(o.end < dtnow)
118 | #
119 | # on = peo.starts_on(self.todaynow)
120 | # # 5 events * 5 or 6 ranges
121 | # if dateranges.is_weekend(self.todaynow):
122 | # self.ae(on.count(), 30)
123 | # else:
124 | # self.ae(on.count(), 25)
125 | #
126 | # # test in a few days when it prob won't be the weekend
127 | # on = peo.starts_on(self.todaynow+timedelta(5))
128 | # # 5 events * 4 or 5 ranges (no today)
129 | # if dateranges.is_weekend(self.todaynow+timedelta(5)):
130 | # self.ae(on.count(), 25)
131 | # else:
132 | # self.ae(on.count(), 20)
133 | #
134 | # week = peo.starts_in_week_of(self.todaynow+timedelta(365))
135 | # # in next year. Only 7 * 5 event
136 | # self.ae(week.count(), 35)
137 |
138 | def test_qs_occurrences(self):
139 | """
140 | You can query ExampleEvent to find only those events that are opening or closing.
141 |
142 | A closing event is defined as the last occurrence start (NOT the last occurrence end, which would be less intuitive for users)
143 |
144 | In trees of events, the latest/earliest in an occurrence's children are
145 | the opening/closing event.
146 |
147 | """
148 |
149 | o = ExampleEvent.eventobjects.opening_occurrences()
150 | o2 = [a.opening_occurrence() for a in ExampleEvent.eventobjects.all()]
151 | self.ae(set(o), set(o2))
152 |
153 | o = ExampleEvent.eventobjects.closing_occurrences()
154 | o2 = [a.closing_occurrence() for a in ExampleEvent.eventobjects.all()]
155 | self.ae(set(o), set(o2))
156 |
157 | def test_change_cascade(self):
158 | """
159 | TestEvents are in an mptt tree, which indicates parents (more general) and children (more specific).
160 | When you save a parent event, every changed field cascades to all children events (and not to parent events).
161 | If the child event has a different value to the original, then the change doesn't cascade.
162 | """
163 | self.ae(self.film.get_descendant_count(), 3)
164 | self.ae(set(self.film.get_descendants(include_self=True)), set([self.film, self.film_with_talk, self.film_with_talk_and_popcorn, self.film_with_popcorn]))
165 |
166 | self.film.title = "Irish fillum night"
167 | self.film.save()
168 |
169 | # reload everything
170 | reload_films(self)
171 |
172 | self.ae(self.film_with_talk.title, "Irish fillum night")
173 | self.ae(self.film_with_talk_and_popcorn.title, "Irish fillum night")
174 | self.ae(self.film_with_popcorn.title, "Irish fillum night")
175 |
176 | self.film_with_talk.title = "Ireland's best films (with free talk)"
177 | self.film_with_talk.save()
178 | # reload everything
179 | reload_films(self)
180 |
181 | self.ae(self.film.title, "Irish fillum night")
182 | self.ae(self.film_with_talk_and_popcorn.title, "Ireland's best films (with free talk)")
183 |
184 | #put it all back
185 | self.film.title = self.film_with_talk.title = "Film Night"
186 | self.film.save()
187 | self.film_with_talk.save()
188 |
189 | # reload everything
190 | reload_films(self)
191 |
192 | def test_diffs(self):
193 | self.ae(unicode(self.film), u'Film Night')
194 | self.ae(unicode(self.film_with_talk), u'Film Night (director\'s talk)')
195 | """
196 | DONE BUT NO TESTS: When you view an event, the diff between itself and its parent is shown, or fields are highlighted, etc, see django-moderation.
197 | """
198 |
199 | def test_times_description(self):
200 | """
201 | Testing the correct formatting and logic for Event.times_description, which tries to infer a regular starting
202 | time from an event's occurrences.
203 | """
204 | d1 = date(2010,1,1)
205 | d2 = date(2010,1,2)
206 | t1 = time(9,00)
207 | t2 = time(11,00)
208 |
209 | e = ExampleEvent.eventobjects.create(title="event with one occurrence")
210 | e.occurrences.create(start=datetime.combine(d1, t1), _duration=25*60)
211 | self.ae(e.times_description(), "9.00am")
212 |
213 | e = ExampleEvent.eventobjects.create(title="event with two occurrences on the same day, with different starting times")
214 | e.occurrences.create(start=datetime.combine(d1, t1), _duration=25*60)
215 | e.occurrences.create(start=datetime.combine(d1, t2), _duration=25*60)
216 | self.ae(e.times_description(), "Times vary")
217 |
218 | e = ExampleEvent.eventobjects.create(title="event with two occurrences on two days, with similar starting times")
219 | e.occurrences.create(start=datetime.combine(d1, t1), _duration=25*60)
220 | e.occurrences.create(start=datetime.combine(d2, t1), _duration=25*60)
221 | self.ae(e.times_description(), "9.00am")
222 |
223 | e = ExampleEvent.eventobjects.create(title="event with two occurrences on two days, with different starting times")
224 | e.occurrences.create(start=datetime.combine(d1, t1), _duration=25*60)
225 | e.occurrences.create(start=datetime.combine(d2, t2), _duration=25*60)
226 | self.ae(e.times_description(), "Times vary")
227 |
228 |
229 |
--------------------------------------------------------------------------------
/eventtools/tests/views.py:
--------------------------------------------------------------------------------
1 | # # -*- coding: utf-8“ -*-
2 | # from datetime import date, time, datetime, timedelta
3 | # from dateutil.relativedelta import relativedelta
4 | #
5 | # from django.conf import settings
6 | # from django.core.urlresolvers import reverse
7 | # from django.test import TestCase
8 | #
9 | # from eventtools.utils import datetimeify
10 | # from eventtools_testapp.models import *
11 | #
12 | # from _fixture import bigfixture, reload_films
13 | # from _inject_app import TestCaseWithApp as AppTestCase
14 | #
15 | # class TestViews(AppTestCase):
16 | #
17 | # def setUp(self):
18 | # if hasattr(settings, 'OCCURRENCES_PER_PAGE'):
19 | # self._old_OCCURRENCES_PER_PAGE = settings.OCCURRENCES_PER_PAGE
20 | # settings.OCCURRENCES_PER_PAGE = 20
21 | # super(TestViews, self).setUp()
22 | #
23 | # def tearDown(self):
24 | # if hasattr(self, '_old_OCCURRENCES_PER_PAGE'):
25 | # settings.OCCURRENCES_PER_PAGE = self._old_OCCURRENCES_PER_PAGE
26 | # else:
27 | # delattr(settings, 'OCCURRENCES_PER_PAGE')
28 | # super(TestViews, self).tearDown()
29 | #
30 | # def test_purls(self):
31 | # """
32 | # An occurrence has a pURL based on its id.
33 | # You can view a page for an occurrence.
34 | # """
35 | #
36 | # e = self.daily_tour
37 | # o = e.occurrences.all()[0]
38 | #
39 | # #occurrence page
40 | # ourl = reverse('occurrence', args=(o.id,))
41 | # self.assertEqual(o.get_absolute_url(), ourl)
42 | # self.assertTrue(str(o.id) in ourl)
43 | # r1 = self.client.get(ourl)
44 | # self.assertEqual(r1.status_code, 200)
45 | #
46 | # self.assertContains(r1, "Daily Tour")
47 | # self.assertContains(r1, "1 January 2010")
48 | # self.assertNotContains(r1, "00:00")
49 | # self.assertNotContains(r1, "12am")
50 | # self.assertNotContains(r1, "midnight")
51 | #
52 | # e2 = self.weekly_talk
53 | # ourl = reverse('occurrence', args=(e2.occurrences.all()[0].id,))
54 | # r1 = self.client.get(ourl)
55 | # self.assertContains(r1, "Weekly Talk")
56 | # self.assertContains(r1, "1 January 2010, 10am–noon")
57 | #
58 | # def test_list_view(self):
59 | # """
60 | # You can view a paginated list of occurrences for an event qs, following a given day, using ?startdate=2010-10-22&page=2.
61 | # Each page shows n=20 occurrences and paginates by that amount.
62 | # The occurrences are in chronological order.
63 | # The times of all-day events do not appear.
64 | # If there are no events in a given day, the day is not shown.
65 | # The occurrences are grouped by day (and thus a day's occurrences may span several pages - this makes computation easier).
66 | # TODO if a day is unfinished, show 'more on page n+1'..
67 | # If there are no events in a given page, a 'no events match' message is shown.
68 | # """
69 | # url = reverse('occurrence_list',)
70 | # r = self.client.get(url, {'startdate':'2010-01-01'})
71 | # self.assertEqual(r.context['occurrence_pool'].count(), 109)
72 | # self.assertEqual(len(r.context['occurrence_page']), 20)
73 | # self.assertEqual(r.context['occurrence_page'][0].start.date(), date(2010,1,1))
74 | #
75 | # #check results in chrono order
76 | # d = r.context['occurrence_pool'][0].start
77 | # for occ in r.context['occurrence_pool']:
78 | # self.assertTrue(occ.start >= d)
79 | # d = occ.start
80 | #
81 | # #should have some pagination (6 pages)
82 | # self.assertNotContains(r, "Earlier") #it's the first page
83 | # self.assertContains(r, "Later")
84 | # self.assertContains(r, "Showing 1–20 of 109")
85 | #
86 | # self.assertContains(r, "Friday, 1 January 2010", 1) #only print the date once
87 | # self.assertNotContains(r, "Saturday, 2 January 2010") #there are no events
88 | # self.assertContains(r, "Sunday, 3 January 2010", 1) #only print the date once
89 | #
90 | # self.assertContains(r, "10am–noon")
91 | # self.assertNotContains(r, "12am")# these are all-day
92 | # self.assertNotContains(r, "00:00")# these are all-day
93 | # self.assertNotContains(r, "midnight") # these are all-day
94 | #
95 | # #doesn't matter how far back you go.
96 | # r2 = self.client.get(url, {'startdate':'2000-01-01'})
97 | # self.assertEqual(list(r.context['occurrence_pool']), list(r2.context['occurrence_pool']))
98 | #
99 | # #links
100 | # o = r.context['occurrence_page'][0]
101 | # ourl = reverse('occurrence', args=(o.id,))
102 | # self.assertContains(r, ourl)
103 | #
104 | # #show a 'not found' message
105 | # r = self.client.get(url, {'startdate':'2020-01-01'})
106 | # self.assertEqual(r.context['occurrence_page'].count(), 0)
107 | # self.assertContains(r, "Sorry, no events were found")
108 | # self.assertNotContains(r, "Earlier")
109 | # self.assertNotContains(r, "Later")
110 | # self.assertNotContains(r, "Showing")
111 | # self.assertEqual(r.status_code, 200) #not 404
112 | #
113 | #
114 | # def test_date_range_view(self):
115 | # """
116 | # You can show all occurrences between two days on one page, by adding ?enddate=2010-10-24. Pagination adds or subtracts the difference in days (+1 - consider a single day) to the range.
117 | # For some ranges, pagination is by a different amount:
118 | # TODO: Precisely a month (paginate by month)
119 | # TODO: Precisely a year (paginate by year)
120 | # """
121 | #
122 | # url = reverse('occurrence_list',)
123 | # r = self.client.get(url, {'startdate':'2010-01-01', 'enddate':'2010-01-05'})
124 | # self.assertEqual(r.context['occurrence_pool'].count(), 109)
125 | # self.assertEqual(len(r.context['occurrence_page']), 5)
126 | # self.assertEqual(r.context['occurrence_page'][0].start.date(), date(2010,1,1))
127 | # self.assertEqual(r.context['occurrence_page'].reverse()[0].start.date(), date(2010,1,5))
128 | #
129 | # self.assertContains(r, "Showing 1–5 January 2010")
130 | # self.assertContains(r, 'Earlier ')
131 | # self.assertContains(r, 'Later ')
132 | #
133 | # r = self.client.get(url, {'startdate':'2010-01-01', 'enddate':'2010-01-31'})
134 | # self.assertContains(r, "Showing January 2010")
135 | # # self.assertContains(r, 'December 2009 ')
136 | # # self.assertContains(r, 'February 2010 ')
137 | #
138 | # def test_event_view(self):
139 | # """
140 | # You can view a paginated list of occurrences for an event.
141 | # """
142 | # #event page
143 | # e = self.daily_tour
144 | # eurl = reverse('event', kwargs={'event_slug': e.slug})
145 | # self.assertEqual(e.get_absolute_url(), eurl)
146 | # r3 = self.client.get(eurl, {'page': 2})
147 | # self.assertEqual(r3.status_code, 200)
148 | #
149 | # #should have some pagination (3 pages)
150 | # self.assertEqual(r3.context['occurrence_page'].count(), 20)
151 | # self.assertContains(r3, "Earlier")
152 | # self.assertContains(r3, "Later")
153 | # self.assertContains(r3, "Showing 21–40 of 49")
154 | #
155 | # def test_ical(self):
156 | # """
157 | # You can view an ical for an occurrence.
158 | # The ical is linked from the occurrence page.
159 | # You can view an ical for a collection of occurrences.
160 | # (TODO: do large icals perform well? If not we might have to make it a feed.)
161 | # """
162 | # e = self.daily_tour
163 | # o = e.occurrences.all()[0]
164 | #
165 | # o_url = reverse('occurrence', kwargs={'occurrence_id': o.id })
166 | # o_ical_url = reverse('occurrence_ical', kwargs={'occurrence_id': o.id })
167 | # r = self.client.get(o_ical_url)
168 | # self.assertEqual(r.status_code, 200)
169 | #
170 | # self.assertContains(r, "BEGIN:VCALENDAR", 1)
171 | # self.assertContains(r, "BEGIN:VEVENT", 1)
172 | #
173 | # self.assertContains(r, "SUMMARY:Daily Tour", 1)
174 | # self.assertContains(r, "DTSTART;VALUE=DATE:20100101", 1)
175 | # self.assertContains(r, "DTEND;VALUE=DATE:20100101", 1)
176 | # self.assertContains(r, "URL:http://testserver%s" % o_url, 1)
177 | # # etc.
178 | #
179 | # #Multiple occurrences
180 | # e_ical_url = reverse('event_ical', kwargs={'event_slug': e.slug })
181 | # r = self.client.get(e_ical_url)
182 | # self.assertEqual(r.status_code, 200)
183 | #
184 | # self.assertContains(r, "BEGIN:VCALENDAR", 1)
185 | # self.assertContains(r, "BEGIN:VEVENT", 49)
186 | # self.assertContains(r, "SUMMARY:Daily Tour", 49)
187 | # self.assertContains(r, "DTSTART;VALUE=DATE:20100101", 1)
188 | # self.assertContains(r, "DTEND;VALUE=DATE:20100101", 1)
189 | #
190 | # def test_hcal(self):
191 | # """
192 | # The occurrence page uses hCalendar microformat.
193 | # The occurrence listing page uses hCalendar microformat.
194 | # """
195 | #
196 | # def test_feeds(self):
197 | # """
198 | # You can view an RSS feed for an iterable of occurrences.
199 | # """
200 | #
201 | # """
202 | # CALENDAR
203 | #
204 | # A template tag shows a calendar of eventoccurrences in a given month.
205 | #
206 | # Calendar's html gives classes for 'today', 'date selection', 'has_events', 'no_events', 'prev_month' 'next_month'.
207 | #
208 | # Calendar optionally shows days.
209 | #
210 | # Calendar optionally hides leading or trailing empty weeks.
211 | #
212 | # Calendar can optionally navigate to prev/next months, which set a start_date to the 1st of the next month.
213 | #
214 | #
215 | #
216 | # API (TODO)
217 | #
218 | # """
--------------------------------------------------------------------------------
/eventtools/models/generator.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | from django.db import models, transaction
5 | from django.db.models.base import ModelBase
6 | from django.utils.translation import ugettext, ugettext_lazy as _
7 | from django.utils.timezone import get_current_timezone, localtime
8 | from django.core import exceptions
9 |
10 | from dateutil import rrule
11 | from eventtools.models.xtimespan import XTimespanModel
12 |
13 | from eventtools.conf import settings
14 | from eventtools.utils.pprint_timespan import (
15 | pprint_datetime_span, pprint_date_span)
16 |
17 | from datetime import date, time, datetime, timedelta
18 |
19 | class GeneratorModel(XTimespanModel):
20 | """
21 | Stores information about repeating Occurrences, and generates them,
22 | unless they already exist, or match an Exception.
23 |
24 | The public API is quite simple:
25 |
26 | save() generates Occurrences.
27 |
28 | clean() makes sure the Generator has valid values (and is called by admin
29 | before the instance is saved)
30 |
31 | robot_description() attempts to provide an English description of this
32 | generator. It's not great at the moment and might be replaced or deprecated
33 | in favour of a hand-written description in the Event.
34 |
35 | EventModel() returns the Model of the Event that this Generator links to.
36 | """
37 |
38 | #define a FK called 'event' in the subclass
39 | rule = models.ForeignKey("eventtools.Rule", verbose_name=_('rule'))
40 | repeat_until = models.DateField(
41 | null=True, blank = True,
42 | verbose_name=_('repeat until'),
43 | help_text=_(u"Occurrences will repeat up to and including this date. If ommitted, the next year's worth of "
44 | "occurrences will be created."
45 | )
46 | )
47 |
48 | class Meta:
49 | abstract = True
50 | ordering = ('start',)
51 | verbose_name = _("repeating occurrence")
52 | verbose_name_plural = _("repeating occurrences")
53 |
54 | def __unicode__(self):
55 | return u"%s, %s" % (self.event, self.robot_description())
56 |
57 | @classmethod
58 | def EventModel(cls):
59 | return cls._meta.get_field('event').rel.to
60 |
61 | def clean(self, ExceptionClass=exceptions.ValidationError):
62 | super(GeneratorModel, self).clean()
63 | if not self.rule_id:
64 | raise ExceptionClass('A Rule must be given')
65 |
66 | if self.start and self.repeat_until and self.repeat_until < self.start.date():
67 | raise ExceptionClass(
68 | 'Repeat until date must not be earlier than start date')
69 |
70 | self.is_clean = True
71 |
72 | @transaction.commit_on_success()
73 | def save(self, *args, **kwargs):
74 | """
75 | Generally (and for a combination of field changes), we take a
76 | two-pass approach:
77 |
78 | 1) First update existing occurrences to match update-compatible fields.
79 | 2) Then synchronise the candidate occurrences with the existing
80 | occurrences.
81 | * For candidate occurrences that exist, do nothing.
82 | * For candidate occurrences that do not exist, add them.
83 | * For existing occurrences that are not candidates, unhook them from
84 | the generator.
85 |
86 | Finally, we also update other generators, because they might have had
87 | clashing occurrences which no longer clash.
88 | """
89 |
90 | cascade = kwargs.pop('cascade', True)
91 |
92 | if not getattr(self, 'is_clean', False):
93 | # if we're saving directly, the ModelForm clean isn't called, so
94 | # we do it here.
95 | self.clean(ExceptionClass=AttributeError)
96 |
97 | # Occurrences updates/generates
98 | if self.pk:
99 | self._update_existing_occurrences() # need to do this before save, so we can detect changes
100 | r = super(GeneratorModel, self).save(*args, **kwargs)
101 | self._sync_occurrences() #need to do this after save, so we have a pk to hang new occurrences from.
102 |
103 | # finally, we should also update other generators, because they might
104 | # have had clashing occurrences
105 | if cascade:
106 | for generator in self.event.generators.exclude(pk=self.pk):
107 | generator.save(cascade=False)
108 |
109 | return r
110 |
111 | def _generate_dates(self):
112 | drop_dead_date = datetime.combine(self.repeat_until or date.today() \
113 | + settings.DEFAULT_GENERATOR_LIMIT, time.max)
114 |
115 | # We may need a timezone-aware datetime if our rule generates
116 | # non-naive datetime occurrences
117 | drop_dead_date_with_tzinfo = drop_dead_date.replace(
118 | tzinfo=get_current_timezone())
119 |
120 | # Yield rule's occurrence datetimes up until "drop dead" date(time)
121 | rule = self.rule.get_rrule(dtstart=localtime(self.start))
122 | date_iter = iter(rule)
123 | while True:
124 | d = date_iter.next()
125 | if d.tzinfo:
126 | dddate = drop_dead_date_with_tzinfo
127 | else:
128 | dddate = drop_dead_date
129 | if d > dddate:
130 | break
131 | yield d
132 |
133 | @transaction.commit_on_success()
134 | def _update_existing_occurrences(self):
135 | """
136 | When you change a generator and save it, it updates existing occurrences
137 | according to the following rules:
138 |
139 | Generally, if we can't automatically delete occurrences, we unhook them
140 | from the generator, and make them one-off. This is to prevent losing
141 | information like tickets sold or shout-outs (we leave implementors to
142 | decide the workflow in these cases). We want to minimise the number of
143 | events that are deleted or unhooked, however. So:
144 |
145 | * If start time or duration is changed, then no occurrences are
146 | added or removed - we timeshift all occurrences. We assume that
147 | visitors/ticket holders are alerted to the time change elsewhere.
148 |
149 | * If other fields are changed - repetition rule, repeat_until, start
150 | date - then there is a chance that Occurrences will be added or
151 | removed.
152 |
153 | * Occurrences that are added are fine, they are added in the normal
154 | way.
155 |
156 | * Occurrences that are removed are deleted or unhooked, for reasons
157 | described above.
158 | """
159 |
160 | """
161 | Pass 1)
162 | if start date or time is changed:
163 | update the start times of my occurrences
164 | if end date or time is changed:
165 | update the end times of my occurrences
166 |
167 | Pass 2 is in _sync_occurrences, below.
168 | """
169 |
170 | # TODO: it would be ideal to minimise the consequences of shifting one
171 | # occurrence to replace another - ie to leave most occurrences untouched
172 | # and to create only new ones and unhook ungenerated ones.
173 | # I tried this by using start date (which is unique per generator) as
174 | # a nominal 'key', but it gets fiddly when you want to vary the end
175 | # date to before the old start date. For now we'll just update the dates
176 | # and times.
177 |
178 | saved_self = type(self).objects.get(pk=self.pk)
179 |
180 | start_shift = self.start - saved_self.start
181 | duration_changed = self._duration != saved_self._duration
182 |
183 | if start_shift or duration_changed:
184 | # Update occurrences in opposite direction to the adjustment of the
185 | # 'start' field, to avoid updating an occurrence to clash with an
186 | # existing one's (event_id, start) DB uniqueness constraint (#606)
187 | if start_shift.total_seconds() >= 0:
188 | start_order_by = '-start' # Moving to future, start from latest
189 | else:
190 | start_order_by = 'start' # Moving to past, start from earliest
191 |
192 | for o in self.occurrences.order_by(start_order_by):
193 | o.start += start_shift
194 | o._duration = self._duration
195 | o.save()
196 |
197 |
198 | @transaction.commit_on_success()
199 | def _sync_occurrences(self):
200 |
201 | """
202 | Pass 2)
203 |
204 | Generate a list of candidate occurrences.
205 | * For candidate occurrences that exist, do nothing.
206 | * For candidate occurrences that do not exist, add them.
207 | * For existing occurrences that are not candidates, delete them, or unhook them from the
208 | generator if they are protected by a Foreign Key.
209 |
210 | In detail:
211 | Get a list, A, of already-generated occurrences.
212 |
213 | Generate candidate Occurrences.
214 | For each candidate Occurrence:
215 | if it exists for the event:
216 | if I created it, unhook, and remove from the list A.
217 | else do nothing
218 | if it is an exclusion, do nothing
219 | otherwise create it.
220 |
221 | The items remaining in list A are 'orphan' occurrences, that were
222 | previously generated, but would no longer be. These are unhooked from
223 | the generator.
224 | """
225 |
226 | all_occurrences = self.event.occurrences_in_listing().all() #regardless of generator
227 | existing_but_not_regenerated = set(self.occurrences.all()) #generated by me only
228 |
229 | for start in self._generate_dates():
230 | # if the proposed occurrence exists, then don't make a new one.
231 | # However, if it belongs to me:
232 | # and if it is marked as an exclusion:
233 | # do nothing (it will later get deleted/unhooked)
234 | # else:
235 | # remove it from the set of existing_but_not_regenerated
236 | # occurrences so it stays hooked up
237 |
238 | try:
239 | o = all_occurrences.filter(start=start)[0]
240 | if o.generated_by == self:
241 | if not o.is_exclusion():
242 | existing_but_not_regenerated.discard(o)
243 | continue
244 | except IndexError:
245 | # no occurrence exists yet.
246 | pass
247 |
248 | # if the proposed occurrence is an exclusion, don't save it.
249 | if self.event.exclusions.filter(
250 | event=self.event, start=start
251 | ).count():
252 | continue
253 |
254 | #OK, we're good to create the occurrence.
255 | o = self.occurrences.create(event=self.event, start=start, _duration=self._duration)
256 | # print "created %s" % o
257 | #implied generated_by = self
258 |
259 | # Finally, delete any unaccounted_for occurrences. If we can't delete, due to protection set by FKs to it, then
260 | # unhook it instead.
261 | for o in existing_but_not_regenerated:
262 | # print "deleting %s" % o
263 | o.delete()
264 |
265 | def delete(self, *args, **kwargs):
266 | """
267 | If I am deleted, then cascade to my Occurrences, UNLESS there is is something FKed to them that is protecting them,
268 | in which case the FK is set to NULL.
269 | """
270 | for o in self.occurrences.all():
271 | o.delete()
272 |
273 | super(GeneratorModel,self).delete(*args, **kwargs)
274 |
275 | def robot_description(self):
276 | r = "%s, repeating %s" % (
277 | pprint_datetime_span(localtime(self.start), localtime(self.end())),
278 | unicode(self.rule).lower(),
279 | )
280 |
281 | if self.repeat_until:
282 | r += " until %s" % pprint_date_span(self.repeat_until, self.repeat_until)
283 |
284 | return r
285 |
--------------------------------------------------------------------------------
/eventtools/admin.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import django
4 | from django import forms
5 | from eventtools.conf import settings
6 | from django.conf.urls.defaults import patterns, url
7 | from django.contrib import admin, messages
8 | from django.core import validators
9 | from django.core.exceptions import ValidationError
10 | from django.core.urlresolvers import reverse
11 | from django.db import models
12 | from django.http import QueryDict
13 | from django.shortcuts import get_object_or_404, redirect
14 | from django.forms.models import BaseInlineFormSet
15 | from mptt.forms import TreeNodeChoiceField
16 | from mptt.admin import MPTTModelAdmin
17 | from django.utils.translation import ugettext, ugettext_lazy as _
18 | from django.template.defaultfilters import date, time
19 |
20 | from utils.diff import generate_diff
21 |
22 | from .models import Rule
23 |
24 | import django
25 | if django.VERSION[0] == 1 and django.VERSION[1] >= 4:
26 | DJANGO14 = True
27 | else:
28 | DJANGO14 = False
29 |
30 | if DJANGO14:
31 | from .filters import IsGeneratedListFilter #needs django 1.4
32 |
33 | MPTT_ADMIN_LEVEL_INDENT = getattr(settings, 'MPTT_ADMIN_LEVEL_INDENT', 10)
34 |
35 |
36 | class TreeModelChoiceField(forms.ModelChoiceField):
37 | """ ModelChoiceField which displays depth of objects within MPTT tree. """
38 | def label_from_instance(self, obj):
39 | super_label = \
40 | super(TreeModelChoiceField, self).label_from_instance(obj)
41 | return u"%s%s" % ("-"*obj.level, super_label)
42 |
43 |
44 | # ADMIN ACTIONS
45 | def _remove_occurrences(modeladmin, request, queryset):
46 | for m in queryset:
47 | # if the occurrence was generated, then add it as an exclusion.
48 | if m.generated_by is not None:
49 | m.event.exclusions.get_or_create(start=m.start)
50 | m.delete()
51 | _remove_occurrences.short_description = _("Delete occurrences (and prevent recreation by a repeating occurrence)")
52 |
53 | def _wipe_occurrences(modeladmin, request, queryset):
54 | queryset.delete()
55 | _wipe_occurrences.short_description = _("Delete occurrences (but allow recreation by a repeating occurrence)")
56 |
57 | def _convert_to_oneoff(modeladmin, request, queryset):
58 | for m in queryset:
59 | # if the occurrence was generated, then add it as an exclusion.
60 | if m.generated_by is not None:
61 | m.event.exclusions.get_or_create(start=m.start)
62 | queryset.update(generated_by=None)
63 | _convert_to_oneoff.short_description = _("Make occurrences one-off (and prevent recreation by a repeating occurrence)")
64 |
65 | def _cancel(modeladmin, request, queryset):
66 | queryset.update(status=settings.OCCURRENCE_STATUS_CANCELLED[0])
67 | _cancel.short_description = _("Make occurrences cancelled")
68 |
69 | def _fully_booked(modeladmin, request, queryset):
70 | queryset.update(status=settings.OCCURRENCE_STATUS_FULLY_BOOKED[0])
71 | _fully_booked.short_description = _("Make occurrences fully booked")
72 |
73 | def _clear_status(modeladmin, request, queryset):
74 | queryset.update(status="")
75 | _clear_status.short_description = _("Clear booked/cancelled status")
76 |
77 | class OccurrenceAdminForm(forms.ModelForm):
78 | def __init__(self, *args, **kwargs):
79 | super(OccurrenceAdminForm, self).__init__(*args, **kwargs)
80 | EventModel = self.instance.EventModel()
81 | self.fields['event'] = TreeModelChoiceField(EventModel.objects)
82 |
83 | event = self.instance.event
84 | if event:
85 | if self.instance.generated_by:
86 | #generated_by events are limited to children of the generator event
87 | #(otherwise syncing breaks). TODO: make syncing look at ancestors as well?
88 | self.fields['event'].queryset = self.instance.generated_by.event.get_descendants(include_self=True)
89 | else:
90 | self.fields['event'].queryset = \
91 | event.get_descendants(include_self = True) | \
92 | event.get_ancestors() | \
93 | event.get_siblings()
94 |
95 |
96 |
97 | def OccurrenceAdmin(OccurrenceModel):
98 | class _OccurrenceAdmin(admin.ModelAdmin):
99 | form = OccurrenceAdminForm
100 | list_display = ['start', '_duration', 'event', 'from_a_repeating_occurrence', 'edit_link', 'status']
101 | list_display_links = ['start'] # this is turned off in __init__
102 | list_editable = ['event', 'status']
103 | if DJANGO14:
104 | list_filter = [IsGeneratedListFilter,]
105 | change_list_template = 'admin/eventtools/occurrence_list.html'
106 | fields = ("event" , "start", "_duration", "generated_by", 'status')
107 | readonly_fields = ('generated_by', )
108 | actions = [_cancel, _fully_booked, _clear_status, _convert_to_oneoff, _remove_occurrences, _wipe_occurrences]
109 | date_hierarchy = 'start'
110 |
111 | def __init__(self, *args, **kwargs):
112 | super(_OccurrenceAdmin, self).__init__(*args, **kwargs)
113 | self.event_model = self.model.EventModel()
114 | self.list_display_links = (None,) #have to specify it here to avoid Django complaining
115 |
116 | def edit_link(self, occurrence):
117 | if occurrence.generated_by is not None:
118 | change_url = reverse(
119 | '%s:%s_%s_change' % (
120 | self.admin_site.name,
121 | self.event_model._meta.app_label,
122 | self.event_model._meta.module_name),
123 | args=(occurrence.generated_by.event.id,)
124 | )
125 | return "via a repeating occurrence in %s " % (
126 | change_url,
127 | occurrence.generated_by.event,
128 | )
129 | else:
130 | change_url = reverse(
131 | '%s:%s_%s_change' % (
132 | self.admin_site.name,
133 | type(occurrence)._meta.app_label,
134 | type(occurrence)._meta.module_name),
135 | args=(occurrence.id,)
136 | )
137 | return "Edit " % (
138 | change_url,
139 | )
140 | edit_link.short_description = "edit"
141 | edit_link.allow_tags = True
142 |
143 | def get_changelist_form(self, request, **kwargs):
144 | kwargs.setdefault('form', OccurrenceAdminForm)
145 | return super(_OccurrenceAdmin, self).get_changelist_form(request, **kwargs)
146 |
147 | def event_edit_url(self, event):
148 | return reverse(
149 | '%s:%s_%s_change' % (
150 | self.admin_site.name,
151 | self.event_model._meta.app_label,
152 | self.event_model._meta.module_name),
153 | args=(event.id,)
154 | )
155 |
156 | def from_a_repeating_occurrence(self, occurrence):
157 | return occurrence.generated_by is not None
158 | from_a_repeating_occurrence.boolean = True
159 |
160 | def get_urls(self):
161 | """
162 | Add the event-specific occurrence list.
163 | """
164 | return patterns('',
165 | # causes redirect to events list, because we don't want to see all occurrences.
166 | url(r'^$',
167 | self.admin_site.admin_view(self.changelist_view_for_event)),
168 | url(r'for_event/(?P\d+)/$',
169 | self.admin_site.admin_view(self.changelist_view_for_event),
170 | name="%s_%s_changelist_for_event" % (
171 | OccurrenceModel._meta.app_label,
172 | OccurrenceModel._meta.module_name)),
173 | # workaround fix for "../" links in changelist breadcrumbs
174 | # causes redirect to events changelist
175 | url(r'for_event/$',
176 | self.admin_site.admin_view(self.changelist_view_for_event)),
177 | url(r'for_event/(?P\d+)/(?P\d+)/$',
178 | self.redirect_to_change_view),
179 | ) + super(_OccurrenceAdmin, self).get_urls()
180 |
181 | def changelist_view_for_event(self, request, event_id=None, extra_context=None):
182 | if event_id:
183 | request._event = get_object_or_404(
184 | self.event_model, id=event_id)
185 | else:
186 | messages.info(
187 | request, "Occurrences can only be accessed via events.")
188 | return redirect("%s:%s_%s_changelist" % (
189 | self.admin_site.name, self.event_model._meta.app_label,
190 | self.event_model._meta.module_name))
191 | extra_context = extra_context or {}
192 | extra_context['root_event'] = request._event
193 | extra_context['root_event_change_url'] = reverse(
194 | '%s:%s_%s_change' % (
195 | self.admin_site.name,
196 | self.event_model._meta.app_label,
197 | self.event_model._meta.module_name),
198 | args=(event_id,))
199 | return super(_OccurrenceAdmin, self).changelist_view(
200 | request, extra_context)
201 |
202 | def redirect_to_change_view(self, request, event_id, object_id):
203 | return redirect('%s:%s_%s_change' % (
204 | self.admin_site.name,
205 | OccurrenceModel._meta.app_label,
206 | OccurrenceModel._meta.module_name), object_id)
207 |
208 | def queryset(self, request):
209 | if hasattr(request, '_event'):
210 | return request._event.occurrences_in_listing()
211 | else:
212 | qs = super(_OccurrenceAdmin, self).queryset(request)
213 | return qs
214 |
215 | def get_actions(self, request):
216 | # remove 'delete' action
217 | actions = super(_OccurrenceAdmin, self).get_actions(request)
218 | if 'delete_selected' in actions:
219 | del actions['delete_selected']
220 | return actions
221 | return _OccurrenceAdmin
222 |
223 | def EventForm(EventModel):
224 | class _EventForm(forms.ModelForm):
225 | parent = TreeNodeChoiceField(queryset=EventModel._event_manager.all(), level_indicator=u"-", required=False)
226 |
227 | class Meta:
228 | model = EventModel
229 | return _EventForm
230 |
231 | def EventAdmin(EventModel, SuperModel=MPTTModelAdmin, show_exclusions=False, show_generator=True, *args, **kwargs):
232 | """ pass in the name of your EventModel subclass to use this admin. """
233 |
234 | class _EventAdmin(SuperModel):
235 | form = EventForm(EventModel)
236 | occurrence_inline = OccurrenceInline(EventModel.OccurrenceModel())
237 | list_display = ['unicode_bold_if_listed', 'occurrence_link', 'season', 'status'] # leave as list to allow extension
238 | change_form_template = kwargs['change_form_template'] if 'change_form_template' in kwargs else 'admin/eventtools/event.html'
239 | save_on_top = kwargs['save_on_top'] if 'save_on_top' in kwargs else True
240 | prepopulated_fields = {'slug': ('title', )}
241 | search_fields = ('title',)
242 |
243 | # def queryset(self, request):
244 | # return EventModel.objects.annotate(occurrence_count=Count('occurrences'))
245 |
246 | def append_eventtools_inlines(self, inline_instances):
247 | eventtools_inlines = [
248 | self.occurrence_inline,
249 | ]
250 | if show_generator:
251 | eventtools_inlines.append(GeneratorInline(EventModel.GeneratorModel()))
252 |
253 | if show_exclusions:
254 | eventtools_inlines.append(ExclusionInline(EventModel.ExclusionModel()))
255 |
256 | for inline_class in eventtools_inlines:
257 | inline_instance = inline_class(self.model, self.admin_site)
258 | inline_instances.append( inline_instance )
259 |
260 |
261 | def get_inline_instances(self, request, *args, **kwargs):
262 | """
263 | This overrides the regular ModelAdmin.get_inline_instances(self, request)
264 | """
265 | # Get any regular Django inlines the user may have defined.
266 | inline_instances = super(_EventAdmin, self).get_inline_instances(
267 | request, *args, **kwargs)
268 | # Append our eventtools inlines
269 | self.append_eventtools_inlines(inline_instances)
270 | return inline_instances
271 |
272 |
273 | def __init__(self, *args, **kwargs):
274 | super(_EventAdmin, self).__init__(*args, **kwargs)
275 | self.occurrence_model = EventModel.OccurrenceModel()
276 |
277 | def unicode_bold_if_listed(self, obj):
278 | if obj.is_listed():
279 | result = "%s "
280 | else:
281 | result = "%s "
282 |
283 | return result % (
284 | (5 + MPTT_ADMIN_LEVEL_INDENT * obj.level),
285 | unicode(obj),
286 | )
287 | unicode_bold_if_listed.allow_tags = True
288 | unicode_bold_if_listed.short_description = _("title (items in bold will be listed; other items are templates or variations)")
289 |
290 | def occurrence_edit_url(self, event):
291 | return reverse("%s:%s_%s_changelist_for_event" % (
292 | self.admin_site.name,
293 | self.occurrence_model._meta.app_label,
294 | self.occurrence_model._meta.module_name),
295 | args=(event.id,)
296 | )
297 |
298 | def occurrence_link(self, event):
299 | count = event.occurrences_in_listing().count()
300 | direct_count = event.occurrences.count()
301 |
302 | url = self.occurrence_edit_url(event)
303 |
304 | if count == 0:
305 | return _('No occurrences yet')
306 | elif count == 1:
307 | r = '1 Occurrence ' % url
308 | else:
309 | r = '%s Occurrences ' % (
310 | url,
311 | count,
312 | )
313 | return r + ' (%s direct)' % direct_count
314 | occurrence_link.short_description = _('Edit Occurrences')
315 | occurrence_link.allow_tags = True
316 |
317 | def get_urls(self):
318 | return patterns(
319 | '',
320 | url(r'(?P\d+)/create_variation/',
321 | self.admin_site.admin_view(self._create_variation))
322 | ) + super(_EventAdmin, self).get_urls()
323 |
324 | def _create_variation(self, request, parent_id):
325 | """
326 | We don't want to try to save child yet, as it is potentially incomplete.
327 | Instead, we'll get the parent and inheriting fields out of Event
328 | and put them into a GET string for the new_event form.
329 |
330 | To get values, we first try inheritable_FOO, to populate the form.
331 |
332 | @property
333 | def inheritable_price:
334 | return self.price.raw
335 | """
336 | parent = get_object_or_404(EventModel, id=parent_id)
337 | GET = QueryDict("parent=%s" % parent.id).copy()
338 |
339 | for field_name in EventModel._event_meta.fields_to_inherit:
340 | inheritable_field_name = "inheritable_%s" % field_name
341 | parent_attr = getattr(parent, inheritable_field_name, getattr(parent, field_name))
342 | if parent_attr:
343 | if hasattr(parent_attr, 'all'): #for m2m. Sufficient?
344 | GET[field_name] = u",".join([unicode(i.pk) for i in parent_attr.all()])
345 | elif hasattr(parent_attr, 'pk'): #for fk. Sufficient?
346 | GET[field_name] = parent_attr.pk
347 | else:
348 | GET[field_name] = parent_attr
349 |
350 | return redirect(
351 | reverse("%s:%s_%s_add" % (
352 | self.admin_site.name, EventModel._meta.app_label,
353 | EventModel._meta.module_name)
354 | )+"?%s" % GET.urlencode())
355 |
356 | def change_view(self, request, object_id, extra_context={}):
357 | obj = EventModel._event_manager.get(pk=object_id)
358 |
359 | if obj.parent:
360 | fields_diff = generate_diff(obj.parent, obj, include=EventModel._event_meta.fields_to_inherit)
361 | else:
362 | fields_diff = None
363 | extra_extra_context = {
364 | 'fields_diff': fields_diff,
365 | 'django_version': django.get_version()[:3],
366 | 'object': obj,
367 | 'occurrence_edit_url': self.occurrence_edit_url(event=obj),
368 | }
369 | extra_context.update(extra_extra_context)
370 | return super(_EventAdmin, self).change_view(request, object_id, extra_context=extra_context)
371 | return _EventAdmin
372 |
373 | try:
374 | from feincms.admin.tree_editor import TreeEditor
375 | except ImportError:
376 | pass
377 | else:
378 | def FeinCMSEventAdmin(EventModel):
379 | class _FeinCMSEventAdmin(EventAdmin(EventModel), TreeEditor):
380 | pass
381 | return _FeinCMSEventAdmin
382 |
383 |
384 | #TODO: Make a read-only display to show 'reassigned' generated occurrences.
385 | class OccurrenceInlineFormSet(BaseInlineFormSet):
386 | """
387 | Shows non-generated occurrences
388 | """
389 | def __init__(self, *args, **kwargs):
390 | event = kwargs.get('instance')
391 | if event:
392 | # Exclude occurrences that are generated by one of my generators
393 | my_generators = event.generators.all()
394 | kwargs['queryset'] = kwargs['queryset'].exclude(generated_by__in=my_generators)
395 | else:
396 | #new form
397 | pass
398 | super(OccurrenceInlineFormSet, self).__init__(*args, **kwargs)
399 |
400 | def OccurrenceInline(OccurrenceModel):
401 | class _OccurrenceInline(admin.TabularInline):
402 | model = OccurrenceModel
403 | formset = OccurrenceInlineFormSet
404 | extra = 1
405 | fields = ('start', '_duration', 'generated_by')
406 | readonly_fields = ('generated_by', )
407 | return _OccurrenceInline
408 |
409 | def ExclusionInline(ExclusionModel):
410 | class _ExclusionInline(admin.TabularInline):
411 | model = ExclusionModel
412 | extra = 0
413 | fields = ('start',)
414 | return _ExclusionInline
415 |
416 | def GeneratorInline(GeneratorModel):
417 | class _GeneratorInline(admin.TabularInline):
418 | model = GeneratorModel
419 | extra = 0
420 | return _GeneratorInline
421 |
422 | admin.site.register(Rule)
423 |
--------------------------------------------------------------------------------