├── .gitignore ├── AUTHORS.txt ├── CHANGES.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── UPGRADING.txt ├── docs ├── Makefile ├── conf.py ├── index.rst ├── install.rst ├── models.rst ├── overview.rst └── settings.rst ├── eventtools ├── REQUIREMENTS.txt ├── TODO.txt ├── __init__.py ├── admin.py ├── conf.py ├── filters.py ├── fixtures │ └── initial_data.json ├── forms.py ├── locale │ └── fr_FR │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── event.py │ ├── exclusion.py │ ├── generator.py │ ├── occurrence.py │ ├── rule.py │ ├── xseason.py │ └── xtimespan.py ├── settings.py ├── static │ └── eventtools │ │ ├── css │ │ └── events.css │ │ └── js │ │ ├── admin.js │ │ └── events.js ├── templates │ ├── admin │ │ └── eventtools │ │ │ ├── event.html │ │ │ ├── feincmsevent.html │ │ │ └── occurrence_list.html │ └── eventtools │ │ ├── _base.html │ │ ├── _event_in_list.html │ │ ├── _occurrence_in_list.html │ │ ├── _occurrences_in_event.html │ │ ├── _pagination.html │ │ ├── calendar │ │ ├── _day.html │ │ ├── _month_header.html │ │ ├── _month_nav.html │ │ ├── calendar.html │ │ └── calendars.html │ │ ├── event.html │ │ ├── occurrence_list.html │ │ └── signage_on_date.html ├── templatetags │ ├── __init__.py │ └── calendar.py ├── tests │ ├── __init__.py │ ├── _fixture.py │ ├── _inject_app.py │ ├── eventtools_testapp │ │ ├── __init__.py │ │ ├── models.py │ │ └── urls.py │ ├── models │ │ ├── __init__.py │ │ ├── event.py │ │ ├── exclusion.py │ │ ├── generator.py │ │ ├── occurrence.py │ │ └── tree.py │ ├── templatetags │ │ └── __init__.py │ ├── utils.py │ └── views.py ├── utils │ ├── __init__.py │ ├── dateranges.py │ ├── datetimeify.py │ ├── diff.py │ ├── domain.py │ ├── inheritingdefault.py │ ├── managertype.py │ ├── pprint_timespan.py │ └── viewutils.py └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *~ 3 | *.pyc 4 | /docs/_build/ 5 | *.DS_Store 6 | glamkit_eventtools.egg-info 7 | .idea -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | glamkit-eventtools Authors: 2 | 3 | * Thomas Ashelford 4 | * Greg Turner 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 | -------------------------------------------------------------------------------- /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 | ------------------------------------------------------------------------------- -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include eventtools/templates * 2 | recursive-include eventtools/locale * 3 | -------------------------------------------------------------------------------- /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 `_. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/ -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Models 3 | ====== 4 | 5 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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/REQUIREMENTS.txt: -------------------------------------------------------------------------------- 1 | -e git://github.com/django-mptt/django-mptt.git@384b1#egg=mptt 2 | # need to use python-dateutil <2.x for python2.x 3 | python-dateutil==1.5 4 | vobject==0.8.1c 5 | -e git://github.com/ixc/glamkit-convenient.git@4df74828d1a9d18ef97e#egg=glamkit-convenient 6 | -e git+git://github.com/ixc/django-jsonfield.git#egg=jsonfield 7 | FeinCMS==1.3.1 8 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/glamkit-eventtools/f94726c145f52bb7771b1c5352a39903d5fa33f3/eventtools/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /eventtools/conf.py: -------------------------------------------------------------------------------- 1 | from convenient.conf import SettingsHandler 2 | from eventtools import settings as app_settings 3 | 4 | settings = SettingsHandler(app_settings) 5 | -------------------------------------------------------------------------------- /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/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/locale/fr_FR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/glamkit-eventtools/f94726c145f52bb7771b1c5352a39903d5fa33f3/eventtools/locale/fr_FR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /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/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/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/glamkit-eventtools/f94726c145f52bb7771b1c5352a39903d5fa33f3/eventtools/migrations/__init__.py -------------------------------------------------------------------------------- /eventtools/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from .rule import Rule 5 | 6 | from .event import * 7 | from .occurrence import * 8 | from .generator import * 9 | from .exclusion import * 10 | from .xseason import * 11 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | ] -------------------------------------------------------------------------------- /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/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); -------------------------------------------------------------------------------- /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); -------------------------------------------------------------------------------- /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 |
    84 |
    85 | 86 |

    {{ field.diff|safe }}

    87 |
    88 |
    89 | {% endfor %} 90 | {% endif %} 91 |
    92 | {% endif %} 93 | {% endif %} 94 | {% endblock %} 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /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 |
    94 |
    95 | 96 |

    {{ field.diff|safe }}

    97 |
    98 |
    99 | {% endfor %} 100 | {% endif %} 101 |
    102 | {% endif %} 103 | {% endif %} 104 | {% endblock %} 105 | -------------------------------------------------------------------------------- /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/_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/templates/eventtools/_event_in_list.html: -------------------------------------------------------------------------------- 1 | 2 |

    {{ event.title }}

    3 |

    {{ event.season }}

    4 | {% if event.status_message %}

    {% endif %} 5 | -------------------------------------------------------------------------------- /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 |

    {{ event.title }}

    12 |
    13 | {% endwith %} -------------------------------------------------------------------------------- /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/templates/eventtools/_pagination.html: -------------------------------------------------------------------------------- 1 | {% load get_string %} 2 | 3 | 4 | {% if pageinfo.has_previous %} 5 | 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 | 18 | {% endif %} 19 | -------------------------------------------------------------------------------- /eventtools/templates/eventtools/calendar/_day.html: -------------------------------------------------------------------------------- 1 | {% if day.href %}{{ day.date|date:"j" }}{% else %}{{ day.date|date:"j" }}{% endif %} -------------------------------------------------------------------------------- /eventtools/templates/eventtools/calendar/_month_header.html: -------------------------------------------------------------------------------- 1 | 2 | {% for day in weeks.0 %} 3 | {{ day.date|date:"l"|slice:":1" }} 4 | {% endfor %} 5 | -------------------------------------------------------------------------------- /eventtools/templates/eventtools/calendar/_month_nav.html: -------------------------------------------------------------------------------- 1 | 2 | {% if prev_month.href %}<{% endif %} 3 | 4 | {% if next_month.href %}>{% endif %} 5 | 6 | -------------------------------------------------------------------------------- /eventtools/templates/eventtools/calendar/calendar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 |
    {{ weeks.0.6.date|date:"F Y" }}
    -------------------------------------------------------------------------------- /eventtools/templates/eventtools/calendar/calendars.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 |
    5 | 6 |
    7 | {% for calendar in calendars %} 8 |
    {# page #} 9 | {% with calendar.weeks as weeks %} 10 | {% include "eventtools/calendar/calendar.html" %} 11 | {% endwith %} 12 |
    13 | {% endfor %} 14 |
    15 |
    16 | 17 | 18 | 19 | 20 | 21 |
    22 | -------------------------------------------------------------------------------- /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/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/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/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/glamkit-eventtools/f94726c145f52bb7771b1c5352a39903d5fa33f3/eventtools/templatetags/__init__.py -------------------------------------------------------------------------------- /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/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from models import * 2 | from utils import * 3 | from views import * -------------------------------------------------------------------------------- /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/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/tests/eventtools_testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/glamkit-eventtools/f94726c145f52bb7771b1c5352a39903d5fa33f3/eventtools/tests/eventtools_testapp/__init__.py -------------------------------------------------------------------------------- /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/tests/eventtools_testapp/urls.py: -------------------------------------------------------------------------------- 1 | from models import ExampleEvent, ExampleOccurrence 2 | from eventtools.views import EventViews 3 | from django.conf.urls.defaults import * 4 | 5 | class TestEventViews(EventViews): 6 | occurrence_qs = ExampleOccurrence.objects.all() 7 | event_qs = ExampleEvent.eventobjects.all() 8 | 9 | views = TestEventViews() 10 | 11 | urlpatterns = views.get_urls() -------------------------------------------------------------------------------- /eventtools/tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | from event import * 2 | from generator import * 3 | from occurrence import * 4 | from exclusion import * 5 | from tree import * -------------------------------------------------------------------------------- /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/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) -------------------------------------------------------------------------------- /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/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()] -------------------------------------------------------------------------------- /eventtools/tests/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixc/glamkit-eventtools/f94726c145f52bb7771b1c5352a39903d5fa33f3/eventtools/tests/templatetags/__init__.py -------------------------------------------------------------------------------- /eventtools/tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | datetimeify converts a date to a datetime by clamping the time to min or max. Datetimes pass through. 3 | 4 | you can convert a list of occurrences to a queryset 5 | 6 | you can convert a list of events to a queryset 7 | 8 | String generation (human date range, datetime range) 9 | 10 | """ -------------------------------------------------------------------------------- /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/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from datetimeify import datetimeify, dayify 2 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------