├── django_ical ├── __init__.py ├── tests │ ├── __init__.py │ ├── test_feed.py │ └── test_recurrence.py ├── utils.py ├── views.py └── feedgenerator.py ├── .pre-commit-config.yaml ├── docs ├── source │ ├── changes.rst │ ├── reference │ │ ├── index.rst │ │ ├── django_ical.utils.rst │ │ ├── django_ical.views.rst │ │ └── django_ical.feedgenerator.rst │ ├── django_ical.feedgenerator │ ├── index.rst │ ├── conf.py │ └── usage.rst ├── make.bat └── Makefile ├── MANIFEST.in ├── .coveragerc ├── .prospector.yaml ├── pyproject.toml ├── .gitignore ├── CONTRIBUTING.md ├── test_settings.py ├── tests.py ├── tox.ini ├── LICENSE ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── README.rst ├── setup.py ├── CODE_OF_CONDUCT.md └── CHANGES.rst /django_ical/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_ical/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: [] 2 | -------------------------------------------------------------------------------- /docs/source/changes.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGES.rst 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst CHANGES.rst LICENSE tests.py 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = django_ical 4 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | ignore-paths: 2 | - docs 3 | 4 | pep8: 5 | options: 6 | max-line-length: 88 7 | 8 | pylint: 9 | disable: 10 | - import-error 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] 3 | 4 | [tool.black] 5 | line-length = 88 6 | target_version = ['py38'] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | .idea 3 | *.orig 4 | *.rej 5 | *~ 6 | *.bak 7 | *.marks 8 | *.o 9 | *.pyc 10 | *.swp 11 | *.egg-info 12 | .eggs 13 | .tox 14 | build 15 | temp 16 | dist 17 | .coverage 18 | 19 | syntax: regexp 20 | .*\#.*\#$ 21 | .venv/ 22 | -------------------------------------------------------------------------------- /docs/source/reference/index.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | API Reference 3 | =============== 4 | 5 | :Release: |version| 6 | :Date: |today| 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | django_ical.feedgenerator 12 | django_ical.views 13 | django_ical.utils 14 | -------------------------------------------------------------------------------- /docs/source/django_ical.feedgenerator: -------------------------------------------------------------------------------- 1 | ======================================== 2 | django_ical.feedgenerator 3 | ======================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_ical.feedgenerator 8 | 9 | .. automodule:: django_ical.feedgenerator 10 | -------------------------------------------------------------------------------- /docs/source/reference/django_ical.utils.rst: -------------------------------------------------------------------------------- 1 | ======================================== 2 | django_ical.utils 3 | ======================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_ical.utils 8 | 9 | .. automodule:: django_ical.utils 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/reference/django_ical.views.rst: -------------------------------------------------------------------------------- 1 | ======================================== 2 | django_ical.views 3 | ======================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_ical.views 8 | 9 | .. automodule:: django_ical.views 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/reference/django_ical.feedgenerator.rst: -------------------------------------------------------------------------------- 1 | ======================================== 2 | django_ical.feedgenerator 3 | ======================================== 4 | 5 | .. contents:: 6 | :local: 7 | .. currentmodule:: django_ical.feedgenerator 8 | 9 | .. automodule:: django_ical.feedgenerator 10 | :members: 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | 5 | Have ideas for how this project can improve? Open a pull request! 6 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | TIME_ZONE="UTC" 2 | SECRET_KEY="snakeoil" 3 | 4 | DATABASES={ 5 | "default": { 6 | "ENGINE": "django.db.backends.sqlite3", 7 | "NAME": ":memory:", 8 | } 9 | } 10 | 11 | INSTALLED_APPS=[ 12 | "django.contrib.contenttypes", 13 | "django_ical", 14 | ] 15 | 16 | MIDDLEWARE_CLASSES=[ 17 | "django.middleware.common.CommonMiddleware", 18 | "django.contrib.sessions.middleware.SessionMiddleware", 19 | ] 20 | 21 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def main(): 6 | """ 7 | Standalone Django model test with a 'memory-only-django-installation'. 8 | You can play with a django model without a complete django app installation. 9 | http://www.djangosnippets.org/snippets/1044/ 10 | """ 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings") 13 | print(os.environ) 14 | 15 | import django 16 | from django.conf import settings 17 | 18 | django.setup() 19 | 20 | from django.test.utils import get_runner 21 | 22 | TestRunner = get_runner(settings) 23 | test_runner = TestRunner() 24 | failures = test_runner.run_tests(["django_ical"]) 25 | sys.exit(bool(failures)) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py310-djqa 4 | py{38,39,310}-dj32 5 | py{38,39,310}-dj40 6 | py{38,39,310,311}-dj41 7 | py{38,39,310,311}-dj42 8 | py{310}-djmain 9 | 10 | [gh-actions] 11 | python = 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.11: py311 16 | 17 | [gh-actions:env] 18 | DJANGO = 19 | 3.2: dj32 20 | 4.1: dj41 21 | 4.2: dj42 22 | main: djmain 23 | qa: djqa 24 | 25 | [testenv] 26 | usedevelop = true 27 | setenv = 28 | PYTHONDONTWRITEBYTECODE=1 29 | deps = 30 | coverage==7.3.2 31 | dj32: django>=3.2,<3.3 32 | dj41: django>=4.1,<4.2 33 | dj42: django>=4.2,<4.3 34 | djmain: https://github.com/django/django/archive/main.tar.gz 35 | commands = 36 | coverage run setup.py test 37 | coverage report -m 38 | 39 | [testenv:py38-djqa] 40 | basepython = python3.8 41 | ignore_errors = true 42 | skip_install = true 43 | setenv= 44 | DJANGO_SETTINGS_MODULE=test_settings 45 | deps = 46 | django==3.2 47 | black==23.10.1 48 | prospector==1.10.3 49 | commands = 50 | prospector 51 | black --check --diff django_ical 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012 Ian Lewis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-ical' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-ical/upload 41 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-ical documentation master file, created by 2 | sphinx-quickstart on Sun May 6 14:57:42 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | django-ical documentation 8 | ========================= 9 | 10 | django-ical is a simple library/framework for creating 11 | `ical `_ 12 | feeds based in Django's 13 | `syndication feed framework `_. 14 | 15 | This documentation is modeled after the documentation for the 16 | syndication feed framework so you can think of it as a simple extension. 17 | 18 | If you are familiar with the Django syndication feed framework you should be 19 | able to be able to use django-ical fairly quickly. It works the same way as 20 | the Django syndication framework but adds a few extension properties to 21 | support iCalendar feeds. 22 | 23 | django-ical uses the 24 | `icalendar `_ 25 | library under the hood to generate iCalendar feeds. 26 | 27 | 28 | Contents 29 | ======== 30 | 31 | .. toctree:: 32 | 33 | usage 34 | reference/index 35 | changelog 36 | 37 | 38 | Indices and tables 39 | ================== 40 | 41 | * :ref:`genindex` 42 | * :ref:`modindex` 43 | * :ref:`search` 44 | 45 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-ical 2 | =========== 3 | 4 | |pypi| |docs| |build| |coverage| |jazzband| 5 | 6 | django-ical is a simple library/framework for creating 7 | `iCal `_ 8 | feeds based in Django's 9 | `syndication feed framework `_. 10 | 11 | This documentation is modeled after the documentation for the syndication feed 12 | framework so you can think of it as a simple extension. 13 | 14 | If you are familiar with the Django syndication feed framework you should be 15 | able to be able to use django-ical fairly quickly. It works the same way as 16 | the Django syndication framework but adds a few extension properties to 17 | support iCalendar feeds. 18 | 19 | django-ical uses the `icalendar `_ library 20 | under the hood to generate iCalendar feeds. 21 | 22 | Documentation 23 | ------------- 24 | 25 | Documentation is hosted on Read the Docs: 26 | 27 | https://django-ical.readthedocs.io/en/latest/ 28 | 29 | 30 | .. |pypi| image:: https://img.shields.io/pypi/v/django-ical.svg 31 | :alt: PyPI 32 | :target: https://pypi.org/project/django-ical/ 33 | 34 | .. |docs| image:: https://readthedocs.org/projects/django-ical/badge/?version=latest 35 | :alt: Documentation Status 36 | :scale: 100% 37 | :target: http://django-ical.readthedocs.io/en/latest/?badge=latest 38 | 39 | .. |build| image:: https://github.com/jazzband/django-ical/workflows/Test/badge.svg 40 | :target: https://github.com/jazzband/django-ical/actions 41 | :alt: GitHub Actions 42 | 43 | .. |coverage| image:: https://codecov.io/gh/jazzband/django-ical/branch/master/graph/badge.svg 44 | :target: https://codecov.io/gh/jazzband/django-ical 45 | :alt: Coverage 46 | 47 | .. |jazzband| image:: https://jazzband.co/static/img/badge.svg 48 | :target: https://jazzband.co/ 49 | :alt: Jazzband 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name="django-ical", 7 | description="iCal feeds for Django based on Django's syndication feed framework.", 8 | long_description="\n".join( 9 | [ 10 | open("README.rst", encoding="utf-8").read(), 11 | open("CHANGES.rst", encoding="utf-8").read(), 12 | ] 13 | ), 14 | keywords="ical calendar django syndication feed", 15 | author="Ian Lewis", 16 | author_email="security@jazzband.com", 17 | maintainer="Jazzband", 18 | maintainer_email="security@jazzband.com", 19 | license="MIT License", 20 | url="https://github.com/jazzband/django-ical", 21 | classifiers=[ 22 | "Development Status :: 5 - Production/Stable", 23 | "Environment :: Web Environment", 24 | "Environment :: Plugins", 25 | "Framework :: Django", 26 | "Framework :: Django :: 3.2", 27 | "Framework :: Django :: 4.1", 28 | "Framework :: Django :: 4.2", 29 | "Intended Audience :: Developers", 30 | "Intended Audience :: System Administrators", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: Implementation :: CPython", 40 | "Topic :: Software Development :: Libraries :: Python Modules", 41 | ], 42 | install_requires=["django>=3.2", "icalendar>=4.0.3", "django-recurrence>=1.11.1"], 43 | packages=find_packages(), 44 | test_suite="tests.main", 45 | use_scm_version=True, 46 | setup_requires=["setuptools_scm"], 47 | ) 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | max-parallel: 5 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11'] 13 | django-version: ['3.2', '4.1', '4.2'] 14 | include: 15 | # Tox configuration for QA environment 16 | - python-version: '3.11' 17 | django-version: 'qa' 18 | # Django main 19 | - python-version: '3.11' 20 | django-version: 'main' 21 | experimental: true 22 | - python-version: '3.11' 23 | django-version: 'main' 24 | experimental: true 25 | exclude: 26 | # Exclude Django 3.2 for Python 3.11 27 | - python-version: '3.11' 28 | django-version: '3.2' 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | 33 | - name: Set up Python ${{ matrix.python-version }} 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | 38 | - name: Get pip cache dir 39 | id: pip-cache 40 | run: | 41 | echo "::set-output name=dir::$(pip cache dir)" 42 | 43 | - name: Cache 44 | uses: actions/cache@v2 45 | with: 46 | path: ${{ steps.pip-cache.outputs.dir }} 47 | key: 48 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }} 49 | restore-keys: | 50 | ${{ matrix.python-version }}-v1- 51 | 52 | - name: Install dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | python -m pip install --upgrade tox tox-gh-actions 56 | 57 | - name: Tox tests 58 | run: | 59 | tox -v 60 | env: 61 | DJANGO: ${{ matrix.django-version }} 62 | 63 | - name: Upload coverage 64 | uses: codecov/codecov-action@v1 65 | with: 66 | name: Python ${{ matrix.python-version }} 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /django_ical/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions to build calendar rules.""" 2 | 3 | from icalendar.prop import vRecur 4 | from recurrence import serialize 5 | 6 | 7 | def build_rrule( # noqa 8 | count=None, 9 | interval=None, 10 | bysecond=None, 11 | byminute=None, 12 | byhour=None, 13 | byweekno=None, 14 | bymonthday=None, 15 | byyearday=None, 16 | bymonth=None, 17 | until=None, 18 | bysetpos=None, 19 | wkst=None, 20 | byday=None, 21 | freq=None, 22 | ): 23 | """ 24 | Build rrule dictionary for vRecur class. 25 | 26 | :param count: int 27 | :param interval: int 28 | :param bysecond: int 29 | :param byminute: int 30 | :param byhour: int 31 | :param byweekno: int 32 | :param bymonthday: int 33 | :param byyearday: int 34 | :param bymonth: int 35 | :param until: datetime 36 | :param bysetpos: int 37 | :param wkst: str, two-letter weekday 38 | :param byday: weekday 39 | :param freq: str, frequency name ('WEEK', 'MONTH', etc) 40 | :return: dict 41 | """ 42 | result = {} 43 | 44 | if count is not None: 45 | result["COUNT"] = count 46 | 47 | if interval is not None: 48 | result["INTERVAL"] = interval 49 | 50 | if bysecond is not None: 51 | result["BYSECOND"] = bysecond 52 | 53 | if byminute is not None: 54 | result["BYMINUTE"] = byminute 55 | 56 | if byhour is not None: 57 | result["BYHOUR"] = byhour 58 | 59 | if byweekno is not None: 60 | result["BYWEEKNO"] = byweekno 61 | 62 | if bymonthday is not None: 63 | result["BYMONTHDAY"] = bymonthday 64 | 65 | if byyearday is not None: 66 | result["BYYEARDAY"] = byyearday 67 | 68 | if bymonth is not None: 69 | result["BYMONTH"] = bymonth 70 | 71 | if until is not None: 72 | result["UNTIL"] = until 73 | 74 | if bysetpos is not None: 75 | result["BYSETPOS"] = bysetpos 76 | 77 | if wkst is not None: 78 | result["WKST"] = wkst 79 | 80 | if byday is not None: 81 | result["BYDAY"] = byday 82 | 83 | if freq is not None: 84 | if freq not in vRecur.frequencies: 85 | raise ValueError(f"Frequency value should be one of: {vRecur.frequencies}") 86 | result["FREQ"] = freq 87 | 88 | return result 89 | 90 | 91 | def build_rrule_from_text(rrule_str): 92 | """Build an rrule from a serialzed RRULE string.""" 93 | recurr = vRecur() 94 | return recurr.from_ical(rrule_str) 95 | 96 | 97 | def build_rrule_from_recurrences_rrule(rule): 98 | """ 99 | Build rrule dictionary for vRecur class from a django_recurrences rrule. 100 | 101 | django_recurrences is a popular implementation for recurrences in django. 102 | https://pypi.org/project/django-recurrence/ 103 | this is a shortcut to interface between recurrences and icalendar. 104 | """ 105 | line = serialize(rule) 106 | if line.startswith("RRULE:"): 107 | line = line[6:] 108 | return build_rrule_from_text(line) 109 | 110 | 111 | def build_rrule_from_dateutil_rrule(rule): 112 | """ 113 | Build rrule dictionary for vRecur class from a dateutil rrule. 114 | 115 | Dateutils rrule is a popular implementation of rrule in python. 116 | https://pypi.org/project/python-dateutil/ 117 | this is a shortcut to interface between dateutil and icalendar. 118 | """ 119 | lines = str(rule).splitlines() 120 | for line in lines: 121 | if line.startswith("DTSTART:"): 122 | continue 123 | if line.startswith("RRULE:"): 124 | line = line[6:] 125 | return build_rrule_from_text(line) 126 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 2 | Changes 3 | ======= 4 | 5 | 6 | 1.9.2 (2023-06-12) 7 | ------------------ 8 | 9 | - Support all properties specified in RFC 5545 10 | [magicbrothers] 11 | 12 | 13 | 1.9.1 (2023-05-01) 14 | ------------------ 15 | 16 | - Fix multiple CATEGORIES and add tests 17 | [mjfinney] 18 | 19 | 20 | 1.9.0 (2023-04-04) 21 | ------------------ 22 | 23 | - Support for iCalendar VTODO elements 24 | [wetneb] 25 | 26 | 27 | 1.8.4 (2023-04-02) 28 | ------------------ 29 | 30 | - Fix content-type charset declarations. 31 | [lchanouha] 32 | - Update test matrix. 33 | Add Python 3.11 and Django 4.1 support. 34 | Deprecate Python 3.7 support. 35 | [aleksihakli] 36 | 37 | 38 | 1.8.3 (2022-01-25) 39 | ------------------ 40 | 41 | - Enable Python 3.10 and Django 4.0 support. 42 | [aleksihakli] 43 | 44 | 45 | 1.8.2 (2022-01-13) 46 | ------------------ 47 | 48 | - Deprecate universal Python 2 wheels. 49 | [aleksihakli] 50 | 51 | 52 | 1.8.1 (2022-01-08) 53 | ------------------ 54 | 55 | - Drop Python 3.6 support and add Python 3.10 support. 56 | [aleksihakli] 57 | 58 | 59 | 1.8.0 (2021-05-21) 60 | ------------------ 61 | 62 | - Add ``VALARM`` support and documentation. 63 | [malteger] 64 | 65 | 66 | 1.7.3 (2021-05-03) 67 | ------------------ 68 | 69 | - Fix calendar ``Content-Type`` headers. 70 | Previous configuration included multiple comma separated values 71 | in the header, which is incompatible with the HTTP header specification. 72 | [aleksihakli] 73 | 74 | 75 | 1.7.2 (2020-12-16) 76 | ------------------ 77 | 78 | - Add support for Python 3.9. [aleksihakli] 79 | - Add support for Django 3.1. [aleksihakli] 80 | - Add tox QA with black and prospector. [aleksihakli] 81 | - Migrate from Travis to GitHub Actions. [aleksihakli] 82 | 83 | 84 | 1.7.1 (2020-05-09) 85 | ------------------ 86 | 87 | - Drop support for Django 1.11 LTS. [aleksihakli] 88 | - Fix string comparison in tests. [aleksihakli] 89 | 90 | 91 | 1.7.0 (2019-10-09) 92 | ------------------ 93 | 94 | - Add calendar MIME types for feeds. [xkill] 95 | - Add attendees for calendar events. [webaholik] 96 | 97 | 98 | 1.6.2 (2019-08-30) 99 | ------------------ 100 | 101 | - Language and documentation improvements. 102 | 103 | 104 | 1.6.1 (2019-08-30) 105 | ------------------ 106 | 107 | - Python 3.8 and PyPy support. 108 | 109 | 110 | 1.6 (2019-08-27) 111 | ---------------- 112 | 113 | - Drop support for old Python and Django versions. 114 | This enables support for new Django versions 115 | which do not have Python 2 compatibility shims. 116 | - Add continuous delivery via Jazzband. 117 | - Add SCM versioning via setuptools_scm. 118 | 119 | 120 | 1.5 (2018-10-10) 121 | ---------------- 122 | 123 | - Add support for Django 1.11. *Thanks, Martin Bächtold* 124 | - Drop support for Python 2.6. *Thanks, Martin Bächtold* 125 | - Add support for categories, rrule, exrule, rrdate, exdate. *Thanks, Armin Leuprecht* 126 | - Fix a documentation typo. *Thanks, Giorgos Logiotatidis* 127 | - Add documentation and testing around recurring events. *Thanks, Christian Ledermann* 128 | - Remove tests for Django versions < 1.8 *Thanks, Christian Ledermann* 129 | 130 | 131 | 1.4 (2016-05-08) 132 | ---------------- 133 | 134 | - Django up to 1.9 is supported. 135 | - Added new `ttl` parameter. *Thanks, Diaz-Gutierrez* 136 | - Added support for Python 3. *Thanks, Ben Lopatin* 137 | - Fixed LAST-MODIFIED support. *Thanks, Brad Bell* 138 | 139 | 140 | 1.3 (2014-11-26) 141 | ---------------- 142 | 143 | - Django up to 1.7 is supported. 144 | - Added a new `file_name` parameter. *Thanks, browniebroke* 145 | - Added support for the `ORGANIZER` field. *Thanks, browniebroke* 146 | 147 | 148 | 1.2 (2012-12-12) 149 | ---------------- 150 | 151 | - Removed support for Django 1.2. It should still work, but it's not supported. 152 | - We now require icalendar 3.1. 153 | - Added support for the `GEO` field. *Thanks, null_radix!* 154 | 155 | 156 | 1.1 (2012-10-26) 157 | ---------------- 158 | 159 | - Fixed issues running tests on Django 1.2 and Django 1.5. 160 | 161 | 162 | 1.0 (2012-05-06) 163 | ---------------- 164 | 165 | - Initial Release 166 | -------------------------------------------------------------------------------- /django_ical/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Views for generating ical feeds. 3 | """ 4 | 5 | from datetime import datetime 6 | from calendar import timegm 7 | from inspect import signature 8 | 9 | from django.http import HttpResponse, Http404 10 | from django.core.exceptions import ObjectDoesNotExist 11 | from django.contrib.syndication.views import Feed 12 | from django.utils.http import http_date 13 | 14 | from django_ical import feedgenerator 15 | 16 | __all__ = ("ICalFeed",) 17 | 18 | # Extra fields added to the Feed object 19 | # to support ical 20 | FEED_EXTRA_FIELDS = ("method", "product_id", "timezone") 21 | 22 | # Extra fields added to items (events) to 23 | # support ical 24 | ICAL_EXTRA_FIELDS = [ 25 | "component_type", # type of calendar component (event, todo) 26 | "timestamp", # dtstamp 27 | "created", # created 28 | "start_datetime", # dtstart 29 | "end_datetime", # dtend 30 | "transparency", # transp 31 | "location", # location 32 | "geolocation", # latitude;longitude 33 | "organizer", # email, cn, and role 34 | "rrule", # rrule 35 | "exrule", # exrule 36 | "rdate", # rdate 37 | "exdate", # exdate 38 | "status", # CONFIRMED|TENTATIVE|CANCELLED 39 | "attendee", # list of attendees 40 | "valarm", # list of icalendar.Alarm objects, 41 | # additional fields for tasks 42 | "completed", # completed 43 | "percent_complete", # percent-complete 44 | "priority", # priority 45 | "due", # due 46 | ] 47 | 48 | 49 | class ICalFeed(Feed): 50 | """ 51 | iCalendar Feed 52 | 53 | Existing Django syndication feeds 54 | 55 | :title: X-WR-CALNAME 56 | :description: X-WR-CALDESC 57 | :item_guid: UID 58 | :item_title: SUMMARY 59 | :item_description: DESCRIPTION 60 | :item_link: URL 61 | :item_updateddate: LAST-MODIFIED 62 | 63 | Extension fields 64 | 65 | :method: METHOD 66 | :timezone: X-WR-TIMEZONE 67 | :item_class: CLASS 68 | :item_timestamp: DTSTAMP 69 | :item_created: CREATED 70 | :item_start_datetime: DTSTART 71 | :item_end_datetime: DTEND 72 | :item_transparency: TRANSP 73 | :item_attendee: ATTENDEE 74 | :item_valarm: VALARM 75 | """ 76 | 77 | feed_type = feedgenerator.DefaultFeed 78 | 79 | def __call__(self, request, *args, **kwargs): 80 | """ 81 | Copied from django.contrib.syndication.views.Feed 82 | 83 | Supports file_name as a dynamic attr. 84 | """ 85 | try: 86 | obj = self.get_object(request, *args, **kwargs) 87 | except ObjectDoesNotExist as exc: 88 | raise Http404("Feed object does not exist.") from exc 89 | 90 | feedgen = self.get_feed(obj, request) 91 | response = HttpResponse(content_type=feedgen.mime_type) 92 | 93 | if hasattr(self, "item_pubdate") or hasattr(self, "item_updateddate"): 94 | # if item_pubdate or item_updateddate is defined for the feed, set 95 | # header so as ConditionalGetMiddleware is able to send 304 NOT MODIFIED 96 | response["Last-Modified"] = http_date( 97 | timegm(feedgen.latest_post_date().utctimetuple()) 98 | ) 99 | feedgen.write(response, "utf-8") 100 | 101 | filename = self._get_dynamic_attr("file_name", obj) 102 | if filename: 103 | response["Content-Disposition"] = f'attachment; filename="{filename}"' 104 | 105 | return response 106 | 107 | def _get_dynamic_attr(self, attname, obj, default=None): 108 | """ 109 | Copied from django.contrib.syndication.views.Feed (v1.7.1) 110 | """ 111 | try: 112 | attr = getattr(self, attname) 113 | except AttributeError: 114 | return default 115 | if callable(attr): 116 | num_args = len(signature(attr).parameters) 117 | if num_args == 0: 118 | return attr() 119 | if num_args == 1: 120 | return attr(obj) 121 | 122 | raise TypeError( 123 | "Number of arguments to _get_dynamic_attr needs to be 0 or 1" 124 | ) 125 | return attr 126 | 127 | # NOTE: Not used by icalendar but required 128 | # by the Django syndication framework. 129 | link = "" 130 | 131 | def method(self, obj): # pylint: disable=unused-argument 132 | return "PUBLISH" 133 | 134 | def feed_extra_kwargs(self, obj): 135 | kwargs = {} 136 | for field in FEED_EXTRA_FIELDS: 137 | val = self._get_dynamic_attr(field, obj) 138 | if val: 139 | kwargs[field] = val 140 | return kwargs 141 | 142 | def item_timestamp(self, obj): # pylint: disable=unused-argument 143 | return datetime.now() 144 | 145 | def item_extra_kwargs(self, item): 146 | kwargs = {} 147 | for field in ICAL_EXTRA_FIELDS: 148 | val = self._get_dynamic_attr("item_" + field, item) 149 | if val: 150 | kwargs[field] = val 151 | return kwargs 152 | -------------------------------------------------------------------------------- /django_ical/feedgenerator.py: -------------------------------------------------------------------------------- 1 | """ 2 | iCalendar feed generation library -- used for generating 3 | iCalendar feeds. 4 | 5 | Sample usage: 6 | 7 | >>> from django_ical import feedgenerator 8 | >>> from datetime import datetime 9 | >>> feed = feedgenerator.ICal20Feed( 10 | ... title="My Events", 11 | ... link="http://www.example.com/events.ical", 12 | ... description="A iCalendar feed of my events.", 13 | ... language="en", 14 | ... ) 15 | >>> feed.add_item( 16 | ... title="Hello", 17 | ... link="http://www.example.com/test/", 18 | ... description="Testing.", 19 | ... start_datetime=datetime(2012, 5, 6, 10, 00), 20 | ... end_datetime=datetime(2012, 5, 6, 12, 00), 21 | ... ) 22 | >>> fp = open('test.ical', 'wb') 23 | >>> feed.write(fp, 'utf-8') 24 | >>> fp.close() 25 | 26 | For definitions of the iCalendar format see: 27 | http://www.ietf.org/rfc/rfc2445.txt 28 | """ 29 | 30 | from icalendar import Calendar, Event, Todo 31 | 32 | from django.utils.feedgenerator import SyndicationFeed 33 | 34 | __all__ = ("ICal20Feed", "DefaultFeed") 35 | 36 | FEED_FIELD_MAP = ( 37 | ("product_id", "prodid"), 38 | ("method", "method"), 39 | ("title", "x-wr-calname"), 40 | ("description", "x-wr-caldesc"), 41 | ("timezone", "x-wr-timezone"), 42 | ( 43 | "ttl", 44 | "x-published-ttl", 45 | ), # See format here: http://www.rfc-editor.org/rfc/rfc2445.txt (sec 4.3.6) 46 | ) 47 | 48 | ITEM_ELEMENT_FIELD_MAP = ( 49 | # 'item_guid' becomes 'unique_id' when passed to the SyndicationFeed 50 | ("unique_id", "uid"), 51 | ("title", "summary"), 52 | ("description", "description"), 53 | ("start_datetime", "dtstart"), 54 | ("end_datetime", "dtend"), 55 | ("updateddate", "last-modified"), 56 | ("created", "created"), 57 | ("timestamp", "dtstamp"), 58 | ("transparency", "transp"), 59 | ("location", "location"), 60 | ("geolocation", "geo"), 61 | ("link", "url"), 62 | ("organizer", "organizer"), 63 | ("categories", "categories"), 64 | ("rrule", "rrule"), 65 | ("exrule", "exrule"), 66 | ("rdate", "rdate"), 67 | ("exdate", "exdate"), 68 | ("status", "status"), 69 | ("attendee", "attendee"), 70 | ("valarm", None), 71 | # additional properties supported by the Todo class (VTODO calendar component). 72 | # see https://icalendar.readthedocs.io/en/latest/_modules/icalendar/cal.html#Todo 73 | ("completed", "completed"), 74 | ("percent_complete", "percent-complete"), 75 | ("priority", "priority"), 76 | ("due", "due"), 77 | ("calscale", "calscale"), 78 | ("method", "method"), 79 | ("prodid", "prodid"), 80 | ("version", "version"), 81 | ("attach", "attach"), 82 | ("class", "class"), 83 | ("comment", "comment"), 84 | ("resources", "resources"), 85 | ("duration", "duration"), 86 | ("freebusy", "freebusy"), 87 | ("tzid", "tzid"), 88 | ("tzname", "tzname"), 89 | ("tzoffsetfrom", "tzoffsetfrom"), 90 | ("tzoffsetto", "tzoffsetto"), 91 | ("tzurl", "tzurl"), 92 | ("contact", "contact"), 93 | ("recurrence_id", "recurrence-id"), 94 | ("related_to", "related-to"), 95 | ("action", "action"), 96 | ("repeat", "repeat"), 97 | ("trigger", "trigger"), 98 | ("sequence", "sequence"), 99 | ("request_status", "request-status"), 100 | ) 101 | 102 | class ICal20Feed(SyndicationFeed): 103 | """ 104 | iCalendar 2.0 Feed implementation. 105 | """ 106 | 107 | mime_type = "text/calendar; charset=utf-8" 108 | 109 | def write(self, outfile, encoding): 110 | """ 111 | Writes the feed to the specified file in the 112 | specified encoding. 113 | """ 114 | cal = Calendar() 115 | cal.add("version", "2.0") 116 | cal.add("calscale", "GREGORIAN") 117 | 118 | for ifield, efield in FEED_FIELD_MAP: 119 | val = self.feed.get(ifield) 120 | if val is not None: 121 | cal.add(efield, val) 122 | 123 | self.write_items(cal) 124 | 125 | to_ical = getattr(cal, "as_string", None) 126 | if not to_ical: 127 | to_ical = cal.to_ical 128 | outfile.write(to_ical()) 129 | 130 | def write_items(self, calendar): 131 | """ 132 | Write all elements to the calendar 133 | """ 134 | for item in self.items: 135 | component_type = item.get("component_type") 136 | if component_type == "todo": 137 | element = Todo() 138 | else: 139 | element = Event() 140 | for ifield, efield in ITEM_ELEMENT_FIELD_MAP: 141 | val = item.get(ifield) 142 | if val is not None: 143 | if ifield == "attendee": 144 | for list_item in val: 145 | element.add(efield, list_item) 146 | elif ifield == "valarm": 147 | for list_item in val: 148 | element.add_component(list_item) 149 | else: 150 | element.add(efield, val) 151 | calendar.add_component(element) 152 | 153 | 154 | DefaultFeed = ICal20Feed 155 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | set I18NSPHINXOPTS=%SPHINXOPTS% source 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-ical.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-ical.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /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) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-ical.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-ical.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-ical" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-ical" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-ical documentation build configuration file, created by 4 | # sphinx-quickstart on Sun May 6 14:57:42 2012. 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 15 | import os 16 | 17 | from pkg_resources import get_distribution 18 | 19 | # So that we can import django for the API reference. 20 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django.conf.global_settings") 21 | 22 | sys.path.append(os.path.abspath(".")) 23 | sys.path.append(os.path.abspath("../../")) 24 | 25 | # If extensions (or modules to document with autodoc) are in another directory, 26 | # add these directories to sys.path here. If the directory is relative to the 27 | # documentation root, use os.path.abspath to make it absolute, like shown here. 28 | # sys.path.insert(0, os.path.abspath('.')) 29 | 30 | # -- General configuration ----------------------------------------------------- 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | # needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be extensions 36 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 37 | extensions = ["sphinx.ext.autodoc"] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ["_templates"] 41 | 42 | # The suffix of source filenames. 43 | source_suffix = ".rst" 44 | 45 | # The encoding of source files. 46 | # source_encoding = 'utf-8-sig' 47 | 48 | # The master toctree document. 49 | master_doc = "index" 50 | 51 | # General information about the project. 52 | project = "django-ical" 53 | copyright = "2012, Ian Lewis" 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The full version, including alpha/beta/rc tags. 60 | release = get_distribution("django_ical").version 61 | # The short X.Y version. 62 | version = ".".join(release.split(".")[:2]) 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # language = None 67 | 68 | # There are two options for replacing |today|: either, you set today to some 69 | # non-false value, then it is used: 70 | # today = '' 71 | # Else, today_fmt is used as the format for a strftime call. 72 | # today_fmt = '%B %d, %Y' 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | exclude_patterns = [] 77 | 78 | # The reST default role (used for this markup: `text`) to use for all documents. 79 | # default_role = None 80 | 81 | # If true, '()' will be appended to :func: etc. cross-reference text. 82 | # add_function_parentheses = True 83 | 84 | # If true, the current module name will be prepended to all description 85 | # unit titles (such as .. function::). 86 | # add_module_names = True 87 | 88 | # If true, sectionauthor and moduleauthor directives will be shown in the 89 | # output. They are ignored by default. 90 | # show_authors = False 91 | 92 | # The name of the Pygments (syntax highlighting) style to use. 93 | pygments_style = "sphinx" 94 | 95 | # A list of ignored prefixes for module index sorting. 96 | # modindex_common_prefix = [] 97 | 98 | 99 | # -- Options for HTML output --------------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | html_theme = "haiku" 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | # html_theme_options = {} 109 | 110 | # Add any paths that contain custom themes here, relative to this directory. 111 | # html_theme_path = [] 112 | 113 | # The name for this set of Sphinx documents. If None, it defaults to 114 | # " v documentation". 115 | # html_title = None 116 | 117 | # A shorter title for the navigation bar. Default is the same as html_title. 118 | # html_short_title = None 119 | 120 | # The name of an image file (relative to this directory) to place at the top 121 | # of the sidebar. 122 | # html_logo = None 123 | 124 | # The name of an image file (within the static path) to use as favicon of the 125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 126 | # pixels large. 127 | # html_favicon = None 128 | 129 | # Add any paths that contain custom static files (such as style sheets) here, 130 | # relative to this directory. They are copied after the builtin static files, 131 | # so a file named "default.css" will overwrite the builtin "default.css". 132 | html_static_path = ["_static"] 133 | 134 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 135 | # using the given strftime format. 136 | # html_last_updated_fmt = '%b %d, %Y' 137 | 138 | # If true, SmartyPants will be used to convert quotes and dashes to 139 | # typographically correct entities. 140 | # html_use_smartypants = True 141 | 142 | # Custom sidebar templates, maps document names to template names. 143 | # html_sidebars = {} 144 | 145 | # Additional templates that should be rendered to pages, maps page names to 146 | # template names. 147 | # html_additional_pages = {} 148 | 149 | # If false, no module index is generated. 150 | # html_domain_indices = True 151 | 152 | # If false, no index is generated. 153 | # html_use_index = True 154 | 155 | # If true, the index is split into individual pages for each letter. 156 | # html_split_index = False 157 | 158 | # If true, links to the reST sources are added to the pages. 159 | # html_show_sourcelink = True 160 | 161 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 162 | # html_show_sphinx = True 163 | 164 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 165 | # html_show_copyright = True 166 | 167 | # If true, an OpenSearch description file will be output, and all pages will 168 | # contain a tag referring to it. The value of this option must be the 169 | # base URL from which the finished HTML is served. 170 | # html_use_opensearch = '' 171 | 172 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 173 | # html_file_suffix = None 174 | 175 | # Output file base name for HTML help builder. 176 | htmlhelp_basename = "django-icaldoc" 177 | 178 | 179 | # -- Options for LaTeX output -------------------------------------------------- 180 | 181 | latex_elements = { 182 | # The paper size ('letterpaper' or 'a4paper'). 183 | #'papersize': 'letterpaper', 184 | # The font size ('10pt', '11pt' or '12pt'). 185 | #'pointsize': '10pt', 186 | # Additional stuff for the LaTeX preamble. 187 | #'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, author, documentclass [howto/manual]). 192 | latex_documents = [ 193 | ("index", "django-ical.tex", "django-ical Documentation", "Ian Lewis", "manual") 194 | ] 195 | 196 | # The name of an image file (relative to this directory) to place at the top of 197 | # the title page. 198 | # latex_logo = None 199 | 200 | # For "manual" documents, if this is true, then toplevel headings are parts, 201 | # not chapters. 202 | # latex_use_parts = False 203 | 204 | # If true, show page references after internal links. 205 | # latex_show_pagerefs = False 206 | 207 | # If true, show URL addresses after external links. 208 | # latex_show_urls = False 209 | 210 | # Documents to append as an appendix to all manuals. 211 | # latex_appendices = [] 212 | 213 | # If false, no module index is generated. 214 | # latex_domain_indices = True 215 | 216 | 217 | # -- Options for manual page output -------------------------------------------- 218 | 219 | # One entry per manual page. List of tuples 220 | # (source start file, name, description, authors, manual section). 221 | man_pages = [("index", "django-ical", "django-ical Documentation", ["Ian Lewis"], 1)] 222 | 223 | # If true, show URL addresses after external links. 224 | # man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ( 234 | "index", 235 | "django-ical", 236 | "django-ical Documentation", 237 | "Ian Lewis", 238 | "django-ical", 239 | "One line description of project.", 240 | "Miscellaneous", 241 | ) 242 | ] 243 | 244 | # Documents to append as an appendix to all manuals. 245 | # texinfo_appendices = [] 246 | 247 | # If false, no module index is generated. 248 | # texinfo_domain_indices = True 249 | 250 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 251 | # texinfo_show_urls = 'footnote' 252 | -------------------------------------------------------------------------------- /django_ical/tests/test_feed.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from datetime import datetime 3 | from datetime import timedelta 4 | from os import linesep 5 | 6 | from django.test import TestCase 7 | from django.test.client import RequestFactory 8 | 9 | from dateutil import tz 10 | import icalendar 11 | 12 | from django_ical import utils 13 | from django_ical.feedgenerator import ICal20Feed 14 | from django_ical.views import ICalFeed 15 | 16 | 17 | class TestICalFeed(ICalFeed): 18 | feed_type = ICal20Feed 19 | title = "Test Feed" 20 | description = "Test ICal Feed" 21 | items = [] 22 | 23 | 24 | class TestItemsFeed(ICalFeed): 25 | feed_type = ICal20Feed 26 | title = "Test Feed" 27 | description = "Test ICal Feed" 28 | 29 | def items(self): 30 | return [ 31 | { 32 | "component_type": "event", 33 | "title": "Title1", 34 | "description": "Description1", 35 | "link": "/event/1", 36 | "start": datetime(2012, 5, 1, 18, 0), 37 | "end": datetime(2012, 5, 1, 20, 0), 38 | "recurrences": { 39 | "rrules": [ 40 | utils.build_rrule(freq="DAILY", byhour=10), 41 | utils.build_rrule(freq="MONTHLY", bymonthday=4), 42 | ], 43 | "xrules": [ 44 | utils.build_rrule(freq="MONTHLY", bymonthday=-4), 45 | utils.build_rrule(freq="MONTHLY", byday="+3TU"), 46 | ], 47 | "rdates": [date(1999, 9, 2), date(1998, 1, 1)], 48 | "xdates": [date(1999, 8, 1), date(1998, 2, 1)], 49 | }, 50 | "geolocation": (37.386013, -122.082932), 51 | "organizer": "john.doe@example.com", 52 | "participants": [ 53 | { 54 | "email": "joe.unresponsive@example.com", 55 | "cn": "Joe Unresponsive", 56 | "partstat": "NEEDS-ACTION", 57 | }, 58 | { 59 | "email": "jane.attender@example.com", 60 | "cn": "Jane Attender", 61 | "partstat": "ACCEPTED", 62 | }, 63 | { 64 | "email": "dan.decliner@example.com", 65 | "cn": "Dan Decliner", 66 | "partstat": "DECLINED", 67 | }, 68 | { 69 | "email": "mary.maybe@example.com", 70 | "cn": "Mary Maybe", 71 | "partstat": "TENTATIVE", 72 | }, 73 | ], 74 | "modified": datetime(2012, 5, 2, 10, 0), 75 | }, 76 | { 77 | "component_type": "event", 78 | "title": "Title2", 79 | "description": "Description2", 80 | "link": "/event/2", 81 | "start": datetime(2012, 5, 6, 18, 0), 82 | "end": datetime(2012, 5, 6, 20, 0), 83 | "recurrences": { 84 | "rrules": [ 85 | utils.build_rrule( 86 | freq="WEEKLY", byday=["MO", "TU", "WE", "TH", "FR"] 87 | ) 88 | ], 89 | "xrules": [utils.build_rrule(freq="MONTHLY", byday="-3TU")], 90 | "rdates": [date(1997, 9, 2)], 91 | "xdates": [date(1997, 8, 1)], 92 | }, 93 | "geolocation": (37.386013, -122.082932), 94 | "modified": datetime(2012, 5, 7, 10, 0), 95 | "organizer": { 96 | "cn": "John Doe", 97 | "email": "john.doe@example.com", 98 | "role": "CHAIR", 99 | }, 100 | "categories": ['Cat1', 'Cat2'], 101 | "alarms": [ 102 | { 103 | "trigger": timedelta(minutes=-30), 104 | "action": "DISPLAY", 105 | "description": "Alarm2a", 106 | }, 107 | { 108 | "trigger": timedelta(days=-1), 109 | "action": "DISPLAY", 110 | "description": "Alarm2b", 111 | }, 112 | ], 113 | }, 114 | { 115 | "component_type": "todo", 116 | "title": "Submit Revised Internet-Draft", 117 | "description": "an important test", 118 | "link": "/event/3", 119 | "start": datetime(2007, 5, 14, 0), 120 | "due": datetime(2007, 5, 16, 0), 121 | "completed": datetime(2007, 3, 20), 122 | "priority": 1, 123 | "status": "NEEDS-ACTION", 124 | "organizer": { 125 | "cn": "Bossy Martin", 126 | "email": "bossy.martin@example.com", 127 | "role": "CHAIR" 128 | }, 129 | "modified": datetime(2012, 5, 2, 10, 0), 130 | "geolocation": (37.386013, 2.238985), 131 | "categories": ['CLEANING'], 132 | "percent_complete": 89, 133 | }, 134 | ] 135 | 136 | def item_component_type(self, obj): 137 | return obj.get("component_type", None) 138 | 139 | def item_title(self, obj): 140 | return obj["title"] 141 | 142 | def item_description(self, obj): 143 | return obj["description"] 144 | 145 | def item_start_datetime(self, obj): 146 | return obj["start"] 147 | 148 | def item_end_datetime(self, obj): 149 | return obj.get("end", None) 150 | 151 | def item_due_datetime(self, obj): 152 | return obj.get("due", None) 153 | 154 | def item_rrule(self, obj): 155 | return obj.get("recurrences", {}).get("rrules", None) 156 | 157 | def item_exrule(self, obj): 158 | return obj.get("recurrences", {}).get("xrules", None) 159 | 160 | def item_rdate(self, obj): 161 | return obj.get("recurrences", {}).get("rdates", None) 162 | 163 | def item_exdate(self, obj): 164 | return obj.get("recurrences", {}).get("xdates", None) 165 | 166 | def item_link(self, obj): 167 | return obj["link"] 168 | 169 | def item_geolocation(self, obj): 170 | return obj.get("geolocation", None) 171 | 172 | def item_updateddate(self, obj): 173 | return obj.get("modified", None) 174 | 175 | def item_pubdate(self, obj): 176 | return obj.get("modified", None) 177 | 178 | def item_completed(self, obj): 179 | return obj.get("completed", None) 180 | 181 | def item_percent_complete(self, obj): 182 | return obj.get("percent_complete", None) 183 | 184 | def item_priority(self, obj): 185 | return obj.get("priority", None) 186 | 187 | def item_due(self, obj): 188 | return obj.get("due", None) 189 | 190 | def item_categories(self, obj): 191 | return obj.get("categories") or [] 192 | 193 | def item_organizer(self, obj): 194 | organizer_dic = obj.get("organizer", None) 195 | if organizer_dic: 196 | if isinstance(organizer_dic, dict): 197 | organizer = icalendar.vCalAddress("MAILTO:%s" % organizer_dic["email"]) 198 | for key, val in organizer_dic.items(): 199 | if key != "email": 200 | organizer.params[key] = icalendar.vText(val) 201 | else: 202 | organizer = icalendar.vCalAddress("MAILTO:%s" % organizer_dic) 203 | return organizer 204 | 205 | def item_attendee(self, obj): 206 | """All calendars support ATTENDEE attribute, however, at this time, Apple calendar (desktop & iOS) and Outlook 207 | display event attendees, while Google does not. For SUBSCRIBED calendars it seems that it is not possible to 208 | use the default method to respond. As an alternative, you may review adding custom links to your description 209 | or setting up something like CalDav with authentication, which can enable the ability for attendees to respond 210 | via the default icalendar protocol.""" 211 | participants = obj.get("participants", None) 212 | if participants: 213 | attendee_list = list() 214 | default_attendee_params = { 215 | "cutype": icalendar.vText("INDIVIDUAL"), 216 | "role": icalendar.vText("REQ-PARTICIPANT"), 217 | "rsvp": icalendar.vText( 218 | "TRUE" 219 | ), # Does not seem to work for subscribed calendars. 220 | } 221 | for participant in participants: 222 | attendee = icalendar.vCalAddress("MAILTO:%s" % participant.pop("email")) 223 | participant_dic = default_attendee_params.copy() 224 | participant_dic.update(participant) 225 | for key, val in participant_dic.items(): 226 | attendee.params[key] = icalendar.vText(val) 227 | attendee_list.append(attendee) 228 | return attendee_list 229 | 230 | def item_valarm(self, obj): 231 | alarms = obj.get("alarms", None) 232 | if alarms: 233 | alarm_list = list() 234 | for alarm in alarms: 235 | valarm = icalendar.Alarm() 236 | for key, value in alarm.items(): 237 | valarm.add(key, value) 238 | alarm_list.append(valarm) 239 | return alarm_list 240 | 241 | 242 | class TestFilenameFeed(ICalFeed): 243 | feed_type = ICal20Feed 244 | title = "Test Filename Feed" 245 | description = "Test ICal Feed" 246 | 247 | def get_object(self, request): 248 | return {"id": 123} 249 | 250 | def items(self, obj): 251 | return [obj] 252 | 253 | def file_name(self, obj): 254 | return "%s.ics" % obj["id"] 255 | 256 | def item_link(self, item): 257 | return "" # Required by the syndication framework 258 | 259 | 260 | class ICal20FeedTest(TestCase): 261 | def test_basic(self): 262 | request = RequestFactory().get("/test/ical") 263 | view = TestICalFeed() 264 | 265 | response = view(request) 266 | calendar = icalendar.Calendar.from_ical(response.content) 267 | self.assertEqual(calendar["X-WR-CALNAME"], "Test Feed") 268 | self.assertEqual(calendar["X-WR-CALDESC"], "Test ICal Feed") 269 | 270 | def test_items(self): 271 | request = RequestFactory().get("/test/ical") 272 | view = TestItemsFeed() 273 | 274 | response = view(request) 275 | 276 | calendar = icalendar.Calendar.from_ical(response.content) 277 | self.assertEqual(len(calendar.subcomponents), 3) 278 | 279 | self.assertEqual(calendar.subcomponents[0]["SUMMARY"], "Title1") 280 | self.assertEqual(calendar.subcomponents[0]["DESCRIPTION"], "Description1") 281 | self.assertTrue(calendar.subcomponents[0]["URL"].endswith("/event/1")) 282 | self.assertEqual( 283 | calendar.subcomponents[0]["DTSTART"].to_ical(), b"20120501T180000" 284 | ) 285 | self.assertEqual( 286 | calendar.subcomponents[0]["DTEND"].to_ical(), b"20120501T200000" 287 | ) 288 | self.assertEqual( 289 | calendar.subcomponents[0]["GEO"].to_ical(), "37.386013;-122.082932" 290 | ) 291 | self.assertEqual( 292 | calendar.subcomponents[0]["LAST-MODIFIED"].to_ical(), b"20120502T100000Z" 293 | ) 294 | self.assertEqual( 295 | calendar.subcomponents[0]["ORGANIZER"].to_ical(), 296 | b"MAILTO:john.doe@example.com", 297 | ) 298 | self.assertEqual( 299 | calendar.subcomponents[0]["ATTENDEE"][0].to_ical(), 300 | b"MAILTO:joe.unresponsive@example.com", 301 | ) 302 | self.assertEqual( 303 | calendar.subcomponents[0]["ATTENDEE"][0].params.to_ical(), 304 | b'CN="Joe Unresponsive";CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;' 305 | b"RSVP=TRUE", 306 | ) 307 | self.assertEqual( 308 | calendar.subcomponents[0]["ATTENDEE"][1].to_ical(), 309 | b"MAILTO:jane.attender@example.com", 310 | ) 311 | self.assertEqual( 312 | calendar.subcomponents[0]["ATTENDEE"][1].params.to_ical(), 313 | b'CN="Jane Attender";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;RSVP=TRUE', 314 | ) 315 | self.assertEqual( 316 | calendar.subcomponents[0]["ATTENDEE"][2].to_ical(), 317 | b"MAILTO:dan.decliner@example.com", 318 | ) 319 | self.assertEqual( 320 | calendar.subcomponents[0]["ATTENDEE"][2].params.to_ical(), 321 | b'CN="Dan Decliner";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT;RSVP=TRUE', 322 | ) 323 | self.assertEqual( 324 | calendar.subcomponents[0]["ATTENDEE"][3].to_ical(), 325 | b"MAILTO:mary.maybe@example.com", 326 | ) 327 | self.assertEqual( 328 | calendar.subcomponents[0]["ATTENDEE"][3].params.to_ical(), 329 | b'CN="Mary Maybe";CUTYPE=INDIVIDUAL;PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;RSVP=TRUE', 330 | ) 331 | self.assertEqual( 332 | calendar.subcomponents[0]["RRULE"][0].to_ical(), b"FREQ=DAILY;BYHOUR=10" 333 | ) 334 | self.assertEqual( 335 | calendar.subcomponents[0]["RRULE"][1].to_ical(), 336 | b"FREQ=MONTHLY;BYMONTHDAY=4", 337 | ) 338 | self.assertEqual( 339 | calendar.subcomponents[0]["EXRULE"][0].to_ical(), 340 | b"FREQ=MONTHLY;BYMONTHDAY=-4", 341 | ) 342 | self.assertEqual( 343 | calendar.subcomponents[0]["EXRULE"][1].to_ical(), b"FREQ=MONTHLY;BYDAY=+3TU" 344 | ) 345 | self.assertEqual( 346 | calendar.subcomponents[0]["RDATE"].to_ical(), b"19990902,19980101" 347 | ) 348 | self.assertEqual( 349 | calendar.subcomponents[0]["EXDATE"].to_ical(), b"19990801,19980201" 350 | ) 351 | 352 | self.assertEqual(calendar.subcomponents[1]["SUMMARY"], "Title2") 353 | self.assertEqual(calendar.subcomponents[1]["DESCRIPTION"], "Description2") 354 | self.assertTrue(calendar.subcomponents[1]["URL"].endswith("/event/2")) 355 | self.assertEqual( 356 | calendar.subcomponents[1]["DTSTART"].to_ical(), b"20120506T180000" 357 | ) 358 | self.assertEqual( 359 | calendar.subcomponents[1]["DTEND"].to_ical(), b"20120506T200000" 360 | ) 361 | self.assertEqual( 362 | calendar.subcomponents[1]["RRULE"].to_ical(), 363 | b"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", 364 | ) 365 | self.assertEqual( 366 | calendar.subcomponents[1]["EXRULE"].to_ical(), b"FREQ=MONTHLY;BYDAY=-3TU" 367 | ) 368 | self.assertEqual(calendar.subcomponents[1]["RDATE"].to_ical(), b"19970902") 369 | self.assertEqual(calendar.subcomponents[1]["EXDATE"].to_ical(), b"19970801") 370 | self.assertEqual( 371 | calendar.subcomponents[1]["GEO"].to_ical(), "37.386013;-122.082932" 372 | ) 373 | self.assertEqual( 374 | calendar.subcomponents[1]["LAST-MODIFIED"].to_ical(), b"20120507T100000Z" 375 | ) 376 | self.assertEqual( 377 | calendar.subcomponents[1]["ORGANIZER"].to_ical(), 378 | b"MAILTO:john.doe@example.com", 379 | ) 380 | self.assertEqual( 381 | calendar.subcomponents[1]["CATEGORIES"].to_ical(), b"Cat1,Cat2" 382 | ) 383 | self.assertIn( 384 | b"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Alarm2a\r\nTRIGGER:-PT30M\r\nEND:VALARM\r\n", 385 | [comp.to_ical() for comp in calendar.subcomponents[1].subcomponents], 386 | ) 387 | self.assertIn( 388 | b"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Alarm2b\r\nTRIGGER:-P1D\r\nEND:VALARM\r\n", 389 | [comp.to_ical() for comp in calendar.subcomponents[1].subcomponents], 390 | ) 391 | 392 | self.assertEqual(calendar.subcomponents[2]["SUMMARY"], "Submit Revised Internet-Draft") 393 | self.assertTrue(calendar.subcomponents[2]["URL"].endswith("/event/3")) 394 | self.assertEqual( 395 | calendar.subcomponents[2]["DTSTART"].to_ical(), b"20070514T000000" 396 | ) 397 | self.assertEqual( 398 | calendar.subcomponents[2]["DUE"].to_ical(), b"20070516T000000" 399 | ) 400 | self.assertEqual( 401 | calendar.subcomponents[2]["GEO"].to_ical(), "37.386013;2.238985" 402 | ) 403 | self.assertEqual( 404 | calendar.subcomponents[2]["LAST-MODIFIED"].to_ical(), b"20120502T100000Z" 405 | ) 406 | self.assertEqual( 407 | calendar.subcomponents[2]["ORGANIZER"].to_ical(), 408 | b"MAILTO:bossy.martin@example.com", 409 | ) 410 | self.assertEqual( 411 | calendar.subcomponents[2]["PRIORITY"].to_ical(), b"1" 412 | ) 413 | self.assertEqual( 414 | calendar.subcomponents[2]["CATEGORIES"].to_ical(), b"CLEANING" 415 | ) 416 | self.assertEqual( 417 | calendar.subcomponents[2]["PERCENT-COMPLETE"].to_ical(), b"89" 418 | ) 419 | 420 | def test_wr_timezone(self): 421 | """ 422 | Test for the x-wr-timezone property. 423 | """ 424 | 425 | class TestTimezoneFeed(TestICalFeed): 426 | timezone = "Asia/Tokyo" 427 | 428 | request = RequestFactory().get("/test/ical") 429 | view = TestTimezoneFeed() 430 | 431 | response = view(request) 432 | calendar = icalendar.Calendar.from_ical(response.content) 433 | self.assertEqual(calendar["X-WR-TIMEZONE"], "Asia/Tokyo") 434 | 435 | def test_timezone(self): 436 | tokyo = tz.gettz("Asia/Tokyo") # or JST or Japan Standard Time 437 | us_eastern = tz.gettz("US/Eastern") # or EDT or Eastern (Daylight) Time 438 | 439 | class TestTimezoneFeed(TestItemsFeed): 440 | def items(self): 441 | return [ 442 | { 443 | "title": "Title1", 444 | "description": "Description1", 445 | "link": "/event/1", 446 | "start": datetime(2012, 5, 1, 18, 00, tzinfo=tokyo), 447 | "end": datetime(2012, 5, 1, 20, 00, tzinfo=tokyo), 448 | "recurrences": { 449 | "rrules": [], 450 | "xrules": [], 451 | "rdates": [], 452 | "xdates": [], 453 | }, 454 | }, 455 | { 456 | "title": "Title2", 457 | "description": "Description2", 458 | "link": "/event/2", 459 | "start": datetime(2012, 5, 6, 18, 00, tzinfo=us_eastern), 460 | "end": datetime(2012, 5, 6, 20, 00, tzinfo=us_eastern), 461 | "recurrences": { 462 | "rrules": [], 463 | "xrules": [], 464 | "rdates": [], 465 | "xdates": [], 466 | }, 467 | }, 468 | ] 469 | 470 | request = RequestFactory().get("/test/ical") 471 | view = TestTimezoneFeed() 472 | 473 | response = view(request) 474 | calendar = icalendar.Calendar.from_ical(response.content) 475 | self.assertEqual(len(calendar.subcomponents), 2) 476 | 477 | self.assertEqual( 478 | calendar.subcomponents[0]["DTSTART"].to_ical(), b"20120501T180000" 479 | ) 480 | self.assertEqual(calendar.subcomponents[0]["DTSTART"].params["TZID"], "JST") 481 | 482 | self.assertEqual( 483 | calendar.subcomponents[0]["DTEND"].to_ical(), b"20120501T200000" 484 | ) 485 | self.assertEqual(calendar.subcomponents[0]["DTEND"].params["TZID"], "JST") 486 | 487 | self.assertEqual( 488 | calendar.subcomponents[1]["DTSTART"].to_ical(), b"20120506T180000" 489 | ) 490 | self.assertEqual(calendar.subcomponents[1]["DTSTART"].params["TZID"], "EDT") 491 | 492 | self.assertEqual( 493 | calendar.subcomponents[1]["DTEND"].to_ical(), b"20120506T200000" 494 | ) 495 | self.assertEqual(calendar.subcomponents[1]["DTEND"].params["TZID"], "EDT") 496 | 497 | def test_file_name(self): 498 | request = RequestFactory().get("/test/ical") 499 | view = TestFilenameFeed() 500 | 501 | response = view(request) 502 | 503 | self.assertIn("Content-Disposition", response) 504 | self.assertEqual( 505 | response["content-disposition"], 'attachment; filename="123.ics"' 506 | ) 507 | 508 | def test_file_type(self): 509 | request = RequestFactory().get("/test/ical") 510 | view = TestFilenameFeed() 511 | response = view(request) 512 | self.assertIn("Content-Type", response) 513 | self.assertEqual(response["content-type"], "text/calendar; charset=utf-8") 514 | 515 | def test_file_header(self): 516 | request = RequestFactory().get("/test/ical") 517 | view = TestFilenameFeed() 518 | response = view(request) 519 | header = b"BEGIN:VCALENDAR\r\nVERSION:2.0" 520 | self.assertTrue(response.content.startswith(header)) 521 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | The high-level framework 2 | ======================== 3 | 4 | Overview 5 | -------- 6 | 7 | The high level iCal feed-generating is supplied by the 8 | :class:`ICalFeed ` class. 9 | To create a feed, write a :class:`ICalFeed ` 10 | class and point to an instance of it in your 11 | `URLconf `_. 12 | 13 | With RSS feeds, the items in the feed represent articles or simple web pages. 14 | The :class:`ICalFeed ` class represents an 15 | iCalendar calendar. Calendars contain items which are events. 16 | 17 | Example 18 | ------- 19 | 20 | Let's look at a simple example. Here the item_start_datetime is a django-ical 21 | extension that supplies the start time of the event. 22 | 23 | .. code-block:: python 24 | 25 | from django_ical.views import ICalFeed 26 | from examplecom.models import Event 27 | 28 | class EventFeed(ICalFeed): 29 | """ 30 | A simple event calender 31 | """ 32 | product_id = '-//example.com//Example//EN' 33 | timezone = 'UTC' 34 | file_name = "event.ics" 35 | 36 | def items(self): 37 | return Event.objects.all().order_by('-start_datetime') 38 | 39 | def item_title(self, item): 40 | return item.title 41 | 42 | def item_description(self, item): 43 | return item.description 44 | 45 | def item_start_datetime(self, item): 46 | return item.start_datetime 47 | 48 | To connect a URL to this calendar, put an instance of the EventFeed object in 49 | your URLconf. For example: 50 | 51 | .. code-block:: python 52 | 53 | from django.conf.urls import patterns, url, include 54 | from myproject.feeds import EventFeed 55 | 56 | urlpatterns = [ 57 | # ... 58 | path('latest/feed.ics', EventFeed()), 59 | # ... 60 | ] 61 | 62 | Example how recurrences are built using the django-recurrence_ package: 63 | 64 | .. code-block:: python 65 | 66 | from django_ical.utils import build_rrule_from_recurrences_rrule 67 | from django_ical.views import ICalFeed 68 | from examplecom.models import Event 69 | 70 | class EventFeed(ICalFeed): 71 | """ 72 | A simple event calender 73 | """ 74 | # ... 75 | 76 | def item_rrule(self, item): 77 | """Adapt Event recurrence to Feed Entry rrule.""" 78 | if item.recurrences: 79 | rules = [] 80 | for rule in item.recurrences.rrules: 81 | rules.append(build_rrule_from_recurrences_rrule(rule)) 82 | return rules 83 | 84 | def item_exrule(self, item): 85 | """Adapt Event recurrence to Feed Entry exrule.""" 86 | if item.recurrences: 87 | rules = [] 88 | for rule in item.recurrences.exrules: 89 | rules.append(build_rrule_from_recurrences_rrule(rule)) 90 | return rules 91 | 92 | def item_rdate(self, item): 93 | """Adapt Event recurrence to Feed Entry rdate.""" 94 | if item.recurrences: 95 | return item.recurrences.rdates 96 | 97 | def item_exdate(self, item): 98 | """Adapt Event recurrence to Feed Entry exdate.""" 99 | if item.recurrences: 100 | return item.recurrences.exdates 101 | 102 | Note that in ``django_ical.utils`` are also convienience methods to build ``rrules`` from 103 | scratch, from string (serialized iCal) and ``dateutil.rrule``. 104 | 105 | 106 | File Downloads 107 | -------------- 108 | 109 | The `file_name` parameter is an optional used as base name when generating the file. By 110 | default django-ical will not set the Content-Disposition header of the response. By setting 111 | the file_name parameter you can cause django_ical to set the Content-Disposition header 112 | and set the file name. In the example below, it will be called "event.ics". 113 | 114 | .. code-block:: python 115 | 116 | class EventFeed(ICalFeed): 117 | """ 118 | A simple event calender 119 | """ 120 | product_id = '-//example.com//Example//EN' 121 | timezone = 'UTC' 122 | file_name = "event.ics" 123 | 124 | # ... 125 | 126 | The `file_name` parameter can be a method like other properties. Here we can set 127 | the file name to include the id of the object returned by `get_object()`. 128 | 129 | .. code-block:: python 130 | 131 | class EventFeed(ICalFeed): 132 | """ 133 | A simple event calender 134 | """ 135 | product_id = '-//example.com//Example//EN' 136 | timezone = 'UTC' 137 | 138 | def file_name(self, obj): 139 | return "feed_%s.ics" % obj.id 140 | 141 | # ... 142 | 143 | 144 | Alarms 145 | ------ 146 | 147 | Alarms must be `icalendar.Alarm` objects, a list is expected as the return value for 148 | `item_valarm`. 149 | 150 | .. code-block:: python 151 | 152 | from icalendar import Alarm 153 | from datetime import timedelta 154 | 155 | def item_valarm(self, item): 156 | valarm = Alarm() 157 | valarm.add('action', 'display') 158 | valarm.add('description', 'Your message text') 159 | valarm.add('trigger', timedelta(days=-1)) 160 | return [valarm] 161 | 162 | 163 | Tasks (Todos) 164 | ------------- 165 | 166 | It is also possible to generate representations of tasks (or deadlines, todos) 167 | which are represented in iCal with the dedicated `VTODO` component instead of the usual `VEVENT`. 168 | 169 | To do so, you can use a specific method to determine which type of component a given item 170 | should be translated as: 171 | 172 | .. code-block:: python 173 | 174 | from django_ical.views import ICalFeed 175 | from examplecom.models import Deadline 176 | 177 | class EventFeed(ICalFeed): 178 | """ 179 | A simple event calender with tasks 180 | """ 181 | product_id = '-//example.com//Example//EN' 182 | timezone = 'UTC' 183 | file_name = "event.ics" 184 | 185 | def items(self): 186 | return Deadline.objects.all().order_by('-due_datetime') 187 | 188 | def item_component_type(self): 189 | return 'todo' # could also be 'event', which is the default 190 | 191 | def item_title(self, item): 192 | return item.title 193 | 194 | def item_description(self, item): 195 | return item.description 196 | 197 | def item_due_datetime(self, item): 198 | return item.due_datetime 199 | 200 | 201 | 202 | 203 | Property Reference and Extensions 204 | --------------------------------- 205 | 206 | django-ical adds a number of extensions to the base syndication framework in 207 | order to support iCalendar feeds and ignores many fields used in RSS feeds. 208 | Here is a table of all of the fields that django-ical supports. 209 | 210 | +-----------------------+-----------------------+-----------------------------+ 211 | | Property name | iCalendar field name | Description | 212 | +=======================+=======================+=============================+ 213 | | product_id | `PRODID`_ | The calendar product ID | 214 | +-----------------------+-----------------------+-----------------------------+ 215 | | timezone | `X-WR-TIMEZONE`_ | The calendar timezone | 216 | +-----------------------+-----------------------+-----------------------------+ 217 | | title | `X-WR-CALNAME`_ | The calendar name/title | 218 | +-----------------------+-----------------------+-----------------------------+ 219 | | description | `X-WR-CALDESC`_ | The calendar description | 220 | +-----------------------+-----------------------+-----------------------------+ 221 | | method | `METHOD`_ | The calendar method such as | 222 | | | | meeting requests. | 223 | +-----------------------+-----------------------+-----------------------------+ 224 | | item_guid | `UID`_ | The event's unique id. | 225 | | | | This id should be | 226 | | | | *globally* unique so you | 227 | | | | should add an | 228 | | | | @ to your id. | 229 | +-----------------------+-----------------------+-----------------------------+ 230 | | item_title | `SUMMARY`_ | The event name/title | 231 | +-----------------------+-----------------------+-----------------------------+ 232 | | item_description | `DESCRIPTION`_ | The event description | 233 | +-----------------------+-----------------------+-----------------------------+ 234 | | item_link | `URL`_ | The event url | 235 | +-----------------------+-----------------------+-----------------------------+ 236 | | item_class | `CLASS`_ | The event class | 237 | | | | (e.g. PUBLIC, PRIVATE, | 238 | | | | CONFIDENTIAL) | 239 | +-----------------------+-----------------------+-----------------------------+ 240 | | item_created | `CREATED`_ | The event create time | 241 | +-----------------------+-----------------------+-----------------------------+ 242 | | item_updateddate | `LAST-MODIFIED`_ | The event modified time | 243 | +-----------------------+-----------------------+-----------------------------+ 244 | | item_start_datetime | `DTSTART`_ | The event start time | 245 | +-----------------------+-----------------------+-----------------------------+ 246 | | item_end_datetime | `DTEND`_ | The event end time | 247 | +-----------------------+-----------------------+-----------------------------+ 248 | | item_location | `LOCATION`_ | The event location | 249 | +-----------------------+-----------------------+-----------------------------+ 250 | | item_geolocation | `GEO`_ | The latitude and longitude | 251 | | | | of the event. The value | 252 | | | | returned by this property | 253 | | | | should be a two-tuple | 254 | | | | containing the latitude and | 255 | | | | longitude as float values. | 256 | | | | semicolon. Ex: | 257 | | | | *(37.386013, -122.082932)* | 258 | +-----------------------+-----------------------+-----------------------------+ 259 | | item_transparency | `TRANSP`_ | The event transparency. | 260 | | | | Defines whether the event | 261 | | | | shows up in busy searches. | 262 | | | | (e.g. OPAQUE, TRANSPARENT) | 263 | +-----------------------+-----------------------+-----------------------------+ 264 | | item_organizer | `ORGANIZER`_ | The event organizer. | 265 | | | | Expected to be a | 266 | | | | vCalAddress object. See | 267 | | | | `iCalendar`_ documentation | 268 | | | | or tests to know how to | 269 | | | | build them. | 270 | +-----------------------+-----------------------+-----------------------------+ 271 | | item_attendee | `ATTENDEE`_ | The event attendees. | 272 | | | | Expected to be a list of | 273 | | | | vCalAddress objects. See | 274 | | | | `iCalendar`_ documentation | 275 | | | | or tests to know how to | 276 | | | | build them. | 277 | +-----------------------+-----------------------+-----------------------------+ 278 | | item_rrule | `RRULE`_ | The recurrence rule for | 279 | | | | repeating events. | 280 | | | | See `iCalendar`_ | 281 | | | | documentation or tests to | 282 | | | | know how to build them. | 283 | +-----------------------+-----------------------+-----------------------------+ 284 | | item_rdate | `RDATE`_ | The recurring dates/times | 285 | | | | for a repeating event. | 286 | | | | See `iCalendar`_ | 287 | | | | documentation or tests to | 288 | | | | know how to build them. | 289 | +-----------------------+-----------------------+-----------------------------+ 290 | | item_exdate | `EXDATE`_ | The dates/times for | 291 | | | | exceptions of a recurring | 292 | | | | event. | 293 | | | | See `iCalendar`_ | 294 | | | | documentation or tests to | 295 | | | | know how to build them. | 296 | +-----------------------+-----------------------+-----------------------------+ 297 | | item_valarm | `VALARM`_ | Alarms for the event, must | 298 | | | | be a list of Alarm objects. | 299 | | | | See `iCalendar`_ | 300 | | | | documentation or tests to | 301 | | | | know how to build them. | 302 | +-----------------------+-----------------------+-----------------------------+ 303 | | item_status | `STATUS`_ | The status of an event. | 304 | | | | Can be CONFIRMED, CANCELLED | 305 | | | | or TENTATIVE. | 306 | +-----------------------+-----------------------+-----------------------------+ 307 | | item_completed | `COMPLETED`_ | The date a task was | 308 | | | | completed. | 309 | +-----------------------+-----------------------+-----------------------------+ 310 | | item_percent_complete | `PERCENT-COMPLETE`_ | A number from 0 to 100 | 311 | | | | indication the completion | 312 | | | | of the task. | 313 | +-----------------------+-----------------------+-----------------------------+ 314 | | item_priority | `PRIORITY`_ | An integer from 0 to 9. | 315 | | | | 0 means undefined. | 316 | | | | 1 means highest priority. | 317 | +-----------------------+-----------------------+-----------------------------+ 318 | | item_due | `DUE`_ | The date a task is due. | 319 | +-----------------------+-----------------------+-----------------------------+ 320 | | item_categories | `CATEGORIES`_ | A list of strings, each | 321 | | | | being a category of the | 322 | | | | task. | 323 | +-----------------------+-----------------------+-----------------------------+ 324 | | calscale | `CALSCALE`_ | Not yet documented. | 325 | +-----------------------+-----------------------+-----------------------------+ 326 | | method | `METHOD`_ | Not yet documented. | 327 | +-----------------------+-----------------------+-----------------------------+ 328 | | prodid | `PRODID`_ | Not yet documented. | 329 | +-----------------------+-----------------------+-----------------------------+ 330 | | version | `VERSION`_ | Not yet documented. | 331 | +-----------------------+-----------------------+-----------------------------+ 332 | | attach | `ATTACH`_ | Not yet documented. | 333 | +-----------------------+-----------------------+-----------------------------+ 334 | | class | `CLASS`_ | Not yet documented. | 335 | +-----------------------+-----------------------+-----------------------------+ 336 | | comment | `COMMENT`_ | Not yet documented. | 337 | +-----------------------+-----------------------+-----------------------------+ 338 | | resources | `RESOURCES`_ | Not yet documented. | 339 | +-----------------------+-----------------------+-----------------------------+ 340 | | duration | `DURATION`_ | Not yet documented. | 341 | +-----------------------+-----------------------+-----------------------------+ 342 | | freebusy | `FREEBUSY`_ | Not yet documented. | 343 | +-----------------------+-----------------------+-----------------------------+ 344 | | tzid | `TZID`_ | Not yet documented. | 345 | +-----------------------+-----------------------+-----------------------------+ 346 | | tzname | `TZNAME`_ | Not yet documented. | 347 | +-----------------------+-----------------------+-----------------------------+ 348 | | tzoffsetfrom | `TZOFFSETFROM`_ | Not yet documented. | 349 | +-----------------------+-----------------------+-----------------------------+ 350 | | tzoffsetto | `TZOFFSETTO`_ | Not yet documented. | 351 | +-----------------------+-----------------------+-----------------------------+ 352 | | tzurl | `TZURL`_ | Not yet documented. | 353 | +-----------------------+-----------------------+-----------------------------+ 354 | | contact | `CONTACT`_ | Not yet documented. | 355 | +-----------------------+-----------------------+-----------------------------+ 356 | | recurrence_id | `RECURRENCE_ID`_ | Not yet documented. | 357 | +-----------------------+-----------------------+-----------------------------+ 358 | | related_to | `RELATED_TO`_ | Not yet documented. | 359 | +-----------------------+-----------------------+-----------------------------+ 360 | | action | `ACTION`_ | Not yet documented. | 361 | +-----------------------+-----------------------+-----------------------------+ 362 | | repeat | `REPEAT`_ | Not yet documented. | 363 | +-----------------------+-----------------------+-----------------------------+ 364 | | trigger | `TRIGGER`_ | Not yet documented. | 365 | +-----------------------+-----------------------+-----------------------------+ 366 | | sequence | `SEQUENCE`_ | Not yet documented. | 367 | +-----------------------+-----------------------+-----------------------------+ 368 | | request_status | `REQUEST_STATUS`_ | Not yet documented. | 369 | +-----------------------+-----------------------+-----------------------------+ 370 | 371 | 372 | .. note:: 373 | django-ical does not use the ``link`` property required by the Django 374 | syndication framework. 375 | 376 | The low-level framework 377 | ======================== 378 | 379 | Behind the scenes, the high-level iCalendar framework uses a lower-level 380 | framework for generating feeds' ical data. This framework lives in a single 381 | module: :mod:`django_ical.feedgenerator`. 382 | 383 | You use this framework on your own, for lower-level feed generation. You can 384 | also create custom feed generator subclasses for use with the feed_type 385 | option. 386 | 387 | See: `The syndication feed framework: Specifying the type of feed `_ 388 | 389 | .. _PRODID: http://www.kanzaki.com/docs/ical/prodid.html 390 | .. _METHOD: http://www.kanzaki.com/docs/ical/method.html 391 | .. _SUMMARY: http://www.kanzaki.com/docs/ical/summary.html 392 | .. _DESCRIPTION: http://www.kanzaki.com/docs/ical/description.html 393 | .. _UID: http://www.kanzaki.com/docs/ical/uid.html 394 | .. _CLASS: http://www.kanzaki.com/docs/ical/class.html 395 | .. _CREATED: http://www.kanzaki.com/docs/ical/created.html 396 | .. _LAST-MODIFIED: http://www.kanzaki.com/docs/ical/lastModified.html 397 | .. _DTSTART: http://www.kanzaki.com/docs/ical/dtstart.html 398 | .. _DTEND: http://www.kanzaki.com/docs/ical/dtend.html 399 | .. _GEO: http://www.kanzaki.com/docs/ical/geo.html 400 | .. _LOCATION: http://www.kanzaki.com/docs/ical/location.html 401 | .. _TRANSP: http://www.kanzaki.com/docs/ical/transp.html 402 | .. _URL: http://www.kanzaki.com/docs/ical/url.html 403 | .. _ORGANIZER: http://www.kanzaki.com/docs/ical/organizer.html 404 | .. _ATTENDEE: https://www.kanzaki.com/docs/ical/attendee.html 405 | .. _RRULE: https://www.kanzaki.com/docs/ical/rrule.html 406 | .. _EXRULE: https://www.kanzaki.com/docs/ical/exrule.html 407 | .. _RDATE: https://www.kanzaki.com/docs/ical/rdate.html 408 | .. _EXDATE: https://www.kanzaki.com/docs/ical/exdate.html 409 | .. _STATUS: https://www.kanzaki.com/docs/ical/status.html 410 | .. _VALARM: https://www.kanzaki.com/docs/ical/valarm.html 411 | .. _COMPLETED: https://www.kanzaki.com/docs/ical/completed.html 412 | .. _PERCENT-COMPLETE: https://www.kanzaki.com/docs/ical/percentComplete.html 413 | .. _PRIORITY: https://www.kanzaki.com/docs/ical/priority.html 414 | .. _DUE: https://www.kanzaki.com/docs/ical/due.html 415 | .. _CALSCALE: https://www.kanzaki.com/docs/ical/calscale.html 416 | .. _METHOD: https://www.kanzaki.com/docs/ical/method.html 417 | .. _PRODID: https://www.kanzaki.com/docs/ical/prodid.html 418 | .. _VERSION: https://www.kanzaki.com/docs/ical/version.html 419 | .. _ATTACH: https://www.kanzaki.com/docs/ical/attach.html 420 | .. _CLASS: https://www.kanzaki.com/docs/ical/class.html 421 | .. _COMMENT: https://www.kanzaki.com/docs/ical/comment.html 422 | .. _RESOURCES: https://www.kanzaki.com/docs/ical/resources.html 423 | .. _DURATION: https://www.kanzaki.com/docs/ical/duration.html 424 | .. _FREEBUSY: https://www.kanzaki.com/docs/ical/freebusy.html 425 | .. _TZID: https://www.kanzaki.com/docs/ical/tzid.html 426 | .. _TZNAME: https://www.kanzaki.com/docs/ical/tzname.html 427 | .. _TZOFFSETFROM: https://www.kanzaki.com/docs/ical/tzoffsetfrom.html 428 | .. _TZOFFSETTO: https://www.kanzaki.com/docs/ical/tzoffsetto.html 429 | .. _TZURL: https://www.kanzaki.com/docs/ical/tzurl.html 430 | .. _CONTACT: https://www.kanzaki.com/docs/ical/contact.html 431 | .. _RECURRENCE-ID: https://www.kanzaki.com/docs/ical/recurrenceId.html 432 | .. _RELATED-TO: https://www.kanzaki.com/docs/ical/relatedTo.html 433 | .. _ACTION: https://www.kanzaki.com/docs/ical/action.html 434 | .. _REPEAT: https://www.kanzaki.com/docs/ical/repeat.html 435 | .. _TRIGGER: https://www.kanzaki.com/docs/ical/trigger.html 436 | .. _SEQUENCE: https://www.kanzaki.com/docs/ical/sequence.html 437 | .. _REQUEST-STATUS: https://icalendar.org/iCalendar-RFC-5545/3-8-8-3-request-status.html 438 | .. _X-WR-CALNAME: http://en.wikipedia.org/wiki/ICalendar#Calendar_extensions 439 | .. _X-WR-CALDESC: http://en.wikipedia.org/wiki/ICalendar#Calendar_extensions 440 | .. _X-WR-TIMEZONE: http://en.wikipedia.org/wiki/ICalendar#Calendar_extensions 441 | .. _iCalendar: http://icalendar.readthedocs.org/en/latest/index.html 442 | .. _CATEGORIES: https://www.kanzaki.com/docs/ical/categories.html 443 | .. _django-recurrence: https://github.com/django-recurrence/django-recurrence 444 | -------------------------------------------------------------------------------- /django_ical/tests/test_recurrence.py: -------------------------------------------------------------------------------- 1 | """Test calendar rrules.""" 2 | 3 | import datetime 4 | 5 | from django.test import TestCase 6 | 7 | from dateutil import tz 8 | from dateutil.rrule import DAILY 9 | from dateutil.rrule import MO 10 | from dateutil.rrule import MONTHLY 11 | from dateutil.rrule import TH 12 | from dateutil.rrule import TU 13 | from dateutil.rrule import WEEKLY 14 | from dateutil.rrule import YEARLY 15 | from dateutil.rrule import rrule 16 | from icalendar.prop import vRecur 17 | import recurrence 18 | 19 | from django_ical import utils 20 | 21 | 22 | class BuildRruleTest(TestCase): 23 | """Test building an Rrule for icalendar.""" 24 | 25 | def test_every_day(self): 26 | """Repeat every day.""" 27 | vrecurr = vRecur(utils.build_rrule(freq="DAILY")) 28 | assert vrecurr["FREQ"] == "DAILY" 29 | assert vrecurr.to_ical().decode() == "FREQ=DAILY" 30 | assert len(vrecurr.keys()) == 1 31 | 32 | def test_daily_byhour(self): 33 | """Repeat every day at 10, 12 and 17.""" 34 | vrecurr = utils.build_rrule(freq="DAILY", byhour=[10, 12, 17]) 35 | assert vrecurr["FREQ"] == "DAILY" 36 | assert vrecurr["BYHOUR"] == [10, 12, 17] 37 | vRecur(vrecurr).to_ical().decode() == "FREQ=DAILY;BYHOUR=10,12,17" 38 | assert len(vrecurr.keys()) == 2 39 | 40 | def test_daily_byhour_once(self): 41 | """Repeat every day at 10.""" 42 | vrecurr = utils.build_rrule(freq="DAILY", byhour=10) 43 | assert vrecurr["FREQ"] == "DAILY" 44 | assert vrecurr["BYHOUR"] == 10 45 | vRecur(vrecurr).to_ical().decode() == "FREQ=DAILY;BYHOUR=10" 46 | assert len(vrecurr.keys()) == 2 47 | 48 | def test_every_week(self): 49 | """Repeat every week.""" 50 | vrecurr = utils.build_rrule(freq="WEEKLY") 51 | assert vrecurr["FREQ"] == "WEEKLY" 52 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY" 53 | assert len(vrecurr.keys()) == 1 54 | 55 | def test_ever_hour(self): 56 | """Repeat every hour.""" 57 | vrecurr = utils.build_rrule(freq="HOURLY") 58 | assert vrecurr["FREQ"] == "HOURLY" 59 | vRecur(vrecurr).to_ical().decode() == "FREQ=HOURLY" 60 | assert len(vrecurr.keys()) == 1 61 | 62 | def test_ever_4_hours(self): 63 | """Repeat every 4 hours.""" 64 | vrecurr = utils.build_rrule(interval=4, freq="HOURLY") 65 | assert vrecurr["FREQ"] == "HOURLY" 66 | assert vrecurr["INTERVAL"] == 4 67 | vRecur(vrecurr).to_ical().decode() == "FREQ=HOURLY;INTERVAL=4" 68 | assert len(vrecurr.keys()) == 2 69 | 70 | def test_weekly_tue(self): 71 | """Repeat every week on Tuesday.""" 72 | vrecurr = utils.build_rrule(freq="WEEKLY", byday="TU") 73 | assert vrecurr["FREQ"] == "WEEKLY" 74 | assert vrecurr["BYDAY"] == "TU" 75 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;BYDAY=TU" 76 | assert len(vrecurr.keys()) == 2 77 | 78 | def test_weekly_mo_wed(self): 79 | """Repeat every week on Monday, Wednesday.""" 80 | vrecurr = utils.build_rrule(freq="WEEKLY", byday=["MO", "WE"]) 81 | assert vrecurr["FREQ"] == "WEEKLY" 82 | assert vrecurr["BYDAY"] == ["MO", "WE"] 83 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;BYDAY=MO,WE" 84 | assert len(vrecurr.keys()) == 2 85 | 86 | def test_every_weekday(self): 87 | """Repeat every weekday.""" 88 | vrecurr = utils.build_rrule(freq="WEEKLY", byday=["MO", "TU", "WE", "TH", "FR"]) 89 | assert vrecurr["FREQ"] == "WEEKLY" 90 | assert vrecurr["BYDAY"] == ["MO", "TU", "WE", "TH", "FR"] 91 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" 92 | assert len(vrecurr.keys()) == 2 93 | 94 | def test_every_2_weeks(self): 95 | """Repeat every 2 weeks.""" 96 | vrecurr = utils.build_rrule(interval=2, freq="WEEKLY") 97 | assert vrecurr["FREQ"] == "WEEKLY" 98 | assert vrecurr["INTERVAL"] == 2 99 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;INTERVAL=2" 100 | assert len(vrecurr.keys()) == 2 101 | 102 | def test_every_month(self): 103 | """Repeat every month.""" 104 | vrecurr = utils.build_rrule(freq="MONTHLY") 105 | assert vrecurr["FREQ"] == "MONTHLY" 106 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY" 107 | assert len(vrecurr.keys()) == 1 108 | 109 | def test_every_6_months(self): 110 | """Repeat very 6 months.""" 111 | vrecurr = utils.build_rrule(interval=6, freq="MONTHLY") 112 | assert vrecurr["FREQ"] == "MONTHLY" 113 | assert vrecurr["INTERVAL"] == 6 114 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;INTERVAL=6" 115 | assert len(vrecurr.keys()) == 2 116 | 117 | def test_every_year(self): 118 | """Repeat every year.""" 119 | vrecurr = utils.build_rrule(freq="YEARLY") 120 | assert vrecurr["FREQ"] == "YEARLY" 121 | vRecur(vrecurr).to_ical().decode() == "FREQ=YEARLY" 122 | assert len(vrecurr.keys()) == 1 123 | 124 | def test_every_month_on_the_4th(self): 125 | """Repeat every month on the 4th.""" 126 | vrecurr = utils.build_rrule(freq="MONTHLY", bymonthday=4) 127 | assert vrecurr["FREQ"] == "MONTHLY" 128 | assert vrecurr["BYMONTHDAY"] == 4 129 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=4" 130 | assert len(vrecurr.keys()) == 2 131 | 132 | def test_every_month_on_the_4th_last(self): 133 | """Repeat every month on the 4th last.""" 134 | vrecurr = utils.build_rrule(freq="MONTHLY", bymonthday=-4) 135 | assert vrecurr["FREQ"] == "MONTHLY" 136 | assert vrecurr["BYMONTHDAY"] == -4 137 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=-4" 138 | assert len(vrecurr.keys()) == 2 139 | 140 | def test_ever_month_3rd_tu(self): 141 | """Repeat every month on the 3rd Tuesday.""" 142 | vrecurr = utils.build_rrule(freq="MONTHLY", byday="+3TU") 143 | assert vrecurr["FREQ"] == "MONTHLY" 144 | assert vrecurr["BYDAY"] == "+3TU" 145 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=+3TU" 146 | assert len(vrecurr.keys()) == 2 147 | 148 | def test_ever_month_3rd_last_tu(self): 149 | """Repeat every month on the 3rd last Tuesday.""" 150 | vrecurr = utils.build_rrule(freq="MONTHLY", byday="-3TU") 151 | assert vrecurr["FREQ"] == "MONTHLY" 152 | assert vrecurr["BYDAY"] == "-3TU" 153 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=-3TU" 154 | assert len(vrecurr.keys()) == 2 155 | 156 | def test_ever_month_last_mo(self): 157 | """Repeat every month on the last Monday.""" 158 | vrecurr = utils.build_rrule(freq="MONTHLY", byday="-1MO") 159 | assert vrecurr["FREQ"] == "MONTHLY" 160 | assert vrecurr["BYDAY"] == "-1MO" 161 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=-1MO" 162 | assert len(vrecurr.keys()) == 2 163 | 164 | def test_every_week_until_jan_2007(self): 165 | """Repeat every week until January 1, 2007.""" 166 | utc = tz.UTC 167 | jan2007 = datetime.datetime(2007, 1, 1, 0, 0, tzinfo=utc) 168 | vrecurr = utils.build_rrule(freq="WEEKLY", until=jan2007) 169 | assert vrecurr["FREQ"] == "WEEKLY" 170 | assert vrecurr["UNTIL"] == jan2007 171 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;UNTIL=20070101T000000Z" 172 | assert len(vrecurr.keys()) == 2 173 | 174 | def test_every_week_20_times(self): 175 | """Repeat every week for 20 times.""" 176 | vrecurr = utils.build_rrule(freq="WEEKLY", count=20) 177 | assert vrecurr["FREQ"] == "WEEKLY" 178 | assert vrecurr["COUNT"] == 20 179 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;COUNT=20" 180 | assert len(vrecurr.keys()) == 2 181 | 182 | def test_every_month_last_working_day(self): 183 | """Repeat the last working day of each month.""" 184 | vrecurr = utils.build_rrule( 185 | freq="MONTHLY", byday=["MO", "TU", "WE", "TH", "FR"], bysetpos=-1 186 | ) 187 | assert vrecurr["FREQ"] == "MONTHLY" 188 | assert vrecurr["BYDAY"] == ["MO", "TU", "WE", "TH", "FR"] 189 | assert vrecurr["BYSETPOS"] == -1 190 | vRecur( 191 | vrecurr 192 | ).to_ical().decode() == "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" 193 | assert len(vrecurr.keys()) == 3 194 | 195 | def test_ever_month_last_day(self): 196 | """Repeat the last day of each month.""" 197 | vrecurr = utils.build_rrule(freq="MONTHLY", bymonthday=-1) 198 | assert vrecurr["FREQ"] == "MONTHLY" 199 | assert vrecurr["BYMONTHDAY"] == -1 200 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=-1" 201 | assert len(vrecurr.keys()) == 2 202 | 203 | def test_every_day_in_jan(self): 204 | """Repeat every day in January""" 205 | vrecurr = utils.build_rrule( 206 | freq="YEARLY", bymonth=1, byday=["MO", "TU", "WE", "TH", "FR", "SA", "SU"] 207 | ) 208 | assert vrecurr["FREQ"] == "YEARLY" 209 | assert vrecurr["BYMONTH"] == 1 210 | assert vrecurr["BYDAY"] == ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] 211 | vRecur( 212 | vrecurr 213 | ).to_ical().decode() == "FREQ=YEARLY;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYMONTH=1" 214 | assert len(vrecurr.keys()) == 3 215 | 216 | def test_every_2nd_15th_of_month(self): 217 | """Repeat monthly on the 2nd and 15th of the month.""" 218 | vrecurr = utils.build_rrule(freq="MONTHLY", bymonthday=[4, 15]) 219 | assert vrecurr["FREQ"] == "MONTHLY" 220 | assert vrecurr["BYMONTHDAY"] == [4, 15] 221 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=4,15" 222 | assert len(vrecurr.keys()) == 2 223 | 224 | def test_every_fr_13th(self): 225 | """Repeat every Friday the 13th.""" 226 | vrecurr = utils.build_rrule(freq="YEARLY", bymonthday=13, byday="FR") 227 | assert vrecurr["FREQ"] == "YEARLY" 228 | assert vrecurr["BYMONTHDAY"] == 13 229 | assert vrecurr["BYDAY"] == "FR" 230 | vRecur(vrecurr).to_ical().decode() == "FREQ=YEARLY;BYDAY=FR;BYMONTHDAY=13" 231 | assert len(vrecurr.keys()) == 3 232 | 233 | 234 | class FromTextTests(TestCase): 235 | """Test build a vRecur dictionary from an RRULE string.""" 236 | 237 | def test_every_day(self): 238 | """Repeat every day.""" 239 | vrecurr = utils.build_rrule_from_text("FREQ=DAILY") 240 | assert vrecurr["FREQ"] == ["DAILY"] 241 | vRecur(vrecurr).to_ical().decode() == "FREQ=DAILY" 242 | assert len(vrecurr.keys()) == 1 243 | 244 | def test_daily_byhour(self): 245 | """Repeat every day at 10, 12 and 17.""" 246 | vrecurr = utils.build_rrule_from_text("FREQ=DAILY;BYHOUR=10,12,17") 247 | assert vrecurr["FREQ"] == ["DAILY"] 248 | assert vrecurr["BYHOUR"] == [10, 12, 17] 249 | vRecur(vrecurr).to_ical().decode() == "FREQ=DAILY;BYHOUR=10,12,17" 250 | assert len(vrecurr.keys()) == 2 251 | 252 | def test_every_week(self): 253 | """Repeat every week.""" 254 | vrecurr = utils.build_rrule_from_text("FREQ=WEEKLY") 255 | assert vrecurr["FREQ"] == ["WEEKLY"] 256 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY" 257 | assert len(vrecurr.keys()) == 1 258 | 259 | def test_ever_hour(self): 260 | """Repeat every hour.""" 261 | vrecurr = utils.build_rrule_from_text("FREQ=HOURLY") 262 | assert vrecurr["FREQ"] == ["HOURLY"] 263 | vRecur(vrecurr).to_ical().decode() == "FREQ=HOURLY" 264 | assert len(vrecurr.keys()) == 1 265 | 266 | def test_ever_4_hours(self): 267 | """Repeat every 4 hours.""" 268 | vrecurr = utils.build_rrule_from_text("INTERVAL=4;FREQ=HOURLY") 269 | assert vrecurr["FREQ"] == ["HOURLY"] 270 | assert vrecurr["INTERVAL"] == [4] 271 | vRecur(vrecurr).to_ical().decode() == "FREQ=HOURLY;INTERVAL=4" 272 | assert len(vrecurr.keys()) == 2 273 | 274 | def test_weekly_tue(self): 275 | """Repeat every week on Tuesday.""" 276 | vrecurr = utils.build_rrule_from_text("FREQ=WEEKLY;BYDAY=TU") 277 | assert vrecurr["FREQ"] == ["WEEKLY"] 278 | assert vrecurr["BYDAY"] == ["TU"] 279 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;BYDAY=TU" 280 | assert len(vrecurr.keys()) == 2 281 | 282 | def test_weekly_mo_wed(self): 283 | """Repeat every week on Monday, Wednesday.""" 284 | vrecurr = utils.build_rrule_from_text("FREQ=WEEKLY;BYDAY=MO,WE") 285 | assert vrecurr["FREQ"] == ["WEEKLY"] 286 | assert vrecurr["BYDAY"] == ["MO", "WE"] 287 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;BYDAY=MO,WE" 288 | assert len(vrecurr.keys()) == 2 289 | 290 | def test_every_weekday(self): 291 | """Repeat every weekday.""" 292 | vrecurr = utils.build_rrule_from_text("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR") 293 | assert vrecurr["FREQ"] == ["WEEKLY"] 294 | assert vrecurr["BYDAY"] == ["MO", "TU", "WE", "TH", "FR"] 295 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" 296 | assert len(vrecurr.keys()) == 2 297 | 298 | def test_every_2_weeks(self): 299 | """Repeat every 2 weeks.""" 300 | vrecurr = utils.build_rrule_from_text("INTERVAL=2;FREQ=WEEKLY") 301 | assert vrecurr["FREQ"] == ["WEEKLY"] 302 | assert vrecurr["INTERVAL"] == [2] 303 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;INTERVAL=2" 304 | assert len(vrecurr.keys()) == 2 305 | 306 | def test_every_month(self): 307 | """Repeat every month.""" 308 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY") 309 | assert vrecurr["FREQ"] == ["MONTHLY"] 310 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY" 311 | assert len(vrecurr.keys()) == 1 312 | 313 | def test_every_6_months(self): 314 | """Repeat very 6 months.""" 315 | vrecurr = utils.build_rrule_from_text("INTERVAL=6;FREQ=MONTHLY") 316 | assert vrecurr["FREQ"] == ["MONTHLY"] 317 | assert vrecurr["INTERVAL"] == [6] 318 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;INTERVAL=6" 319 | assert len(vrecurr.keys()) == 2 320 | 321 | def test_every_year(self): 322 | """Repeat every year.""" 323 | vrecurr = utils.build_rrule_from_text("FREQ=YEARLY") 324 | assert vrecurr["FREQ"] == ["YEARLY"] 325 | vRecur(vrecurr).to_ical().decode() == "FREQ=YEARLY" 326 | assert len(vrecurr.keys()) == 1 327 | 328 | def test_every_month_on_the_4th(self): 329 | """Repeat every month on the 4th.""" 330 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYMONTHDAY=4") 331 | assert vrecurr["FREQ"] == ["MONTHLY"] 332 | assert vrecurr["BYMONTHDAY"] == [4] 333 | assert len(vrecurr.keys()) == 2 334 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYMONTHDAY=+4") 335 | assert vrecurr["FREQ"] == ["MONTHLY"] 336 | assert vrecurr["BYMONTHDAY"] == [4] 337 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=4" 338 | assert len(vrecurr.keys()) == 2 339 | 340 | def test_every_month_on_the_4th_last(self): 341 | """Repeat every month on the 4th last.""" 342 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYMONTHDAY=-4") 343 | assert vrecurr["FREQ"] == ["MONTHLY"] 344 | assert vrecurr["BYMONTHDAY"] == [-4] 345 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=-4" 346 | assert len(vrecurr.keys()) == 2 347 | 348 | def test_ever_month_3rd_tu(self): 349 | """Repeat every month on the 3rd Tuesday.""" 350 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYDAY=+3TU") 351 | assert vrecurr["FREQ"] == ["MONTHLY"] 352 | assert vrecurr["BYDAY"] == ["+3TU"] 353 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=+3TU" 354 | assert len(vrecurr.keys()) == 2 355 | 356 | def test_ever_month_3rd_last_tu(self): 357 | """Repeat every month on the 3rd last Tuesday.""" 358 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYDAY=-3TU") 359 | assert vrecurr["FREQ"] == ["MONTHLY"] 360 | assert vrecurr["BYDAY"] == ["-3TU"] 361 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=-3TU" 362 | assert len(vrecurr.keys()) == 2 363 | 364 | def test_ever_month_last_mo(self): 365 | """Repeat every month on the last Monday.""" 366 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYDAY=-1MO") 367 | assert vrecurr["FREQ"] == ["MONTHLY"] 368 | assert vrecurr["BYDAY"] == ["-1MO"] 369 | assert len(vrecurr.keys()) == 2 370 | 371 | def test_ever_month_second_last_fr(self): 372 | """Repeat every month on the 2nd last Friday.""" 373 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYDAY=-2FR") 374 | assert vrecurr["FREQ"] == ["MONTHLY"] 375 | assert vrecurr["BYDAY"] == ["-2FR"] 376 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=-2FR" 377 | assert len(vrecurr.keys()) == 2 378 | 379 | def test_every_week_until_jan_2007(self): 380 | """Repeat every week until January 1, 2007.""" 381 | utc = tz.UTC 382 | vrecurr = utils.build_rrule_from_text("FREQ=WEEKLY;UNTIL=20070101T000000Z") 383 | assert vrecurr["FREQ"] == ["WEEKLY"] 384 | assert vrecurr["UNTIL"] == [datetime.datetime(2007, 1, 1, 0, 0, tzinfo=utc)] 385 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;UNTIL=20070101T000000Z" 386 | assert len(vrecurr.keys()) == 2 387 | 388 | def test_every_week_20_times(self): 389 | """Repeat every week for 20 times.""" 390 | vrecurr = utils.build_rrule_from_text("FREQ=WEEKLY;COUNT=20") 391 | assert vrecurr["FREQ"] == ["WEEKLY"] 392 | assert vrecurr["COUNT"] == [20] 393 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;COUNT=20" 394 | assert len(vrecurr.keys()) == 2 395 | 396 | def test_every_month_last_working_day(self): 397 | """Repeat the last working day of each month.""" 398 | vrecurr = utils.build_rrule_from_text( 399 | "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;" 400 | ) 401 | assert vrecurr["FREQ"] == ["MONTHLY"] 402 | assert vrecurr["BYDAY"] == ["MO", "TU", "WE", "TH", "FR"] 403 | assert vrecurr["BYSETPOS"] == [-1] 404 | vRecur( 405 | vrecurr 406 | ).to_ical().decode() == "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" 407 | assert len(vrecurr.keys()) == 3 408 | 409 | def test_ever_month_last_day(self): 410 | """Repeat the last day of each month.""" 411 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYMONTHDAY=-1") 412 | assert vrecurr["FREQ"] == ["MONTHLY"] 413 | assert vrecurr["BYMONTHDAY"] == [-1] 414 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=-1" 415 | assert len(vrecurr.keys()) == 2 416 | 417 | def test_every_day_in_jan(self): 418 | """Repeat every day in January""" 419 | vrecurr = utils.build_rrule_from_text( 420 | "FREQ=YEARLY;BYMONTH=1;BYDAY=MO,TU,WE,TH,FR,SA,SU;" 421 | ) 422 | assert vrecurr["FREQ"] == ["YEARLY"] 423 | assert vrecurr["BYMONTH"] == [1] 424 | assert vrecurr["BYDAY"] == ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] 425 | vRecur( 426 | vrecurr 427 | ).to_ical().decode() == "FREQ=YEARLY;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYMONTH=1" 428 | assert len(vrecurr.keys()) == 3 429 | 430 | def test_every_2nd_15th_of_month(self): 431 | """Repeat monthly on the 2nd and 15th of the month.""" 432 | vrecurr = utils.build_rrule_from_text("FREQ=MONTHLY;BYMONTHDAY=4,15") 433 | assert vrecurr["FREQ"] == ["MONTHLY"] 434 | assert vrecurr["BYMONTHDAY"] == [4, 15] 435 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYMONTHDAY=4,15" 436 | assert len(vrecurr.keys()) == 2 437 | 438 | def test_every_fr_13th(self): 439 | """Repeat every Friday the 13th.""" 440 | vrecurr = utils.build_rrule_from_text("FREQ=YEARLY;BYMONTHDAY=13;BYDAY=FR") 441 | assert vrecurr["FREQ"] == ["YEARLY"] 442 | assert vrecurr["BYMONTHDAY"] == [13] 443 | assert vrecurr["BYDAY"] == ["FR"] 444 | vRecur(vrecurr).to_ical().decode() == "FREQ=YEARLY;BYDAY=FR;BYMONTHDAY=13" 445 | assert len(vrecurr.keys()) == 3 446 | 447 | 448 | class FromDateutilRruleTests(TestCase): 449 | """Build an ical string from a dateutil rrule.""" 450 | 451 | def test_weekly_by_month_year_day(self): 452 | rule = rrule( 453 | WEEKLY, 454 | bymonth=(1, 7), 455 | byyearday=(1, 100, 200, 365), 456 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 457 | ) 458 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 459 | vRecur( 460 | vrecurr 461 | ).to_ical().decode() == "FREQ=WEEKLY;BYYEARDAY=1,100,200,365;BYMONTH=1,7" 462 | 463 | def test_weekly_by_month_nweekday(self): 464 | rule = rrule( 465 | WEEKLY, 466 | count=3, 467 | bymonth=(1, 3), 468 | byweekday=(TU(1), TH(-1)), 469 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 470 | ) 471 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 472 | vRecur( 473 | vrecurr 474 | ).to_ical().decode() == "FREQ=WEEKLY;COUNT=3;BYDAY=TU,TH;BYMONTH=1,3" 475 | 476 | def test_weekly_by_monthday(self): 477 | rule = rrule( 478 | WEEKLY, 479 | count=3, 480 | bymonthday=(1, 3), 481 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 482 | ) 483 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 484 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;COUNT=3;BYMONTHDAY=1,3" 485 | 486 | def test_weekly_by_weekday(self): 487 | rule = rrule( 488 | WEEKLY, 489 | count=3, 490 | byweekday=(TU, TH), 491 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 492 | ) 493 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 494 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY;COUNT=3;BYDAY=TU,TH" 495 | 496 | def test_daily_by_month_nweekday(self): 497 | rule = rrule( 498 | DAILY, 499 | count=3, 500 | bymonth=(1, 3), 501 | byweekday=(TU(1), TH(-1)), 502 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 503 | ) 504 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 505 | vRecur( 506 | vrecurr 507 | ).to_ical().decode() == "FREQ=DAILY;COUNT=3;BYDAY=TU,TH;BYMONTH=1,3" 508 | 509 | def test_yearly_month_nweekday(self): 510 | rule = rrule( 511 | YEARLY, 512 | count=3, 513 | bymonth=(1, 3), 514 | byweekday=(TU(1), TH(-1)), 515 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 516 | ) 517 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 518 | vRecur( 519 | vrecurr 520 | ).to_ical().decode() == "FREQ=YEARLY;COUNT=3;BYDAY=+1TU,-1TH;BYMONTH=1,3" 521 | 522 | def test_yearly_month_yearday(self): 523 | rule = rrule( 524 | YEARLY, 525 | count=4, 526 | bymonth=(4, 7), 527 | byyearday=(1, 100, 200, 365), 528 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 529 | ) 530 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 531 | assert ( 532 | vRecur(vrecurr).to_ical().decode() 533 | == "FREQ=YEARLY;COUNT=4;BYYEARDAY=1,100,200,365;BYMONTH=4,7" 534 | ) 535 | 536 | def test_yearly_weekno_weekday(self): 537 | rule = rrule( 538 | YEARLY, 539 | count=3, 540 | byweekno=1, 541 | byweekday=MO, 542 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 543 | ) 544 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 545 | vRecur(vrecurr).to_ical().decode() == "FREQ=YEARLY;COUNT=3;BYDAY=MO;BYWEEKNO=1" 546 | 547 | def test_yearly_setpos(self): 548 | rule = rrule( 549 | YEARLY, 550 | count=3, 551 | bymonthday=15, 552 | byhour=(6, 18), 553 | bysetpos=(3, -3), 554 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 555 | ) 556 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 557 | assert ( 558 | vRecur(vrecurr).to_ical().decode() 559 | == "FREQ=YEARLY;COUNT=3;BYHOUR=6,18;BYMONTHDAY=15;BYSETPOS=3,-3" 560 | ) 561 | 562 | def test_monthly_month_monthday(self): 563 | rule = rrule( 564 | MONTHLY, 565 | count=3, 566 | bymonth=(1, 3), 567 | bymonthday=(5, 7), 568 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 569 | ) 570 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 571 | vRecur( 572 | vrecurr 573 | ).to_ical().decode() == "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=5,7;BYMONTH=1,3" 574 | 575 | def test_monthly_nweekday(self): 576 | rule = rrule( 577 | MONTHLY, 578 | count=3, 579 | byweekday=(TU(1), TH(-1)), 580 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 581 | ) 582 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 583 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;COUNT=3;BYDAY=+1TU,-1TH" 584 | 585 | def test_monthly_month_nweekday(self): 586 | rule = rrule( 587 | MONTHLY, 588 | bymonth=(1, 3), 589 | byweekday=(TU(1), TH(-1)), 590 | dtstart=datetime.datetime(1997, 9, 2, 9, 0), 591 | ) 592 | vrecurr = utils.build_rrule_from_dateutil_rrule(rule) 593 | vRecur(vrecurr).to_ical().decode() == "FREQ=MONTHLY;BYDAY=+1TU,-1TH;BYMONTH=1,3" 594 | 595 | 596 | class FromDjangoRecurrenceRruleTests(TestCase): 597 | """Build an ical string from a django-recurrence rrule.""" 598 | 599 | def test_rule(self): 600 | rule = recurrence.Rule(recurrence.WEEKLY) 601 | vrecurr = utils.build_rrule_from_recurrences_rrule(rule) 602 | vRecur(vrecurr).to_ical().decode() == "FREQ=WEEKLY" 603 | 604 | def test_complex_rule_serialization(self): 605 | rule = recurrence.Rule( 606 | recurrence.WEEKLY, 607 | interval=17, 608 | wkst=1, 609 | count=7, 610 | byday=[recurrence.to_weekday("-1MO"), recurrence.to_weekday("TU")], 611 | bymonth=[1, 3], 612 | ) 613 | vrecurr = utils.build_rrule_from_recurrences_rrule(rule) 614 | assert ( 615 | vRecur(vrecurr).to_ical().decode() 616 | == "FREQ=WEEKLY;COUNT=7;INTERVAL=17;BYDAY=-1MO,TU;BYMONTH=1,3;WKST=TU" 617 | ) 618 | 619 | def test_complex_rule_serialization_with_weekday_instance(self): 620 | rule = recurrence.Rule( 621 | recurrence.WEEKLY, 622 | interval=17, 623 | wkst=recurrence.to_weekday(1), 624 | count=7, 625 | byday=[recurrence.to_weekday("-1MO"), recurrence.to_weekday("TU")], 626 | bymonth=[1, 3], 627 | ) 628 | vrecurr = utils.build_rrule_from_recurrences_rrule(rule) 629 | assert ( 630 | vRecur(vrecurr).to_ical().decode() 631 | == "FREQ=WEEKLY;COUNT=7;INTERVAL=17;BYDAY=-1MO,TU;BYMONTH=1,3;WKST=TU" 632 | ) 633 | --------------------------------------------------------------------------------