├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── src └── spectator │ ├── __init__.py │ ├── core │ ├── __init__.py │ ├── admin.py │ ├── app_settings.py │ ├── apps.py │ ├── factories.py │ ├── fields.py │ ├── imagegenerators.py │ ├── managers.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_creator_slug.py │ │ ├── 0003_set_creator_slug.py │ │ ├── 0004_auto_20180102_0959.py │ │ └── __init__.py │ ├── models.py │ ├── paginator.py │ ├── sitemaps.py │ ├── static │ │ └── spectator-core │ │ │ └── css │ │ │ ├── bootstrap.min.css │ │ │ └── bootstrap.min.css.map │ ├── templates │ │ └── spectator_core │ │ │ ├── admin │ │ │ ├── detail_thumbnail.html │ │ │ └── list_thumbnail.html │ │ │ ├── base.html │ │ │ ├── creator_detail.html │ │ │ ├── creator_list.html │ │ │ ├── home.html │ │ │ └── includes │ │ │ ├── card_change_object_link.html │ │ │ ├── card_chart.html │ │ │ ├── card_nav.html │ │ │ ├── chart.html │ │ │ ├── pager.html │ │ │ ├── pagination.html │ │ │ ├── roles.html │ │ │ ├── roles_list.html │ │ │ ├── thumbnail_detail.html │ │ │ └── thumbnail_list.html │ ├── templatetags │ │ ├── __init__.py │ │ └── spectator_core.py │ ├── urls │ │ ├── __init__.py │ │ ├── core.py │ │ └── creators.py │ ├── utils.py │ └── views.py │ ├── events │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── factories.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── generate_letterboxd_export.py │ ├── managers.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_event_slug.py │ │ ├── 0003_auto_20171101_1645.py │ │ ├── 0004_venue_slug.py │ │ ├── 0005_auto_20180102_0959.py │ │ ├── 0006_event_slug_20180102_1127.py │ │ ├── 0007_work_slug_20180102_1137.py │ │ ├── 0008_venue_slug_20180102_1147.py │ │ ├── 0009_event_note.py │ │ ├── 0010_auto_20180118_0906.py │ │ ├── 0011_auto_20180125_1348.py │ │ ├── 0012_add_classical_and_dance_through_models.py │ │ ├── 0013_copy_classical_and_dance_data.py │ │ ├── 0014_remove_old_classical_dance.py │ │ ├── 0015_rename_classical_dance_on_event.py │ │ ├── 0016_add_movies_plays_m2ms_on_event.py │ │ ├── 0017_copy_movies_and_plays_data.py │ │ ├── 0018_remove_old_movie_play_fields.py │ │ ├── 0019_auto_20180127_1653.py │ │ ├── 0020_venue_note.py │ │ ├── 0021_auto_20180129_1735.py │ │ ├── 0022_auto_20180129_1752.py │ │ ├── 0023_venue_cinema_treasures_id.py │ │ ├── 0024_event_venue_name.py │ │ ├── 0025_auto_20180131_1755.py │ │ ├── 0026_auto_20180208_1126.py │ │ ├── 0027_classicalworks_to_works.py │ │ ├── 0028_dancepieces_to_works.py │ │ ├── 0029_plays_to_works.py │ │ ├── 0030_movies_to_works.py │ │ ├── 0031_auto_20180208_1412.py │ │ ├── 0032_recreate_work_slugs.py │ │ ├── 0033_auto_20180208_1613.py │ │ ├── 0034_auto_20180208_1618.py │ │ ├── 0035_change_event_kinds.py │ │ ├── 0036_auto_20180417_1218.py │ │ ├── 0037_exhibition_to_museum.py │ │ ├── 0038_auto_20180417_1224.py │ │ ├── 0039_populate_exhibitions.py │ │ ├── 0040_auto_20180417_1721.py │ │ ├── 0041_event_ticket.py │ │ ├── 0042_auto_20200407_1039.py │ │ ├── 0043_rename_ticket_to_thumbnail.py │ │ ├── 0044_change_thumbnail_upload_to_function.py │ │ ├── 0045_auto_20201221_1358.py │ │ ├── 0046_alter_work_imdb_id.py │ │ └── __init__.py │ ├── models.py │ ├── signals.py │ ├── sitemaps.py │ ├── static │ │ ├── css │ │ │ └── admin │ │ │ │ └── location_picker.css │ │ ├── img │ │ │ └── map-marker.png │ │ └── js │ │ │ ├── admin │ │ │ ├── location_picker_google.js │ │ │ └── location_picker_mapbox.js │ │ │ └── venue_map.js │ ├── templates │ │ ├── admin │ │ │ └── spectator_events │ │ │ │ └── venue │ │ │ │ └── change_form.html │ │ └── spectator_events │ │ │ ├── base.html │ │ │ ├── event_archive_year.html │ │ │ ├── event_detail.html │ │ │ ├── event_list.html │ │ │ ├── includes │ │ │ ├── card_annual_event_counts.html │ │ │ ├── card_events.html │ │ │ ├── card_nav.html │ │ │ ├── card_years.html │ │ │ ├── event_list_tabs.html │ │ │ ├── events.html │ │ │ ├── events_paginated.html │ │ │ ├── selections.html │ │ │ ├── visits.html │ │ │ ├── work.html │ │ │ ├── works.html │ │ │ └── works_paginated.html │ │ │ ├── venue_detail.html │ │ │ ├── venue_list.html │ │ │ ├── work_detail.html │ │ │ └── work_list.html │ ├── templatetags │ │ ├── __init__.py │ │ └── spectator_events.py │ ├── urls.py │ └── views.py │ └── reading │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── factories.py │ ├── managers.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_publicationseries_slug.py │ ├── 0003_publication_slug.py │ ├── 0004_slugs_20180102_1153.py │ ├── 0005_auto_20180125_1348.py │ ├── 0006_publication_cover.py │ ├── 0007_rename_cover_to_thumbnail.py │ ├── 0008_change_thumbnail_upload_to_function.py │ └── __init__.py │ ├── models.py │ ├── sitemaps.py │ ├── templates │ └── spectator_reading │ │ ├── base.html │ │ ├── home.html │ │ ├── includes │ │ ├── card_annual_reading_counts.html │ │ ├── card_nav.html │ │ ├── card_publications.html │ │ ├── card_years.html │ │ ├── publication.html │ │ ├── publications.html │ │ ├── publications_paginated.html │ │ └── reading.html │ │ ├── publication_detail.html │ │ ├── publication_list.html │ │ ├── publicationseries_detail.html │ │ ├── publicationseries_list.html │ │ └── reading_archive_year.html │ ├── templatetags │ ├── __init__.py │ └── spectator_reading.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── tests ├── __init__.py ├── core │ ├── __init__.py │ ├── fields │ │ ├── __init__.py │ │ ├── models.py │ │ └── tests.py │ ├── fixtures │ │ └── images │ │ │ └── tester_exif_gps.jpg │ ├── test_admin.py │ ├── test_apps.py │ ├── test_managers.py │ ├── test_models.py │ ├── test_paginator.py │ ├── test_templatetags.py │ ├── test_urls.py │ ├── test_utils.py │ └── test_views.py ├── events │ ├── __init__.py │ ├── test_managers.py │ ├── test_models.py │ ├── test_templatetags.py │ ├── test_urls.py │ └── test_views.py ├── reading │ ├── __init__.py │ ├── test_admin.py │ ├── test_managers.py │ ├── test_models.py │ ├── test_templatetags.py │ ├── test_urls.py │ ├── test_utils.py │ └── test_views.py ├── settings.py ├── test_admins.py ├── test_pending_migrations.py └── urls.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | env: 12 | FORCE_COLOR: 1 13 | 14 | jobs: 15 | test: 16 | name: "Python ${{ matrix.python-version }}, Django ${{ matrix.django-version}}" 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 23 | django-version: ["4.2", "5.1", "main"] 24 | exclude: 25 | - python-version: "3.13" 26 | django-version: "4.2" 27 | - python-version: "3.9" 28 | django-version: "5.1" 29 | - python-version: "3.9" 30 | django-version: "main" 31 | 32 | steps: 33 | - name: Git clone 34 | uses: actions/checkout@v4 35 | 36 | - name: Install Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | - name: Get pip cache dir 42 | id: pip-cache 43 | run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 44 | 45 | - name: Set up pip cache 46 | uses: actions/cache@v4 47 | with: 48 | path: ${{ steps.pip-cache.outputs.dir }} 49 | key: ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }} 50 | restore-keys: | 51 | ${{ matrix.python-version }}-v2- 52 | 53 | - name: Install Python packages 54 | run: | 55 | python -m pip install --upgrade pip 56 | python -m pip install --upgrade tox tox-gh-actions 57 | 58 | - name: Test with Tox 59 | run: tox --verbose --parallel auto 60 | env: 61 | DJANGO: ${{ matrix.django-version }} 62 | 63 | - name: Upload Coverage to Codecov 64 | uses: codecov/codecov-action@v5 65 | with: 66 | flags: ${{ matrix.python-version }} 67 | name: Python ${{ matrix.python-version }} 68 | token: ${{ secrets.CODECOV_TOKEN }} 69 | 70 | ruff: 71 | name: Run ruff 72 | runs-on: ubuntu-latest 73 | 74 | strategy: 75 | matrix: 76 | toxenv: 77 | - ruff 78 | 79 | steps: 80 | - name: Git clone 81 | uses: actions/checkout@v4 82 | 83 | - name: Set up Python 84 | uses: actions/setup-python@v5 85 | with: 86 | python-version: "3.12" 87 | 88 | - name: Get pip cache dir 89 | id: pip-cache 90 | run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 91 | 92 | - name: Set up pip cache 93 | uses: actions/cache@v4 94 | with: 95 | path: ${{ steps.pip-cache.outputs.dir }} 96 | key: ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }} 97 | restore-keys: | 98 | ${{ matrix.python-version }}-v2- 99 | 100 | - name: Install dependencies 101 | run: | 102 | python -m pip install --upgrade pip 103 | python -m pip install --upgrade tox tox-gh-actions 104 | 105 | - name: Run ruff 106 | run: python -m tox -e ruff 107 | 108 | slack: 109 | # https://github.com/8398a7/action-slack/issues/72#issuecomment-649910353 110 | name: Slack notification 111 | runs-on: ubuntu-latest 112 | needs: [test, ruff] 113 | 114 | # this is required, otherwise it gets skipped if any needed jobs fail. 115 | # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idneeds 116 | if: always() # Pick up events even if the job fails or is cancelled. 117 | 118 | steps: 119 | - uses: technote-space/workflow-conclusion-action@v3 120 | 121 | - name: Send Slack notification 122 | uses: 8398a7/action-slack@v3 123 | with: 124 | status: ${{ job.status }} 125 | # fields: repo,message,commit,author,action,eventName,ref,workflow,job,took # selectable (default: repo,message) 126 | fields: repo,message,commit,author,action 127 | env: 128 | SLACK_WEBHOOK_URL: ${{ secrets.ACTIONS_CI_SLACK_HOOK }} 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | devproject/db*.sqlite3 2 | devproject/media 3 | devproject/static 4 | devproject/.env 5 | devproject/venv 6 | django_spectator.egg-info 7 | htmlcov 8 | .coverage 9 | import_gyford_reading.py 10 | dist 11 | build 12 | .vscode 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.13 3 | 4 | exclude: | 5 | (?x)^( 6 | dist/.* 7 | |spectator/core/static/spectator-core/css/bootstrap.* 8 | )$ 9 | repos: 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: trailing-whitespace 14 | - id: end-of-file-fixer 15 | - id: check-yaml 16 | - id: check-added-large-files 17 | - repo: https://github.com/rbubley/mirrors-prettier 18 | rev: v3.5.3 19 | hooks: 20 | - id: prettier 21 | types_or: 22 | - css 23 | - javascript 24 | - json 25 | - repo: https://github.com/astral-sh/ruff-pre-commit 26 | rev: v0.11.3 27 | hooks: 28 | - id: ruff 29 | args: [--fix] 30 | - id: ruff-format 31 | - repo: https://github.com/asottile/pyupgrade 32 | rev: v3.19.1 33 | hooks: 34 | - id: pyupgrade 35 | args: [--py38-plus] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2025 Phil Gyford 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include src/spectator * 4 | 5 | recursive-exclude tests * 6 | 7 | recursive-exclude * *.pyc *.pyo *.sw* *.sv* *.un~ *.DS_Store 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # project ###################################################################### 2 | 3 | [project] 4 | authors = [{ "name" = "Phil Gyford", "email" = "phil@gyford.com" }] 5 | classifiers = [ 6 | "Development Status :: 5 - Production/Stable", 7 | "Environment :: Web Environment", 8 | "Framework :: Django", 9 | "Framework :: Django :: 4.2", 10 | "Framework :: Django :: 5.1", 11 | "Framework :: Django :: 5.2", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Natural Language :: English", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Topic :: Internet :: WWW/HTTP", 24 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 25 | "Topic :: Software Development :: Libraries", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | dependencies = [ 29 | "Django>=4.2", 30 | "django-imagekit>=4.0,<6.0", 31 | "hashids>=1.2.0,<1.4", 32 | "piexif>=1.1.3,<2.0", 33 | "pillow>=9.0.0,<12.0", 34 | ] 35 | description = "A Django app to track book reading, movie viewing, gig going, play watching, etc." 36 | dynamic = ["version"] 37 | keywords = [ 38 | "spectator", 39 | "books", 40 | "reading", 41 | "cinema", 42 | "gigs", 43 | "theatre", 44 | "theater", 45 | ] 46 | license = { file = "LICENSE" } 47 | name = "django-spectator" 48 | readme = "README.md" 49 | requires-python = ">=3.9" 50 | 51 | [project.urls] 52 | "Changelog" = "https://github.com/philgyford/django-spectator/blob/main/CHANGELOG.md" 53 | "Documentation" = "https://github.com/philgyford/django-spectator/blob/main/README.md" 54 | "Issues" = "https://github.com/philgyford/django-spectator/issues" 55 | "Source Code" = "https://github.com/philgyford/django-spectator" 56 | 57 | # dependency-groups ############################################################ 58 | 59 | [dependency-groups] 60 | dev = [ 61 | "coverage[toml]", 62 | "factory-boy", 63 | "freezegun", 64 | "pre-commit", 65 | "python-dotenv", 66 | "pyupgrade", 67 | "ruff", 68 | "unittest-parametrize", 69 | ] 70 | 71 | # coverage ##################################################################### 72 | 73 | [tool.coverage.paths] 74 | # Reduce length of paths in the report 75 | # via https://hynek.me/articles/testing-packaging/ 76 | source = ["src", ".tox/py*/**/site-packages"] 77 | 78 | [tool.coverage.report] 79 | show_missing = true 80 | skip_covered = true 81 | 82 | [tool.coverage.run] 83 | branch = true 84 | parallel = true 85 | omit = ["*/migrations/*.py"] 86 | source = ["spectator"] 87 | 88 | # ruff ######################################################################### 89 | 90 | [tool.ruff] 91 | extend-exclude = ["*/migrations/*"] 92 | line-length = 88 93 | target-version = "py39" 94 | 95 | [tool.ruff.lint] 96 | ignore = [] 97 | select = [ 98 | # Reference: https://docs.astral.sh/ruff/rules/ 99 | "B", # flake8-bugbear 100 | "E", # pycodestyle 101 | "F", # Pyflakes 102 | "G", # flake8-logging-format 103 | "I", # isort 104 | "N", # pip8-naming 105 | "Q", # flake8-quotes 106 | "BLE", # flake8-blind-except 107 | "DJ", # flake8-django 108 | "DTZ", # flake8-datetimez 109 | "EM", # flake8-errmsg 110 | "INP", # flake8-no-pep420 111 | "FBT", # flake8-boolean-trap 112 | "PIE", # flake8-pie 113 | "RSE", # flake-raise 114 | "SIM", # flake8-simplify 115 | "T20", # flake8-print 116 | "TID", # flake8-tidy-imports 117 | "UP", # pyupgrade 118 | "RUF100", # unused-noqa 119 | "RUF200", # invalid-pyproject-toml 120 | ] 121 | 122 | # setuptools ################################################################### 123 | 124 | [tool.setuptools.dynamic] 125 | version = { attr = "src.spectator.__version__" } 126 | 127 | # uv ########################################################################### 128 | 129 | [tool.uv] 130 | # Ensure Tox always gets a fresh package. 131 | reinstall-package = ["spectator"] 132 | -------------------------------------------------------------------------------- /src/spectator/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Django Spectator" 2 | __version__ = "15.2.1" 3 | __author__ = "Phil Gyford" 4 | __author_email__ = "phil@gyford.com" 5 | __license__ = "MIT" 6 | 7 | VERSION = __version__ # synonym 8 | TITLE = __title__ # synonym 9 | -------------------------------------------------------------------------------- /src/spectator/core/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "spectator.core.apps.SpectatorCoreAppConfig" 2 | -------------------------------------------------------------------------------- /src/spectator/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Creator 4 | 5 | 6 | @admin.register(Creator) 7 | class CreatorAdmin(admin.ModelAdmin): 8 | list_display = ( 9 | "name", 10 | "name_sort", 11 | "kind", 12 | ) 13 | list_filter = ("kind",) 14 | search_fields = ( 15 | "name", 16 | "name_sort", 17 | ) 18 | 19 | fieldsets = ( 20 | ( 21 | None, 22 | { 23 | "fields": ( 24 | "name", 25 | "name_sort", 26 | "slug", 27 | "kind", 28 | ) 29 | }, 30 | ), 31 | ( 32 | "Times", 33 | { 34 | "classes": ("collapse",), 35 | "fields": ( 36 | "time_created", 37 | "time_modified", 38 | ), 39 | }, 40 | ), 41 | ) 42 | 43 | radio_fields = {"kind": admin.HORIZONTAL} 44 | readonly_fields = ( 45 | "slug", 46 | "name_sort", 47 | "time_created", 48 | "time_modified", 49 | ) 50 | -------------------------------------------------------------------------------- /src/spectator/core/app_settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | 5 | LOGGER = logging.getLogger(__name__) 6 | 7 | # Creating all the defaults for settings. 8 | # In our code, if we want to use a SPECTATOR_* setting we should import 9 | # from here, not django.conf.settings. 10 | 11 | if getattr(settings, "SPECTATOR_GOOGLE_MAPS_API_KEY", False): 12 | LOGGER.warning( 13 | "The SPECTATOR_GOOGLE_MAPS_API_KEY setting is no longer recognised. " 14 | "Please update to use the SPECTATOR_MAPS dictionary setting." 15 | ) 16 | 17 | 18 | MAPS = getattr(settings, "SPECTATOR_MAPS", {"enable": False}) 19 | 20 | 21 | # The characters to use for things that require an automatically-generated 22 | # URL slug: 23 | SLUG_ALPHABET = getattr( 24 | settings, "SPECTATOR_SLUG_ALPHABET", "abcdefghijkmnopqrstuvwxyz23456789" 25 | ) 26 | 27 | # The salt value to use when generating URL slugs: 28 | SLUG_SALT = getattr(settings, "SPECTATOR_SLUG_SALT", "Django Spectator") 29 | 30 | # For Events and card titles. 31 | DATE_FORMAT = getattr(settings, "SPECTATOR_DATE_FORMAT", "%-d %b %Y") 32 | 33 | # For Reading and Events: 34 | THUMBNAIL_DETAIL_SIZE = getattr(settings, "SPECTATOR_THUMBNAIL_DETAIL_SIZE", (320, 320)) 35 | THUMBNAIL_LIST_SIZE = getattr(settings, "SPECTATOR_THUMBNAIL_LIST_SIZE", (80, 160)) 36 | 37 | # Top-level directories, within MEDIA_ROOT, for the Event and 38 | # Publication thumbnails to go in: 39 | EVENTS_DIR_BASE = getattr(settings, "SPECTATOR_EVENTS_DIR_BASE", "events") 40 | READING_DIR_BASE = getattr(settings, "SPECTATOR_READING_DIR_BASE", "reading") 41 | -------------------------------------------------------------------------------- /src/spectator/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | 3 | 4 | class SpectatorCoreAppConfig(AppConfig): 5 | label = "spectator_core" 6 | name = "spectator.core" 7 | verbose_name = "Spectator Core" 8 | 9 | # Maintain pre Django 3.2 default behaviour: 10 | default_auto_field = "django.db.models.AutoField" 11 | 12 | 13 | class Apps: 14 | """Methods for seeing which Spectator apps are installed/enabled. 15 | At the moment installed is the same as enabled, but in future we may add 16 | conditions that mean an installed app can be disabled. 17 | 18 | So use installed to check if the code is physically in INSTALLED_APPS. 19 | And use enabled to check if we're allowed to use that app on the site. 20 | """ 21 | 22 | def all(self): 23 | "A list of all possible Spectator apps that could be installed/enabled." 24 | return [ 25 | "events", 26 | "reading", 27 | ] 28 | 29 | def installed(self): 30 | "A list of all the installed Spectator apps." 31 | return [app for app in self.all() if self.is_installed(app)] 32 | 33 | def enabled(self): 34 | "A list of all the enabled Spectator apps." 35 | return [app for app in self.all() if self.is_enabled(app)] 36 | 37 | def is_installed(self, app_name): 38 | "Is this Spectator app installed?" 39 | return apps.is_installed(f"spectator.{app_name}") 40 | 41 | def is_enabled(self, app_name): 42 | """Determine if a particular Spectator app is installed and enabled. 43 | 44 | app_name is like 'events' or 'reading'. 45 | 46 | Usage: 47 | if is_enabled('events'): 48 | print("Events is enabled") 49 | 50 | Doesn't offer much over apps.is_installed() yet, but would let us add 51 | other conditions in future, like being able to enable/disable installed 52 | apps. 53 | """ 54 | return apps.is_installed(f"spectator.{app_name}") 55 | 56 | 57 | spectator_apps = Apps() 58 | -------------------------------------------------------------------------------- /src/spectator/core/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from . import models 4 | 5 | 6 | class IndividualCreatorFactory(factory.django.DjangoModelFactory): 7 | "A creator that is an individual person." 8 | 9 | class Meta: 10 | model = models.Creator 11 | 12 | name = factory.Sequence(lambda n: f"Individual {n}") 13 | kind = "individual" 14 | 15 | 16 | class GroupCreatorFactory(factory.django.DjangoModelFactory): 17 | "A creator that is a group/organisation/etc." 18 | 19 | class Meta: 20 | model = models.Creator 21 | 22 | name = factory.Sequence(lambda n: f"Group {n}") 23 | kind = "group" 24 | -------------------------------------------------------------------------------- /src/spectator/core/imagegenerators.py: -------------------------------------------------------------------------------- 1 | from imagekit import ImageSpec, register 2 | from imagekit.processors import ResizeToFit 3 | 4 | from spectator.core import app_settings 5 | 6 | # NOTE: All of these generators are deprecated. 7 | # Use the thumbnail properties on Publication and Reading models instead. 8 | 9 | 10 | class Thumbnail(ImageSpec): 11 | "Base class" 12 | 13 | format = "JPEG" 14 | options = {"quality": 80} 15 | 16 | 17 | class ListThumbnail(Thumbnail): 18 | "For displaying in lists of Publications, Events, etc." 19 | 20 | processors = [ResizeToFit(*app_settings.THUMBNAIL_LIST_SIZE)] 21 | 22 | 23 | class ListThumbnail2x(ListThumbnail): 24 | """Retina version of ListThumbnail 25 | Generated twice the size of our set dimensions. 26 | """ 27 | 28 | dimensions = [d * 2 for d in app_settings.THUMBNAIL_LIST_SIZE] 29 | processors = [ResizeToFit(*dimensions)] 30 | 31 | 32 | class DetailThumbnail(Thumbnail): 33 | "For displaying on the detail pages of Publication, Event, etc" 34 | 35 | processors = [ResizeToFit(*app_settings.THUMBNAIL_DETAIL_SIZE)] 36 | 37 | 38 | class DetailThumbnail2x(DetailThumbnail): 39 | """Retina version of DetailThumbnail 40 | Generated twice the size of our set dimensions. 41 | """ 42 | 43 | dimensions = [d * 2 for d in app_settings.THUMBNAIL_DETAIL_SIZE] 44 | processors = [ResizeToFit(*dimensions)] 45 | 46 | 47 | register.generator("spectator:list_thumbnail", ListThumbnail) 48 | register.generator("spectator:list_thumbnail2x", ListThumbnail2x) 49 | register.generator("spectator:detail_thumbnail", DetailThumbnail) 50 | register.generator("spectator:detail_thumbnail2x", DetailThumbnail2x) 51 | -------------------------------------------------------------------------------- /src/spectator/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-04-17 17:14 2 | 3 | from django.db import migrations, models 4 | 5 | import spectator.core.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Creator", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ( 28 | "time_created", 29 | models.DateTimeField( 30 | auto_now_add=True, 31 | help_text="The time this item was created in the database.", 32 | ), 33 | ), 34 | ( 35 | "time_modified", 36 | models.DateTimeField( 37 | auto_now=True, 38 | help_text="The time this item was last saved to the database.", 39 | ), 40 | ), 41 | ( 42 | "name", 43 | models.CharField( 44 | help_text="e.g. 'Douglas Adams' or 'The Long Blondes'.", 45 | max_length=255, 46 | ), 47 | ), 48 | ( 49 | "name_sort", 50 | spectator.core.fields.NaturalSortField( 51 | "name", 52 | db_index=True, 53 | default="", 54 | editable=False, 55 | help_text="Best for sorting groups. e.g. 'long blondes, the' or 'adams, douglas'.", # noqa: E501 56 | max_length=255, 57 | ), 58 | ), 59 | ( 60 | "kind", 61 | models.CharField( 62 | choices=[("individual", "Individual"), ("group", "Group")], 63 | default="individual", 64 | max_length=20, 65 | ), 66 | ), 67 | ], 68 | options={"ordering": ("name_sort",)}, 69 | ), 70 | ] 71 | -------------------------------------------------------------------------------- /src/spectator/core/migrations/0002_creator_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 08:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_core", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="creator", 15 | name="slug", 16 | field=models.SlugField(null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/spectator/core/migrations/0003_set_creator_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 09:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_core", "0002_creator_slug"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="creator", 15 | name="slug", 16 | field=models.SlugField(blank=True, max_length=10), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/spectator/core/migrations/0004_auto_20180102_0959.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-02 09:59 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | from hashids import Hashids 6 | 7 | 8 | def generate_slug(value): 9 | "A copy of spectator.core.models.SluggedModelMixin._generate_slug()" 10 | alphabet = "abcdefghijkmnopqrstuvwxyz23456789" 11 | salt = "Django Spectator" 12 | 13 | if hasattr(settings, "SPECTATOR_SLUG_ALPHABET"): 14 | alphabet = settings.SPECTATOR_SLUG_ALPHABET 15 | 16 | if hasattr(settings, "SPECTATOR_SLUG_SALT"): 17 | salt = settings.SPECTATOR_SLUG_SALT 18 | 19 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 20 | 21 | return hashids.encode(value) 22 | 23 | 24 | def set_slug(apps, schema_editor): 25 | """ 26 | Create a slug for each Creator already in the DB. 27 | """ 28 | Creator = apps.get_model("spectator_core", "Creator") 29 | 30 | for c in Creator.objects.all(): 31 | c.slug = generate_slug(c.pk) 32 | c.save(update_fields=["slug"]) 33 | 34 | 35 | class Migration(migrations.Migration): 36 | 37 | dependencies = [ 38 | ("spectator_core", "0003_set_creator_slug"), 39 | ] 40 | 41 | operations = [ 42 | migrations.AlterField( 43 | model_name="creator", 44 | name="slug", 45 | field=models.SlugField(blank=True, max_length=10), 46 | ), 47 | migrations.RunPython(set_slug), 48 | ] 49 | -------------------------------------------------------------------------------- /src/spectator/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/core/migrations/__init__.py -------------------------------------------------------------------------------- /src/spectator/core/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sitemaps import Sitemap 2 | 3 | from .models import Creator 4 | 5 | 6 | class CreatorSitemap(Sitemap): 7 | changefreq = "yearly" 8 | priority = 0.5 9 | 10 | def items(self): 11 | return Creator.objects.all() 12 | 13 | def lastmod(self, obj): 14 | return obj.time_modified 15 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/admin/detail_thumbnail.html: -------------------------------------------------------------------------------- 1 | {# For displaying thumbnails in the admin create/edit pages #} 2 | {% if thumbnail %} 3 | {% include "spectator_core/includes/thumbnail_detail.html" with obj=model alt_text="Thumbnail" %} 4 | {% endif %} 5 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/admin/list_thumbnail.html: -------------------------------------------------------------------------------- 1 | {# For displaying thumbnails in the admin list #} 2 | {% if thumbnail %} 3 | {% include "spectator_core/includes/thumbnail_list.html" with obj=model alt_text="Thumbnail" %} 4 | {% endif %} 5 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block head_title %}{% block head_page_title %}{% endblock %} ({% block head_site_title %}Spectator{% endblock %}){% endblock %} 8 | 9 | {% load static %} 10 | 11 | {% block bootstrap_css %} 12 | 13 | {% endblock %} 14 | 15 | {% block head_extra %} 16 | {# Use this if you need to more to the of every page. #} 17 | {% endblock %} 18 | 19 | 20 | {% load spectator_core %} 21 | {% get_enabled_apps as enabled_apps %} 22 | 23 | {% block navbar %} 24 | 61 | {% endblock navbar %} 62 | 63 |
64 | 65 | 72 | 73 | {% block content_heading %} 74 |

75 | {% block content_title %} 76 | {% endblock %} 77 |

78 | {% endblock %} 79 | 80 | {% block content_main %} 81 |
82 |
83 | {% block content %} 84 | {% endblock %} 85 |
86 |
87 | {% block sidebar %} 88 | {% block sidebar_nav %} 89 | {% if 'reading' in enabled_apps %} 90 | {% include 'spectator_reading/includes/card_nav.html' %} 91 | {% endif %} 92 | {% if 'events' in enabled_apps %} 93 | {% include 'spectator_events/includes/card_nav.html' %} 94 | {% endif %} 95 | {% include 'spectator_core/includes/card_nav.html' %} 96 | {% endblock sidebar_nav %} 97 | 98 | {% block sidebar_content %} 99 | {% endblock sidebar_content %} 100 | {% endblock sidebar %} 101 |
102 |
103 | {% endblock content_main %} 104 |
105 | 106 | {% block footer %} 107 | {# Use this if you need to add a visible footer to every page. #} 108 | {% endblock %} 109 | 110 | {% block foot_extra %} 111 | {# Use this if you need to add JS etc to the foot of the page. #} 112 | {% endblock %} 113 | 114 | 115 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/creator_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_core/base.html' %} 2 | {% load l10n %} 3 | 4 | {% block head_page_title %}{{ creator.name }}{% endblock %} 5 | {% block content_title %}{{ creator.name }}{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | {{ block.super }} 9 | 10 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 | 15 | {% if creator.publications.count > 0 %} 16 |

Publications

17 | 18 | {% include 'spectator_reading/includes/publications.html' with publication_list=creator.publications.all show_readings='none' show_thumbnails=True only %} 19 | {% endif %} 20 | 21 | {% if creator.events.count > 0 %} 22 |

Events

23 | 24 | {% include 'spectator_events/includes/events.html' with event_list=creator.get_events %} 25 | {% endif %} 26 | 27 | {% include 'spectator_events/includes/works.html' with work_list=creator.get_movies heading="Movies" only %} 28 | 29 | {% include 'spectator_events/includes/works.html' with work_list=creator.get_plays heading="Plays" only %} 30 | 31 | {% include 'spectator_events/includes/works.html' with work_list=creator.get_classical_works heading="Classical works" only %} 32 | 33 | {% include 'spectator_events/includes/works.html' with work_list=creator.get_dance_pieces heading="Dance pieces" only %} 34 | 35 | {% include 'spectator_events/includes/works.html' with work_list=creator.get_exhibitions heading="Exhibitions" only %} 36 | 37 | {% endblock content %} 38 | 39 | 40 | {% block sidebar_nav %} 41 | {% load spectator_core %} 42 | {% change_object_link_card object perms %} 43 | 44 | {{ block.super }} 45 | {% endblock sidebar_nav %} 46 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/creator_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_core/base.html' %} 2 | 3 | {% block head_page_title %}Creators{% endblock %} 4 | {% block content_title %}Creators{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 | {% if individual_count > 0 or group_count > 0 %} 14 | 30 | {% endif %} 31 | 32 | {% if creator_list|length > 0 %} 33 | 34 | {% if page_obj|default:False and page_obj.number > 1 %} 35 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 36 | {% endif %} 37 | 38 | 47 | 48 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 49 | 50 | {% else %} 51 | 52 |

There are no {% if creator_kind == 'group' %}groups{% else %}people{% endif %} to display.

53 | 54 | {% endif %} 55 | 56 | {% endblock content %} 57 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_core/base.html' %} 2 | {% load spectator_core %} 3 | 4 | {% block head_title %}Spectator{% endblock %} 5 | {% block content_title %}Spectator{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 | {% if in_progress_publication_list|length > 0 %} 14 |

Currently reading

15 | 16 | {% include 'spectator_reading/includes/publications.html' with publication_list=in_progress_publication_list show_readings='current' show_thumbnails=True only %} 17 | {% endif %} 18 | 19 | {% if recent_event_list|length > 0 %} 20 |

Recent events

21 | 22 | {% include 'spectator_events/includes/events.html' with event_list=recent_event_list only %} 23 | 24 | {% endif %} 25 | 26 | {% endblock content %} 27 | 28 | 29 | {% block sidebar_content %} 30 | {{ block.super }} 31 | 32 | {% most_read_creators_card num=10 %} 33 | 34 | {% most_visited_venues_card num=10 %} 35 | 36 | {% endblock sidebar_content %} 37 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/card_change_object_link.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the edit_object_link_card core template tag. 3 | 4 | Expects: 5 | 6 | * display_link - Boolean, whether the user has permission to view this. 7 | * change_url - The link to change the object in the Admin. 8 | {% endcomment %} 9 | 10 | {% if display_link %} 11 |
12 | 17 |
18 | {% endif %} 19 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/card_chart.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | For displaying a list of objects in a chart within a card. 3 | 4 | Expects: 5 | 6 | * card_title - The text for the card's heading. 7 | 8 | * object_list - A list/QuerySet of objects. 9 | 10 | * score_attr - The name of the attribute on each object that contains the score 11 | for the chart. e.g. 'num_visits' 12 | 13 | * name_attr - Optional. The name of the attribute on each object to be used for 14 | its display value. Default is 'name'. 15 | 16 | * in_card - Optional. Boolean, default is False. Is this displayed within a 17 | card element? 18 | 19 | * use_cite - If True, then wrap the name of each object in tags. Default: False. 20 | 21 | {% endcomment %} 22 | 23 | {% if object_list %} 24 |
25 |
26 |

{{ card_title }}

27 | 28 | {% include 'spectator_core/includes/chart.html' with object_list=object_list score_attr=score_attr name_attr=name_attr|default:None in_card=True use_cite=use_cite|default_if_none:False only %} 29 |
30 |
31 | {% endif %} 32 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/card_nav.html: -------------------------------------------------------------------------------- 1 | 2 | {% load spectator_core %} 3 | {% current_url_name as url_name %} 4 | 5 |
6 | 15 |
16 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/chart.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | For displaying a list of objects in a chart. 3 | 4 | Expects: 5 | * object_list - A list/QuerySet of objects, each having a `chart_position` attribue. 6 | OR, each item could be a list of objects, again with `chart_position` attributes (presumably each would have the same `chart_position` value). 7 | 8 | * score_attr - The name of the attribute on each object that contains the score for the chart. e.g. 'num_visits' 9 | 10 | * name_attr - The name of the attribute on each object to be used for its display value. Default is 'name'. 11 | 12 | * in_card - Boolean, default is False. Is this displayed within a .card element? 13 | 14 | * use_cite - If True, then wrap the name in tags. Default: False. 15 | {% endcomment %} 16 | 17 | {% load spectator_core %} 18 | 19 | 20 | {% for obj in object_list %} 21 |
  • 22 | {% spaceless %} 23 | {% with use_cite|default_if_none:False as use_cite %} 24 | {% if obj|length == 0 %} 25 | {# A single object. #} 26 | {% if use_cite %}{% endif %}{% if name_attr %}{{ obj|get_attr:name_attr }}{% else %}{{ obj.name }}{% endif %}{% if use_cite %}{% endif %} 27 | {% else %} 28 | {# A list of objects. #} 29 | {% for subobj in obj %} 30 | {% if forloop.first %}{% else %}{% if forloop.last %} and {% else %}, {% endif %}{% endif %} 31 | {% if use_cite %}{% endif %}{% if name_attr %}{{ subobj|get_attr:name_attr }}{% else %}{{ subobj.name }}{% endif %}{% if use_cite %}{% endif %} 32 | {% endfor %} 33 | {% endif %} 34 | {% endwith %} 35 | {% endspaceless %} 36 | ({% if obj|length == 0 %}{{ obj|get_attr:score_attr }}{% else %}{{ obj.0|get_attr:score_attr }}{% endif %}) 37 |
  • 38 | {% endfor %} 39 | 40 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/pager.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Display next/prev year links. 3 | 4 | Expects: 5 | 6 | url_name -- The name of the URL to link to, including namespace, if any. 7 | next -- date. 8 | previous -- date. 9 | {% endcomment %} 10 | 11 | {% if next or previous %} 12 | 30 | {% endif %} 31 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/pagination.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | 3 | Expects: 4 | * page_obj, a DiggPaginator instance. 5 | {% endcomment %} 6 | 7 | 8 | {% if page_obj.paginator.num_pages > 1 %} 9 | {% load spectator_core %} 10 | 64 | {% endif %} 65 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/roles.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays Creators/Roles, separated by commas, with 'and' for the last one. 3 | 4 | Expects: 5 | * roles - A list/QuerySet of Roles. 6 | * intro - Optional text to display before the list, eg, 'By' or '
    '. 7 | * show_role_name - Display the role names, if any. Default: True. 8 | 9 | {% endcomment %} 10 | 11 | {% if roles|length > 0 %} 12 | {{ intro }} 13 | {% for r in roles %}{% if forloop.first %}{% else %}{% if forloop.last %} and {% else %}, {% endif %}{% endif %}{{ r.creator.name }}{% if r.role_name and show_role_name|default_if_none:True %} ({{ r.role_name }}){% endif %}{% endfor %} 14 | {% endif %} 15 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/roles_list.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays an unordered list of Creators/Roles. 3 | 4 | Expects: 5 | * roles - A list/QuerySet of Roles. 6 | * show_role_name - Display the role names, if any. Default: True. 7 | * heading - Optional text to use for the heading. If none, no heading is shown. 8 | 9 | {% endcomment %} 10 | 11 | {% if roles|length > 0 %} 12 | {% if heading %} 13 |

    {{ heading }}

    14 | {% endif %} 15 | 16 | 26 | {% endif %} 27 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/thumbnail_detail.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays an image for a page like publication_detail or event_detail. 3 | 4 | Expects: 5 | * obj - The object whose thumbnail we're showing. 6 | * alt_text - The text to use as the image's `alt` text. 7 | {% endcomment %} 8 | 9 | {% with link_url=url|default:obj.thumbnail.url %} 10 | 11 | {{ alt_text }} 12 | 13 | {% endwith %} 14 | -------------------------------------------------------------------------------- /src/spectator/core/templates/spectator_core/includes/thumbnail_list.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays an image for use in a list of things (publication, events) etc. 3 | 4 | Expects: 5 | * obj - The object whose thumbnail we're showing. 6 | * alt_text - The text to use as the image's `alt` text. 7 | {% endcomment %} 8 | 9 | {% with link_url=url|default:obj.thumbnail.url %} 10 | 11 | {{ alt_text }} 12 | 13 | {% endwith %} 14 | -------------------------------------------------------------------------------- /src/spectator/core/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/core/templatetags/__init__.py -------------------------------------------------------------------------------- /src/spectator/core/urls/__init__.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from spectator.core.apps import spectator_apps 4 | 5 | # The aim of this is to: 6 | # a) Make it easy to include Spectator's URLs in a project with a single line. 7 | # b) Also make it easy to only include parts of the project, if necessary. 8 | 9 | app_name = "spectator" 10 | 11 | urlpatterns = [ 12 | path("", include("spectator.core.urls.core")), 13 | path("creators/", include("spectator.core.urls.creators")), 14 | ] 15 | 16 | if spectator_apps.is_enabled("events"): 17 | urlpatterns.append( 18 | path("events/", include("spectator.events.urls", namespace="events")), 19 | ) 20 | 21 | if spectator_apps.is_enabled("reading"): 22 | urlpatterns.append( 23 | path("reading/", include("spectator.reading.urls", namespace="reading")), 24 | ) 25 | -------------------------------------------------------------------------------- /src/spectator/core/urls/core.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from spectator.core import views 4 | 5 | # Only the home page. 6 | # This should be under the namespace 'spectator:core'. 7 | 8 | app_name = "core" 9 | 10 | urlpatterns = [ 11 | path("", view=views.HomeView.as_view(), name="home"), 12 | ] 13 | -------------------------------------------------------------------------------- /src/spectator/core/urls/creators.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from spectator.core import views 4 | 5 | app_name = "creators" 6 | 7 | urlpatterns = [ 8 | # Individuals: 9 | path("", view=views.CreatorListView.as_view(), name="creator_list"), 10 | # Groups: 11 | path( 12 | "groups/", 13 | view=views.CreatorListView.as_view(), 14 | name="creator_list_group", 15 | kwargs={"kind": "group"}, 16 | ), 17 | re_path( 18 | r"^(?P[\w-]+)/$", 19 | view=views.CreatorDetailView.as_view(), 20 | name="creator_detail", 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/spectator/core/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils.html import strip_tags 2 | from django.utils.text import Truncator 3 | 4 | 5 | def truncate_string( 6 | text, *, strip_html=True, chars=255, truncate="…", at_word_boundary=False 7 | ): 8 | """Truncate a string to a certain length, removing line breaks and mutliple 9 | spaces, optionally removing HTML, and appending a 'truncate' string. 10 | 11 | Keyword arguments: 12 | strip_html -- boolean. 13 | chars -- Number of characters to return. 14 | at_word_boundary -- Only truncate at a word boundary, which will probably 15 | result in a string shorter than chars. 16 | truncate -- String to add to the end. 17 | """ 18 | if strip_html: 19 | text = strip_tags(text) 20 | text = text.replace("\n", " ").replace("\r", "") 21 | text = " ".join(text.split()) 22 | if at_word_boundary: 23 | if len(text) > chars: 24 | text = text[:chars].rsplit(" ", 1)[0] + truncate 25 | else: 26 | text = Truncator(text).chars(chars, html=False, truncate=truncate) 27 | return text 28 | 29 | 30 | def chartify(qs, score_field, *, cutoff=0, ensure_chartiness=True): 31 | """ 32 | Given a QuerySet it will go through and add a `chart_position` property to 33 | each object returning a list of the objects. 34 | 35 | If adjacent objects have the same 'score' (based on `score_field`) then 36 | they will have the same `chart_position`. This can then be used in 37 | templates for the `value` of
  • elements in an
      . 38 | 39 | By default any objects with a score of 0 or less will be removed. 40 | 41 | By default, if all the items in the chart have the same position, no items 42 | will be returned (it's not much of a chart). 43 | 44 | Keyword arguments: 45 | qs -- The QuerySet 46 | score_field -- The name of the numeric field that each object in the 47 | QuerySet has, that will be used to compare their positions. 48 | cutoff -- Any objects with a score of this value or below will be removed 49 | from the list. Set to None to disable this. 50 | ensure_chartiness -- If True, then if all items in the list have the same 51 | score, an empty list will be returned. 52 | """ 53 | chart = [] 54 | position = 0 55 | prev_obj = None 56 | 57 | for counter, obj in enumerate(qs): 58 | score = getattr(obj, score_field) 59 | 60 | if score != getattr(prev_obj, score_field, None): 61 | position = counter + 1 62 | 63 | if cutoff is None or score > cutoff: 64 | obj.chart_position = position 65 | chart.append(obj) 66 | 67 | prev_obj = obj 68 | 69 | if ( 70 | ensure_chartiness 71 | and len(chart) > 0 72 | and getattr(chart[0], score_field) == getattr(chart[-1], score_field) 73 | ): 74 | chart = [] 75 | 76 | return chart 77 | -------------------------------------------------------------------------------- /src/spectator/events/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "spectator.events.apps.SpectatorEventsAppConfig" 2 | -------------------------------------------------------------------------------- /src/spectator/events/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SpectatorEventsAppConfig(AppConfig): 5 | label = "spectator_events" 6 | name = "spectator.events" 7 | verbose_name = "Spectator Events" 8 | 9 | # Maintain pre Django 3.2 default behaviour: 10 | default_auto_field = "django.db.models.AutoField" 11 | 12 | def ready(self): 13 | import spectator.events.signals # noqa: F401 14 | -------------------------------------------------------------------------------- /src/spectator/events/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from spectator.core.factories import IndividualCreatorFactory 4 | 5 | from . import models 6 | 7 | 8 | class VenueFactory(factory.django.DjangoModelFactory): 9 | class Meta: 10 | model = models.Venue 11 | 12 | name = factory.Sequence(lambda n: f"Venue {n}") 13 | 14 | 15 | class WorkFactory(factory.django.DjangoModelFactory): 16 | class Meta: 17 | model = models.Work 18 | 19 | title = factory.Sequence(lambda n: f"Work {n}") 20 | 21 | 22 | class ClassicalWorkFactory(WorkFactory): 23 | kind = "classicalwork" 24 | title = factory.Sequence(lambda n: f"Classical Work {n}") 25 | 26 | 27 | class DancePieceFactory(WorkFactory): 28 | kind = "dancepiece" 29 | title = factory.Sequence(lambda n: f"Dance Piece {n}") 30 | 31 | 32 | class ExhibitionFactory(WorkFactory): 33 | kind = "exhibition" 34 | title = factory.Sequence(lambda n: f"Exhibition {n}") 35 | 36 | 37 | class MovieFactory(WorkFactory): 38 | kind = "movie" 39 | title = factory.Sequence(lambda n: f"Movie {n}") 40 | 41 | 42 | class PlayFactory(WorkFactory): 43 | kind = "play" 44 | title = factory.Sequence(lambda n: f"Play {n}") 45 | 46 | 47 | class WorkRoleFactory(factory.django.DjangoModelFactory): 48 | class Meta: 49 | model = models.WorkRole 50 | 51 | role_name = factory.Sequence(lambda n: f"Role {n}") 52 | creator = factory.SubFactory(IndividualCreatorFactory) 53 | work = factory.SubFactory(WorkFactory) 54 | 55 | 56 | class EventFactory(factory.django.DjangoModelFactory): 57 | class Meta: 58 | model = models.Event 59 | 60 | title = factory.Sequence(lambda n: f"Event {n}") 61 | venue = factory.SubFactory(VenueFactory) 62 | # Bigger width/height than default detail_thumbnail_2x size: 63 | thumbnail = factory.django.ImageField(color="blue", width=800, height=800) 64 | 65 | 66 | class ComedyEventFactory(EventFactory): 67 | kind = "comedy" 68 | 69 | 70 | class ConcertEventFactory(EventFactory): 71 | kind = "concert" 72 | 73 | 74 | class DanceEventFactory(EventFactory): 75 | kind = "dance" 76 | 77 | 78 | class MuseumEventFactory(EventFactory): 79 | kind = "museum" 80 | 81 | 82 | class GigEventFactory(EventFactory): 83 | kind = "gig" 84 | 85 | 86 | class MiscEventFactory(EventFactory): 87 | kind = "misc" 88 | 89 | 90 | class CinemaEventFactory(EventFactory): 91 | kind = "cinema" 92 | 93 | 94 | class TheatreEventFactory(EventFactory): 95 | kind = "theatre" 96 | 97 | 98 | class EventRoleFactory(factory.django.DjangoModelFactory): 99 | class Meta: 100 | model = models.EventRole 101 | 102 | role_name = factory.Sequence(lambda n: f"Role {n}") 103 | creator = factory.SubFactory(IndividualCreatorFactory) 104 | event = factory.SubFactory(MiscEventFactory) 105 | 106 | 107 | class WorkSelectionFactory(factory.django.DjangoModelFactory): 108 | class Meta: 109 | model = models.WorkSelection 110 | 111 | event = factory.SubFactory(MiscEventFactory) 112 | work = factory.SubFactory(WorkFactory) 113 | -------------------------------------------------------------------------------- /src/spectator/events/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/events/management/__init__.py -------------------------------------------------------------------------------- /src/spectator/events/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/events/management/commands/__init__.py -------------------------------------------------------------------------------- /src/spectator/events/management/commands/generate_letterboxd_export.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | from spectator.events.models import Event 6 | 7 | 8 | class Command(BaseCommand): 9 | """ 10 | Generates a CSV file containing information about Works of kind 11 | "movie" that have been seen, suitable for importing into a 12 | Letterboxd.com account. 13 | 14 | Import docs: https://letterboxd.com/about/importing-data/ 15 | """ 16 | 17 | help = ( 18 | "Generates a CSV file of watched movies suitable for import into Letterboxd.com" 19 | ) 20 | 21 | filename = "watched_movies.csv" 22 | 23 | def handle(self, *args, **options): 24 | "This is called when the command is run." 25 | rows = self.make_rows() 26 | 27 | if len(rows) == 0: 28 | msg = "No movies were found." 29 | raise CommandError(msg) 30 | 31 | self.write_csv_file(rows) 32 | 33 | plural = "movie" if len(rows) == 1 else "movies" 34 | self.stdout.write( 35 | self.style.SUCCESS(f"Wrote {len(rows)} {plural} to {self.filename}") 36 | ) 37 | 38 | def make_rows(self): 39 | """ 40 | Returns a list of dicts, each one about a single viewing of 41 | a movie. 42 | """ 43 | rows = [] 44 | watched_work_ids = [] 45 | 46 | for event in Event.objects.filter(kind="cinema").order_by("date"): 47 | for selection in event.get_movies(): 48 | work = selection.work 49 | is_rewatch = work.id in watched_work_ids 50 | 51 | directors = [] 52 | for role in work.roles.all(): 53 | if role.role_name.lower() == "director": 54 | directors.append(role.creator.name) 55 | 56 | rows.append( 57 | { 58 | "imdbID": work.imdb_id, 59 | "Title": work.title, 60 | "Year": work.year, 61 | "Directors": ", ".join(directors), 62 | "WatchedDate": event.date.strftime("%Y-%m-%d"), 63 | "Rewatch": str(is_rewatch).lower(), 64 | } 65 | ) 66 | 67 | watched_work_ids.append(work.id) 68 | 69 | return rows 70 | 71 | def write_csv_file(self, rows): 72 | """ 73 | Passed a list of dicts - each one being data about single 74 | viewing of a movie - writes this out to a CSV file. 75 | """ 76 | with open(self.filename, mode="w") as movies_file: 77 | writer = csv.DictWriter( 78 | movies_file, 79 | fieldnames=rows[0].keys(), 80 | delimiter=",", 81 | quotechar='"', 82 | quoting=csv.QUOTE_MINIMAL, 83 | ) 84 | 85 | writer.writeheader() 86 | 87 | for row in rows: 88 | writer.writerow(row) 89 | -------------------------------------------------------------------------------- /src/spectator/events/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Count 3 | 4 | 5 | class VenueManager(models.Manager): 6 | def by_visits(self, event_kind=None): 7 | """ 8 | Gets Venues in order of how many Events have been held there. 9 | Adds a `num_visits` field to each one. 10 | 11 | event_kind filters by kind of Event, e.g. 'theatre', 'cinema', etc. 12 | """ 13 | qs = self.get_queryset() 14 | 15 | if event_kind is not None: 16 | qs = qs.filter(event__kind=event_kind) 17 | 18 | qs = qs.annotate(num_visits=Count("event")).order_by("-num_visits", "name_sort") 19 | 20 | return qs 21 | 22 | 23 | class WorkManager(models.Manager): 24 | def by_views(self, kind=None): 25 | """ 26 | Gets Works in order of how many times they've been attached to 27 | Events. 28 | 29 | kind is the kind of Work, e.g. 'play', 'movie', etc. 30 | """ 31 | qs = self.get_queryset() 32 | 33 | if kind is not None: 34 | qs = qs.filter(kind=kind) 35 | 36 | qs = qs.annotate(num_views=Count("event")).order_by("-num_views", "title_sort") 37 | 38 | return qs 39 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0002_event_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 16:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="event", 15 | name="slug", 16 | field=models.SlugField(blank=True, default="a", max_length=10), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0003_auto_20171101_1645.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 16:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0002_event_slug"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="classicalwork", 15 | name="slug", 16 | field=models.SlugField(blank=True, default="a", max_length=10), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name="dancepiece", 21 | name="slug", 22 | field=models.SlugField(blank=True, default="a", max_length=10), 23 | preserve_default=False, 24 | ), 25 | migrations.AddField( 26 | model_name="movie", 27 | name="slug", 28 | field=models.SlugField(blank=True, default="a", max_length=10), 29 | preserve_default=False, 30 | ), 31 | migrations.AddField( 32 | model_name="play", 33 | name="slug", 34 | field=models.SlugField(blank=True, default="a", max_length=10), 35 | preserve_default=False, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0004_venue_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 17:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0003_auto_20171101_1645"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="venue", 15 | name="slug", 16 | field=models.SlugField(blank=True, default="a", max_length=10), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0005_auto_20180102_0959.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-02 09:59 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("spectator_events", "0004_venue_slug"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="event", 16 | name="movie", 17 | field=models.ForeignKey( 18 | blank=True, 19 | help_text="Only used if event is of 'Movie' kind.", 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | to="spectator_events.Movie", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="event", 27 | name="play", 28 | field=models.ForeignKey( 29 | blank=True, 30 | help_text="Only used if event is of 'Play' kind.", 31 | null=True, 32 | on_delete=django.db.models.deletion.SET_NULL, 33 | to="spectator_events.Play", 34 | ), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0006_event_slug_20180102_1127.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-02 11:27 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | from hashids import Hashids 6 | 7 | 8 | def generate_slug(value): 9 | "A copy of spectator.core.models.SluggedModelMixin._generate_slug()" 10 | alphabet = "abcdefghijkmnopqrstuvwxyz23456789" 11 | salt = "Django Spectator" 12 | 13 | if hasattr(settings, "SPECTATOR_SLUG_ALPHABET"): 14 | alphabet = settings.SPECTATOR_SLUG_ALPHABET 15 | 16 | if hasattr(settings, "SPECTATOR_SLUG_SALT"): 17 | salt = settings.SPECTATOR_SLUG_SALT 18 | 19 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 20 | 21 | return hashids.encode(value) 22 | 23 | 24 | def set_slug(apps, schema_editor): 25 | """ 26 | Create a slug for each Event already in the DB. 27 | """ 28 | Event = apps.get_model("spectator_events", "Event") 29 | 30 | for e in Event.objects.all(): 31 | e.slug = generate_slug(e.pk) 32 | e.save(update_fields=["slug"]) 33 | 34 | 35 | class Migration(migrations.Migration): 36 | 37 | dependencies = [ 38 | ("spectator_events", "0005_auto_20180102_0959"), 39 | ] 40 | 41 | operations = [ 42 | migrations.AlterField( 43 | model_name="event", 44 | name="slug", 45 | field=models.SlugField(blank=True, default="a", max_length=10), 46 | preserve_default=False, 47 | ), 48 | migrations.RunPython(set_slug), 49 | ] 50 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0007_work_slug_20180102_1137.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-02 11:37 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | from hashids import Hashids 6 | 7 | 8 | def generate_slug(value): 9 | "A copy of spectator.core.models.SluggedModelMixin._generate_slug()" 10 | alphabet = "abcdefghijkmnopqrstuvwxyz23456789" 11 | salt = "Django Spectator" 12 | 13 | if hasattr(settings, "SPECTATOR_SLUG_ALPHABET"): 14 | alphabet = settings.SPECTATOR_SLUG_ALPHABET 15 | 16 | if hasattr(settings, "SPECTATOR_SLUG_SALT"): 17 | salt = settings.SPECTATOR_SLUG_SALT 18 | 19 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 20 | 21 | return hashids.encode(value) 22 | 23 | 24 | def set_slug(apps, schema_editor, class_name): 25 | """ 26 | Create a slug for each Work already in the DB. 27 | """ 28 | Cls = apps.get_model("spectator_events", class_name) 29 | 30 | for obj in Cls.objects.all(): 31 | obj.slug = generate_slug(obj.pk) 32 | obj.save(update_fields=["slug"]) 33 | 34 | 35 | def set_classicalwork_slug(apps, schema_editor): 36 | set_slug(apps, schema_editor, "ClassicalWork") 37 | 38 | 39 | def set_dancepiece_slug(apps, schema_editor): 40 | set_slug(apps, schema_editor, "DancePiece") 41 | 42 | 43 | def set_movie_slug(apps, schema_editor): 44 | set_slug(apps, schema_editor, "Movie") 45 | 46 | 47 | def set_play_slug(apps, schema_editor): 48 | set_slug(apps, schema_editor, "Play") 49 | 50 | 51 | class Migration(migrations.Migration): 52 | 53 | dependencies = [ 54 | ("spectator_events", "0006_event_slug_20180102_1127"), 55 | ] 56 | 57 | operations = [ 58 | migrations.AlterField( 59 | model_name="classicalwork", 60 | name="slug", 61 | field=models.SlugField(blank=True, default="a", max_length=10), 62 | preserve_default=False, 63 | ), 64 | migrations.RunPython(set_classicalwork_slug), 65 | migrations.AlterField( 66 | model_name="dancepiece", 67 | name="slug", 68 | field=models.SlugField(blank=True, default="a", max_length=10), 69 | preserve_default=False, 70 | ), 71 | migrations.RunPython(set_dancepiece_slug), 72 | migrations.AlterField( 73 | model_name="movie", 74 | name="slug", 75 | field=models.SlugField(blank=True, default="a", max_length=10), 76 | preserve_default=False, 77 | ), 78 | migrations.RunPython(set_movie_slug), 79 | migrations.AlterField( 80 | model_name="play", 81 | name="slug", 82 | field=models.SlugField(blank=True, default="a", max_length=10), 83 | preserve_default=False, 84 | ), 85 | migrations.RunPython(set_play_slug), 86 | ] 87 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0008_venue_slug_20180102_1147.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-02 11:47 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | from hashids import Hashids 6 | 7 | 8 | def generate_slug(value): 9 | "A copy of spectator.core.models.SluggedModelMixin._generate_slug()" 10 | alphabet = "abcdefghijkmnopqrstuvwxyz23456789" 11 | salt = "Django Spectator" 12 | 13 | if hasattr(settings, "SPECTATOR_SLUG_ALPHABET"): 14 | alphabet = settings.SPECTATOR_SLUG_ALPHABET 15 | 16 | if hasattr(settings, "SPECTATOR_SLUG_SALT"): 17 | salt = settings.SPECTATOR_SLUG_SALT 18 | 19 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 20 | 21 | return hashids.encode(value) 22 | 23 | 24 | def set_slug(apps, schema_editor): 25 | """ 26 | Create a slug for each Venue already in the DB. 27 | """ 28 | Cls = apps.get_model("spectator_events", "Venue") 29 | 30 | for obj in Cls.objects.all(): 31 | obj.slug = generate_slug(obj.pk) 32 | obj.save(update_fields=["slug"]) 33 | 34 | 35 | class Migration(migrations.Migration): 36 | 37 | dependencies = [ 38 | ("spectator_events", "0007_work_slug_20180102_1137"), 39 | ] 40 | 41 | operations = [ 42 | migrations.AlterField( 43 | model_name="venue", 44 | name="slug", 45 | field=models.SlugField(blank=True, default="a", max_length=10), 46 | preserve_default=False, 47 | ), 48 | migrations.RunPython(set_slug), 49 | ] 50 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0009_event_note.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-18 09:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0008_venue_slug_20180102_1147"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="event", 15 | name="note", 16 | field=models.TextField( 17 | blank=True, 18 | help_text="Optional. Paragraphs will be surrounded with

      tags. HTML allowed.", # noqa: E501 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0010_auto_20180118_0906.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-18 09:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0009_event_note"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="event", 15 | name="note", 16 | field=models.TextField( 17 | blank=True, 18 | help_text="Optional. Paragraphs will be surrounded with <p></p> tags. HTML allowed.", # noqa: E501 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0011_auto_20180125_1348.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-25 13:48 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0010_auto_20180118_0906"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="classicalwork", 15 | options={"ordering": ("title_sort",), "verbose_name": "classical work"}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name="classicalworkrole", 19 | options={ 20 | "ordering": ("role_order", "role_name"), 21 | "verbose_name": "classical work role", 22 | }, 23 | ), 24 | migrations.AlterModelOptions( 25 | name="dancepiece", 26 | options={"ordering": ("title_sort",), "verbose_name": "dance piece"}, 27 | ), 28 | migrations.AlterModelOptions( 29 | name="dancepiecerole", 30 | options={ 31 | "ordering": ("role_order", "role_name"), 32 | "verbose_name": "dance piece role", 33 | }, 34 | ), 35 | migrations.AlterModelOptions( 36 | name="eventrole", 37 | options={ 38 | "ordering": ("role_order", "role_name"), 39 | "verbose_name": "event role", 40 | }, 41 | ), 42 | migrations.AlterModelOptions( 43 | name="movierole", 44 | options={ 45 | "ordering": ("role_order", "role_name"), 46 | "verbose_name": "movie role", 47 | }, 48 | ), 49 | migrations.AlterModelOptions( 50 | name="playrole", 51 | options={ 52 | "ordering": ("role_order", "role_name"), 53 | "verbose_name": "play role", 54 | }, 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0013_copy_classical_and_dance_data.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-25 17:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Copy the ClassicalWork and DancePiece data to use the new through models. 9 | """ 10 | Event = apps.get_model("spectator_events", "Event") 11 | ClassicalWorkSelection = apps.get_model( 12 | "spectator_events", "ClassicalWorkSelection" 13 | ) 14 | DancePieceSelection = apps.get_model("spectator_events", "DancePieceSelection") 15 | 16 | for event in Event.objects.all(): 17 | 18 | for work in event.classicalworks.all(): 19 | selection = ClassicalWorkSelection(classical_work=work, event=event) 20 | selection.save() 21 | 22 | for piece in event.dancepieces.all(): 23 | selection = DancePieceSelection(dance_piece=piece, event=event) 24 | selection.save() 25 | 26 | 27 | class Migration(migrations.Migration): 28 | 29 | dependencies = [ 30 | ("spectator_events", "0012_add_classical_and_dance_through_models"), 31 | ] 32 | 33 | operations = [ 34 | migrations.RunPython(forwards), 35 | ] 36 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0014_remove_old_classical_dance.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-25 17:56 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ 8 | Remove the old ClassicalWork and DancePiece relationships to Events, 9 | now we've copied their data to the new version with a through model. 10 | """ 11 | 12 | dependencies = [ 13 | ("spectator_events", "0013_copy_classical_and_dance_data"), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name="event", 19 | name="classicalworks", 20 | ), 21 | migrations.RemoveField( 22 | model_name="event", 23 | name="dancepieces", 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0015_rename_classical_dance_on_event.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-25 17:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ 8 | Now we've deleted the old classicalworks and dancepieces fields, 9 | rename the new ones to be the same as the old. 10 | """ 11 | 12 | dependencies = [ 13 | ("spectator_events", "0014_remove_old_classical_dance"), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name="event", 19 | name="classicalworks2", 20 | ), 21 | migrations.RemoveField( 22 | model_name="event", 23 | name="dancepieces2", 24 | ), 25 | migrations.AddField( 26 | model_name="event", 27 | name="classicalworks", 28 | field=models.ManyToManyField( 29 | blank=True, 30 | help_text="Only used if event is of 'Classical Concert' kind.", 31 | through="spectator_events.ClassicalWorkSelection", 32 | to="spectator_events.ClassicalWork", 33 | ), 34 | ), 35 | migrations.AddField( 36 | model_name="event", 37 | name="dancepieces", 38 | field=models.ManyToManyField( 39 | blank=True, 40 | help_text="Only used if event is of 'Dance' kind.", 41 | through="spectator_events.DancePieceSelection", 42 | to="spectator_events.DancePiece", 43 | ), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0017_copy_movies_and_plays_data.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-26 09:09 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forward(apps, schema_editor): 7 | """ 8 | Copying data from the old `Event.movie` and `Event.play` ForeignKey fields 9 | into the new `Event.movies` and `Event.plays` ManyToManyFields. 10 | """ 11 | 12 | Event = apps.get_model("spectator_events", "Event") 13 | MovieSelection = apps.get_model("spectator_events", "MovieSelection") 14 | PlaySelection = apps.get_model("spectator_events", "PlaySelection") 15 | 16 | for event in Event.objects.all(): 17 | if event.movie is not None: 18 | selection = MovieSelection(event=event, movie=event.movie) 19 | selection.save() 20 | 21 | if event.play is not None: 22 | selection = PlaySelection(event=event, play=event.play) 23 | selection.save() 24 | 25 | 26 | class Migration(migrations.Migration): 27 | 28 | dependencies = [ 29 | ("spectator_events", "0016_add_movies_plays_m2ms_on_event"), 30 | ] 31 | 32 | operations = [ 33 | migrations.RunPython(forward), 34 | ] 35 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0018_remove_old_movie_play_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-26 09:16 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ 8 | Now we've copied their data to the new movies and plays m2m fields, 9 | we can remove the old movie and play fk fields. 10 | """ 11 | 12 | dependencies = [ 13 | ("spectator_events", "0017_copy_movies_and_plays_data"), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name="event", 19 | name="movie", 20 | ), 21 | migrations.RemoveField( 22 | model_name="event", 23 | name="play", 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0019_auto_20180127_1653.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-27 16:53 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0018_remove_old_movie_play_fields"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name="classicalworkselection", 15 | old_name="classical_work", 16 | new_name="work", 17 | ), 18 | migrations.RenameField( 19 | model_name="dancepieceselection", 20 | old_name="dance_piece", 21 | new_name="work", 22 | ), 23 | migrations.RenameField( 24 | model_name="movieselection", 25 | old_name="movie", 26 | new_name="work", 27 | ), 28 | migrations.RenameField( 29 | model_name="playselection", 30 | old_name="play", 31 | new_name="work", 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0020_venue_note.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-29 17:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0019_auto_20180127_1653"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="venue", 15 | name="note", 16 | field=models.TextField( 17 | blank=True, 18 | help_text="Optional. Paragraphs will be surrounded with <p></p> tags. HTML allowed.", # noqa: E501 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0021_auto_20180129_1735.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-29 17:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("spectator_events", "0020_venue_note"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="event", 16 | name="venue", 17 | field=models.ForeignKey( 18 | blank=True, 19 | on_delete=django.db.models.deletion.CASCADE, 20 | to="spectator_events.Venue", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0022_auto_20180129_1752.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-29 17:52 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("spectator_events", "0021_auto_20180129_1735"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="event", 16 | name="venue", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to="spectator_events.Venue", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0023_venue_cinema_treasures_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-31 14:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0022_auto_20180129_1752"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="venue", 15 | name="cinema_treasures_id", 16 | field=models.PositiveSmallIntegerField( 17 | blank=True, 18 | help_text='Optional. ID of a cinema at Cinema Treasures.', # noqa: E501 19 | null=True, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0024_event_venue_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-31 15:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Set the venue_name field of all Events that have a Venue. 9 | """ 10 | Event = apps.get_model("spectator_events", "Event") 11 | 12 | for event in Event.objects.all(): 13 | if event.venue is not None: 14 | event.venue_name = event.venue.name 15 | event.save() 16 | 17 | 18 | class Migration(migrations.Migration): 19 | 20 | dependencies = [ 21 | ("spectator_events", "0023_venue_cinema_treasures_id"), 22 | ] 23 | 24 | operations = [ 25 | migrations.AddField( 26 | model_name="event", 27 | name="venue_name", 28 | field=models.CharField( 29 | blank=True, 30 | help_text="The name of the Venue when this event occurred. If left blank, will be set automatically.", # noqa: E501 31 | max_length=255, 32 | ), 33 | ), 34 | migrations.RunPython(forwards), 35 | ] 36 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0025_auto_20180131_1755.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-31 17:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0024_event_venue_name"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="venue", 15 | name="cinema_treasures_id", 16 | field=models.PositiveIntegerField( 17 | blank=True, 18 | help_text='Optional. ID of a cinema at Cinema Treasures.', # noqa: E501 19 | null=True, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0027_classicalworks_to_works.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 11:32 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Change all ClassicalWork objects into Work objects, and their associated 9 | data into WorkRole and WorkSelection models, then delete the ClassicalWork. 10 | """ 11 | ClassicalWork = apps.get_model("spectator_events", "ClassicalWork") 12 | Work = apps.get_model("spectator_events", "Work") 13 | WorkRole = apps.get_model("spectator_events", "WorkRole") 14 | WorkSelection = apps.get_model("spectator_events", "WorkSelection") 15 | 16 | for cw in ClassicalWork.objects.all(): 17 | 18 | work = Work.objects.create( 19 | kind="classicalwork", title=cw.title, title_sort=cw.title_sort 20 | ) 21 | 22 | for role in cw.roles.all(): 23 | WorkRole.objects.create( 24 | creator=role.creator, 25 | work=work, 26 | role_name=role.role_name, 27 | role_order=role.role_order, 28 | ) 29 | 30 | for selection in cw.events.all(): 31 | WorkSelection.objects.create( 32 | event=selection.event, work=work, order=selection.order 33 | ) 34 | 35 | cw.delete() 36 | 37 | 38 | class Migration(migrations.Migration): 39 | 40 | dependencies = [ 41 | ("spectator_events", "0026_auto_20180208_1126"), 42 | ] 43 | 44 | operations = [ 45 | migrations.RunPython(forwards), 46 | ] 47 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0028_dancepieces_to_works.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 11:45 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Change all DancePiece objects into Work objects, and their associated 9 | data into WorkRole and WorkSelection models, then delete the DancePiece. 10 | """ 11 | DancePiece = apps.get_model("spectator_events", "DancePiece") 12 | Work = apps.get_model("spectator_events", "Work") 13 | WorkRole = apps.get_model("spectator_events", "WorkRole") 14 | WorkSelection = apps.get_model("spectator_events", "WorkSelection") 15 | 16 | for dp in DancePiece.objects.all(): 17 | 18 | work = Work.objects.create( 19 | kind="dancepiece", title=dp.title, title_sort=dp.title_sort 20 | ) 21 | 22 | for role in dp.roles.all(): 23 | WorkRole.objects.create( 24 | creator=role.creator, 25 | work=work, 26 | role_name=role.role_name, 27 | role_order=role.role_order, 28 | ) 29 | 30 | for selection in dp.events.all(): 31 | WorkSelection.objects.create( 32 | event=selection.event, work=work, order=selection.order 33 | ) 34 | 35 | dp.delete() 36 | 37 | 38 | class Migration(migrations.Migration): 39 | 40 | dependencies = [ 41 | ("spectator_events", "0027_classicalworks_to_works"), 42 | ] 43 | 44 | operations = [ 45 | migrations.RunPython(forwards), 46 | ] 47 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0029_plays_to_works.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 11:47 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Change all Play objects into Work objects, and their associated 9 | data into WorkRole and WorkSelection models, then delete the Play. 10 | """ 11 | Play = apps.get_model("spectator_events", "Play") 12 | Work = apps.get_model("spectator_events", "Work") 13 | WorkRole = apps.get_model("spectator_events", "WorkRole") 14 | WorkSelection = apps.get_model("spectator_events", "WorkSelection") 15 | 16 | for p in Play.objects.all(): 17 | 18 | work = Work.objects.create(kind="play", title=p.title, title_sort=p.title_sort) 19 | 20 | for role in p.roles.all(): 21 | WorkRole.objects.create( 22 | creator=role.creator, 23 | work=work, 24 | role_name=role.role_name, 25 | role_order=role.role_order, 26 | ) 27 | 28 | for selection in p.events.all(): 29 | WorkSelection.objects.create( 30 | event=selection.event, work=work, order=selection.order 31 | ) 32 | 33 | p.delete() 34 | 35 | 36 | class Migration(migrations.Migration): 37 | 38 | dependencies = [ 39 | ("spectator_events", "0028_dancepieces_to_works"), 40 | ] 41 | 42 | operations = [ 43 | migrations.RunPython(forwards), 44 | ] 45 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0030_movies_to_works.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 11:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Change all Movie objects into Work objects, and their associated 9 | data into WorkRole and WorkSelection models, then delete the Movie. 10 | """ 11 | Movie = apps.get_model("spectator_events", "Movie") 12 | Work = apps.get_model("spectator_events", "Work") 13 | WorkRole = apps.get_model("spectator_events", "WorkRole") 14 | WorkSelection = apps.get_model("spectator_events", "WorkSelection") 15 | 16 | for m in Movie.objects.all(): 17 | 18 | work = Work.objects.create( 19 | kind="movie", 20 | title=m.title, 21 | title_sort=m.title_sort, 22 | year=m.year, 23 | imdb_id=m.imdb_id, 24 | ) 25 | 26 | for role in m.roles.all(): 27 | WorkRole.objects.create( 28 | creator=role.creator, 29 | work=work, 30 | role_name=role.role_name, 31 | role_order=role.role_order, 32 | ) 33 | 34 | for selection in m.events.all(): 35 | WorkSelection.objects.create( 36 | event=selection.event, work=work, order=selection.order 37 | ) 38 | 39 | m.delete() 40 | 41 | 42 | class Migration(migrations.Migration): 43 | 44 | dependencies = [ 45 | ("spectator_events", "0029_plays_to_works"), 46 | ] 47 | 48 | operations = [ 49 | migrations.RunPython(forwards), 50 | ] 51 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0031_auto_20180208_1412.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 14:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0030_movies_to_works"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="event", 15 | name="classicalworks", 16 | ), 17 | migrations.RemoveField( 18 | model_name="event", 19 | name="dancepieces", 20 | ), 21 | migrations.RemoveField( 22 | model_name="event", 23 | name="movies", 24 | ), 25 | migrations.RemoveField( 26 | model_name="event", 27 | name="plays", 28 | ), 29 | migrations.AddField( 30 | model_name="event", 31 | name="works", 32 | field=models.ManyToManyField( 33 | blank=True, 34 | through="spectator_events.WorkSelection", 35 | to="spectator_events.Work", 36 | ), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0032_recreate_work_slugs.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 14:17 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | from hashids import Hashids 6 | 7 | 8 | def generate_slug(value): 9 | """ 10 | Generates a slug using a Hashid of `value`. 11 | 12 | Taken from spectator_core.models.SluggedModelMixin 13 | """ 14 | # Defaults: 15 | alphabet = "abcdefghijkmnopqrstuvwxyz23456789" 16 | salt = "Django Spectator" 17 | 18 | if hasattr(settings, "SPECTATOR_SLUG_ALPHABET"): 19 | alphabet = settings.SPECTATOR_SLUG_ALPHABET 20 | 21 | if hasattr(settings, "SPECTATOR_SLUG_SALT"): 22 | salt = settings.SPECTATOR_SLUG_SALT 23 | 24 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 25 | 26 | return hashids.encode(value) 27 | 28 | 29 | def forwards(apps, schema_editor): 30 | """ 31 | Re-save all the Works because something earlier didn't create their slugs. 32 | """ 33 | Work = apps.get_model("spectator_events", "Work") 34 | 35 | for work in Work.objects.all(): 36 | if not work.slug: 37 | work.slug = generate_slug(work.pk) 38 | work.save() 39 | 40 | 41 | class Migration(migrations.Migration): 42 | 43 | dependencies = [ 44 | ("spectator_events", "0031_auto_20180208_1412"), 45 | ] 46 | 47 | operations = [ 48 | migrations.RunPython(forwards), 49 | ] 50 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0034_auto_20180208_1618.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-08 16:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0033_auto_20180208_1613"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="event", 15 | name="kind", 16 | field=models.CharField( 17 | choices=[ 18 | ("concert", "Classical"), 19 | ("comedy", "Comedy"), 20 | ("dance", "Dance"), 21 | ("exhibition", "Exhibition"), 22 | ("gig", "Gig"), 23 | ("misc", "Other"), 24 | ("movie", "Movie"), 25 | ("play", "Theatre"), 26 | ], 27 | help_text="Used to categorise event. But any kind of Work can be added to any kind of Event.", # noqa: E501 28 | max_length=20, 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0035_change_event_kinds.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-02-14 13:24 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Change Events with kind 'movie' to 'cinema' 9 | and Events with kind 'play' to 'theatre'. 10 | 11 | Purely for more consistency. 12 | """ 13 | Event = apps.get_model("spectator_events", "Event") 14 | 15 | for ev in Event.objects.filter(kind="movie"): 16 | ev.kind = "cinema" 17 | ev.save() 18 | 19 | for ev in Event.objects.filter(kind="play"): 20 | ev.kind = "theatre" 21 | ev.save() 22 | 23 | 24 | class Migration(migrations.Migration): 25 | 26 | dependencies = [ 27 | ("spectator_events", "0034_auto_20180208_1618"), 28 | ] 29 | 30 | operations = [ 31 | migrations.RunPython(forwards), 32 | ] 33 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0036_auto_20180417_1218.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-04-17 12:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0035_change_event_kinds"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="event", 15 | name="kind", 16 | field=models.CharField( 17 | choices=[ 18 | ("cinema", "Cinema"), 19 | ("concert", "Concert"), 20 | ("comedy", "Comedy"), 21 | ("dance", "Dance"), 22 | ("exhibition", "Exhibition"), 23 | ("museum", "Gallery/Museum"), 24 | ("gig", "Gig"), 25 | ("theatre", "Theatre"), 26 | ("misc", "Other"), 27 | ], 28 | help_text="Used to categorise event. But any kind of Work can be added to any kind of Event.", # noqa: E501 29 | max_length=20, 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0037_exhibition_to_museum.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-04-17 12:18 2 | 3 | from django.db import migrations 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | """ 8 | Migrate all 'exhibition' Events to the new 'museum' Event kind. 9 | """ 10 | Event = apps.get_model("spectator_events", "Event") 11 | 12 | for ev in Event.objects.filter(kind="exhibition"): 13 | ev.kind = "museum" 14 | ev.save() 15 | 16 | 17 | class Migration(migrations.Migration): 18 | 19 | dependencies = [ 20 | ("spectator_events", "0036_auto_20180417_1218"), 21 | ] 22 | 23 | operations = [ 24 | migrations.RunPython(forwards), 25 | ] 26 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0038_auto_20180417_1224.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-04-17 12:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0037_exhibition_to_museum"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="event", 15 | name="kind", 16 | field=models.CharField( 17 | choices=[ 18 | ("cinema", "Cinema"), 19 | ("concert", "Concert"), 20 | ("comedy", "Comedy"), 21 | ("dance", "Dance"), 22 | ("museum", "Gallery/Museum"), 23 | ("gig", "Gig"), 24 | ("theatre", "Theatre"), 25 | ("misc", "Other"), 26 | ], 27 | help_text="Used to categorise event. But any kind of Work can be added to any kind of Event.", # noqa: E501 28 | max_length=20, 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0039_populate_exhibitions.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-04-17 12:44 2 | 3 | from django.db import migrations 4 | from hashids import Hashids 5 | 6 | from spectator.core import app_settings 7 | 8 | 9 | def generate_slug(value): 10 | """ 11 | Generates a slug using a Hashid of `value`. 12 | 13 | COPIED from spectator.core.models.SluggedModelMixin() because migrations 14 | don't make this happen automatically and perhaps the least bad thing is 15 | to copy the method here, ugh. 16 | """ 17 | alphabet = app_settings.SLUG_ALPHABET 18 | salt = app_settings.SLUG_SALT 19 | 20 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 21 | 22 | return hashids.encode(value) 23 | 24 | 25 | def forwards(apps, schema_editor): 26 | """ 27 | Having added the new 'exhibition' Work type, we're going to assume that 28 | every Event of type 'museum' should actually have one Exhibition attached. 29 | 30 | So, we'll add one, with the same title as the Event. 31 | And we'll move all Creators from the Event to the Exhibition. 32 | """ 33 | Event = apps.get_model("spectator_events", "Event") 34 | Work = apps.get_model("spectator_events", "Work") 35 | WorkRole = apps.get_model("spectator_events", "WorkRole") 36 | WorkSelection = apps.get_model("spectator_events", "WorkSelection") 37 | 38 | for event in Event.objects.filter(kind="museum"): 39 | 40 | # Create a new Work based on this Event's details. 41 | 42 | work = Work.objects.create( 43 | kind="exhibition", title=event.title, title_sort=event.title_sort 44 | ) 45 | # This doesn't generate the slug field automatically because Django. 46 | # So we'll have to do it manually. Graarhhh. 47 | work.slug = generate_slug(work.pk) 48 | work.save() 49 | 50 | # Associate the new Work with the Event. 51 | WorkSelection.objects.create(event=event, work=work) 52 | 53 | # Associate any Creators on the Event with the new Work. 54 | for role in event.roles.all(): 55 | WorkRole.objects.create( 56 | creator=role.creator, 57 | work=work, 58 | role_name=role.role_name, 59 | role_order=role.role_order, 60 | ) 61 | 62 | # Remove Creators from the Event. 63 | role.delete() 64 | 65 | 66 | class Migration(migrations.Migration): 67 | 68 | dependencies = [ 69 | ("spectator_events", "0038_auto_20180417_1224"), 70 | ] 71 | 72 | operations = [ 73 | migrations.RunPython(forwards), 74 | ] 75 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0040_auto_20180417_1721.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-04-17 17:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0039_populate_exhibitions"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="work", 15 | name="kind", 16 | field=models.CharField( 17 | choices=[ 18 | ("classicalwork", "Classical work"), 19 | ("dancepiece", "Dance piece"), 20 | ("exhibition", "Exhibition"), 21 | ("movie", "Movie"), 22 | ("play", "Play"), 23 | ], 24 | max_length=20, 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0041_event_ticket.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-31 12:34 2 | 3 | from django.db import migrations, models 4 | 5 | import spectator.events.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("spectator_events", "0040_auto_20180417_1721"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="event", 17 | name="ticket", 18 | field=models.ImageField( 19 | blank=True, 20 | default="", 21 | upload_to=spectator.events.models.event_upload_path, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0042_auto_20200407_1039.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-07 10:39 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("spectator_events", "0041_event_ticket"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="venue", 16 | name="cinema_treasures_id", 17 | field=models.PositiveIntegerField( 18 | blank=True, 19 | help_text='Optional. ID of a cinema at\nCinema Treasures.', # noqa: E501 20 | null=True, 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="work", 25 | name="imdb_id", 26 | field=models.CharField( 27 | blank=True, 28 | help_text="Starts with 'tt', e.g. 'tt0100842'.\nFrom IMDb.", # noqa: E501 29 | max_length=12, 30 | validators=[ 31 | django.core.validators.RegexValidator( 32 | code="invalid_imdb_id", 33 | message='IMDb ID should be like "tt1234567"', 34 | regex="^tt\\d{7,10}$", 35 | ) 36 | ], 37 | verbose_name="IMDb ID", 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0043_rename_ticket_to_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-07 10:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_events", "0042_auto_20200407_1039"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name="event", 15 | old_name="ticket", 16 | new_name="thumbnail", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0044_change_thumbnail_upload_to_function.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-07 10:51 2 | 3 | from django.db import migrations, models 4 | 5 | import spectator.core.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("spectator_events", "0043_rename_ticket_to_thumbnail"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="event", 17 | name="thumbnail", 18 | field=models.ImageField( 19 | blank=True, 20 | default="", 21 | upload_to=spectator.core.models.thumbnail_upload_path, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0045_auto_20201221_1358.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-21 13:58 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("spectator_events", "0044_change_thumbnail_upload_to_function"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="venue", 16 | name="cinema_treasures_id", 17 | field=models.PositiveIntegerField( 18 | blank=True, 19 | help_text='Optional. ID of a cinema at\n Cinema Treasures.', # noqa: E501 20 | null=True, 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="work", 25 | name="imdb_id", 26 | field=models.CharField( 27 | blank=True, 28 | help_text="Starts with 'tt', e.g. 'tt0100842'.\n From IMDb.", # noqa: E501 29 | max_length=12, 30 | validators=[ 31 | django.core.validators.RegexValidator( 32 | code="invalid_imdb_id", 33 | message='IMDb ID should be like "tt1234567"', 34 | regex="^tt\\d{7,10}$", 35 | ) 36 | ], 37 | verbose_name="IMDb ID", 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/0046_alter_work_imdb_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2025-04-22 08:56 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('spectator_events', '0045_auto_20201221_1358'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='work', 16 | name='imdb_id', 17 | field=models.CharField(blank=True, help_text='Starts with \'tt\', e.g. \'tt0100842\'.\n From IMDb.', max_length=12, validators=[django.core.validators.RegexValidator(code='invalid_imdb_id', message='IMDb ID should be like "tt1234567"', regex='^tt[0-9]{7,10}$')], verbose_name='IMDb ID'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /src/spectator/events/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/events/migrations/__init__.py -------------------------------------------------------------------------------- /src/spectator/events/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_delete, post_save 2 | from django.dispatch import receiver 3 | 4 | from .models import EventRole 5 | 6 | 7 | @receiver(post_delete, sender=EventRole, dispatch_uid="spectator.delete.event_role") 8 | @receiver(post_save, sender=EventRole, dispatch_uid="spectator.save.event_role") 9 | def eventrole_changed(sender, **kwargs): 10 | """ 11 | When an Event's creators are changed we want to re-save the Event 12 | itself so that its title_sort can be recreated if necessary 13 | """ 14 | kwargs["instance"].event.save() 15 | -------------------------------------------------------------------------------- /src/spectator/events/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sitemaps import Sitemap 2 | 3 | from .models import Event, Venue, Work 4 | 5 | 6 | class EventSitemap(Sitemap): 7 | changefreq = "never" 8 | priority = 0.5 9 | 10 | def items(self): 11 | # Exclude movies and plays because they'll have the same URLs as their 12 | # Movie and Play objects. 13 | return Event.objects.exclude(kind="movie").exclude(kind="play") 14 | 15 | def lastmod(self, obj): 16 | return obj.time_modified 17 | 18 | 19 | class VenueSitemap(Sitemap): 20 | changefreq = "monthly" 21 | priority = 0.5 22 | 23 | def items(self): 24 | return Venue.objects.all() 25 | 26 | def lastmod(self, obj): 27 | return obj.time_modified 28 | 29 | 30 | class WorkSitemap(Sitemap): 31 | changefreq = "monthly" 32 | priority = 0.5 33 | 34 | def items(self): 35 | return Work.objects.all() 36 | 37 | def lastmod(self, obj): 38 | return obj.time_modified 39 | -------------------------------------------------------------------------------- /src/spectator/events/static/css/admin/location_picker.css: -------------------------------------------------------------------------------- 1 | .setloc-map { 2 | max-width: 100%; 3 | height: 400px; 4 | margin-top: 1em; 5 | border: 1px solid #eee; 6 | } 7 | -------------------------------------------------------------------------------- /src/spectator/events/static/img/map-marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/events/static/img/map-marker.png -------------------------------------------------------------------------------- /src/spectator/events/static/js/venue_map.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For displaying the map on the VenueDetail page. 3 | * 4 | * Expects there to be three variables declared: 5 | * spectator_map_latitude 6 | * spectator_map_longitude 7 | * spectator_map_config 8 | * 9 | * And for there to be a div, sized appropriately, with a class of 10 | * 'js-venue-map-container'. 11 | * 12 | * spectator_map_config should be set for one of the supported map libraries, 13 | * and the releveant CSS, and JS for the library should have been included in 14 | * the page. See the Django Spectator docs for more details. 15 | * 16 | * 1. Google 17 | * { 18 | * "library": "google" 19 | * } 20 | * 21 | * 2. Mabox 22 | * { 23 | * "library": "mapbox", 24 | * "tile_style": "mapbox://styles/mapbox/light-v10" 25 | * } 26 | */ 27 | function spectatorInitMap() { 28 | var lat = 29 | typeof spectator_map_latitude !== "undefined" ? spectator_map_latitude : ""; 30 | var lon = 31 | typeof spectator_map_longitude !== "undefined" 32 | ? spectator_map_longitude 33 | : ""; 34 | var mapConfig = 35 | typeof spectator_map_config !== "undefined" ? spectator_map_config : false; 36 | 37 | if (!lat || !lon || !mapConfig) { 38 | return; 39 | } 40 | 41 | // Create the map element. 42 | var container = document.getElementsByClassName("js-venue-map-container")[0]; 43 | container.innerHTML = '
      '; 44 | 45 | var mapEl = document.getElementsByClassName("js-venue-map")[0]; 46 | 47 | var map; 48 | 49 | // Create the map and marker depending on which library we're using... 50 | 51 | if (mapConfig.library === "google") { 52 | var position = { lat: parseFloat(lat), lng: parseFloat(lon) }; 53 | 54 | var tileStyle = mapConfig.tile_style ? mapConfig.tile_style : "roadmap"; 55 | 56 | map = new google.maps.Map(mapEl, { 57 | zoom: 12, 58 | center: position, 59 | mapTypeId: tileStyle, 60 | }); 61 | 62 | new google.maps.Marker({ 63 | map: map, 64 | position: position, 65 | }); 66 | } else if (mapConfig.library == "mapbox") { 67 | var position = [parseFloat(lon), parseFloat(lat)]; 68 | 69 | var tilesStyle = "mapbox://styles/mapbox/streets-v11"; 70 | if (mapConfig.tile_style) { 71 | tileStyle = mapConfig.tile_style; 72 | } 73 | 74 | map = new mapboxgl.Map({ 75 | container: mapEl, 76 | center: position, 77 | style: tileStyle, 78 | zoom: 11, 79 | }); 80 | 81 | map.addControl(new mapboxgl.NavigationControl()); 82 | 83 | // Create and add marker 84 | var el = document.createElement("div"); 85 | el.className = "spectator-marker"; // The CSS classname 86 | new mapboxgl.Marker(el, { anchor: "bottom" }) 87 | .setLngLat(position) 88 | .addTo(map); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/spectator/events/templates/admin/spectator_events/venue/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load static %} 3 | {# Overriding default template for the Venue change form, so we can include 4 | the below CSS and JS. #} 5 | 6 | {% block extrastyle %} 7 | {{ block.super }} 8 | 9 | {% if SPECTATOR_MAPS and SPECTATOR_MAPS.enable and SPECTATOR_MAPS.library == "mapbox" %} 10 | 19 | {% endif %} 20 | {% endblock extrastyle %} 21 | 22 | 23 | {% block footer %} 24 | {{ block.super }} 25 | 26 | {% if SPECTATOR_MAPS and SPECTATOR_MAPS.enable %} 27 | {# Best way of putting the config dict into a JS variable. #} 28 | {{ SPECTATOR_MAPS|json_script:"spectator-maps-config" }} 29 | 34 | 35 | {% if SPECTATOR_MAPS.library == "mapbox" %} 36 | 39 | {% endif %} 40 | {% endif %} 41 | {% endblock footer %} 42 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_core/base.html' %} 2 | 3 | {% load spectator_core spectator_events %} 4 | 5 | {% block events_nav_active %}active{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | {{ block.super }} 9 | 10 | {% endblock %} 11 | 12 | {% block sidebar_nav %} 13 | {% include 'spectator_events/includes/card_nav.html' %} 14 | {% include 'spectator_core/includes/card_nav.html' %} 15 | {% endblock sidebar_nav %} 16 | 17 | {% block sidebar_content %} 18 | {% current_url_name as url_name %} 19 | 20 | {% if url_name != 'spectator:events:home' and url_name != 'spectator:events:event_year_archive'%} 21 | {% recent_events_card 5 %} 22 | {% endif %} 23 | 24 | {% comment %} 25 | Just links to year pages: #} 26 | {% events_years_card current_year=year %} 27 | {% endcomment %} 28 | 29 | {% if event_kind %} 30 | {% annual_event_counts_card current_year=year kind=event_kind %} 31 | {% else %} 32 | {% annual_event_counts_card current_year=year %} 33 | {% endif %} 34 | {% endblock sidebar_content %} 35 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/event_archive_year.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | 3 | {% load spectator_events %} 4 | 5 | {% block head_page_title %}{{ year|date:"Y" }} events{% endblock %} 6 | {% block content_title %}{{ year|date:"Y" }} events{% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | {{ block.super }} 10 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 | {% include 'spectator_core/includes/pager.html' with url_name='spectator:events:event_year_archive' previous=previous_year next=next_year only %} 15 | 16 | {% if event_list|length > 0 %} 17 | 18 | {% for event in event_list %} 19 | {% ifchanged event.date|date:"m" %} 20 | {% if not forloop.first %} 21 | 22 | {% endif %} 23 |

      {{ event.date|date:"F"}}

      24 | 35 | 36 | {% include 'spectator_core/includes/pager.html' with url_name='spectator:events:event_year_archive' previous=previous_year next=next_year only %} 37 | 38 | {% else %} 39 |

      No events in {{ year|date:"Y" }}.

      40 | {% endif %} 41 | 42 | {% endblock content %} 43 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/event_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | {% load spectator_core spectator_events %} 3 | 4 | {% block head_page_title %}{{ event }}{% if event.venue %} at {{ event.venue_name }}{% endif %} on {% display_date event.date as event_date %}{{ event_date|striptags }}{% endblock %} 5 | {% block content_title %}{{ event.title_html }}{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | {{ block.super }} 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 | {% if event.thumbnail %} 15 | {% include 'spectator_core/includes/thumbnail_detail.html' with url=event.thumbnail.url obj=event alt_text="Ticket" only %} 16 | {% endif %} 17 | 18 |

      19 | {% if event.venue %}At {{ event.venue_name }} 20 | on{% else %}On{% endif %} {% display_date event.date %}. 21 |

      22 | 23 | {% if event.note %} 24 | {{ event.note|safe|linebreaks }} 25 | {% endif %} 26 | 27 | {% include 'spectator_events/includes/selections.html' with selection_list=event.get_movies heading="Movies" only %} 28 | 29 | {% include 'spectator_events/includes/selections.html' with selection_list=event.get_plays heading="Plays" only %} 30 | 31 | {% include 'spectator_events/includes/selections.html' with selection_list=event.get_classical_works heading="Classical works" only %} 32 | 33 | {% include 'spectator_events/includes/selections.html' with selection_list=event.get_dance_pieces heading="Dance pieces" only %} 34 | 35 | {% include 'spectator_events/includes/selections.html' with selection_list=event.get_exhibitions heading="Exhibitions" only %} 36 | 37 | {% include 'spectator_core/includes/roles_list.html' with roles=event.roles.all heading='Featuring' only %} 38 | 39 | {% endblock content %} 40 | 41 | 42 | {% block sidebar_nav %} 43 | {% change_object_link_card object perms %} 44 | 45 | {{ block.super }} 46 | {% endblock sidebar_nav %} 47 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/event_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | 3 | {% load spectator_core spectator_events %} 4 | 5 | {% block head_page_title %}{% if event_kind_name_plural %}{{ event_kind_name_plural }}{% else %}Events{% endif %}{% if page_obj.number > 1 %} page {{ page_obj.number }}{% endif %}{% endblock %} 6 | {% block content_title %}Events{% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | {% if event_kind %} 10 | {{ block.super }} 11 | {% else %} 12 | 13 | 14 | {% endif %} 15 | {% endblock %} 16 | 17 | {% block content %} 18 | 19 | {% if event_list|length > 0 %} 20 | {% event_list_tabs counts event_kind page_obj.number %} 21 | {% endif %} 22 | 23 | {% include 'spectator_events/includes/events_paginated.html' with event_list=event_list page_obj=page_obj only %} 24 | 25 | {% endblock content %} 26 | 27 | 28 | {% block sidebar_content %} 29 | 30 | {% most_seen_creators_card event_kind=event_kind num=10 %} 31 | 32 | {{ block.super }} 33 | 34 | {% endblock sidebar_content %} 35 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/card_annual_event_counts.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the annual_event_counts_card template tag. 3 | 4 | Expects: 5 | 6 | * current_year: A date object representing the current year, if any. 7 | * years: A QuerySet of dicts. 8 | * kind: An Event kind (like 'cinema', 'gig', etc.) or 'all'. 9 | * card_title: Text for the card's title. 10 | {% endcomment %} 11 | 12 | {% if years|length > 0 %} 13 | {% load spectator_core %} 14 | {% current_url_name as url_name %} 15 |
      16 |
      17 |

      {{ card_title }}

      18 | 19 |
        20 | {% for year_data in years %} 21 |
      • 22 | {% if url_name == 'spectator:events:event_year_archive' and current_year == year_data.year %} 23 | {{ year_data.year|date:"Y" }} 24 | {% else %} 25 | {{ year_data.year|date:"Y" }} 26 | {% endif %} 27 | 28 | ({{ year_data.total }}) 29 | 30 |
      • 31 | {% endfor %} 32 |
      33 |
      34 |
      35 | {% endif %} 36 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/card_events.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the recent_events_card template tag. 3 | 4 | Expects: 5 | * event_list - A QuerySet of Publications. 6 | * card_title - The title for the card. 7 | {% endcomment %} 8 | 9 | {% if event_list|length > 0 %} 10 |
      11 |
      12 |

      {{ card_title }}

      13 | {% include 'spectator_events/includes/events.html' with event_list=event_list style='unstyled' only %} 14 |
      15 |
      16 | {% endif %} 17 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/card_nav.html: -------------------------------------------------------------------------------- 1 | 2 | {% load spectator_core %} 3 | {% current_url_name as url_name %} 4 | 5 |
      6 |
        7 |
      • 8 | {% if url_name == 'spectator:events:home' %} 9 | Events 10 | {% else %} 11 | Events 12 | {% endif %} 13 |
      • 14 |
      • 15 | {% if url_name == 'spectator:events:venue_list' %} 16 | Venues 17 | {% else %} 18 | Venues 19 | {% endif %} 20 |
      • 21 |
      22 |
      23 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/card_years.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the events_years_card template tag. 3 | 4 | Expects: 5 | 6 | * current_year: A date object representing the current year, if any. 7 | * years: A QuerySet of date objects, one for each year to link to. 8 | {% endcomment %} 9 | 10 | {% if years|length > 0 %} 11 | {% load spectator_core %} 12 | {% current_url_name as url_name %} 13 |
      14 |
      15 |

      Events by year

      16 |
        17 | {% for events_year in years %} 18 |
      • 19 | {% if url_name == 'spectator:events:event_year_archive' and current_year|date:"Y" == events_year|date:"Y" %} 20 | {{ events_year|date:"Y" }} 21 | {% else %} 22 | {{ events_year|date:"Y" }} 23 | {% endif %} 24 |
      • 25 | {% endfor %} 26 |
      27 |
      28 |
      29 | {% endif %} 30 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/event_list_tabs.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays the tabs to different event_list pages. 3 | Used by the event_list_tabs() template inclusion tag. 4 | {% endcomment %} 5 | 6 | {% load spectator_core %} 7 | 8 | 27 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/events.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays a list of Events. 3 | 4 | Expects: 5 | * event_list - List or Queryset of children of Event. 6 | * style: 'bullets' (default) or 'unstyled'. 7 | 8 | {% endcomment %} 9 | 10 | {% load spectator_events %} 11 | 12 | {% if event_list|length > 0 %} 13 | 14 | {% for event in event_list %} 15 |
    1. 16 | {{ event.title_html }}
      17 | {% if event.venue %}{{ event.venue_name }}, {% endif %}{% display_date event.date %}
      18 |
    2. 19 | {% endfor %} 20 | 21 | {% endif %} 22 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/events_paginated.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays a paginated list of Events. 3 | 4 | Expects: 5 | * event_list - List or Queryset of Events. 6 | * page_obj - A DiggPaginator instance. 7 | {% endcomment %} 8 | 9 | {% if event_list|length > 0 %} 10 | {% if page_obj|default:False and page_obj.number > 1 %} 11 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 12 | {% endif %} 13 | 14 | {% include 'spectator_events/includes/events.html' with event_list=event_list only %} 15 | 16 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 17 | 18 | {% else %} 19 |

      There are no events to show.

      20 | {% endif %} 21 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/selections.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | For displaying a list of Selections that are attached to an Event. e.g., ClassicalWorkSelections or MovieSelections. 3 | 4 | Used on the Event Detail page. 5 | 6 | Expects: 7 | * selection_list - A list/QuerySet of ClassicalWorkSelections, MovieSelections, etc. 8 | * heading - Optional text to use for the heading. If none, no heading is shown. 9 | {% endcomment %} 10 | 11 | {% if selection_list|length > 0 %} 12 | {% if heading %} 13 |

      {{ heading }}

      14 | {% endif %} 15 | 16 |
        17 | {% for selection in selection_list %} 18 | {% include 'spectator_events/includes/work.html' with work=selection.work only %} 19 | {% endfor %} 20 |
      21 | {% endif %} 22 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/visits.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used for Plays and Movies, listing different Events that item was visited. 3 | 4 | Expects: 5 | 6 | * events - A list or Queryset of Event objects. 7 | * heading_level - The level of heading to use, eg ('h2'). Default is None; no heading. 8 | {% endcomment %} 9 | 10 | {% load spectator_events %} 11 | 12 | {% if events|length > 0 %} 13 | {% if heading_level|default:False %} 14 | <{{ heading_level }}>Viewings 15 | {% endif %} 16 | 31 | {% endif %} 32 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/work.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | For displaying a single Movie, Concert Work, etc in a list. 3 | 4 | Expects: 5 | * work - A Movie, ConcertWork, DancePiece or Play. 6 | {% endcomment %} 7 | 8 | {% load l10n %} 9 | 10 |
    3. 11 | {{ work.title }} 12 | {% if work.kind == 'movie' and work.year %} 13 | ({{ work.year|unlocalize }}) 14 | {% endif %} 15 | {% include 'spectator_core/includes/roles.html' with roles=work.roles.all intro='
      ' %} 16 |
    4. 17 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/works.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | For displaying a list of works that are attached to an Event by a ManyToManyField. e.g., Classical Works or Dance Pieces. 3 | 4 | Expects: 5 | * work_list - A list/QuerySet of ClassicalWorks, Movies, etc. 6 | * heading - Optional text to use for the heading. If none, no heading is shown. 7 | {% endcomment %} 8 | 9 | {% if work_list|length > 0 %} 10 | {% if heading %} 11 |

      {{ heading }}

      12 | {% endif %} 13 | 14 |
        15 | {% for work in work_list %} 16 | {% include 'spectator_events/includes/work.html' with work=work only %} 17 | {% endfor %} 18 |
      19 | {% endif %} 20 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/includes/works_paginated.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | For displaying a paginated list of works that are attached to an Event by a ManyToManyField. e.g., Classical Works or Dance Pieces. 3 | 4 | Expects: 5 | * selection_list - A list/QuerySet of ClassicalWorkSelections, MovieSelections, etc. 6 | * page_obj - A DiggPaginator instance. 7 | {% endcomment %} 8 | 9 | {% if work_list|length > 0 %} 10 | {% if page_obj|default:False and page_obj.number > 1 %} 11 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 12 | {% endif %} 13 | 14 | {% include 'spectator_events/includes/works.html' with work_list=work_list heading=heading|default:None only %} 15 | 16 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 17 | 18 | {% else %} 19 |

      There are no works to show.

      20 | {% endif %} 21 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/venue_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | {% load spectator_events static %} 3 | 4 | {% block head_extra %} 5 | {% if SPECTATOR_MAPS and SPECTATOR_MAPS.enable %} 6 | {% if SPECTATOR_MAPS.library == "mapbox" %} 7 | 8 | 17 | {% endif %} 18 | 19 | 27 | {% endif %} 28 | {{ block.super }} 29 | {% endblock %} 30 | 31 | {% block head_page_title %}{{ venue.name }}{% if page_obj.number > 1 %} (page {{ page_obj.number }}){% endif %}{% endblock %} 32 | {% block content_title %}{{ venue.name }}{% endblock %} 33 | 34 | {% block breadcrumbs %} 35 | {{ block.super }} 36 | 37 | 38 | {% endblock %} 39 | 40 | {% block content %} 41 | 42 |

      43 | {{ venue.address }}{% if venue.address and venue.country_name %}, {% endif %}{% if venue.country_name %}{{ venue.country_name }}{% endif %} 44 |

      45 | 46 | {% with names=venue.previous_names %} 47 | {% if names|length > 0 %} 48 |

      Previously visited as: {{ names|join:", " }}.

      49 | {% endif %} 50 | {% endwith %} 51 | 52 | {% if venue.note %} 53 | {{ venue.note|safe|linebreaks }} 54 | {% endif %} 55 | 56 | {% if venue.cinema_treasures_url %} 57 |

      See at Cinema Treasures.

      58 | {% endif %} 59 | 60 | {% if SPECTATOR_MAPS.enable %} 61 |
      62 | {% endif %} 63 | 64 | {% if event_list|length > 0 %} 65 |

      {{ page_obj.paginator.count }} event{{ page_obj.paginator.count|pluralize }}

      66 | {% include 'spectator_events/includes/events_paginated.html' with event_list=event_list page_obj=page_obj only %} 67 | {% else %} 68 |

      There are no events to show.

      69 | {% endif %} 70 | 71 | {% endblock content %} 72 | 73 | 74 | {% block sidebar_nav %} 75 | {% load spectator_core %} 76 | {% change_object_link_card object perms %} 77 | 78 | {{ block.super }} 79 | {% endblock sidebar_nav %} 80 | 81 | 82 | {% block sidebar_content %} 83 | 84 | {% most_visited_venues_card num=10 %} 85 | 86 | {{ block.super }} 87 | {% endblock sidebar_content %} 88 | 89 | 90 | {% block foot_extra %} 91 | {% if SPECTATOR_MAPS and SPECTATOR_MAPS.enable %} 92 | 93 | {# Best way of putting the config dict into a JS variable. #} 94 | {{ SPECTATOR_MAPS|json_script:"spectator-maps-config" }} 95 | 102 | 103 | 104 | 105 | {% if SPECTATOR_MAPS.library == "google" and SPECTATOR_MAPS.api_key %} 106 | 107 | {% else %} 108 | 109 | {% if SPECTATOR_MAPS.library == "mapbox" %} 110 | 111 | 114 | {% endif %} 115 | 116 | 119 | {% endif %} 120 | {% endif %} 121 | 122 | {{ block.super }} 123 | {% endblock %} 124 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/venue_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | {% load spectator_core %} 3 | 4 | {% block head_page_title %}Venues{% endblock %} 5 | {% block content_title %}Venues{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | {{ block.super }} 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 | {% if venue_list|length > 0 %} 15 |

      {{ page_obj.paginator.count }} venue{{ page_obj.paginator.count|pluralize }}{% if country_list|length > 1 %} in {{ country_list|length }} countr{{ country_list|length|pluralize:'y,ies' }}{% endif %}.

      16 | 17 | {% if page_obj|default:False and page_obj.number > 1 %} 18 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 19 | {% endif %} 20 | 21 |
        22 | {% for venue in venue_list %} 23 |
      • 24 | {{ venue.name }}
        25 | {{ venue.address }}{% if venue.address and venue.country_name %}, {% endif %}{% if venue.country_name %}{{ venue.country_name }}{% endif %} 26 |
      • 27 | {% endfor %} 28 |
      29 | 30 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 31 | 32 | {% else %} 33 |

      There are no venues to show.

      34 | {% endif %} 35 | 36 | {% endblock content %} 37 | 38 | 39 | {% block sidebar_content %} 40 | 41 | {% most_visited_venues_card num=10 %} 42 | 43 | {{ block.super }} 44 | {% endblock sidebar_content %} 45 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/work_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | {% comment %} 3 | Displays a single Movie, Play, ClassicalWork or DancePiece. 4 | 5 | As well as `object`, expects: 6 | * breadcrumb_list_title - Text for linking to the parent list view. 7 | * breadcrumb_list_url - URL for linking to the parent list view. 8 | {% endcomment %} 9 | 10 | {% load l10n %} 11 | 12 | {% block head_page_title %} 13 | {% if work.kind == 'movie' and work.year %} 14 | {{ work.title }} ({{ work.year|unlocalize }}) 15 | {% else %} 16 | {{ work.title }} 17 | {% endif %} 18 | {% endblock %} 19 | 20 | {% block content_title %} 21 | {% if work.kind == 'movie' and work.year %} 22 | {{ work.title }} ({{ work.year|unlocalize }}) 23 | {% else %} 24 | {{ work.title }} 25 | {% endif %} 26 | {% endblock %} 27 | 28 | {% block breadcrumbs %} 29 | {{ block.super }} 30 | 31 | 32 | {% endblock %} 33 | 34 | {% block content %} 35 | 36 | {% if work.kind != 'movie' and work.year %} 37 |

      Year: {{ work.year|unlocalize }}

      38 | {% endif %} 39 | 40 | {% if work.imdb_id %} 41 |

      View at IMDb

      42 | {% endif %} 43 | 44 | {% include 'spectator_core/includes/roles_list.html' with roles=work.roles.all heading='By' only %} 45 | 46 | {% include 'spectator_events/includes/visits.html' with events=work.event_set.all heading_level='h2' only %} 47 | 48 | {% endblock content %} 49 | 50 | 51 | {% block sidebar_nav %} 52 | {% load spectator_core %} 53 | {% change_object_link_card work perms %} 54 | 55 | {{ block.super }} 56 | {% endblock sidebar_nav %} 57 | -------------------------------------------------------------------------------- /src/spectator/events/templates/spectator_events/work_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_events/base.html' %} 2 | {% load spectator_events %} 3 | 4 | {% comment %} 5 | Displays a paginated list of Movies, Plays, ClassicalWorks or DancePieces. 6 | {% endcomment %} 7 | 8 | {% block head_page_title %}{{ page_title }}{% endblock %} 9 | {% block content_title %}{{ page_title }}{% endblock %} 10 | 11 | {% block breadcrumbs %} 12 | {{ block.super }} 13 | 14 | {% endblock %} 15 | 16 | {% block content %} 17 | 18 |

      {{ page_obj.paginator.count }} {% if page_obj.paginator.count == 1 %}{{ work_kind_name|lower }}{% else %}{{ work_kind_name_plural|lower }}{% endif %}.

      19 | 20 | {% include 'spectator_events/includes/works_paginated.html' with work_list=object_list page_obj=page_obj only %} 21 | 22 | {% endblock content %} 23 | 24 | 25 | {% block sidebar_content %} 26 | 27 | {% most_seen_works_card kind=work_kind num=10 %} 28 | 29 | {{ block.super }} 30 | {% endblock sidebar_content %} 31 | -------------------------------------------------------------------------------- /src/spectator/events/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/events/templatetags/__init__.py -------------------------------------------------------------------------------- /src/spectator/events/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from . import views 4 | from .models import Event, Work 5 | 6 | app_name = "events" 7 | 8 | # Will be like 'movies|plays|concerts' etc: 9 | event_kind_slugs = "|".join(Event.get_valid_kind_slugs()) 10 | 11 | # Will be like 'classical-works|dance-pieces' etc: 12 | work_kind_slugs = "|".join(Work.get_valid_kind_slugs()) 13 | 14 | urlpatterns = [ 15 | path("", view=views.EventListView.as_view(), name="home"), 16 | re_path( 17 | rf"^types/(?P{event_kind_slugs})/$", 18 | view=views.EventListView.as_view(), 19 | name="event_list", 20 | ), 21 | path( 22 | "venues/", 23 | view=views.VenueListView.as_view(), 24 | name="venue_list", 25 | ), 26 | re_path( 27 | r"^venues/(?P[\w-]+)/$", 28 | view=views.VenueDetailView.as_view(), 29 | name="venue_detail", 30 | ), 31 | re_path( 32 | r"^(?P[0-9]{4})/$", 33 | view=views.EventYearArchiveView.as_view(), 34 | name="event_year_archive", 35 | ), 36 | re_path( 37 | rf"^(?P{work_kind_slugs})/$", 38 | view=views.WorkListView.as_view(), 39 | name="work_list", 40 | ), 41 | re_path( 42 | rf"^(?P{work_kind_slugs})/(?P[\w-]+)/$", 43 | view=views.WorkDetailView.as_view(), 44 | name="work_detail", 45 | ), 46 | re_path( 47 | r"^(?P[\w-]+)/$", 48 | view=views.EventDetailView.as_view(), 49 | name="event_detail", 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /src/spectator/reading/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "spectator.reading.apps.SpectatorReadingAppConfig" 2 | -------------------------------------------------------------------------------- /src/spectator/reading/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from imagekit.admin import AdminThumbnail 3 | 4 | from .models import Publication, PublicationRole, PublicationSeries, Reading 5 | 6 | 7 | class ReadingInline(admin.TabularInline): 8 | model = Reading 9 | fields = ( 10 | "publication", 11 | "start_date", 12 | "end_date", 13 | "is_finished", 14 | "start_granularity", 15 | "end_granularity", 16 | ) 17 | raw_id_fields = ("publication",) 18 | extra = 1 19 | 20 | 21 | class PublicationRoleInline(admin.TabularInline): 22 | model = PublicationRole 23 | fields = ("creator", "role_name", "role_order") 24 | raw_id_fields = ("creator",) 25 | extra = 1 26 | 27 | 28 | @admin.register(PublicationSeries) 29 | class PublicationSeriesAdmin(admin.ModelAdmin): 30 | list_display = ("title",) 31 | 32 | fieldsets = ( 33 | (None, {"fields": ("title", "title_sort", "slug", "url")}), 34 | ( 35 | "Times", 36 | {"classes": ("collapse",), "fields": ("time_created", "time_modified")}, 37 | ), 38 | ) 39 | 40 | readonly_fields = ("title_sort", "slug", "time_created", "time_modified") 41 | 42 | 43 | class ReadingsListFilter(admin.SimpleListFilter): 44 | """ 45 | Add filters for publications to list 'Unread' and 'In-progress' publications. 46 | """ 47 | 48 | title = "reading" 49 | parameter_name = "readings" 50 | 51 | def lookups(self, request, model_admin): 52 | return (("in-progress", ("In progress")), ("unread", ("Unread"))) 53 | 54 | def queryset(self, request, queryset): 55 | if self.value() == "in-progress": 56 | return queryset.filter( 57 | reading__start_date__isnull=False, reading__end_date__isnull=True 58 | ) 59 | 60 | if self.value() == "unread": 61 | return queryset.filter(reading__isnull=True) 62 | 63 | 64 | @admin.register(Publication) 65 | class PublicationAdmin(admin.ModelAdmin): 66 | list_display = ("title", "list_thumbnail", "kind", "show_creators", "series") 67 | list_filter = (ReadingsListFilter, "kind", "series") 68 | search_fields = ("title",) 69 | list_select_related = ("series",) 70 | 71 | fieldsets = ( 72 | ( 73 | None, 74 | { 75 | "fields": ( 76 | "title", 77 | "title_sort", 78 | "detail_thumbnail", 79 | "thumbnail", 80 | "slug", 81 | "kind", 82 | "series", 83 | "isbn_uk", 84 | "isbn_us", 85 | "official_url", 86 | "notes_url", 87 | ) 88 | }, 89 | ), 90 | ( 91 | "Times", 92 | {"classes": ("collapse",), "fields": ("time_created", "time_modified")}, 93 | ), 94 | ) 95 | 96 | radio_fields = {"kind": admin.HORIZONTAL} 97 | readonly_fields = ( 98 | "title_sort", 99 | "detail_thumbnail", 100 | "slug", 101 | "time_created", 102 | "time_modified", 103 | ) 104 | 105 | inlines = [PublicationRoleInline, ReadingInline] 106 | 107 | def show_creators(self, instance): 108 | names = [str(r.creator) for r in instance.roles.all()] 109 | if names: 110 | return ", ".join(names) 111 | else: 112 | return "-" 113 | 114 | show_creators.short_description = "Creators" 115 | 116 | detail_thumbnail = AdminThumbnail( 117 | image_field="thumbnail", template="spectator_core/admin/detail_thumbnail.html" 118 | ) 119 | 120 | list_thumbnail = AdminThumbnail( 121 | image_field="thumbnail", template="spectator_core/admin/list_thumbnail.html" 122 | ) 123 | -------------------------------------------------------------------------------- /src/spectator/reading/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SpectatorReadingAppConfig(AppConfig): 5 | label = "spectator_reading" 6 | name = "spectator.reading" 7 | verbose_name = "Spectator Reading" 8 | 9 | # Maintain pre Django 3.2 default behaviour: 10 | default_auto_field = "django.db.models.AutoField" 11 | -------------------------------------------------------------------------------- /src/spectator/reading/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from spectator.core.factories import IndividualCreatorFactory 4 | 5 | from . import models 6 | 7 | 8 | class PublicationSeriesFactory(factory.django.DjangoModelFactory): 9 | class Meta: 10 | model = models.PublicationSeries 11 | 12 | title = factory.Sequence(lambda n: f"Publication Series {n}") 13 | 14 | 15 | class PublicationFactory(factory.django.DjangoModelFactory): 16 | class Meta: 17 | model = models.Publication 18 | 19 | title = factory.Sequence(lambda n: f"Publication {n}") 20 | series = factory.SubFactory(PublicationSeriesFactory) 21 | # Bigger width/height than default detail_thumbnail_2x size: 22 | thumbnail = factory.django.ImageField(color="blue", width=800, height=800) 23 | 24 | 25 | class PublicationRoleFactory(factory.django.DjangoModelFactory): 26 | class Meta: 27 | model = models.PublicationRole 28 | 29 | role_name = factory.Sequence(lambda n: f"Role {n}") 30 | creator = factory.SubFactory(IndividualCreatorFactory) 31 | publication = factory.SubFactory(PublicationFactory) 32 | 33 | 34 | class ReadingFactory(factory.django.DjangoModelFactory): 35 | class Meta: 36 | model = models.Reading 37 | 38 | publication = factory.SubFactory(PublicationFactory) 39 | -------------------------------------------------------------------------------- /src/spectator/reading/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Min 3 | 4 | 5 | class InProgressPublicationsManager(models.Manager): 6 | """ 7 | Returns Publications that are currently being read, ordered with the 8 | most-recently-started last. 9 | They might have previously been finished. 10 | """ 11 | 12 | def get_queryset(self): 13 | from .models import Publication # noqa: F401 14 | 15 | return ( 16 | super() 17 | .get_queryset() 18 | .filter(reading__start_date__isnull=False, reading__end_date__isnull=True) 19 | .annotate(min_start_date=Min("reading__start_date")) 20 | .order_by("min_start_date") 21 | ) 22 | 23 | 24 | class UnreadPublicationsManager(models.Manager): 25 | """ 26 | Returns Publications that haven't been started (have no Readings). 27 | """ 28 | 29 | def get_queryset(self): 30 | return super().get_queryset().filter(reading__isnull=True) 31 | 32 | 33 | class EndDateAscendingReadingsManager(models.Manager): 34 | """ 35 | Returns Readings in descending end_date order, with Readings that have 36 | no end_date first. 37 | Via http://stackoverflow.com/a/15125261/250962 38 | """ 39 | 40 | def get_queryset(self): 41 | qs = super().get_queryset() 42 | qs = qs.extra(select={"end_date_null": "end_date is null"}) 43 | return qs.extra(order_by=["end_date_null", "end_date"]) 44 | 45 | 46 | class EndDateDescendingReadingsManager(models.Manager): 47 | """ 48 | Returns Readings in ascending end_date order, with Readings that have 49 | no end_date last. 50 | Via http://stackoverflow.com/a/15125261/250962 51 | """ 52 | 53 | def get_queryset(self): 54 | qs = super().get_queryset() 55 | qs = qs.extra(select={"end_date_null": "end_date is null"}) 56 | return qs.extra(order_by=["-end_date_null", "-end_date"]) 57 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0002_publicationseries_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 15:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_reading", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="publicationseries", 15 | name="slug", 16 | field=models.SlugField(blank=True, default="a", max_length=10), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0003_publication_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-11-01 16:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_reading", "0002_publicationseries_slug"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="publication", 15 | name="slug", 16 | field=models.SlugField(blank=True, default="a", max_length=10), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0004_slugs_20180102_1153.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-02 11:53 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | from hashids import Hashids 6 | 7 | 8 | def generate_slug(value): 9 | "A copy of spectator.core.models.SluggedModelMixin._generate_slug()" 10 | alphabet = "abcdefghijkmnopqrstuvwxyz23456789" 11 | salt = "Django Spectator" 12 | 13 | if hasattr(settings, "SPECTATOR_SLUG_ALPHABET"): 14 | alphabet = settings.SPECTATOR_SLUG_ALPHABET 15 | 16 | if hasattr(settings, "SPECTATOR_SLUG_SALT"): 17 | salt = settings.SPECTATOR_SLUG_SALT 18 | 19 | hashids = Hashids(alphabet=alphabet, salt=salt, min_length=5) 20 | 21 | return hashids.encode(value) 22 | 23 | 24 | def set_slug(apps, schema_editor, class_name): 25 | """ 26 | Create a slug for each Object already in the DB. 27 | """ 28 | Cls = apps.get_model("spectator_reading", class_name) 29 | 30 | for obj in Cls.objects.all(): 31 | obj.slug = generate_slug(obj.pk) 32 | obj.save(update_fields=["slug"]) 33 | 34 | 35 | def set_publication_slug(apps, schema_editor): 36 | set_slug(apps, schema_editor, "Publication") 37 | 38 | 39 | def set_publicationseries_slug(apps, schema_editor): 40 | set_slug(apps, schema_editor, "PublicationSeries") 41 | 42 | 43 | class Migration(migrations.Migration): 44 | 45 | dependencies = [ 46 | ("spectator_reading", "0003_publication_slug"), 47 | ] 48 | 49 | operations = [ 50 | migrations.AlterField( 51 | model_name="publication", 52 | name="slug", 53 | field=models.SlugField(blank=True, default="a", max_length=10), 54 | preserve_default=False, 55 | ), 56 | migrations.RunPython(set_publication_slug), 57 | migrations.AlterField( 58 | model_name="publicationseries", 59 | name="slug", 60 | field=models.SlugField(blank=True, default="a", max_length=10), 61 | preserve_default=False, 62 | ), 63 | migrations.RunPython(set_publicationseries_slug), 64 | ] 65 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0005_auto_20180125_1348.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-01-25 13:48 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_reading", "0004_slugs_20180102_1153"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="publicationrole", 15 | options={ 16 | "ordering": ("role_order", "role_name"), 17 | "verbose_name": "Publication role", 18 | }, 19 | ), 20 | migrations.AlterModelOptions( 21 | name="publicationseries", 22 | options={ 23 | "ordering": ("title_sort",), 24 | "verbose_name": "Publication series", 25 | "verbose_name_plural": "Publication series", 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0006_publication_cover.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-22 16:47 2 | 3 | from django.db import migrations, models 4 | 5 | import spectator.reading.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("spectator_reading", "0005_auto_20180125_1348"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="publication", 17 | name="cover", 18 | field=models.ImageField( 19 | blank=True, 20 | default="", 21 | upload_to=spectator.reading.models.publication_upload_path, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0007_rename_cover_to_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-07 10:50 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("spectator_reading", "0006_publication_cover"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name="publication", 15 | old_name="cover", 16 | new_name="thumbnail", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/0008_change_thumbnail_upload_to_function.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-07 10:52 2 | 3 | from django.db import migrations, models 4 | 5 | import spectator.core.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("spectator_reading", "0007_rename_cover_to_thumbnail"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="publication", 17 | name="thumbnail", 18 | field=models.ImageField( 19 | blank=True, 20 | default="", 21 | upload_to=spectator.core.models.thumbnail_upload_path, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/spectator/reading/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/reading/migrations/__init__.py -------------------------------------------------------------------------------- /src/spectator/reading/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sitemaps import Sitemap 2 | 3 | from .models import Publication, PublicationSeries 4 | 5 | 6 | class PublicationSitemap(Sitemap): 7 | changefreq = "yearly" 8 | priority = 0.5 9 | 10 | def items(self): 11 | return Publication.objects.all() 12 | 13 | def lastmod(self, obj): 14 | return obj.time_modified 15 | 16 | 17 | class PublicationSeriesSitemap(Sitemap): 18 | changefreq = "monthly" 19 | priority = 0.5 20 | 21 | def items(self): 22 | return PublicationSeries.objects.all() 23 | 24 | def lastmod(self, obj): 25 | return obj.time_modified 26 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_core/base.html' %} 2 | 3 | {% load spectator_core spectator_reading %} 4 | 5 | {% block reading_nav_active %}active{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | {{ block.super }} 9 | 10 | {% endblock %} 11 | 12 | 13 | {% block sidebar_nav %} 14 | 15 | {% include 'spectator_reading/includes/card_nav.html' %} 16 | {% include 'spectator_core/includes/card_nav.html' %} 17 | 18 | {% endblock sidebar_nav %} 19 | 20 | {% block sidebar_content %} 21 | {% current_url_name as url_name %} 22 | 23 | {% if url_name != 'spectator:reading:home' and url_name != 'spectator:reading:reading_year_archive' %} 24 | {% in_progress_publications_card %} 25 | {% endif %} 26 | 27 | {% comment %} 28 | Just links to year pages: #} 29 | {% reading_years_card current_year=year %} 30 | {% endcomment %} 31 | 32 | {% annual_reading_counts_card current_year=year kind='all' %} 33 | 34 | {% endblock sidebar_content %} 35 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_reading/base.html' %} 2 | {% load spectator_core %} 3 | 4 | {% block head_page_title %}Reading{% endblock %} 5 | {% block content_title %}Reading{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 | {% if in_progress_publication_list|length > 0 %} 15 |

      Currently reading ({{ in_progress_publication_list|length }})

      16 | 17 | {% include 'spectator_reading/includes/publications.html' with publication_list=in_progress_publication_list show_readings='current' show_thumbnails=True only %} 18 | {% endif %} 19 | 20 | {% if publication_list|length > 0 %} 21 |

      Unread ({{ publication_list|length }})

      22 | 23 | {% include 'spectator_reading/includes/publications.html' with publication_list=publication_list show_thumbnails=True only %} 24 | {% endif %} 25 | 26 | {% endblock content %} 27 | 28 | 29 | {% block sidebar_content %} 30 | 31 | {% most_read_creators_card num=10 %} 32 | 33 | {{ block.super }} 34 | {% endblock sidebar_content %} 35 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/card_annual_reading_counts.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the annual_reading_counts_card template tag. 3 | 4 | Expects: 5 | 6 | * current_year: A date object representing the current year, if any. 7 | * years: A list of dicts. 8 | * kind: One of 'book', 'publication', 'all' 9 | * card_title: Text for the card's title. 10 | {% endcomment %} 11 | 12 | {% if years|length > 0 %} 13 | {% load spectator_core %} 14 | {% current_url_name as url_name %} 15 |
      16 |
      17 |

      {{ card_title }}

      18 | 19 | {% if kind == 'all' %} 20 |

      Books, periodicals and total

      21 |
      22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for year_data in years %} 33 | 34 | 41 | 42 | 43 | 44 | 45 | {% endfor %} 46 | 47 |
      YearBooksPeriodicalsTotal
      35 | {% if url_name == 'spectator:reading:reading_year_archive' and current_year == year_data.year %} 36 | {{ year_data.year|date:"Y" }} 37 | {% else %} 38 | {{ year_data.year|date:"Y" }} 39 | {% endif %} 40 | {{ year_data.book }}{{ year_data.periodical }}{{ year_data.total }}
      48 |
      49 | {% else %} 50 |
        51 | {% for year_data in years %} 52 |
      • 53 | {% if url_name == 'spectator:reading:reading_year_archive' and current_year == year_data.year %} 54 | {{ year_data.year|date:"Y" }} 55 | {% else %} 56 | {{ year_data.year|date:"Y" }} 57 | {% endif %} 58 | 59 | {% if kind == 'periodical' %} 60 | ({{ year_data.periodical }}) 61 | {% else %} 62 | ({{ year_data.book }}) 63 | {% endif %} 64 | 65 |
      • 66 | {% endfor %} 67 |
      68 | {% endif %} 69 |
      70 |
      71 | {% endif %} 72 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/card_nav.html: -------------------------------------------------------------------------------- 1 | 2 | {% load spectator_core %} 3 | {% current_url_name as url_name %} 4 | 5 |
      6 |
        7 |
      • 8 | {% if url_name == 'spectator:reading:publication_list' %} 9 | Publications 10 | {% else %} 11 | Publications 12 | {% endif %} 13 |
      • 14 |
      • 15 | {% if url_name == 'spectator:reading:publicationseries_list' %} 16 | Series 17 | {% else %} 18 | Series 19 | {% endif %} 20 |
      • 21 |
      22 |
      23 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/card_publications.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the in_progress_publications_card template tag. 3 | 4 | Expects: 5 | * publication_list - A QuerySet of Publications. 6 | * card_title - The title for the card. 7 | {% endcomment %} 8 | 9 | {% if publication_list|length > 0 %} 10 |
      11 |
      12 |

      {{ card_title }}

      13 | {% include 'spectator_reading/includes/publications.html' with publication_list=publication_list show_readings='none' style='unstyled' only %} 14 |
      15 |
      16 | {% endif %} 17 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/card_years.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Used by the reading_years_card template tag. 3 | 4 | Expects: 5 | 6 | * current_year: A date object representing the current year, if any. 7 | * years: A QuerySet of date objects, one for each year to link to. 8 | {% endcomment %} 9 | 10 | {% if years|length > 0 %} 11 | {% load spectator_core %} 12 | {% current_url_name as url_name %} 13 |
      14 |
      15 |

      Reading by year

      16 |
        17 | {% for reading_year in years %} 18 |
      • 19 | {% if url_name == 'spectator:reading:reading_year_archive' and current_year|date:"Y" == reading_year|date:"Y" %} 20 | {{ reading_year|date:"Y" }} 21 | {% else %} 22 | {{ reading_year|date:"Y" }} 23 | {% endif %} 24 |
      • 25 | {% endfor %} 26 |
      27 |
      28 |
      29 | {% endif %} 30 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/publication.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Display a single Publication, probably used in a list. 3 | 4 | Expects: 5 | 6 | * publication: The Publication to display. 7 | * show_readings: 'none' (default), 'all' or 'current' (the in-progress reading, if any). 8 | * show_thumbnail: Boolean, default is False. 9 | 10 | {% endcomment %} 11 | 12 | {% if show_thumbnail|default_if_none:False and publication.thumbnail %} 13 |
      14 | {% include 'spectator_core/includes/thumbnail_list.html' with url=publication.get_absolute_url obj=publication alt_text="Cover" %} 15 | 16 |
      17 | {% endif %} 18 | 19 | 20 | 21 | {% if publication.series %} 22 | {{ publication.series.title }}, 23 | {{ publication.title }} 24 | {% else %} 25 | {{ publication.title }} 26 | {% endif %} 27 | 28 | 29 | 30 | {% include 'spectator_core/includes/roles.html' with roles=publication.roles.all intro='
      by' %} 31 | 32 | {% if show_readings == 'all' or show_readings == 'current' %} 33 |
        34 | {% if show_readings == 'all' %} 35 | {% with publication.reading_set.all as readings %} 36 | {% if readings|length > 0 %} 37 | {% for reading in readings %} 38 |
      • 39 | {% include 'spectator_reading/includes/reading.html' with reading=reading only %} 40 |
      • 41 | {% endfor %} 42 | {% endif %} 43 | {% endwith %} 44 | {% else %} 45 | {% with publication.get_current_reading as reading %} 46 | {% if reading %} 47 |
      • 48 | {% include 'spectator_reading/includes/reading.html' with reading=reading only %} 49 |
      • 50 | {% endif %} 51 | {% endwith %} 52 | {% endif %} 53 |
      54 | {% endif %} 55 | 56 | {% if show_thumbnail|default_if_none:False and publication.thumbnail %} 57 |
      58 |
      59 | {% endif %} 60 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/publications.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Expects: 3 | 4 | * publication_list: The list or QuerySet of Publications to display. 5 | * show_readings: 'none' (default), 'all' or 'current' (the in-progress reading, if any). 6 | * show_thumbnails: Boolean, default is False. 7 | * style: 'bullets' (default) or 'unstyled'. 8 | 9 | {% endcomment %} 10 | 11 | 12 | {% for publication in publication_list %} 13 |
    5. 14 | {% include 'spectator_reading/includes/publication.html' with publication=publication show_readings=show_readings|default:'none' show_thumbnail=show_thumbnails|default_if_none:False only %} 15 |
    6. 16 | {% endfor %} 17 | 18 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/publications_paginated.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Displays a list of publications, with pagination. 3 | 4 | Expects: 5 | * publication_list - List or Queryset of Publications. 6 | * show_readings: 'none' (default), 'all' or 'current' (the in-progress reading, if any). 7 | * page_obj, a DiggPaginator instance. 8 | 9 | {% endcomment %} 10 | 11 | {% if publication_list|length > 0 %} 12 | {% if page_obj|default:False and page_obj.number > 1 %} 13 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 14 | {% endif %} 15 | 16 | {% include 'spectator_reading/includes/publications.html' with publication_list=publication_list show_readings=show_readings|default:'none' show_thumbnails=True only %} 17 | 18 | {% include 'spectator_core/includes/pagination.html' with page_obj=page_obj only %} 19 | 20 | {% else %} 21 | 22 |

      There are no publications to display.

      23 | 24 | {% endif %} 25 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/includes/reading.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | Display one Reading of a Publication. 3 | 4 | Expects: 5 | * reading - A Reading object. 6 | {% endcomment %} 7 | 8 | {% load spectator_reading %} 9 | 10 | {% reading_dates reading %} 11 | {% if reading.end_date and not reading.is_finished %}(Unfinished){% endif %} 12 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/publication_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_reading/base.html' %} 2 | {% load spectator_core %} 3 | 4 | {% block head_page_title %} 5 | {% if publication.series %} 6 | {{ publication.series.title }}, {{ publication.title }} 7 | {% else %} 8 | {{ publication.title }} 9 | {% endif %} 10 | {% endblock %} 11 | 12 | {% block content_title %} 13 | {% if publication.series %} 14 | {{ publication.series.title }}
      15 | {% endif %} 16 | {{ publication.title }} 17 | {% endblock %} 18 | 19 | {% block breadcrumbs %} 20 | {{ block.super }} 21 | 22 | 23 | {% endblock %} 24 | 25 | {% block content %} 26 | 27 |

      28 | {% include 'spectator_core/includes/roles.html' with roles=publication.roles.all intro='By' %} 29 |

      30 | 31 | {% if publication.thumbnail %} 32 | {% include 'spectator_core/includes/thumbnail_detail.html' with url=publication.thumbnail.url obj=publication alt_text="Cover" only %} 33 | {% endif %} 34 | 35 | {% if publication.has_urls %} 36 |
        37 | {% if publication.notes_url %} 38 |
      • View notes at {{ publication.notes_url|domain_urlize }}.
      • 39 | {% endif %} 40 | 41 | {% if publication.official_url %} 42 |
      • View official site at {{ publication.official_url|domain_urlize }}.
      • 43 | {% endif %} 44 | 45 | {% with publication.amazon_urls as urls %} 46 | {% if urls|length > 0 %} 47 |
      • 48 | View at 49 | {% for url in urls %}{% if forloop.first %}{% else %}{% if forloop.last %} or {% else %}, {% endif %}{% endif %}{{ url.name }}{% endfor %}. 50 |
      • 51 | {% endif %} 52 | {% endwith %} 53 |
      54 | {% endif %} 55 | 56 | {% with publication.reading_set.all as readings %} 57 | {% if readings|length > 0 %} 58 |

      Readings

      59 |
        60 | {% for reading in readings %} 61 |
      • 62 | {% include 'spectator_reading/includes/reading.html' with reading=reading only %} 63 |
      • 64 | {% endfor %} 65 |
      66 | {% else %} 67 |

      Unread.

      68 | {% endif %} 69 | {% endwith %} 70 | 71 | {% endblock content %} 72 | 73 | 74 | {% block sidebar_nav %} 75 | {% load spectator_core %} 76 | {% change_object_link_card object perms %} 77 | 78 | {{ block.super }} 79 | {% endblock sidebar_nav %} 80 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/publication_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_reading/base.html' %} 2 | 3 | {% block head_page_title %}Publications{% endblock %} 4 | {% block content_title %}Publications{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 | {% if book_count > 0 or periodical_count > 0 %} 14 | 30 | {% endif %} 31 | 32 | {% include 'spectator_reading/includes/publications_paginated.html' with publication_list=publication_list show_readings='none' page_obj=page_obj only %} 33 | 34 | {% endblock content %} 35 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/publicationseries_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_reading/base.html' %} 2 | 3 | {% block head_page_title %}{{ publicationseries.title }}{% endblock %} 4 | {% block content_title %}{{ publicationseries.title }}{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | {{ block.super }} 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 | {% include 'spectator_reading/includes/publications_paginated.html' with publication_list=publication_list show_readings='all' page_obj=page_obj show_readings='none' show_thumbnails=True only %} 15 | 16 | {% endblock content %} 17 | 18 | 19 | {% block sidebar_nav %} 20 | {% load spectator_core %} 21 | {% change_object_link_card object perms %} 22 | 23 | {{ block.super }} 24 | {% endblock sidebar_nav %} 25 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/publicationseries_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_reading/base.html' %} 2 | 3 | {% block head_page_title %}Series{% endblock %} 4 | {% block content_title %}Series{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 | {% if publicationseries_list|length > 0 %} 14 |
        15 | {% for series in publicationseries_list %} 16 |
      • {{ series.title }} ({{ series.publication_set.count }})
      • 17 | {% endfor %} 18 |
      19 | {% endif %} 20 | 21 | {% endblock content %} 22 | -------------------------------------------------------------------------------- /src/spectator/reading/templates/spectator_reading/reading_archive_year.html: -------------------------------------------------------------------------------- 1 | {% extends 'spectator_reading/base.html' %} 2 | 3 | {% block head_page_title %}{% if publication_kind %}{{ publication_kind|title }}s{% else %}Publications{% endif %} finished in {{ year|date:"Y" }}{% endblock %} 4 | {% block content_title %}{% if publication_kind %}{{ publication_kind|title }}s{% else %}Publications{% endif %} finished in {{ year|date:"Y" }}{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | {% include 'spectator_core/includes/pager.html' with url_name='spectator:reading:reading_year_archive' previous=previous_year next=next_year only %} 13 | 14 | {% if book_count > 0 or periodical_count > 0 %} 15 | 36 | {% endif %} 37 | 38 | {% if reading_list|length > 0 %} 39 | 40 | {% if show_months|default_if_none:"True" is False %} 41 |
        42 | {% endif %} 43 | 44 | {% for reading in reading_list %} 45 | 46 | {% if show_months|default_if_none:"True" is True %} 47 | {% ifchanged reading.end_date|date:"m" %} 48 | {% if not forloop.first %} 49 |
      50 | {% endif %} 51 |

      {{ reading.end_date|date:"F"}}

      52 |
        53 | {% endifchanged %} 54 | {% endif %} 55 |
      • 56 | {% include 'spectator_reading/includes/publication.html' with publication=reading.publication show_readings='none' show_thumbnail=True only %} 57 |
      • 58 | {% endfor %} 59 |
      60 | 61 | {% include 'spectator_core/includes/pager.html' with url_name='spectator:reading:reading_year_archive' previous=previous_year next=next_year only %} 62 | 63 | {% else %} 64 |

      Nothing was read in {{ year|date:"Y" }}.

      65 | {% endif %} 66 | 67 | {% endblock content %} 68 | -------------------------------------------------------------------------------- /src/spectator/reading/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/src/spectator/reading/templatetags/__init__.py -------------------------------------------------------------------------------- /src/spectator/reading/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from . import views 4 | 5 | app_name = "reading" 6 | 7 | urlpatterns = [ 8 | path("", view=views.ReadingHomeView.as_view(), name="home"), 9 | path( 10 | "series/", 11 | view=views.PublicationSeriesListView.as_view(), 12 | name="publicationseries_list", 13 | ), 14 | re_path( 15 | r"^series/(?P[\w-]+)/$", 16 | view=views.PublicationSeriesDetailView.as_view(), 17 | name="publicationseries_detail", 18 | ), 19 | path( 20 | "publications/", 21 | view=views.PublicationListView.as_view(), 22 | name="publication_list", 23 | ), 24 | path( 25 | "publications/periodicals/", 26 | view=views.PublicationListView.as_view(), 27 | name="publication_list_periodical", 28 | kwargs={"kind": "periodical"}, 29 | ), 30 | re_path( 31 | r"^publications/(?P[\w-]+)/$", 32 | view=views.PublicationDetailView.as_view(), 33 | name="publication_detail", 34 | ), 35 | re_path( 36 | r"^(?P[0-9]{4})/$", 37 | view=views.ReadingYearArchiveView.as_view(), 38 | name="reading_year_archive", 39 | ), 40 | re_path( 41 | r"^(?P[0-9]{4})/(?P[\w-]+)/$", 42 | view=views.ReadingYearArchiveView.as_view(), 43 | name="reading_year_archive", 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /src/spectator/reading/utils.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.db.models import Count 4 | from django.db.models.functions import TruncYear 5 | 6 | from .models import Reading 7 | 8 | 9 | def annual_reading_counts(kind="all"): 10 | """ 11 | Returns a list of dicts, one per year of reading. In year order. 12 | Each dict is like this (if kind is 'all'): 13 | 14 | {'year': datetime.date(2003, 1, 1), 15 | 'book': 12, # only included if kind is 'all' or 'book' 16 | 'periodical': 18, # only included if kind is 'all' or 'periodical' 17 | 'total': 30, # only included if kind is 'all' 18 | } 19 | 20 | We use the end_date of a Reading to count when that thing was read. 21 | 22 | kind is one of 'book', 'periodical' or 'all', for both. 23 | """ 24 | kinds = ["book", "periodical"] if kind == "all" else [kind] 25 | 26 | # This will have keys of years (strings) and dicts of data: 27 | # { 28 | # '2003': {'books': 12, 'periodicals': 18}, 29 | # } 30 | counts = OrderedDict() 31 | 32 | for k in kinds: 33 | qs = ( 34 | Reading.objects.exclude(end_date__isnull=True) 35 | .filter(publication__kind=k) 36 | .annotate(year=TruncYear("end_date")) 37 | .values("year") 38 | .annotate(count=Count("id")) 39 | .order_by("year") 40 | ) 41 | 42 | for year_data in qs: 43 | year_str = year_data["year"].strftime("%Y") 44 | if year_str not in counts: 45 | counts[year_str] = { 46 | "year": year_data["year"], 47 | } 48 | 49 | counts[year_str][k] = year_data["count"] 50 | 51 | # Now translate counts into our final list, with totals, and 0s for kinds 52 | # when they have no Readings for that year. 53 | counts_list = [] 54 | 55 | for _year_str, data in counts.items(): 56 | year_data = { 57 | "year": data["year"], 58 | } 59 | if kind == "all": 60 | year_data["total"] = 0 61 | 62 | for k in kinds: 63 | if k in data: 64 | year_data[k] = data[k] 65 | if kind == "all": 66 | year_data["total"] += data[k] 67 | else: 68 | year_data[k] = 0 69 | 70 | counts_list.append(year_data) 71 | 72 | return counts_list 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from spectator.core import app_settings 4 | 5 | 6 | def make_date(d): 7 | "For convenience." 8 | return datetime.strptime(d, "%Y-%m-%d").astimezone(timezone.utc).date() 9 | 10 | 11 | def override_app_settings(**test_settings): 12 | """ 13 | A decorator for overriding settings that takes uses our core.app_settings 14 | module. 15 | 16 | Because if we use the standard @override_settings decorator that doesn't 17 | work with our app_settings, which are set before we override them. 18 | 19 | Use like: 20 | 21 | from django.test import TestCase 22 | from tests.core import override_app_settings 23 | 24 | class MyTestCase(TestCase): 25 | 26 | @override_app_settings(MY_SETTING='hello') 27 | def test_does_a_thing(self): 28 | # ... 29 | 30 | From https://gist.github.com/integricho/6502772fd3c144c719a7 31 | """ 32 | 33 | def _override_app_settings(func): 34 | def __override_app_settings(*args, **kwargs): 35 | old_values = dict() 36 | for key, value in test_settings.items(): 37 | old_values[key] = getattr(app_settings, key) 38 | setattr(app_settings, key, value) 39 | 40 | result = func(*args, **kwargs) 41 | 42 | for key, _value in test_settings.items(): 43 | setattr(app_settings, key, old_values[key]) 44 | 45 | return result 46 | 47 | return __override_app_settings 48 | 49 | return _override_app_settings 50 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/core/fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/tests/core/fields/__init__.py -------------------------------------------------------------------------------- /tests/core/fields/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from spectator.core.fields import NaturalSortField 4 | 5 | 6 | class TitleModel(models.Model): 7 | id = models.AutoField(primary_key=True) 8 | title = models.CharField(max_length=255) 9 | title_sort = NaturalSortField("title") 10 | 11 | def __str__(self): 12 | return f"" 13 | 14 | 15 | class PersonModel(models.Model): 16 | id = models.AutoField(primary_key=True) 17 | name = models.CharField(max_length=255) 18 | name_sort = NaturalSortField("name") 19 | sort_as = "person" 20 | 21 | def __str__(self): 22 | return f"" 23 | -------------------------------------------------------------------------------- /tests/core/fixtures/images/tester_exif_gps.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/tests/core/fixtures/images/tester_exif_gps.jpg -------------------------------------------------------------------------------- /tests/core/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.sites import AdminSite 2 | from django.test import TestCase 3 | 4 | 5 | class AdminTestCase(TestCase): 6 | "For all other admin test cases to inherit from." 7 | 8 | def setUp(self): 9 | self.site = AdminSite() 10 | -------------------------------------------------------------------------------- /tests/core/test_apps.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from spectator.core.apps import Apps, spectator_apps 6 | 7 | 8 | class SpectatorAppsTestCase(TestCase): 9 | def test_all(self): 10 | all_apps = spectator_apps.all() 11 | self.assertEqual(2, len(all_apps)) 12 | self.assertEqual(all_apps[0], "events") 13 | self.assertEqual(all_apps[1], "reading") 14 | 15 | @patch.object(Apps, "all") 16 | def test_installed(self, patched_all): 17 | # all() will return an app that is not installed: 18 | patched_all.return_value = ["events", "reading", "NOPE"] 19 | 20 | # So 'NOPE' shouldn't be returned here: 21 | installed_apps = spectator_apps.installed() 22 | self.assertEqual(2, len(installed_apps)) 23 | self.assertEqual(installed_apps[0], "events") 24 | self.assertEqual(installed_apps[1], "reading") 25 | 26 | @patch.object(Apps, "all") 27 | def test_enabled(self, patched_all): 28 | # all() will return an app that is not installed: 29 | patched_all.return_value = ["events", "reading", "NOPE"] 30 | 31 | # So 'NOPE' shouldn't be returned here: 32 | enabled_apps = spectator_apps.enabled() 33 | self.assertEqual(2, len(enabled_apps)) 34 | self.assertEqual(enabled_apps[0], "events") 35 | self.assertEqual(enabled_apps[1], "reading") 36 | 37 | def test_is_installed(self): 38 | self.assertTrue(spectator_apps.is_installed("events")) 39 | self.assertFalse(spectator_apps.is_installed("NOPE")) 40 | 41 | def test_is_enabled(self): 42 | self.assertTrue(spectator_apps.is_enabled("events")) 43 | self.assertFalse(spectator_apps.is_enabled("NOPE")) 44 | -------------------------------------------------------------------------------- /tests/core/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import resolve, reverse 3 | 4 | from spectator.core import views 5 | from spectator.core.factories import IndividualCreatorFactory 6 | 7 | # Testing that the named URLs map the correct name to URL, 8 | # and that the correct views are called. 9 | 10 | 11 | class CoreUrlsTestCase(TestCase): 12 | def test_home_url(self): 13 | self.assertEqual(reverse("spectator:core:home"), "/") 14 | 15 | def test_home_view(self): 16 | "Should use the correct view." 17 | self.assertEqual(resolve("/").func.view_class, views.HomeView) 18 | 19 | def test_creator_list_url(self): 20 | self.assertEqual(reverse("spectator:creators:creator_list"), "/creators/") 21 | 22 | def test_creator_list_view(self): 23 | "Should use the correct view." 24 | self.assertEqual(resolve("/creators/").func.view_class, views.CreatorListView) 25 | 26 | def test_creator_list_group_url(self): 27 | self.assertEqual( 28 | reverse("spectator:creators:creator_list_group"), "/creators/groups/" 29 | ) 30 | 31 | def test_creator_list_group_view(self): 32 | "Should use the correct view." 33 | self.assertEqual( 34 | resolve("/creators/groups/").func.view_class, views.CreatorListView 35 | ) 36 | 37 | def test_creator_detail_url(self): 38 | IndividualCreatorFactory(name="Bob Ferris") 39 | self.assertEqual( 40 | reverse("spectator:creators:creator_detail", kwargs={"slug": "bob-ferris"}), 41 | "/creators/bob-ferris/", 42 | ) 43 | 44 | def test_creator_detail_view(self): 45 | "Should use the correct view." 46 | IndividualCreatorFactory(pk=123) 47 | self.assertEqual( 48 | resolve("/creators/9g5o8/").func.view_class, views.CreatorDetailView 49 | ) 50 | -------------------------------------------------------------------------------- /tests/core/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from spectator.core.factories import IndividualCreatorFactory 4 | from spectator.core.utils import chartify 5 | 6 | 7 | class ChartifyTestCase(TestCase): 8 | def setUp(self): 9 | super().setUp() 10 | 11 | self.creators = IndividualCreatorFactory.create_batch(5) 12 | self.creators[0].num_readings = 10 13 | self.creators[1].num_readings = 8 14 | self.creators[2].num_readings = 8 15 | self.creators[3].num_readings = 6 16 | self.creators[4].num_readings = 0 17 | 18 | def test_default_list(self): 19 | chart = chartify(self.creators, "num_readings") 20 | 21 | self.assertEqual(len(chart), 4) 22 | self.assertEqual(chart[0].chart_position, 1) 23 | self.assertEqual(chart[1].chart_position, 2) 24 | self.assertEqual(chart[2].chart_position, 2) 25 | self.assertEqual(chart[3].chart_position, 4) 26 | 27 | def test_cutoff_is_none(self): 28 | "Should include the 0-scoring item." 29 | chart = chartify(self.creators, "num_readings", cutoff=None) 30 | 31 | self.assertEqual(len(chart), 5) 32 | self.assertEqual(chart[0].chart_position, 1) 33 | self.assertEqual(chart[1].chart_position, 2) 34 | self.assertEqual(chart[2].chart_position, 2) 35 | self.assertEqual(chart[3].chart_position, 4) 36 | self.assertEqual(chart[4].chart_position, 5) 37 | 38 | def test_cutoff_value(self): 39 | "Should be possible to set a custom cutoff value." 40 | chart = chartify(self.creators, "num_readings", cutoff=6) 41 | 42 | self.assertEqual(len(chart), 3) 43 | self.assertEqual(chart[0].chart_position, 1) 44 | self.assertEqual(chart[1].chart_position, 2) 45 | self.assertEqual(chart[2].chart_position, 2) 46 | 47 | def test_ensure_chartiness(self): 48 | "By default list should be empty if all objects have the same score." 49 | creators = IndividualCreatorFactory.create_batch(3) 50 | for c in creators: 51 | c.num_readings = 10 52 | 53 | chart = chartify(creators, "num_readings") 54 | 55 | self.assertEqual(len(chart), 0) 56 | 57 | def test_ensure_chartiness_false(self): 58 | "Should be possible to disable the behaviour." 59 | creators = IndividualCreatorFactory.create_batch(3) 60 | 61 | for c in creators: 62 | c.num_readings = 10 63 | 64 | chart = chartify(creators, "num_readings", ensure_chartiness=False) 65 | 66 | self.assertEqual(len(chart), 3) 67 | 68 | def test_handle_empty_chart(self): 69 | "There was an error if all items in chart met the cutoff value." 70 | creator = IndividualCreatorFactory() 71 | creator.num_readings = 1 72 | 73 | chart = chartify([creator], "num_readings", cutoff=1) 74 | 75 | self.assertEqual(len(chart), 0) 76 | -------------------------------------------------------------------------------- /tests/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/tests/events/__init__.py -------------------------------------------------------------------------------- /tests/events/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import resolve, reverse 3 | 4 | from spectator.events import views 5 | from spectator.events.factories import GigEventFactory 6 | from tests import make_date 7 | 8 | 9 | class EventsUrlsTestCase(TestCase): 10 | # HOME 11 | 12 | def test_events_home_url(self): 13 | self.assertEqual(reverse("spectator:events:home"), "/events/") 14 | 15 | def test_events_home_view(self): 16 | "Should use the correct view." 17 | self.assertEqual(resolve("/events/").func.view_class, views.EventListView) 18 | 19 | # VENUES 20 | 21 | def test_venue_list_url(self): 22 | self.assertEqual(reverse("spectator:events:venue_list"), "/events/venues/") 23 | 24 | def test_venue_list_view(self): 25 | "Should use the correct view." 26 | self.assertEqual( 27 | resolve("/events/venues/").func.view_class, views.VenueListView 28 | ) 29 | 30 | def test_venue_detail_url(self): 31 | self.assertEqual( 32 | reverse("spectator:events:venue_detail", kwargs={"slug": "my-venue"}), 33 | "/events/venues/my-venue/", 34 | ) 35 | 36 | def test_venue_detail_view(self): 37 | "Should use the correct view." 38 | self.assertEqual( 39 | resolve("/events/venues/my-venue/").func.view_class, views.VenueDetailView 40 | ) 41 | 42 | # YEARS 43 | 44 | def test_event_year_archive_url(self): 45 | GigEventFactory(date=make_date("2017-02-15")) 46 | self.assertEqual( 47 | reverse("spectator:events:event_year_archive", kwargs={"year": 2017}), 48 | "/events/2017/", 49 | ) 50 | 51 | def test_event_year_archive_view(self): 52 | "Should use the correct view." 53 | GigEventFactory(date=("2017-02-15")) 54 | self.assertEqual( 55 | resolve("/events/2017/").func.view_class, views.EventYearArchiveView 56 | ) 57 | 58 | # EVENTS 59 | 60 | def test_event_list_url(self): 61 | self.assertEqual( 62 | reverse("spectator:events:event_list", kwargs={"kind_slug": "gigs"}), 63 | "/events/types/gigs/", 64 | ) 65 | 66 | def test_event_list_view(self): 67 | "Should use the correct view." 68 | self.assertEqual( 69 | resolve("/events/types/gigs/").func.view_class, views.EventListView 70 | ) 71 | 72 | def test_event_detail_url(self): 73 | self.assertEqual( 74 | reverse("spectator:events:event_detail", kwargs={"slug": "my-event"}), 75 | "/events/my-event/", 76 | ) 77 | 78 | def test_event_detail_view(self): 79 | "Should use the correct view." 80 | self.assertEqual( 81 | resolve("/events/my-event/").func.view_class, views.EventDetailView 82 | ) 83 | 84 | # WORKS 85 | 86 | def test_work_list_url(self): 87 | self.assertEqual( 88 | reverse("spectator:events:work_list", kwargs={"kind_slug": "movies"}), 89 | "/events/movies/", 90 | ) 91 | 92 | def test_work_list_view(self): 93 | "Should use the correct view." 94 | self.assertEqual(resolve("/events/movies/").func.view_class, views.WorkListView) 95 | 96 | def test_work_detail_url(self): 97 | self.assertEqual( 98 | reverse( 99 | "spectator:events:work_detail", 100 | kwargs={"kind_slug": "movies", "slug": "my-work"}, 101 | ), 102 | "/events/movies/my-work/", 103 | ) 104 | 105 | def test_work_detail_view(self): 106 | "Should use the correct view." 107 | self.assertEqual( 108 | resolve("/events/movies/my-work/").func.view_class, views.WorkDetailView 109 | ) 110 | -------------------------------------------------------------------------------- /tests/reading/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philgyford/django-spectator/1541e54c136acef9b249b5583601207112e913be/tests/reading/__init__.py -------------------------------------------------------------------------------- /tests/reading/test_admin.py: -------------------------------------------------------------------------------- 1 | from spectator.core.factories import IndividualCreatorFactory 2 | from spectator.reading.admin import PublicationAdmin 3 | from spectator.reading.factories import PublicationFactory, PublicationRoleFactory 4 | from spectator.reading.models import Publication 5 | from tests.core.test_admin import AdminTestCase 6 | 7 | 8 | class PublicationAdminTestCase(AdminTestCase): 9 | def test_show_creators_with_roles(self): 10 | "When a Publication has roles, display them." 11 | pub = PublicationFactory() 12 | PublicationRoleFactory( 13 | publication=pub, creator=IndividualCreatorFactory(name="Bob"), role_order=1 14 | ) 15 | PublicationRoleFactory( 16 | publication=pub, 17 | creator=IndividualCreatorFactory(name="Terry"), 18 | role_order=2, 19 | ) 20 | 21 | ba = PublicationAdmin(Publication, self.site) 22 | self.assertEqual(ba.show_creators(pub), "Bob, Terry") 23 | 24 | def test_show_creators_no_roles(self): 25 | "When a Publication has no roles, display '-'." 26 | pub = PublicationFactory() 27 | 28 | ba = PublicationAdmin(Publication, self.site) 29 | self.assertEqual(ba.show_creators(pub), "-") 30 | -------------------------------------------------------------------------------- /tests/reading/test_managers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from spectator.reading.factories import PublicationFactory, ReadingFactory 4 | from spectator.reading.models import Publication, Reading 5 | from tests import make_date 6 | 7 | 8 | class PublicationManagersTestCase(TestCase): 9 | def setUp(self): 10 | self.unread_pub = PublicationFactory() 11 | 12 | self.read_pub = PublicationFactory() 13 | ReadingFactory( 14 | publication=self.read_pub, 15 | start_date=make_date("2017-02-15"), 16 | end_date=make_date("2017-02-28"), 17 | ) 18 | 19 | # Has been read once but is being read again: 20 | self.in_progress_pub = PublicationFactory() 21 | ReadingFactory( 22 | publication=self.in_progress_pub, 23 | start_date=make_date("2017-02-15"), 24 | end_date=make_date("2017-02-28"), 25 | ) 26 | ReadingFactory( 27 | publication=self.in_progress_pub, start_date=make_date("2017-02-15") 28 | ) 29 | 30 | def test_default_manager(self): 31 | "Should return all publications, no matter their reading state." 32 | pubs = Publication.objects.all() 33 | self.assertEqual(len(pubs), 3) 34 | 35 | def test_in_progress_manager(self): 36 | "Should only return started-but-not-finished Publications." 37 | pubs = Publication.in_progress_objects.all() 38 | self.assertEqual(len(pubs), 1) 39 | self.assertEqual(pubs[0], self.in_progress_pub) 40 | 41 | def test_in_progress_manager_ordering(self): 42 | "Should be ordered by reading start_date ASC." 43 | earliest_in_progress_pub = PublicationFactory() 44 | ReadingFactory( 45 | publication=earliest_in_progress_pub, start_date=make_date("2017-02-14") 46 | ) 47 | latest_in_progress_pub = PublicationFactory() 48 | ReadingFactory( 49 | publication=latest_in_progress_pub, start_date=make_date("2017-02-16") 50 | ) 51 | pubs = Publication.in_progress_objects.all() 52 | self.assertEqual(len(pubs), 3) 53 | self.assertEqual(pubs[0], earliest_in_progress_pub) 54 | self.assertEqual(pubs[1], self.in_progress_pub) 55 | self.assertEqual(pubs[2], latest_in_progress_pub) 56 | 57 | def test_unread_manager(self): 58 | "Should only return unread Publications." 59 | pubs = Publication.unread_objects.all() 60 | self.assertEqual(len(pubs), 1) 61 | self.assertEqual(pubs[0], self.unread_pub) 62 | 63 | 64 | class ReadingManagersTestCase(TestCase): 65 | def setUp(self): 66 | self.in_progress = ReadingFactory(start_date=make_date("2017-02-10")) 67 | self.reading1 = ReadingFactory( 68 | start_date=make_date("2017-01-15"), end_date=make_date("2017-01-28") 69 | ) 70 | self.reading2 = ReadingFactory( 71 | start_date=make_date("2017-02-15"), end_date=make_date("2017-02-28") 72 | ) 73 | 74 | def test_default_manager(self): 75 | "EndDateAscendingReadingsManager. A reading that's in progress should be last." 76 | readings = Reading.objects.all() 77 | self.assertEqual(readings[0], self.reading1) 78 | self.assertEqual(readings[1], self.reading2) 79 | self.assertEqual(readings[2], self.in_progress) 80 | 81 | def test_objects_asc_manager(self): 82 | """EndDateDescendingReadingsManager. A reading that's in 83 | progress should be first. 84 | """ 85 | readings = Reading.objects_desc.all() 86 | self.assertEqual(readings[0], self.in_progress) 87 | self.assertEqual(readings[1], self.reading2) 88 | self.assertEqual(readings[2], self.reading1) 89 | -------------------------------------------------------------------------------- /tests/reading/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import resolve, reverse 3 | 4 | from spectator.reading import views 5 | from spectator.reading.factories import ( 6 | PublicationFactory, 7 | PublicationSeriesFactory, 8 | ReadingFactory, 9 | ) 10 | 11 | 12 | class ReadingUrlsTestCase(TestCase): 13 | def test_reading_home_url(self): 14 | self.assertEqual(reverse("spectator:reading:home"), "/reading/") 15 | 16 | def test_reading_home_view(self): 17 | "Should use the correct view." 18 | self.assertEqual(resolve("/reading/").func.view_class, views.ReadingHomeView) 19 | 20 | def test_publicationseries_list_home_url(self): 21 | self.assertEqual( 22 | reverse("spectator:reading:publicationseries_list"), "/reading/series/" 23 | ) 24 | 25 | def test_publicationseries_list_view(self): 26 | "Should use the correct view." 27 | self.assertEqual( 28 | resolve("/reading/series/").func.view_class, views.PublicationSeriesListView 29 | ) 30 | 31 | def test_publicationseries_detail_url(self): 32 | PublicationSeriesFactory(title="My Series") 33 | self.assertEqual( 34 | reverse( 35 | "spectator:reading:publicationseries_detail", 36 | kwargs={"slug": "my-series"}, 37 | ), 38 | "/reading/series/my-series/", 39 | ) 40 | 41 | def test_publicationseries_detail_view(self): 42 | "Should use the correct view." 43 | PublicationSeriesFactory(title="My Series") 44 | self.assertEqual( 45 | resolve("/reading/series/my-series/").func.view_class, 46 | views.PublicationSeriesDetailView, 47 | ) 48 | 49 | def test_publication_list_url(self): 50 | self.assertEqual( 51 | reverse("spectator:reading:publication_list"), "/reading/publications/" 52 | ) 53 | 54 | def test_publication_list_view(self): 55 | "Should use the correct view." 56 | self.assertEqual( 57 | resolve("/reading/publications/").func.view_class, views.PublicationListView 58 | ) 59 | 60 | def test_publication_list_periodical_url(self): 61 | self.assertEqual( 62 | reverse("spectator:reading:publication_list_periodical"), 63 | "/reading/publications/periodicals/", 64 | ) 65 | 66 | def test_publication_list_periodical_view(self): 67 | "Should use the correct view." 68 | self.assertEqual( 69 | resolve("/reading/publications/periodicals/").func.view_class, 70 | views.PublicationListView, 71 | ) 72 | 73 | def test_publication_detail_url(self): 74 | PublicationFactory(title="My Book") 75 | self.assertEqual( 76 | reverse("spectator:reading:publication_detail", kwargs={"slug": "my-book"}), 77 | "/reading/publications/my-book/", 78 | ) 79 | 80 | def test_publication_detail_view(self): 81 | "Should use the correct view." 82 | PublicationFactory(title="My Book") 83 | self.assertEqual( 84 | resolve("/reading/publications/my-book/").func.view_class, 85 | views.PublicationDetailView, 86 | ) 87 | 88 | def test_reading_year_archive_url(self): 89 | ReadingFactory(end_date=("2017-02-15")) 90 | self.assertEqual( 91 | reverse("spectator:reading:reading_year_archive", kwargs={"year": 2017}), 92 | "/reading/2017/", 93 | ) 94 | 95 | def test_reading_year_archive_view(self): 96 | "Should use the correct view." 97 | ReadingFactory(end_date=("2017-02-15")) 98 | self.assertEqual( 99 | resolve("/reading/2017/").func.view_class, views.ReadingYearArchiveView 100 | ) 101 | -------------------------------------------------------------------------------- /tests/reading/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from spectator.reading.factories import PublicationFactory, ReadingFactory 4 | from spectator.reading.utils import annual_reading_counts 5 | from tests import make_date 6 | 7 | 8 | class AnnualReadingCountsTestCase(TestCase): 9 | def setUp(self): 10 | # Books only in 2015: 11 | ReadingFactory.create_batch( 12 | 2, 13 | publication=PublicationFactory(kind="book"), 14 | end_date=make_date("2015-01-01"), 15 | ) 16 | 17 | # Nothing in 2016. 18 | 19 | # Books and periodicals in 2017: 20 | ReadingFactory.create_batch( 21 | 3, 22 | publication=PublicationFactory(kind="book"), 23 | end_date=make_date("2017-09-01"), 24 | ) 25 | ReadingFactory.create_batch( 26 | 2, 27 | publication=PublicationFactory(kind="periodical"), 28 | end_date=make_date("2017-09-01"), 29 | ) 30 | 31 | # Periodicals only in 2018: 32 | ReadingFactory.create_batch( 33 | 2, 34 | publication=PublicationFactory(kind="periodical"), 35 | end_date=make_date("2018-01-01"), 36 | ) 37 | 38 | def test_default(self): 39 | "With no argugments" 40 | 41 | result = annual_reading_counts() 42 | 43 | self.assertEqual(len(result), 3) 44 | self.assertEqual( 45 | result[0], 46 | {"year": make_date("2015-01-01"), "book": 2, "periodical": 0, "total": 2}, 47 | ) 48 | self.assertEqual( 49 | result[1], 50 | {"year": make_date("2017-01-01"), "book": 3, "periodical": 2, "total": 5}, 51 | ) 52 | self.assertEqual( 53 | result[2], 54 | {"year": make_date("2018-01-01"), "book": 0, "periodical": 2, "total": 2}, 55 | ) 56 | 57 | def test_all(self): 58 | "With kind=all" 59 | 60 | result = annual_reading_counts(kind="all") 61 | 62 | self.assertEqual(len(result), 3) 63 | self.assertEqual( 64 | result[0], 65 | {"year": make_date("2015-01-01"), "book": 2, "periodical": 0, "total": 2}, 66 | ) 67 | self.assertEqual( 68 | result[1], 69 | {"year": make_date("2017-01-01"), "book": 3, "periodical": 2, "total": 5}, 70 | ) 71 | self.assertEqual( 72 | result[2], 73 | {"year": make_date("2018-01-01"), "book": 0, "periodical": 2, "total": 2}, 74 | ) 75 | 76 | def test_book(self): 77 | "With kind=book" 78 | 79 | result = annual_reading_counts(kind="book") 80 | 81 | self.assertEqual(len(result), 2) 82 | self.assertEqual(result[0], {"year": make_date("2015-01-01"), "book": 2}) 83 | self.assertEqual(result[1], {"year": make_date("2017-01-01"), "book": 3}) 84 | 85 | def test_periodical(self): 86 | "With kind=periodical" 87 | 88 | result = annual_reading_counts(kind="periodical") 89 | 90 | self.assertEqual(len(result), 2) 91 | self.assertEqual(result[0], {"year": make_date("2017-01-01"), "periodical": 2}) 92 | self.assertEqual(result[1], {"year": make_date("2018-01-01"), "periodical": 2}) 93 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | SECRET_KEY = "fake-key" 8 | 9 | DEBUG = False 10 | 11 | ALLOWED_HOSTS = [] 12 | 13 | INSTALLED_APPS = ( 14 | "django.contrib.admin", 15 | "django.contrib.auth", 16 | "django.contrib.contenttypes", 17 | "django.contrib.sessions", 18 | "django.contrib.messages", 19 | "django.contrib.staticfiles", 20 | "spectator.core", 21 | "spectator.events", 22 | "spectator.reading", 23 | "tests.core.fields", 24 | ) 25 | 26 | MIDDLEWARE = [ 27 | "django.middleware.security.SecurityMiddleware", 28 | "django.contrib.sessions.middleware.SessionMiddleware", 29 | "django.middleware.common.CommonMiddleware", 30 | "django.middleware.csrf.CsrfViewMiddleware", 31 | "django.contrib.auth.middleware.AuthenticationMiddleware", 32 | "django.contrib.messages.middleware.MessageMiddleware", 33 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 34 | ] 35 | 36 | ROOT_URLCONF = "tests.urls" 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "DIRS": [], 42 | "APP_DIRS": True, 43 | "OPTIONS": { 44 | "context_processors": [ 45 | "django.template.context_processors.debug", 46 | "django.template.context_processors.request", 47 | "django.contrib.auth.context_processors.auth", 48 | "django.contrib.messages.context_processors.messages", 49 | ] 50 | }, 51 | } 52 | ] 53 | 54 | WSGI_APPLICATION = "tests.wsgi.application" 55 | 56 | DATABASES = { 57 | "default": { 58 | "ENGINE": "django.db.backends.sqlite3", 59 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 60 | } 61 | } 62 | 63 | LANGUAGE_CODE = "en-us" 64 | 65 | TIME_ZONE = "UTC" 66 | 67 | USE_I18N = True 68 | 69 | USE_L10N = True 70 | 71 | USE_TZ = True 72 | 73 | STATIC_URL = "/static/" 74 | 75 | # So that ImageField/FileField fields on models don't have FactoryBoy 76 | # create test files in the project's file structure: 77 | MEDIA_ROOT = tempfile.mkdtemp() 78 | 79 | MEDIA_URL = "/media/" 80 | -------------------------------------------------------------------------------- /tests/test_admins.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.contrib.admin.sites import all_sites 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | from unittest_parametrize import ParametrizedTestCase, param, parametrize 8 | 9 | each_model_admin = parametrize( 10 | "site,model,model_admin", 11 | [ 12 | param( 13 | site, 14 | model, 15 | model_admin, 16 | id=f"{site.name}_{str(model_admin).replace('.', '_')}", 17 | ) 18 | for site in all_sites 19 | for model, model_admin in site._registry.items() 20 | ], 21 | ) 22 | 23 | 24 | class ModelAdminTests(ParametrizedTestCase, TestCase): 25 | """ 26 | Test that all the project's ModelAdmins' fields contain valid values 27 | https://adamj.eu/tech/2023/03/17/django-parameterized-tests-model-admin-classes/ 28 | """ 29 | 30 | user: User 31 | 32 | @classmethod 33 | def setUpTestData(cls): 34 | cls.user = User.objects.create_superuser( 35 | username="admin", email="admin@example.com", password="test" 36 | ) 37 | 38 | def setUp(self): 39 | self.client.force_login(self.user) 40 | 41 | def make_url(self, site, model, page): 42 | return reverse( 43 | f"{site.name}:{model._meta.app_label}_{model._meta.model_name}_{page}" 44 | ) 45 | 46 | @each_model_admin 47 | def test_changelist(self, site, model, model_admin): 48 | url = self.make_url(site, model, "changelist") 49 | response = self.client.get(url, {"q": "example.com"}) 50 | assert response.status_code == HTTPStatus.OK 51 | 52 | @each_model_admin 53 | def test_add(self, site, model, model_admin): 54 | url = self.make_url(site, model, "add") 55 | response = self.client.get(url) 56 | assert response.status_code in ( 57 | HTTPStatus.OK, 58 | HTTPStatus.FORBIDDEN, # some admin classes blanket disallow "add" 59 | ) 60 | -------------------------------------------------------------------------------- /tests/test_pending_migrations.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | 7 | class PendingMigrationsTests(TestCase): 8 | def test_no_pending_migrations(self): 9 | # No migrations pending 10 | # See: https://adamj.eu/tech/2024/06/23/django-test-pending-migrations/ 11 | out = StringIO() 12 | try: 13 | call_command("makemigrations", "--check", stdout=out, stderr=StringIO()) 14 | except SystemExit: # pragma: no cover 15 | raise AssertionError("Pending migrations:\n" + out.getvalue()) from None 16 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("", include("spectator.core.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | ; Minimum version of Tox 3 | minversion = 1.8 4 | 5 | ; Should match the strategy matrix in the GitHub Action 6 | envlist = 7 | ruff 8 | py39-django{42} 9 | py310-django{42,51,52} 10 | py311-django{42,51,52} 11 | py312-django{42,51,52,main} 12 | py313-django{51,52,main} 13 | 14 | [gh-actions] 15 | ; Maps GitHub Actions python version numbers to tox env vars: 16 | python = 17 | 3.9: py39 18 | 3.10: py310 19 | 3.11: py311 20 | 3.12: py312 21 | 3.13: py313 22 | 23 | [gh-actions:env] 24 | ; Maps GitHub Actions DJANGO version env var to tox env vars: 25 | DJANGO = 26 | 4.2: django42 27 | 5.1: django51 28 | 5.2: django52 29 | main: djangomain 30 | 31 | [testenv] 32 | dependency_groups = 33 | dev 34 | deps = 35 | django42: Django >= 4.2, < 4.3 36 | django51: Django >= 5.1, < 5.2 37 | django52: Django >= 5.2, < 5.3 38 | djangomain: https://github.com/django/django/archive/master.tar.gz 39 | setenv = 40 | DJANGO_SETTINGS_MODULE=tests.settings 41 | PYTHONPATH={toxinidir} 42 | commands = 43 | ; posargs will be replaced with anything after the -- when calling tox, eg; 44 | ; tox -- tests.ditto.tests.test_views.DittoViewTests.test_home_templates 45 | ; would run that single test (in all environments) 46 | coverage run {envbindir}/django-admin test {posargs:} 47 | coverage combine 48 | coverage report -m 49 | 50 | [testenv:ruff] 51 | skip_install = true 52 | deps = ruff 53 | commands = ruff check {posargs:--output-format=full .} 54 | --------------------------------------------------------------------------------