├── tests ├── __init__.py ├── testextends │ ├── __init__.py │ └── templates │ │ └── wagtailmedia │ │ └── media │ │ ├── index.html │ │ ├── legacy │ │ └── index.html │ │ └── edit.html ├── testapp │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ ├── templates │ │ └── wagtailmedia_tests │ │ │ ├── blog_stream_page.html │ │ │ └── event_page.html │ ├── urls.py │ ├── fixtures │ │ └── test.json │ ├── settings.py │ └── models.py ├── testextends_legacy │ ├── __init__.py │ └── templates │ │ └── wagtailmedia │ │ └── media │ │ ├── legacy │ │ └── index.html │ │ └── edit.html ├── .gitignore ├── templates │ └── wagtailmedia │ │ └── media │ │ ├── add.html │ │ └── edit.html ├── test_tags.py ├── test_utils.py ├── test_settings.py ├── utils.py ├── manage.py ├── test_form_override.py ├── test_admin.py ├── test_widgets.py ├── test_legacy_template_usage.py ├── test_blocks.py ├── test_compare.py ├── test_permissions.py └── test_edit_handlers.py ├── src └── wagtailmedia │ ├── locale │ ├── .gitkeep │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ro │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── uk │ │ └── LC_MESSAGES │ │ │ └── django.mo │ └── zh_Hans │ │ └── LC_MESSAGES │ │ └── django.mo │ ├── views │ ├── __init__.py │ ├── chooser.py │ └── media.py │ ├── migrations │ ├── __init__.py │ ├── 0005_alter_media_options.py │ ├── 0004_duration_optional_floatfield.py │ ├── 0003_copy_media_permissions_to_collections.py │ ├── 0002_initial_data.py │ └── 0001_initial.py │ ├── templatetags │ ├── __init__.py │ └── media_tags.py │ ├── __init__.py │ ├── static │ └── wagtailmedia │ │ ├── css │ │ ├── wagtailmedia.css │ │ └── wagtailmedia-comparison.css │ │ └── js │ │ ├── media-chooser-telepath.js │ │ ├── media-chooser.js │ │ └── media-chooser-modal.js │ ├── deprecation.py │ ├── templates │ └── wagtailmedia │ │ ├── media │ │ ├── _file_field_legacy.html │ │ ├── _file_field.html │ │ ├── _thumbnail_field_legacy.html │ │ ├── _thumbnail_field.html │ │ ├── _file_field_as_li.html │ │ ├── _thumbnail_field_as_li.html │ │ ├── confirm_delete.html │ │ ├── results.html │ │ ├── usage.html │ │ ├── index.html │ │ ├── add.html │ │ ├── edit.html │ │ └── list.html │ │ ├── permissions │ │ └── includes │ │ │ └── media_permissions_formset.html │ │ ├── widgets │ │ ├── media_chooser.html │ │ └── compare.html │ │ ├── icons │ │ ├── wagtailmedia-audio.svg │ │ └── wagtailmedia-video.svg │ │ ├── homepage │ │ └── site_summary_media.html │ │ └── chooser │ │ ├── results.html │ │ └── chooser.html │ ├── permissions.py │ ├── admin.py │ ├── signal_handlers.py │ ├── api │ ├── serializers.py │ └── views.py │ ├── admin_urls.py │ ├── apps.py │ ├── utils.py │ ├── edit_handlers.py │ ├── widgets.py │ ├── forms.py │ ├── blocks.py │ ├── settings.py │ ├── wagtail_hooks.py │ └── models.py ├── .github ├── workflows │ ├── ruff.yml │ ├── publish.yml │ ├── nightly-tests.yml │ └── test.yml ├── ISSUE_TEMPLATE.md └── report_nightly_build_failure.py ├── ruff.toml ├── .editorconfig ├── .pre-commit-config.yaml ├── .coveragerc ├── Makefile ├── .gitignore ├── LICENSE ├── SPECIFICATION.md ├── pyproject.toml └── tox.ini /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/wagtailmedia/locale/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testextends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/wagtailmedia/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testextends_legacy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/wagtailmedia/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/wagtailmedia/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | test-media 2 | test-static 3 | -------------------------------------------------------------------------------- /src/wagtailmedia/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.17.2" 2 | -------------------------------------------------------------------------------- /src/wagtailmedia/static/wagtailmedia/css/wagtailmedia.css: -------------------------------------------------------------------------------- 1 | .wagtailmedia-tabs .messages { 2 | margin-bottom: 1em; 3 | } 4 | -------------------------------------------------------------------------------- /tests/templates/wagtailmedia/media/add.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/add.html" %} 2 | 3 | {% block action %}/somewhere/else{% endblock %} 4 | -------------------------------------------------------------------------------- /src/wagtailmedia/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/wagtailmedia/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/wagtailmedia/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/wagtailmedia/locale/ro/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/ro/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/wagtailmedia/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /src/wagtailmedia/locale/uk/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/uk/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /tests/templates/wagtailmedia/media/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/edit.html" %} 2 | 3 | {% block action %}/somewhere/else/edit{% endblock %} 4 | -------------------------------------------------------------------------------- /src/wagtailmedia/locale/zh_Hans/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/wagtailmedia/HEAD/src/wagtailmedia/locale/zh_Hans/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /tests/testextends/templates/wagtailmedia/media/index.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/index.html" %} 2 | 3 | {% block add_actions %}You shan't act{% endblock %} 4 | -------------------------------------------------------------------------------- /tests/testextends/templates/wagtailmedia/media/legacy/index.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/legacy/index.html" %} 2 | 3 | {% block add_actions %}You shan't act{% endblock %} 4 | -------------------------------------------------------------------------------- /tests/testextends_legacy/templates/wagtailmedia/media/legacy/index.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/legacy/index.html" %} 2 | 3 | {% block add_actions %}You shan't act{% endblock %} 4 | -------------------------------------------------------------------------------- /src/wagtailmedia/deprecation.py: -------------------------------------------------------------------------------- 1 | class RemovedInWagtailMedia015Warning(PendingDeprecationWarning): 2 | pass 3 | 4 | 5 | class RemovedInWagtailMedia016Warning(DeprecationWarning): 6 | pass 7 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | 3 | 4 | if DJANGO_VERSION >= (3, 2): 5 | # The declaration is only needed for older Django versions 6 | pass 7 | else: 8 | default_app_config = "testapp.apps.WagtailmediaTestsAppConfig" 9 | -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WagtailmediaTestsAppConfig(AppConfig): 5 | name = "testapp" 6 | label = "wagtailmedia_tests" 7 | verbose_name = "Wagtail media tests" 8 | default_auto_field = "django.db.models.AutoField" 9 | -------------------------------------------------------------------------------- /src/wagtailmedia/templatetags/media_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from wagtail.utils.version import get_main_version 3 | 4 | 5 | register = Library() 6 | 7 | 8 | @register.simple_tag 9 | def wagtail_version_gte(version: str) -> bool: 10 | return get_main_version() >= version 11 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/_file_field_legacy.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/shared/field.html" %} 2 | {% load i18n %} 3 | {% block form_field %} 4 | {{ media.filename }}

5 | {% trans "Change media file:" %} 6 | {{ field }} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /src/wagtailmedia/permissions.py: -------------------------------------------------------------------------------- 1 | from wagtail.permission_policies.collections import CollectionOwnershipPermissionPolicy 2 | 3 | from wagtailmedia.models import Media, get_media_model 4 | 5 | 6 | permission_policy = CollectionOwnershipPermissionPolicy( 7 | get_media_model(), auth_model=Media, owner_field_name="uploaded_by_user" 8 | ) 9 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/_file_field.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | {% rawformattedfield field=field %} 3 | {% icon name="media" classname="middle" %}  {{ media.filename }}

4 | {% trans "Change media file:" %} 5 | {{ field }} 6 | {% endrawformattedfield %} 7 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/permissions/includes/media_permissions_formset.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/permissions/includes/collection_member_permissions_formset.html" %} 2 | {% load i18n %} 3 | 4 | {% block icon %}media{% endblock %} 5 | {% block title %}{% trans "Media permissions" %}{% endblock %} 6 | {% block add_button_label %}{% trans "Add a media permission" %}{% endblock %} 7 | -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms.widgets import Widget 2 | 3 | from wagtailmedia.forms import BaseMediaForm 4 | 5 | 6 | class OverridenWidget(Widget): 7 | pass 8 | 9 | 10 | class AlternateMediaForm(BaseMediaForm): 11 | class Meta: 12 | widgets = { 13 | "tags": OverridenWidget, 14 | "file": OverridenWidget, 15 | "thumbnail": OverridenWidget, 16 | } 17 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/_thumbnail_field_legacy.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/shared/field.html" %} 2 | {% load i18n %} 3 | {% block form_field %} 4 |
5 | {% if media.thumbnail %} 6 | {{ media.thumbnail_filename }}

7 | {% endif %} 8 | 9 | {{ field }} 10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/widgets/media_chooser.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/widgets/chooser.html" %} 2 | {% block chooser_class %}media-chooser{% endblock %} 3 | 4 | {% block chosen_state_view %} 5 | {{ title }} 6 | {% endblock %} 7 | 8 | {% block edit_chosen_item_url %}{{ edit_url }}{% endblock %} 9 | {% block chooser_attributes %}data-chooser-url="{{ chooser_url }}"{% endblock %} 10 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/_thumbnail_field.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | {% rawformattedfield field=field %} 3 |
4 | {% if media.thumbnail %} 5 | {% icon name="image" classname="middle" %}  {{ media.thumbnail_filename }}

6 | {% endif %} 7 | 8 | {{ field }} 9 |
10 | {% endrawformattedfield %} 11 | -------------------------------------------------------------------------------- /src/wagtailmedia/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from wagtailmedia.settings import wagtailmedia_settings 4 | 5 | 6 | if wagtailmedia_settings.MEDIA_MODEL == "wagtailmedia.Media": 7 | # Only expose the package-provided media class in the Django admin if the installation 8 | # does not provide its own custom media class in order to avoid confusion. 9 | from wagtailmedia.models import Media 10 | 11 | admin.site.register(Media) 12 | -------------------------------------------------------------------------------- /tests/testextends/templates/wagtailmedia/media/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/edit.html" %} 2 | 3 | {% block extra_css %} 4 | 5 | {% endblock %} 6 | 7 | {% block extra_js %} 8 | 9 | {% endblock %} 10 | 11 | {% block form_row %} 12 | {{ block.super }} 13 | sweet-form-row 14 | {% endblock %} 15 | 16 | {% block media_stats %} 17 | sweet-stats 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/icons/wagtailmedia-audio.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/testextends_legacy/templates/wagtailmedia/media/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailmedia/media/edit.html" %} 2 | 3 | {% block extra_css %} 4 | 5 | {% endblock %} 6 | 7 | {% block extra_js %} 8 | 9 | {% endblock %} 10 | 11 | {% block form_row %} 12 | {{ block.super }} 13 | sweet-form-row 14 | {% endblock %} 15 | 16 | {% block media_stats %} 17 | sweet-stats 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/icons/wagtailmedia-video.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/wagtailmedia/migrations/0005_alter_media_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2 on 2025-05-06 16:58 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("wagtailmedia", "0004_duration_optional_floatfield"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="media", 14 | options={"verbose_name": "media", "verbose_name_plural": "media items"}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/_file_field_as_li.html: -------------------------------------------------------------------------------- 1 | {% load wagtailadmin_tags media_tags %} 2 | 3 | {% wagtail_version_gte "6.0" as wagtail_gte_60 %} 4 | 5 |
  • 6 | {% if wagtail_gte_60 %} 7 | {% include "wagtailmedia/media/_file_field.html" %} 8 | {% else %} 9 | {% include "wagtailmedia/media/_file_field_legacy.html" %} 10 | {% endif %} 11 |
  • 12 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/_thumbnail_field_as_li.html: -------------------------------------------------------------------------------- 1 | {% load wagtailadmin_tags media_tags %} 2 | 3 | {% wagtail_version_gte "6.0" as wagtail_gte_60 %} 4 | 5 |
  • 6 | {% if wagtail_gte_60 %} 7 | {% include "wagtailmedia/media/_thumbnail_field.html" %} 8 | {% else %} 9 | {% include "wagtailmedia/media/_thumbnail_field_legacy.html" %} 10 | {% endif %} 11 |
  • 12 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 'stable/**' 8 | pull_request: 9 | branches: [main] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | ruff: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v5 20 | with: 21 | persist-credentials: false 22 | 23 | - run: python -Im pip install --user ruff==0.12.9 24 | 25 | - name: Run ruff 26 | working-directory: ./src 27 | run: ruff check --output-format=github wagtailmedia 28 | -------------------------------------------------------------------------------- /tests/testapp/templates/wagtailmedia_tests/blog_stream_page.html: -------------------------------------------------------------------------------- 1 | {% load wagtailcore_tags %} 2 | 3 | 4 | 5 | 6 | 7 | {{ page.title }} 8 | 9 | 10 |
    11 |
    12 |
    13 |

    {{ page.title }}

    14 | {{ page.featured_media }} 15 | {% for block in page.body %} 16 | {% include_block block %} 17 | {% endfor %} 18 |
    19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/testapp/templates/wagtailmedia_tests/event_page.html: -------------------------------------------------------------------------------- 1 | {% load wagtailcore_tags %} 2 | 3 | 4 | 5 | 6 | 7 | {{ page.title }} 8 | 9 | 10 |
    11 |
    12 |
    13 |

    {{ page.title }}

    14 |

    From to , in {{ page.location }}.

    15 | {{ page.body|richtext }} 16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/homepage/site_summary_media.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | 3 |
  • 4 | {% icon name="media" %} 5 | 6 | {% blocktrans trimmed count counter=total_media with total_media|intcomma as total %} 7 | {{ total }} Media file created in {{ site_name }} 8 | {% plural %} 9 | {{ total }} Media files created in {{ site_name }} 10 | {% endblocktrans %} 11 | 12 |
  • 13 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | "B", # flake8-bugbear 4 | "C4", # flake8-comprehensions 5 | "E", # pycodestyle errors 6 | "F", # pyflakes 7 | "I", # isort 8 | "S", # flake8-bandit 9 | "W", # pycodestyle warnings 10 | "UP", # pyupgrade 11 | ] 12 | 13 | extend-ignore = [ 14 | "E501", # no line length errors 15 | ] 16 | 17 | fixable = ["C4", "E", "F", "I", "UP"] 18 | 19 | [lint.per-file-ignores] 20 | "tests/**.py" = ["S105", "S106"] 21 | 22 | [lint.isort] 23 | known-first-party = ["src"] 24 | lines-between-types = 1 25 | lines-after-imports = 2 26 | 27 | [format] 28 | docstring-code-format = true 29 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Defines the coding style for different editors and IDEs. 2 | # https://editorconfig.org/ 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 4 14 | max_line_length = 80 15 | 16 | [*.py] 17 | max_line_length = 120 18 | 19 | [*.{yml,yaml}] 20 | indent_size = 2 21 | 22 | # Documentation. 23 | [*.md] 24 | max_line_length = 0 25 | trim_trailing_whitespace = false 26 | 27 | # Git commit messages. 28 | [COMMIT_EDITMSG] 29 | max_line_length = 0 30 | trim_trailing_whitespace = false 31 | -------------------------------------------------------------------------------- /src/wagtailmedia/signal_handlers.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | from django.db.models.signals import post_delete 3 | 4 | from wagtailmedia.models import get_media_model 5 | 6 | 7 | def delete_files(instance): 8 | # Pass false so FileField doesn't save the model. 9 | instance.file.delete(False) 10 | if instance.thumbnail: 11 | instance.thumbnail.delete(False) 12 | 13 | 14 | def post_delete_file_cleanup(instance, **kwargs): 15 | transaction.on_commit(lambda: delete_files(instance)) 16 | 17 | 18 | def register_signal_handlers(): 19 | Media = get_media_model() 20 | post_delete.connect(post_delete_file_cleanup, sender=Media) 21 | -------------------------------------------------------------------------------- /src/wagtailmedia/static/wagtailmedia/css/wagtailmedia-comparison.css: -------------------------------------------------------------------------------- 1 | .comparison--media { 2 | display: inline-block; 3 | max-width: 49%; 4 | } 5 | 6 | .comparison--media audio, 7 | .comparison--media video { 8 | max-width: 100%; 9 | } 10 | 11 | .comparison--media.addition, 12 | .comparison--media.deletion { 13 | padding: 5px; 14 | margin-inline-end: 5px; 15 | border-style: solid; 16 | border-width: 1px; 17 | } 18 | 19 | .comparison--media.addition { 20 | background-color: #ebffeb; 21 | border-color: #f8cbcb; 22 | } 23 | 24 | .comparison--media.deletion { 25 | background-color: #ffebeb; 26 | border-color: #f8cbcb; 27 | } 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_prs: false 3 | autoupdate_schedule: 'quarterly' 4 | 5 | default_language_version: 6 | python: python3.13 7 | 8 | repos: 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: check-added-large-files 13 | - id: check-case-conflict 14 | - id: check-json 15 | - id: check-merge-conflict 16 | - id: check-symlinks 17 | - id: check-toml 18 | - id: check-yaml 19 | - id: end-of-file-fixer 20 | - id: trailing-whitespace 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: 'v0.12.9' # keep in sync with .github/workflows/ruff.yml 23 | hooks: 24 | - id: ruff-check 25 | args: [--fix] 26 | - id: ruff-format 27 | -------------------------------------------------------------------------------- /tests/test_tags.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from wagtailmedia.templatetags.media_tags import wagtail_version_gte 6 | 7 | 8 | class MediaTagsTests(TestCase): 9 | def test_wagtail_version_gte(self): 10 | scenarios = [ 11 | ("5.2", "5.2", True), 12 | ("5.2.1", "5.2", True), 13 | ("5.2", "5.2.1", False), 14 | ("4.2", "5.2", False), 15 | ] 16 | 17 | for wagtail_version, version_to_test, result in scenarios: 18 | with patch( 19 | "wagtailmedia.templatetags.media_tags.get_main_version", 20 | return_value=wagtail_version, 21 | ): 22 | self.assertEqual(wagtail_version_gte(version_to_test), result) 23 | -------------------------------------------------------------------------------- /src/wagtailmedia/migrations/0004_duration_optional_floatfield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.8 on 2020-08-17 15:30 2 | 3 | import django.core.validators 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("wagtailmedia", "0003_copy_media_permissions_to_collections"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="media", 16 | name="duration", 17 | field=models.FloatField( 18 | blank=True, 19 | default=0, 20 | help_text="Duration in seconds", 21 | validators=[django.core.validators.MinValueValidator(0)], 22 | verbose_name="duration", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/wagtailmedia/api/serializers.py: -------------------------------------------------------------------------------- 1 | import rest_framework.fields 2 | 3 | from rest_framework.fields import ReadOnlyField 4 | from wagtail.api.v2.serializers import BaseSerializer 5 | from wagtail.api.v2.utils import get_full_url 6 | 7 | 8 | class MediaDownloadUrlField(ReadOnlyField): 9 | """ 10 | Serializes the "download_url" field for media items. 11 | 12 | Example: 13 | "download_url": "http://api.example.com/media/my_video.mp4" 14 | """ 15 | 16 | def get_attribute(self, instance): 17 | return instance 18 | 19 | def to_representation(self, instance): 20 | return get_full_url(self.context["request"], instance.url) 21 | 22 | 23 | class MediaItemSerializer(BaseSerializer): 24 | download_url = MediaDownloadUrlField() 25 | media_type = rest_framework.fields.CharField(source="type") 26 | -------------------------------------------------------------------------------- /src/wagtailmedia/static/wagtailmedia/js/media-chooser-telepath.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | function MediaChooser(html, idPattern, _opts) { 3 | this.html = html; 4 | this.idPattern = idPattern; 5 | } 6 | 7 | MediaChooser.prototype.render = function(placeholder, name, id, initialState) { 8 | const html = this.html.replace(/__NAME__/g, name).replace(/__ID__/g, id); 9 | // eslint-disable-next-line no-param-reassign 10 | placeholder.outerHTML = html; 11 | /* the chooser object returned by createMediaChooser also serves as the JS widget representation */ 12 | // eslint-disable-next-line no-undef 13 | const chooser = createMediaChooser(id); 14 | chooser.setState(initialState); 15 | return chooser; 16 | }; 17 | 18 | window.telepath.register('wagtailmedia.MediaChooser', MediaChooser); 19 | })(); 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Found a bug? Please fill out the sections below. 👍 2 | 3 | ### Issue Summary 4 | 5 | A summary of the issue. 6 | 7 | 8 | ### Steps to Reproduce 9 | 10 | 1. (for example) Start a new project with `wagtail start myproject` 11 | 2. Integrate `wagtailmedia` as follows… 12 | 3. ... 13 | 14 | Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead? 15 | 16 | ### Technical details 17 | 18 | * Python version: Run `python --version`. 19 | * Django version: Look in your requirements.txt, or run `pip show django | grep Version`. 20 | * Wagtail version: Hover over the Wagtail bird in the admin, or run `pip show wagtail | grep Version:`. 21 | * `wagtailmedia` version: Look in your requirements.txt, or run `pip show wagtailmedia | grep Version`. 22 | * Browser version: You can use http://www.whatsmybrowser.org/ to find this out. 23 | -------------------------------------------------------------------------------- /src/wagtailmedia/admin_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from wagtailmedia.views import chooser, media 4 | 5 | 6 | urlpatterns = [ 7 | path("", media.index, name="index"), 8 | re_path(r"^(?Paudio|video|media)/add/$", media.add, name="add"), 9 | path("edit//", media.edit, name="edit"), 10 | path("delete//", media.delete, name="delete"), 11 | path("chooser/", chooser.chooser, name="chooser"), 12 | re_path( 13 | r"chooser/(?Paudio|video)/", chooser.chooser, name="chooser_typed" 14 | ), 15 | path("chooser//", chooser.media_chosen, name="media_chosen"), 16 | re_path( 17 | r"^(?Paudio|video)/chooser/upload/$", 18 | chooser.chooser_upload, 19 | name="chooser_upload", 20 | ), 21 | path("usage//", media.usage, name="media_usage"), 22 | ] 23 | -------------------------------------------------------------------------------- /src/wagtailmedia/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.db.models import ForeignKey 3 | 4 | 5 | class WagtailMediaAppConfig(AppConfig): 6 | default_auto_field = "django.db.models.AutoField" 7 | name = "wagtailmedia" 8 | label = "wagtailmedia" 9 | verbose_name = "Wagtail media" 10 | 11 | def ready(self): 12 | from wagtail.admin.compare import register_comparison_class 13 | 14 | from .edit_handlers import MediaFieldComparison 15 | from .models import get_media_model 16 | from .signal_handlers import register_signal_handlers 17 | 18 | register_signal_handlers() 19 | 20 | # Set up image ForeignKeys to use ImageFieldComparison as the comparison class 21 | # when comparing page revisions 22 | register_comparison_class( 23 | ForeignKey, to=get_media_model(), comparison_class=MediaFieldComparison 24 | ) 25 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/widgets/compare.html: -------------------------------------------------------------------------------- 1 | {% load wagtailadmin_tags %} 2 | 3 | {% if media_item_a != media_item_b %} 4 | {% if media_item_a and media_item_b %} 5 | {# Item has changed #} 6 |
    7 | {{ media_item_a }} 8 |
    9 | 10 |
    11 | {{ media_item_b }} 12 |
    13 | {% elif media_item_b %} 14 | {# Item has been added #} 15 |
    16 | {{ media_item_b }} 17 |
    18 | {% elif media_item_a %} 19 | {# Image has been removed #} 20 |
    21 | {{ media_item_a }} 22 |
    23 | {% endif %} 24 | {% else %} 25 |
    26 | {% if media_item_a %} 27 | {{ media_item_a }} 28 | {% endif %} 29 |
    30 | {% endif %} 31 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | parallel = true 4 | concurrency = multiprocessing, thread 5 | source = wagtailmedia 6 | 7 | omit = **/migrations/*,tests/*,src/wagtailmedia/admin.py,src/wagtailmedia/deprecation.py 8 | 9 | [paths] 10 | source = src,.tox/py*/**/site-packages 11 | 12 | [report] 13 | show_missing = true 14 | ignore_errors = true 15 | skip_empty = true 16 | skip_covered = true 17 | 18 | exclude_also = 19 | # Have to re-enable the standard pragma 20 | pragma: no cover 21 | 22 | # Don't complain about missing debug-only code: 23 | def __repr__ 24 | if self.debug 25 | if settings.DEBUG 26 | 27 | # Don't complain if tests don't hit defensive assertion code: 28 | raise AssertionError 29 | raise NotImplementedError 30 | 31 | # Don't complain if non-runnable code isn't run: 32 | if 0: 33 | if __name__ == .__main__.: 34 | 35 | # Nor complain about type checking 36 | "if TYPE_CHECKING:", 37 | class .*\bProtocol\): 38 | @(abc\.)?abstractmethod 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ---------------------------------------------------------------------------- 2 | # Self-Documented Makefile 3 | # ref: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 4 | # ---------------------------------------------------------------------------- 5 | .PHONY: help 6 | .DEFAULT_GOAL := help 7 | 8 | help: ## ⁉️ - Display help comments for each make command 9 | @grep -E '^[0-9a-zA-Z_-]+:.*? .*$$' \ 10 | $(MAKEFILE_LIST) \ 11 | | awk 'BEGIN { FS=":.*?## " }; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' \ 12 | | sort 13 | 14 | clean: ## 🗑️ - Removes pycache and test media 15 | @echo "🗑️ - Removing __pycache__ and test artifacts" 16 | rm -rf test-media test-static .tox 17 | find . -type d -name "__pycache__" -exec rm -r {} + 18 | 19 | package-setup: 20 | @echo "📦 - Packaging for PyPI" 21 | flit build --setup-py 22 | 23 | package: clean package-setup ## 📦 - Package for PyPI 24 | 25 | test: ## 🧪 - Run test suite 26 | @echo "🧪 - Running test suite" 27 | tox 28 | -------------------------------------------------------------------------------- /.github/report_nightly_build_failure.py: -------------------------------------------------------------------------------- 1 | """ 2 | Called by GitHub Action when the nightly build fails. 3 | 4 | This reports an error to the #nightly-build-failures Slack channel. 5 | """ 6 | 7 | import os 8 | 9 | import requests 10 | 11 | 12 | if "SLACK_WEBHOOK_URL" in os.environ: 13 | # https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables 14 | repository = os.environ["GITHUB_REPOSITORY"] 15 | run_id = os.environ["GITHUB_RUN_ID"] 16 | url = f"https://github.com/{repository}/actions/runs/{run_id}" 17 | 18 | print("Reporting to #nightly-build-failures slack channel") 19 | response = requests.post( # noqa: S113 20 | os.environ["SLACK_WEBHOOK_URL"], 21 | json={ 22 | "text": f"A Nightly build failed. See {url}", 23 | }, 24 | ) 25 | 26 | print(f"Slack responded with: {response}") 27 | 28 | else: 29 | print( 30 | "Unable to report to #nightly-build-failures slack channel because SLACK_WEBHOOK_URL is not set" 31 | ) 32 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import include, path 3 | from django.views.static import serve 4 | from wagtail import urls as wagtail_urls 5 | from wagtail.admin import urls as wagtailadmin_urls 6 | from wagtail.api.v2.router import WagtailAPIRouter 7 | from wagtail.documents import urls as wagtaildocs_urls 8 | 9 | from wagtailmedia.api.views import MediaAPIViewSet 10 | 11 | 12 | api_router = WagtailAPIRouter("wagtailapi_v2") 13 | api_router.register_endpoint("media", MediaAPIViewSet) 14 | 15 | urlpatterns = [ 16 | path("admin/", include(wagtailadmin_urls)), 17 | path("documents/", include(wagtaildocs_urls)), 18 | path("api/", api_router.urls), 19 | path("", include(wagtail_urls)), 20 | ] + [ 21 | path( 22 | f"{prefix.lstrip('/')}", 23 | serve, 24 | kwargs={"document_root": document_root}, 25 | ) 26 | for prefix, document_root in ( 27 | (settings.STATIC_URL, settings.STATIC_ROOT), 28 | (settings.MEDIA_URL, settings.MEDIA_ROOT), 29 | ) 30 | ] 31 | -------------------------------------------------------------------------------- /src/wagtailmedia/api/views.py: -------------------------------------------------------------------------------- 1 | from wagtail.api.v2.filters import FieldsFilter, OrderingFilter, SearchFilter 2 | from wagtail.api.v2.views import BaseAPIViewSet 3 | 4 | from ..models import get_media_model 5 | from .serializers import MediaItemSerializer 6 | 7 | 8 | class MediaAPIViewSet(BaseAPIViewSet): 9 | base_serializer_class = MediaItemSerializer 10 | filter_backends = [FieldsFilter, OrderingFilter, SearchFilter] 11 | body_fields = BaseAPIViewSet.body_fields + [ 12 | "title", 13 | "width", 14 | "height", 15 | "media_type", 16 | "collection", 17 | ] 18 | meta_fields = BaseAPIViewSet.meta_fields + [ 19 | "tags", 20 | "download_url", 21 | ] 22 | listing_default_fields = BaseAPIViewSet.listing_default_fields + [ 23 | "media_type", 24 | "title", 25 | "width", 26 | "height", 27 | "tags", 28 | "collection", 29 | "thumbnail", 30 | "download_url", 31 | ] 32 | nested_default_fields = BaseAPIViewSet.nested_default_fields + [ 33 | "title", 34 | "collection", 35 | "download_url", 36 | ] 37 | name = "media" 38 | model = get_media_model() 39 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n %} 3 | {% block titletag %}{% blocktrans with title=media.title %}Delete {{ title }}{% endblocktrans %}{% endblock %} 4 | {% block content %} 5 | {% trans "Delete media file" as del_str %} 6 | {% include "wagtailadmin/shared/header.html" with title=del_str subtitle=media.title icon="media" %} 7 | 8 |
    9 | 12 | 13 |

    {% trans "Are you sure you want to delete this media file?" %}

    14 |
    15 | {% csrf_token %} 16 | {% if next %}{% endif %} 17 | 18 | {% trans "No, don't delete" %} 19 |
    20 |
    21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.core.files.base import ContentFile 2 | from django.test import TestCase 3 | 4 | from wagtailmedia.models import get_media_model 5 | from wagtailmedia.utils import format_audio_html, format_video_html 6 | 7 | 8 | Media = get_media_model() 9 | 10 | 11 | class MediaUtilsTest(TestCase): 12 | def test_format_audio_html(self): 13 | audio = Media( 14 | title="Test audio 2", 15 | duration=1000, 16 | file=ContentFile("Test1", name="test1.mp3"), 17 | type="audio", 18 | ) 19 | 20 | self.assertEqual( 21 | format_audio_html(audio), 22 | f'", 24 | ) 25 | 26 | def test_format_video_html(self): 27 | video = Media( 28 | title="Test video 1", 29 | duration=1024, 30 | file=ContentFile("Test1", name="test1.mp4"), 31 | type="video", 32 | ) 33 | 34 | self.assertEqual( 35 | format_video_html(video), 36 | f'", 38 | ) 39 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase, override_settings 4 | 5 | from wagtailmedia.settings import WagtailMediaSettings, wagtailmedia_settings 6 | 7 | 8 | class SettingsTests(TestCase): 9 | def test_compatibility_with_override_settings(self): 10 | self.assertEqual( 11 | wagtailmedia_settings.MEDIA_MODEL, 12 | "wagtailmedia.Media", 13 | "Checking a known default", 14 | ) 15 | 16 | with override_settings(WAGTAILMEDIA={"MEDIA_MODEL": "myapp.CustomMedia"}): 17 | self.assertEqual( 18 | wagtailmedia_settings.MEDIA_MODEL, 19 | "myapp.CustomMedia", 20 | "Setting should have been updated", 21 | ) 22 | 23 | self.assertEqual( 24 | wagtailmedia_settings.MEDIA_MODEL, 25 | "wagtailmedia.Media", 26 | "Setting should have been restored", 27 | ) 28 | 29 | @mock.patch("wagtailmedia.settings.REMOVED_SETTINGS", ["A_REMOVED_SETTING"]) 30 | def test_runtimeerror_raised_on_removed_setting(self): 31 | msg = ( 32 | "The 'A_REMOVED_SETTING' setting has been removed. " 33 | "Please refer to the wagtailmedia documentation for available settings." 34 | ) 35 | with self.assertRaisesMessage(RuntimeError, msg): 36 | WagtailMediaSettings({"A_REMOVED_SETTING": "myapp.CustomMedia"}) 37 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/chooser/results.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if media_files %} 3 | {% if is_searching %} 4 |

    5 | {% blocktrans count counter=media_files.paginator.count %} 6 | There is one match 7 | {% plural %} 8 | There are {{ counter }} matches 9 | {% endblocktrans %} 10 |

    11 | {% else %} 12 |

    {% trans "Latest media" %}

    13 | {% endif %} 14 | 15 | {% include "wagtailmedia/media/list.html" with choosing=1 %} 16 | 17 | {% include "wagtailadmin/shared/pagination_nav.html" with items=media_files linkurl=chooser_url %} 18 | {% else %} 19 | {% if is_searching %} 20 |

    {% blocktrans %}Sorry, no media files match "{{ query_string }}"{% endblocktrans %}

    21 | {% else %} 22 | {% if media_type %} 23 | {% url 'wagtailmedia:add' media_type as wagtailmedia_add_url %} 24 | {% else %} 25 | {% url 'wagtailmedia:add' 'media' as wagtailmedia_add_url %} 26 | {% endif %} 27 | {% if current_collection %} 28 |

    {% blocktrans %}You haven't uploaded any media in this collection. Why not upload one now?{% endblocktrans %}

    29 | {% else %} 30 |

    {% blocktrans %}You haven't uploaded any media. Why not upload one now?{% endblocktrans %}

    31 | {% endif %} 32 | {% endif %} 33 | {% endif %} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | .vscode 27 | .DS_Store 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | coverage_html_report 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Django stuff: 52 | *.log 53 | local_settings.py 54 | 55 | # Flask instance folder 56 | instance/ 57 | 58 | # Scrapy stuff: 59 | .scrapy 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | 64 | # PyBuilder 65 | target/ 66 | 67 | # IPython Notebook 68 | .ipynb_checkpoints 69 | 70 | # pyenv 71 | .python-version 72 | 73 | # celery beat schedule file 74 | celerybeat-schedule 75 | 76 | # dotenv 77 | .env 78 | 79 | # virtualenv 80 | venv/ 81 | ENV/ 82 | .venv 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | # SQLite database 91 | *.sqlite3 92 | .ruff_cache 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Torchbox Ltd and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Torchbox nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /SPECIFICATION.md: -------------------------------------------------------------------------------- 1 | # Wagtail audio / video module 2 | 3 | 4 | A module for Wagtail that provides functionality similar to `wagtail.wagtaildocs` module, 5 | but for audio and video files. 6 | 7 | 8 | ## Models 9 | 10 | ### Essential 11 | 12 | * Extend the existing `Document` model or introduce new model for audio/video files (like `Image`). 13 | * The model should contain at least: duration (for audio and video), dimensions (for video), thumbnail (for video). 14 | 15 | ### Optional 16 | 17 | * Get duration of audio files automatically. 18 | * Get duration and dimensions of video files automatically. 19 | * Generate thumbnail automatically. 20 | 21 | ### Out of scope 22 | 23 | * Uploaded videos will not be converted / compressed / resized 24 | 25 | ## Admin 26 | 27 | ### Essential 28 | 29 | * Allow users to manage audio / video. 30 | * Allow users to upload custom thumbnail to videos. 31 | * Provide custom `StreamField` blocks for media items. 32 | 33 | ### Optional 34 | 35 | * Preview video / audio in an embedded player. 36 | * Allow users to insert audio / video files within the rich text editor. 37 | * Support oEmbed. 38 | * Note this removes the previous requirement, since oEmbed is already supported by the rich text editor. 39 | * Requires us to provide audio and video players within the app (because we need to generate the HTML code that initializes the player). 40 | * Template tags, providing shortcuts for template designers. This feature also requires a player. 41 | 42 | ## Tests 43 | 44 | Comprehensive unit test coverage. 45 | 46 | ## Documentation 47 | 48 | A detailed README in the Github project, for site implementers and module developers. 49 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/results.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | {% if media_files %} 3 | {% if is_searching %} 4 |

    5 | {% blocktrans trimmed count counter=media_files|length %} 6 | There is {{ counter }} match 7 | {% plural %} 8 | There are {{ counter }} matches 9 | {% endblocktrans %} 10 |

    11 | 12 | {% search_other %} 13 | {% endif %} 14 | 15 | {% include "wagtailmedia/media/list.html" %} 16 | 17 | {% include "wagtailadmin/shared/pagination_nav.html" with items=media_files is_searching=is_searching linkurl="wagtailmedia:index" %} 18 | {% else %} 19 | {% if is_searching %} 20 |

    {% blocktrans %}Sorry, no media files match "{{ query_string }}"{% endblocktrans %}

    21 | 22 | {% search_other %} 23 | {% else %} 24 | {% url 'wagtailmedia:add' 'audio' as wagtailmedia_add_audio_url %} 25 | {% url 'wagtailmedia:add' 'video' as wagtailmedia_add_video_url %} 26 | {% if current_collection %} 27 |

    {% blocktrans %}You haven't uploaded any media files in this collection. You can upload audio or video files.{% endblocktrans %}

    28 | {% else %} 29 |

    {% blocktrans %}You haven't uploaded any media files. You can upload audio or video files.{% endblocktrans %}

    30 | {% endif %} 31 | {% endif %} 32 | {% endif %} 33 | -------------------------------------------------------------------------------- /src/wagtailmedia/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.forms.utils import flatatt 6 | from django.utils.html import format_html, format_html_join 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | try: 11 | from wagtail.admin.paginator import WagtailPaginator as Paginator 12 | except ImportError: 13 | from django.core.paginator import Paginator 14 | 15 | 16 | if TYPE_CHECKING: 17 | from django.core.paginator import Page as PaginatorPage 18 | from django.http import HttpRequest 19 | 20 | from .models import AbstractMedia 21 | 22 | DEFAULT_PAGE_KEY: str = "p" 23 | 24 | 25 | def paginate( 26 | request: HttpRequest, items, page_key: str = DEFAULT_PAGE_KEY, per_page: int = 20 27 | ) -> tuple[Paginator, PaginatorPage]: 28 | paginator = Paginator(items, per_page) 29 | page = paginator.get_page(request.GET.get(page_key)) 30 | return paginator, page 31 | 32 | 33 | def format_audio_html(item: AbstractMedia) -> str: 34 | return format_html( 35 | "", 36 | sources=format_html_join( 37 | "\n", "", [[flatatt(s)] for s in item.sources] 38 | ), 39 | fallback=_("Your browser does not support the audio element."), 40 | ) 41 | 42 | 43 | def format_video_html(item: AbstractMedia) -> str: 44 | return format_html( 45 | "", 46 | sources=format_html_join( 47 | "\n", "", [[flatatt(s)] for s in item.sources] 48 | ), 49 | fallback=_("Your browser does not support the video element."), 50 | ) 51 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | # https://docs.pypi.org/trusted-publishers/using-a-publisher/ 9 | release: 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: 'release' 13 | url: https://pypi.org/p/wagtailmedia 14 | permissions: 15 | contents: read # to fetch code (actions/checkout) 16 | id-token: write # Mandatory for trusted publishing 17 | steps: 18 | - name: 🔒 Harden Runner 19 | uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 20 | with: 21 | disable-sudo: true 22 | egress-policy: block 23 | allowed-endpoints: > 24 | github.com:443 25 | api.github.com:443 26 | ghcr.io:443 27 | pkg-containers.githubusercontent.com:443 28 | pypi.org:443 29 | upload.pypi.org:443 30 | files.pythonhosted.org:443 31 | fulcio.sigstore.dev:443 32 | rekor.sigstore.dev:443 33 | tuf-repo-cdn.sigstore.dev:443 34 | 35 | - uses: actions/checkout@v5 36 | with: 37 | persist-credentials: false 38 | fetch-depth: 0 39 | 40 | - uses: actions/setup-python@v6 41 | with: 42 | python-version: '3.13' 43 | cache: "pip" 44 | cache-dependency-path: "**/pyproject.toml" 45 | 46 | - name: ⬇️ Install build dependencies 47 | run: | 48 | python -Im pip install -U flit 49 | 50 | - name: 🏗️ Build 51 | run: python -Im flit build 52 | 53 | - name: 🚀 Publish package distributions to PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | import tempfile 4 | 5 | from typing import Optional 6 | 7 | from django.core.files.base import ContentFile 8 | from django.test import TestCase 9 | 10 | from wagtailmedia.models import MediaType, get_media_model 11 | 12 | 13 | Media = get_media_model() 14 | 15 | 16 | class TempDirMediaRootMixin(TestCase): 17 | @classmethod 18 | def setUpClass(cls): 19 | cls.tmpdir = tempfile.mkdtemp() 20 | super().setUpClass() 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | super().tearDownClass() 25 | shutil.rmtree(cls.tmpdir, ignore_errors=True) 26 | 27 | def run(self, result=None): 28 | with self.settings(MEDIA_ROOT=self.tmpdir): 29 | return super().run(result) 30 | 31 | 32 | def create_media( 33 | media_type: str, 34 | title: str, 35 | duration: Optional[int] = 100, 36 | thumbnail: Optional[str] = False, 37 | ) -> Media: 38 | filename = re.sub(r"[/:\"\'] ", "_", title).lower() 39 | extension = "mp3" if media_type == MediaType.AUDIO else "mp4" 40 | item = Media.objects.create( 41 | type=media_type, 42 | title=title, 43 | duration=duration, 44 | file=ContentFile("📼", name=f"{filename}.{extension}"), 45 | ) 46 | 47 | if thumbnail: 48 | item.thumbnail = ContentFile("Thumbnail", name=thumbnail) 49 | item.save() 50 | 51 | return item 52 | 53 | 54 | def create_video(title="Test video", duration=100, thumbnail=None) -> Media: 55 | return create_media( 56 | MediaType.VIDEO, title=title, duration=duration, thumbnail=thumbnail 57 | ) 58 | 59 | 60 | def create_audio(title="Test audio", duration=100, thumbnail=None) -> Media: 61 | return create_media( 62 | MediaType.AUDIO, title=title, duration=duration, thumbnail=thumbnail 63 | ) 64 | -------------------------------------------------------------------------------- /tests/testapp/fixtures/test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "wagtailcore.page", 5 | "fields": { 6 | "title": "Root", 7 | "numchild": 1, 8 | "show_in_menus": false, 9 | "live": true, 10 | "depth": 1, 11 | "content_type": [ 12 | "wagtailcore", 13 | "page" 14 | ], 15 | "path": "0001", 16 | "url_path": "/", 17 | "slug": "root" 18 | } 19 | }, 20 | 21 | { 22 | "pk": 2, 23 | "model": "wagtailcore.page", 24 | "fields": { 25 | "title": "Welcome to the Wagtail test site!", 26 | "numchild": 6, 27 | "show_in_menus": false, 28 | "live": true, 29 | "depth": 2, 30 | "content_type": ["wagtailcore", "page"], 31 | "path": "00010001", 32 | "url_path": "/home/", 33 | "slug": "home" 34 | } 35 | }, 36 | { 37 | "pk": 3, 38 | "model": "wagtailcore.page", 39 | "fields": { 40 | "title": "Event - Christmas", 41 | "numchild": 6, 42 | "show_in_menus": true, 43 | "live": true, 44 | "depth": 3, 45 | "content_type": ["wagtailmedia_tests", "eventpage"], 46 | "path": "000100010001", 47 | "url_path": "/home/events/", 48 | "slug": "event" 49 | } 50 | }, 51 | { 52 | "pk": 3, 53 | "model": "wagtailmedia_tests.eventpage", 54 | "fields": { 55 | "date_from": "2014-12-25", 56 | "location": "The North Pole", 57 | "body": "

    Chestnuts roasting on an open fire

    ", 58 | "cost": "Free" 59 | } 60 | }, 61 | { 62 | "pk": 1, 63 | "model": "wagtailmedia.Media", 64 | "fields": { 65 | "title": "test media", 66 | "created_at": "2014-01-01T12:00:00.000Z", 67 | "type": "video", 68 | "file": "media/music.mp3", 69 | "duration": "100" 70 | } 71 | } 72 | ] 73 | -------------------------------------------------------------------------------- /.github/workflows/nightly-tests.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Wagtail test 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 1" 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | nightly-test: 14 | # Cannot check the existence of secrets, so limiting to repository name to prevent all forks to run nightly. 15 | # See: https://github.com/actions/runner/issues/520 16 | if: ${{ github.repository == 'torchbox/wagtailmedia' }} 17 | runs-on: ubuntu-latest 18 | 19 | services: 20 | postgres: 21 | image: postgres:15 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | ports: 25 | - 5432:5432 26 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | with: 31 | persist-credentials: false 32 | - name: Set up Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.13" 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install "psycopg2>=2.9" 40 | pip install "git+https://github.com/wagtail/wagtail.git@main#egg=wagtail" 41 | pip install -e .[testing] 42 | - name: Test 43 | id: test 44 | continue-on-error: true 45 | run: | 46 | cd tests 47 | python manage.py test 48 | env: 49 | DATABASE_ENGINE: django.db.backends.postgresql 50 | DATABASE_HOST: localhost 51 | DATABASE_USER: postgres 52 | DATABASE_PASS: postgres 53 | 54 | - name: Send Slack notification on failure 55 | if: steps.test.outcome == 'failure' 56 | run: | 57 | python .github/report_nightly_build_failure.py 58 | env: 59 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "wagtailmedia" 3 | description = "A Wagtail module for audio and video files." 4 | authors = [{name = "Mikalai Radchuk ", email = "hello@torchbox.com"}] 5 | maintainers = [{name = "Dan Braghis", email="dan.braghis@torchbox.com"}] 6 | readme = "README.md" 7 | license = "BSD-3-Clause" 8 | license-files = [ "LICENSE" ] 9 | keywords = ["Wagtail", "Django", "media"] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Environment :: Web Environment", 13 | "Intended Audience :: Developers", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Framework :: Wagtail", 23 | "Framework :: Wagtail :: 6", 24 | "Framework :: Wagtail :: 7", 25 | ] 26 | 27 | dynamic = ["version"] 28 | requires-python = ">=3.9" 29 | dependencies = [ 30 | "Wagtail>=6.3", 31 | "Django>=4.2", 32 | ] 33 | 34 | [project.optional-dependencies] 35 | testing = [ 36 | "coverage>=7.10.0", 37 | "tox>=4.28.4", 38 | ] 39 | linting = [ 40 | "pre-commit>=4.3.0", 41 | ] 42 | 43 | [project.urls] 44 | Repository = "https://github.com/torchbox/wagtailmedia" 45 | Changelog = "https://github.com/torchbox/wagtailmedia/blob/main/CHANGELOG.md" 46 | Issues = "https://github.com/torchbox/wagtailmedia/issues" 47 | 48 | [build-system] 49 | requires = ["flit_core >=3.11,<4"] 50 | build-backend = "flit_core.buildapi" 51 | 52 | [tool.flit.module] 53 | name = "wagtailmedia" 54 | 55 | [tool.flit.sdist] 56 | exclude = [ 57 | "tests", 58 | "Makefile", 59 | "docs", 60 | ".*", 61 | "*.json", 62 | "*.ini", 63 | "*.yml", 64 | "CHANGELOG.md", 65 | "SPECIFICATION.md", 66 | "ruff.toml", 67 | ] 68 | -------------------------------------------------------------------------------- /src/wagtailmedia/migrations/0003_copy_media_permissions_to_collections.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def get_media_permissions(apps): 5 | # return a queryset of the 'add_media' and 'change_media' permissions 6 | Permission = apps.get_model("auth.Permission") 7 | ContentType = apps.get_model("contenttypes.ContentType") 8 | 9 | media_content_type, _created = ContentType.objects.get_or_create( 10 | model="media", 11 | app_label="wagtailmedia", 12 | ) 13 | return Permission.objects.filter( 14 | content_type=media_content_type, codename__in=["add_media", "change_media"] 15 | ) 16 | 17 | 18 | def copy_media_permissions_to_collections(apps, schema_editor): 19 | Collection = apps.get_model("wagtailcore.Collection") 20 | Group = apps.get_model("auth.Group") 21 | GroupCollectionPermission = apps.get_model("wagtailcore.GroupCollectionPermission") 22 | 23 | root_collection = Collection.objects.get(depth=1) 24 | 25 | for permission in get_media_permissions(apps): 26 | for group in Group.objects.filter(permissions=permission): 27 | GroupCollectionPermission.objects.create( 28 | group=group, collection=root_collection, permission=permission 29 | ) 30 | 31 | 32 | def remove_media_permissions_from_collections(apps, schema_editor): 33 | GroupCollectionPermission = apps.get_model("wagtailcore.GroupCollectionPermission") 34 | media_permissions = get_media_permissions(apps) 35 | 36 | GroupCollectionPermission.objects.filter(permission__in=media_permissions).delete() 37 | 38 | 39 | class Migration(migrations.Migration): 40 | dependencies = [ 41 | ("wagtailmedia", "0002_initial_data"), 42 | ("wagtailcore", "0026_group_collection_permission"), 43 | ] 44 | 45 | operations = [ 46 | migrations.RunPython( 47 | copy_media_permissions_to_collections, 48 | remove_media_permissions_from_collections, 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import os 5 | import shutil 6 | import sys 7 | import warnings 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | 12 | os.environ["DJANGO_SETTINGS_MODULE"] = "testapp.settings" 13 | sys.path.append("tests") 14 | 15 | 16 | def make_parser(): 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument( 19 | "--deprecation", 20 | choices=["all", "pending", "imminent", "none"], 21 | default="imminent", 22 | ) 23 | return parser 24 | 25 | 26 | def parse_args(args=None): 27 | return make_parser().parse_known_args(args) 28 | 29 | 30 | def runtests(): 31 | args, rest = parse_args() 32 | 33 | only_wagtail = r"^wagtail(\.|$)" 34 | if args.deprecation == "all": 35 | # Show all deprecation warnings from all packages 36 | warnings.simplefilter("default", DeprecationWarning) 37 | warnings.simplefilter("default", PendingDeprecationWarning) 38 | elif args.deprecation == "pending": 39 | # Show all deprecation warnings from wagtail 40 | warnings.filterwarnings( 41 | "default", category=DeprecationWarning, module=only_wagtail 42 | ) 43 | warnings.filterwarnings( 44 | "default", category=PendingDeprecationWarning, module=only_wagtail 45 | ) 46 | elif args.deprecation == "imminent": 47 | # Show only imminent deprecation warnings from wagtail 48 | warnings.filterwarnings( 49 | "default", category=DeprecationWarning, module=only_wagtail 50 | ) 51 | elif args.deprecation == "none": 52 | # Deprecation warnings are ignored by default 53 | pass 54 | 55 | argv = [sys.argv[0]] + rest 56 | 57 | try: 58 | execute_from_command_line(argv) 59 | finally: 60 | from wagtail.test.settings import MEDIA_ROOT, STATIC_ROOT 61 | 62 | shutil.rmtree(STATIC_ROOT, ignore_errors=True) 63 | shutil.rmtree(MEDIA_ROOT, ignore_errors=True) 64 | 65 | 66 | if __name__ == "__main__": 67 | runtests() 68 | -------------------------------------------------------------------------------- /tests/test_form_override.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.test import TestCase, override_settings 3 | from testapp.forms import AlternateMediaForm, OverridenWidget 4 | from wagtail.admin import widgets 5 | 6 | from wagtailmedia import models 7 | from wagtailmedia.forms import BaseMediaForm, get_media_base_form, get_media_form 8 | 9 | 10 | class TestFormOverride(TestCase): 11 | def test_get_media_base_form(self): 12 | self.assertIs(get_media_base_form(), BaseMediaForm) 13 | 14 | def test_get_media_form(self): 15 | bases = get_media_form(models.Media).__bases__ 16 | self.assertIn(BaseMediaForm, bases) 17 | self.assertNotIn(AlternateMediaForm, bases) 18 | 19 | def test_get_media_form_widgets(self): 20 | Form = get_media_form(models.Media) 21 | form = Form() 22 | self.assertIsInstance(form.fields["tags"].widget, widgets.AdminTagWidget) 23 | self.assertIsInstance(form.fields["file"].widget, forms.FileInput) 24 | self.assertIsInstance(form.fields["thumbnail"].widget, forms.ClearableFileInput) 25 | 26 | @override_settings( 27 | WAGTAILMEDIA={"MEDIA_FORM_BASE": "testapp.forms.AlternateMediaForm"} 28 | ) 29 | def test_overridden_base_form(self): 30 | self.assertIs(get_media_base_form(), AlternateMediaForm) 31 | 32 | @override_settings( 33 | WAGTAILMEDIA={"MEDIA_FORM_BASE": "testapp.forms.AlternateMediaForm"} 34 | ) 35 | def test_get_overridden_media_form(self): 36 | bases = get_media_form(models.Media).__bases__ 37 | self.assertNotIn(BaseMediaForm, bases) 38 | self.assertIn(AlternateMediaForm, bases) 39 | 40 | @override_settings( 41 | WAGTAILMEDIA={"MEDIA_FORM_BASE": "testapp.forms.AlternateMediaForm"} 42 | ) 43 | def test_get_overridden_media_form_widgets(self): 44 | Form = get_media_form(models.Media) 45 | form = Form() 46 | self.assertIsInstance(form.fields["tags"].widget, OverridenWidget) 47 | self.assertIsInstance(form.fields["file"].widget, OverridenWidget) 48 | self.assertIsInstance(form.fields["thumbnail"].widget, OverridenWidget) 49 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/usage.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n %} 3 | {% block titletag %}{% blocktrans trimmed with title=media_item.title %}Usage of {{ title }}{% endblocktrans %}{% endblock %} 4 | {% block content %} 5 | {% trans "Usage of" as usage_str %} 6 | {% include "wagtailadmin/shared/header.html" with title=usage_str subtitle=media_item.title %} 7 | 8 |
    9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for label, edit_url, edit_link_title, verbose_name, references in results %} 19 | 20 | 27 | 28 | 39 | 40 | {% endfor %} 41 | 42 |
    {% trans "Title" %}{% trans "Type" %}{% trans "Field" %}
    21 |
    22 | {% if edit_url %}{% endif %} 23 | {{ label }} 24 | {% if edit_url %}{% endif %} 25 |
    26 |
    {{ verbose_name }} 29 | 38 |
    43 |
    44 | {% include "wagtailadmin/shared/pagination_nav.html" with items=object_page %} 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /src/wagtailmedia/edit_handlers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.template.loader import render_to_string 6 | from wagtail.admin.compare import ForeignObjectComparison 7 | from wagtail.admin.panels import FieldPanel 8 | 9 | from .models import MediaType 10 | from .utils import format_audio_html, format_video_html 11 | from .widgets import AdminAudioChooser, AdminMediaChooser, AdminVideoChooser 12 | 13 | 14 | if TYPE_CHECKING: 15 | from .models import AbstractMedia 16 | 17 | 18 | class MediaChooserPanel(FieldPanel): 19 | object_type_name = "media" 20 | 21 | def __init__(self, field_name, *args, media_type=None, **kwargs): 22 | super().__init__(field_name, *args, **kwargs) 23 | 24 | self.media_type = media_type 25 | 26 | def clone_kwargs(self): 27 | kwargs = super().clone_kwargs() 28 | kwargs.update(media_type=self.media_type) 29 | return kwargs 30 | 31 | @property 32 | def _widget_class(self): 33 | if self.media_type == "audio": 34 | return AdminAudioChooser 35 | elif self.media_type == "video": 36 | return AdminVideoChooser 37 | return AdminMediaChooser 38 | 39 | def get_form_options(self) -> dict: 40 | opts = super().get_form_options() 41 | if "widgets" in opts: 42 | opts["widgets"][self.field_name] = self._widget_class 43 | else: 44 | opts["widgets"] = {self.field_name: self._widget_class} 45 | 46 | return opts 47 | 48 | 49 | class MediaFieldComparison(ForeignObjectComparison): 50 | def htmldiff(self) -> str: 51 | media_item_a, media_item_b = self.get_objects() 52 | if not all([media_item_a, media_item_b]): 53 | return "" 54 | 55 | return render_to_string( 56 | "wagtailmedia/widgets/compare.html", 57 | { 58 | "media_item_a": self.render_media_item(media_item_a), 59 | "media_item_b": self.render_media_item(media_item_b), 60 | }, 61 | ) 62 | 63 | @staticmethod 64 | def render_media_item(item: AbstractMedia) -> str: 65 | if item.type == MediaType.AUDIO: 66 | return format_audio_html(item) 67 | else: 68 | return format_video_html(item) 69 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4.22 3 | 4 | env_list = 5 | python{3.9,3.10,3.11}-dj42-wagtail{63,70,71} 6 | python{3.12}-dj51-wagtail{63,70,71} 7 | python{3.13}-dj52-wagtail{70,71} 8 | 9 | [gh-actions] 10 | python = 11 | 3.9: py3.9 12 | 3.10: py3.10 13 | 3.11: py3.11 14 | 3.12: py3.12 15 | 3.13: py3.13 16 | 17 | [testenv] 18 | package = wheel 19 | wheel_build_env = .pkg 20 | use_frozen_constraints = true 21 | constrain_package_deps = true 22 | 23 | pass_env = 24 | FORCE_COLOR 25 | NO_COLOR 26 | 27 | set_env = 28 | PYTHONPATH = {toxinidir} 29 | DJANGO_SETTINGS_MODULE = tests.settings 30 | PYTHONDEVMODE = 1 31 | 32 | python3.12: COVERAGE_CORE=sysmon 33 | python3.13: COVERAGE_CORE=sysmon 34 | 35 | extras = testing 36 | 37 | deps = 38 | dj42: Django>=4.2,<5.0 39 | dj51: Django>=5.1,<5.2 40 | dj52: Django>=5.2,<5.3 41 | wagtail63: wagtail>=6.3,<6.4 42 | wagtail70: wagtail>=7.0,<7.1 43 | wagtail71: wagtail>=7.1,<7.2 44 | 45 | commands_pre = 46 | python -I {toxinidir}/tests/manage.py migrate 47 | commands = 48 | python -Im coverage run {toxinidir}/tests/manage.py test --deprecation all {posargs: -v 2} 49 | 50 | [testenv:coverage-report] 51 | base_python = python3.13 52 | package = skip 53 | deps = 54 | coverage>=7.0,<8.0 55 | commands = 56 | python -Im coverage combine 57 | python -Im coverage report -m 58 | 59 | [testenv:wagtailmain] 60 | description = Test with latest Wagtail main branch 61 | base_python = python3.13 62 | deps = 63 | wagtailmain: git+https://github.com/wagtail/wagtail.git@main#egg=Wagtail 64 | 65 | [testenv:interactive] 66 | package = editable 67 | description = An interactive environment for local testing purposes 68 | base_python = python3.13 69 | 70 | deps = 71 | wagtail>=6.3,<6.4 72 | 73 | commands_pre = 74 | python {toxinidir}/tests/manage.py makemigrations 75 | python {toxinidir}/tests/manage.py migrate 76 | python {toxinidir}/tests/manage.py shell -c "from django.contrib.auth.models import User;(not User.objects.filter(username='admin').exists()) and User.objects.create_superuser('admin', 'super@example.com', 'changeme')" 77 | python {toxinidir}/tests/manage.py createcachetable 78 | 79 | commands = 80 | {posargs:python {toxinidir}/tests/manage.py runserver 0.0.0.0:8020} 81 | 82 | set_env = 83 | INTERACTIVE = 1 84 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | from wagtail.models import Page 4 | from wagtail.test.utils import WagtailTestUtils 5 | 6 | from wagtailmedia.blocks import AbstractMediaChooserBlock 7 | 8 | 9 | class TestMediaBlock(AbstractMediaChooserBlock): 10 | def render_basic(self, value, context=None): 11 | return None 12 | 13 | 14 | class TestAdminInterface(TestCase, WagtailTestUtils): 15 | def setUp(self): 16 | self.user = self.login() 17 | self.root_page = Page.objects.first() 18 | 19 | def test_media_field_in_admin(self): 20 | """ 21 | EventPage does not trigger telepath to be loaded, but media-chooser 22 | should be included. 23 | """ 24 | response = self.client.get( 25 | reverse( 26 | "wagtailadmin_pages:add", 27 | args=("wagtailmedia_tests", "eventpage", self.root_page.id), 28 | ) 29 | ) 30 | 31 | from wagtail import VERSION as WAGTAIL_VERSION 32 | 33 | if WAGTAIL_VERSION >= (4, 2): 34 | self.assertContains( 35 | response, 36 | '', 37 | ) 38 | else: 39 | self.assertContains( 40 | response, 41 | ' 31 | {% endblock %} 32 | 33 | {% block extra_css %} 34 | {{ block.super }} 35 | {{ form.media.css }} 36 | {% endblock %} 37 | 38 | {% block content %} 39 | {% if media_type == 'audio' %} 40 | {% trans "Add audio" as add_str %} 41 | {% elif media_type == 'video' %} 42 | {% trans "Add video" as add_str %} 43 | {% else %} 44 | {% trans "Add audio or video" as add_str %} 45 | {% endif %} 46 | {% include "wagtailadmin/shared/header.html" with title=add_str icon="media" %} 47 | 48 |
    49 | {% include "wagtailadmin/shared/non_field_errors.html" %} 50 |
    51 | {% csrf_token %} 52 |
      53 | {% for field in form %} 54 | {% if field.is_hidden %} 55 | {{ field }} 56 | {% else %} 57 |
    • {% include "wagtailadmin/shared/field.html" with field=field %}
    • 58 | {% endif %} 59 | {% endfor %} 60 |
    • 61 | 71 |
    • 72 |
    73 |
    74 |
    75 | {% endblock %} 76 | -------------------------------------------------------------------------------- /src/wagtailmedia/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.forms.models import modelform_factory 3 | from django.utils.module_loading import import_string 4 | from django.utils.translation import gettext_lazy as _ 5 | from wagtail.admin import widgets 6 | from wagtail.admin.forms.collections import ( 7 | BaseCollectionMemberForm, 8 | CollectionChoiceField, 9 | collection_member_permission_formset_factory, 10 | ) 11 | from wagtail.models import Collection 12 | 13 | from wagtailmedia.models import Media 14 | from wagtailmedia.permissions import permission_policy as media_permission_policy 15 | from wagtailmedia.settings import wagtailmedia_settings 16 | 17 | 18 | # Callback to allow us to override the default form field for the collection field 19 | def formfield_for_dbfield(db_field, **kwargs): 20 | if db_field.name == "collection": 21 | return CollectionChoiceField( 22 | label=_("Collection"), 23 | queryset=Collection.objects.all(), 24 | empty_label=None, 25 | **kwargs, 26 | ) 27 | 28 | # For all other fields, just call its formfield() method. 29 | return db_field.formfield(**kwargs) 30 | 31 | 32 | class BaseMediaForm(BaseCollectionMemberForm): 33 | class Meta: 34 | widgets = { 35 | "tags": widgets.AdminTagWidget, 36 | "file": forms.FileInput, 37 | "thumbnail": forms.ClearableFileInput, 38 | } 39 | 40 | permission_policy = media_permission_policy 41 | 42 | def __init__(self, *args, **kwargs): 43 | super().__init__(*args, **kwargs) 44 | 45 | if self.instance.type == "audio": 46 | for name in ("width", "height"): 47 | # these fields might be editable=False so verify before accessing 48 | if name in self.fields: 49 | del self.fields[name] 50 | 51 | 52 | def get_media_base_form(): 53 | base_form_override = wagtailmedia_settings.MEDIA_FORM_BASE 54 | if base_form_override: 55 | base_form = import_string(base_form_override) 56 | else: 57 | base_form = BaseMediaForm 58 | return base_form 59 | 60 | 61 | def get_media_form(model): 62 | fields = model.admin_form_fields 63 | if "collection" not in fields: 64 | # force addition of the 'collection' field, because leaving it out can 65 | # cause dubious results when multiple collections exist (e.g adding the 66 | # media to the root collection where the user may not have permission) - 67 | # and when only one collection exists, it will get hidden anyway. 68 | fields = list(fields) + ["collection"] 69 | 70 | return modelform_factory( 71 | model, 72 | form=get_media_base_form(), 73 | fields=fields, 74 | formfield_callback=formfield_for_dbfield, 75 | ) 76 | 77 | 78 | GroupMediaPermissionFormSet = collection_member_permission_formset_factory( 79 | Media, 80 | [ 81 | ("add_media", _("Add"), _("Add/edit media you own")), 82 | ("change_media", _("Edit"), _("Edit any media")), 83 | ], 84 | "wagtailmedia/permissions/includes/media_permissions_formset.html", 85 | ) 86 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n media_tags %} 3 | {% block titletag %}{% blocktrans with title=media.title %}Editing {{ title }}{% endblocktrans %}{% endblock %} 4 | 5 | {% block extra_js %} 6 | {{ block.super }} 7 | {{ form.media.js }} 8 | 9 | {% endblock %} 10 | 11 | {% block extra_css %} 12 | {{ block.super }} 13 | {{ form.media.css }} 14 | {% endblock %} 15 | 16 | {% block content %} 17 | {% trans "Editing" as editing_str %} 18 | {% include "wagtailadmin/shared/header.html" with title=editing_str subtitle=media.title icon=media.icon %} 19 | {% include "wagtailadmin/shared/non_field_errors.html" %} 20 | 21 | {% wagtail_version_gte "6.1" as wagtail_gte_61 %} 22 | 23 | {% block form_row %} 24 |
    25 |
    26 |
    27 | {% csrf_token %} 28 | {% if next %}{% endif %} 29 |
      30 | {% for field in form %} 31 | {% if field.name == 'file' %} 32 | {% include "wagtailmedia/media/_file_field_as_li.html" %} 33 | {% elif field.name == 'thumbnail' %} 34 | {% include "wagtailmedia/media/_thumbnail_field_as_li.html" %} 35 | {% else %} 36 |
    • {% include "wagtailadmin/shared/field.html" with field=field %}
    • 37 | {% endif %} 38 | {% endfor %} 39 |
    • 40 | 41 | {% if user_can_delete %} 42 | {% trans "Delete" %} 43 | {% endif %} 44 |
    • 45 |
    46 |
    47 |
    48 |
    49 |
    50 | {% block media_stats %} 51 | {% if media.file %} 52 |
    {% trans "Filesize" %}
    53 |
    {% if filesize %}{{ filesize|filesizeformat }}{% else %}{% trans "File not found" %}{% endif %}
    54 | {% endif %} 55 | 56 |
    {% trans "Usage" %}
    57 |
    58 | {% blocktrans count usage_count=media.get_usage.count %}Used {{ usage_count }} time{% plural %}Used {{ usage_count }} times{% endblocktrans %} 59 |
    60 | {% endblock %} 61 |
    62 |
    63 |
    64 | {% endblock %} 65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/media/list.html: -------------------------------------------------------------------------------- 1 | {% load i18n l10n wagtailadmin_tags %} 2 | 3 | 4 | 5 | {% if collections %} 6 | 7 | {% endif %} 8 | 9 | 10 | 11 | 26 | 27 | 28 | {% if collections %} 29 | 30 | {% endif %} 31 | 44 | 45 | 46 | 47 | {% for media_file in media_files %} 48 | 49 | 56 | 63 | 64 | {% if collections %} 65 | 66 | {% endif %} 67 | 68 | 69 | {% endfor %} 70 | 71 |
    12 | {% if not is_searching %} 13 | {% if ordering == "-title" %} 14 | 15 | {% elif ordering == "title" %} 16 | 17 | {% else %}{# ordering by created at, so default to title, asc #} 18 | 19 | {% endif %} 20 | {% trans "Title" %} 21 | 22 | {% else %} 23 | {% trans "Title" %} 24 | {% endif %} 25 | {% trans "File" %}{% trans "Type" %}{% trans "Collection" %} 32 | {% if not is_searching %} 33 | {% if ordering == "-created_at" %} 34 | 35 | {% else %} 36 | 37 | {% endif %} 38 | {% trans "Uploaded" %} 39 | 40 | {% else %} 41 | {% trans "Uploaded" %} 42 | {% endif %} 43 |
    50 | {% if choosing %} 51 | 52 | {% else %} 53 | 54 | {% endif %} 55 | 57 | {% if choosing %} 58 | {{ media_file.filename }} 59 | {% else %} 60 | {{ media_file.filename }} 61 | {% endif %} 62 | {{ media_file.get_type_display }}{{ media_file.collection.name }}{% human_readable_date media_file.created_at %}
    72 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read # to fetch code (actions/checkout) 15 | 16 | env: 17 | FORCE_COLOR: "1" # Make tools pretty. 18 | TOX_TESTENV_PASSENV: FORCE_COLOR 19 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 20 | PIP_NO_PYTHON_VERSION_WARNING: "1" 21 | # Keep in sync with .pre-commit-config.yaml/default_language_version/python. 22 | PYTHON_LATEST: "3.13" # because harden runner fails on the 3.13 download 23 | 24 | jobs: 25 | tests: 26 | name: Python ${{ matrix.python-version }} 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | matrix: 31 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 32 | 33 | steps: 34 | - name: 🔒 Harden Runner 35 | uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 36 | with: 37 | disable-sudo: true 38 | egress-policy: block 39 | allowed-endpoints: > 40 | files.pythonhosted.org:443 41 | objects.githubusercontent.com:443 42 | github.com:443 43 | pypi.org:443 44 | api.github.com:443 45 | - uses: actions/checkout@v5 46 | with: 47 | persist-credentials: false 48 | - name: 🐍 Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | 53 | - name: ⬇️ Install dependencies 54 | run: | 55 | python -Im pip install --upgrade pip 56 | python -Im pip install flit tox tox-gh-actions 57 | 58 | - name: 🏗️ Build wheel 59 | run: python -Im flit build --format wheel 60 | 61 | - name: 🧪 Run tox targets for Python ${{ matrix.python-version }} 62 | run: tox --installpkg ./dist/*.whl 63 | 64 | - name: ⬆️ Upload coverage data 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: coverage-data-${{ matrix.python-version }} 68 | path: .coverage.* 69 | if-no-files-found: ignore 70 | include-hidden-files: true 71 | retention-days: 1 72 | 73 | coverage: 74 | runs-on: ubuntu-latest 75 | needs: tests 76 | 77 | steps: 78 | - name: 🔒 Harden Runner 79 | uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 80 | with: 81 | disable-sudo: true 82 | egress-policy: block 83 | allowed-endpoints: > 84 | files.pythonhosted.org:443 85 | github.com:443 86 | pypi.org:443 87 | api.github.com:443 88 | - uses: actions/checkout@v5 89 | with: 90 | persist-credentials: false 91 | fetch-depth: 0 92 | - uses: actions/setup-python@v5 93 | with: 94 | # Use latest Python, so it understands all syntax. 95 | python-version: ${{env.PYTHON_LATEST}} 96 | 97 | - run: python -Im pip install --upgrade coverage 98 | 99 | - name: Download coverage data 100 | uses: actions/download-artifact@v5 101 | with: 102 | pattern: coverage-data-* 103 | merge-multiple: true 104 | 105 | - name: + Combine coverage 106 | run: | 107 | python -Im coverage combine 108 | python -Im coverage html --skip-covered --skip-empty 109 | python -Im coverage report 110 | echo "## Coverage summary" >> $GITHUB_STEP_SUMMARY 111 | python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 112 | - name: 📈 Upload HTML report if check failed. 113 | uses: actions/upload-artifact@v4 114 | with: 115 | name: html-report 116 | path: htmlcov 117 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | DEBUG = "INTERACTIVE" in os.environ 5 | 6 | WAGTAILMEDIA_ROOT = os.path.dirname(__file__) 7 | STATIC_ROOT = os.path.join(WAGTAILMEDIA_ROOT, "test-static") 8 | MEDIA_ROOT = os.path.join(WAGTAILMEDIA_ROOT, "test-media") 9 | MEDIA_URL = "/media/" 10 | 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": os.environ.get("DATABASE_ENGINE", "django.db.backends.sqlite3"), 14 | "NAME": os.environ.get("DATABASE_NAME", "db.sqlite3"), 15 | "USER": os.environ.get("DATABASE_USER", None), 16 | "PASSWORD": os.environ.get("DATABASE_PASS", None), 17 | "HOST": os.environ.get("DATABASE_HOST", None), 18 | "TEST": { 19 | "NAME": os.environ.get("DATABASE_NAME", None), 20 | }, 21 | } 22 | } 23 | 24 | 25 | SECRET_KEY = "not needed" 26 | 27 | ROOT_URLCONF = "testapp.urls" 28 | 29 | STATIC_URL = "/static/" 30 | STATIC_ROOT = STATIC_ROOT 31 | 32 | STATICFILES_FINDERS = ("django.contrib.staticfiles.finders.AppDirectoriesFinder",) 33 | 34 | USE_TZ = True 35 | 36 | TEMPLATES = [ 37 | { 38 | "BACKEND": "django.template.backends.django.DjangoTemplates", 39 | "DIRS": [], 40 | "APP_DIRS": True, 41 | "OPTIONS": { 42 | "context_processors": [ 43 | "django.template.context_processors.debug", 44 | "django.template.context_processors.request", 45 | "django.contrib.auth.context_processors.auth", 46 | "django.contrib.messages.context_processors.messages", 47 | "django.template.context_processors.request", 48 | "wagtail.contrib.settings.context_processors.settings", 49 | ], 50 | "debug": True, 51 | }, 52 | } 53 | ] 54 | 55 | MIDDLEWARE = [ 56 | "django.middleware.common.CommonMiddleware", 57 | "django.contrib.sessions.middleware.SessionMiddleware", 58 | "django.middleware.csrf.CsrfViewMiddleware", 59 | "django.contrib.auth.middleware.AuthenticationMiddleware", 60 | "django.contrib.messages.middleware.MessageMiddleware", 61 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 62 | ] 63 | 64 | MIDDLEWARE += [ 65 | "wagtail.contrib.redirects.middleware.RedirectMiddleware", 66 | ] 67 | 68 | INSTALLED_APPS = [ 69 | "testapp", 70 | "wagtailmedia", 71 | "taggit", 72 | "django.contrib.auth", 73 | "django.contrib.contenttypes", 74 | "django.contrib.sessions", 75 | "django.contrib.staticfiles", 76 | "wagtail.contrib.redirects", 77 | "wagtail.contrib.search_promotions", 78 | "wagtail.images", 79 | "wagtail.users", 80 | "wagtail.documents", 81 | "wagtail.admin", 82 | "wagtail", 83 | "wagtail.search", 84 | ] 85 | 86 | 87 | # Using DatabaseCache to make sure THAT the cache is cleared between tests. 88 | # This prevents false-positives in some wagtail core tests where we are 89 | # changing the 'wagtail_root_paths' key which may cause future tests to fail. 90 | 91 | REDIS_PORT = os.getenv("REDIS_6379_TCP_PORT", "") 92 | 93 | if REDIS_PORT: 94 | CACHES = { 95 | "default": { 96 | "BACKEND": "redis_cache.RedisCache", 97 | "LOCATION": f"localhost:{REDIS_PORT}", 98 | }, 99 | } 100 | else: 101 | CACHES = { 102 | "default": { 103 | "BACKEND": "django.core.cache.backends.db.DatabaseCache", 104 | "LOCATION": "cache", 105 | } 106 | } 107 | 108 | PASSWORD_HASHERS = ( 109 | "django.contrib.auth.hashers.MD5PasswordHasher", # don't use the intentionally slow default password hasher 110 | ) 111 | 112 | 113 | WAGTAILSEARCH_BACKENDS = {"default": {"BACKEND": "wagtail.search.backends.database"}} 114 | 115 | # must be set for interactive demo, copied per 116 | # https://github.com/django/django/commit/adb96617897690b3a01e39e8297ae7d67825d2bc 117 | ALLOWED_HOSTS = ["*"] 118 | 119 | WAGTAIL_SITE_NAME = "Test Site" 120 | WAGTAILADMIN_BASE_URL = "http://localhost:8020" 121 | -------------------------------------------------------------------------------- /src/wagtailmedia/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.5 on 2016-04-20 12:32 2 | 3 | import django.db.models.deletion 4 | import taggit.managers 5 | 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | from wagtail import models as wagtail_models 9 | from wagtail.search import index 10 | 11 | 12 | class Migration(migrations.Migration): 13 | initial = True 14 | 15 | dependencies = [ 16 | ("taggit", "0002_auto_20150616_2121"), 17 | ("wagtailcore", "0028_merge"), 18 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 19 | ] 20 | 21 | operations = [ 22 | migrations.CreateModel( 23 | name="Media", 24 | fields=[ 25 | ( 26 | "id", 27 | models.AutoField( 28 | auto_created=True, 29 | primary_key=True, 30 | serialize=False, 31 | verbose_name="ID", 32 | ), 33 | ), 34 | ("title", models.CharField(max_length=255, verbose_name="title")), 35 | ("file", models.FileField(upload_to="media", verbose_name="file")), 36 | ( 37 | "type", 38 | models.CharField( 39 | choices=[("audio", "Audio file"), ("video", "Video file")], 40 | max_length=255, 41 | ), 42 | ), 43 | ( 44 | "duration", 45 | models.PositiveIntegerField( 46 | help_text="Duration in seconds", verbose_name="duration" 47 | ), 48 | ), 49 | ( 50 | "width", 51 | models.PositiveIntegerField( 52 | blank=True, null=True, verbose_name="width" 53 | ), 54 | ), 55 | ( 56 | "height", 57 | models.PositiveIntegerField( 58 | blank=True, null=True, verbose_name="height" 59 | ), 60 | ), 61 | ( 62 | "thumbnail", 63 | models.FileField( 64 | blank=True, 65 | upload_to="media_thumbnails", 66 | verbose_name="thumbnail", 67 | ), 68 | ), 69 | ( 70 | "created_at", 71 | models.DateTimeField(auto_now_add=True, verbose_name="created at"), 72 | ), 73 | ( 74 | "collection", 75 | models.ForeignKey( 76 | default=wagtail_models.get_root_collection_id, 77 | on_delete=django.db.models.deletion.CASCADE, 78 | related_name="+", 79 | to="wagtailcore.Collection", 80 | verbose_name="collection", 81 | ), 82 | ), 83 | ( 84 | "tags", 85 | taggit.managers.TaggableManager( 86 | blank=True, 87 | help_text=None, 88 | through="taggit.TaggedItem", 89 | to="taggit.Tag", 90 | verbose_name="tags", 91 | ), 92 | ), 93 | ( 94 | "uploaded_by_user", 95 | models.ForeignKey( 96 | blank=True, 97 | editable=False, 98 | null=True, 99 | on_delete=django.db.models.deletion.SET_NULL, 100 | to=settings.AUTH_USER_MODEL, 101 | verbose_name="uploaded by user", 102 | ), 103 | ), 104 | ], 105 | options={ 106 | "abstract": False, 107 | "verbose_name": "media", 108 | }, 109 | bases=(models.Model, index.Indexed), 110 | ), 111 | ] 112 | -------------------------------------------------------------------------------- /src/wagtailmedia/blocks.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django.forms import ModelChoiceField 4 | from django.template.loader import render_to_string 5 | from django.utils.functional import cached_property 6 | from wagtail.admin.compare import BlockComparison 7 | from wagtail.blocks import ChooserBlock 8 | 9 | from .utils import format_audio_html, format_video_html 10 | 11 | 12 | if TYPE_CHECKING: 13 | from .widgets import AdminAudioChooser, AdminVideoChooser 14 | 15 | 16 | class AbstractMediaChooserBlock(ChooserBlock): 17 | def __init__( 18 | self, required=True, help_text=None, validators=(), media_type=None, **kwargs 19 | ): 20 | super().__init__( 21 | required=required, help_text=help_text, validators=validators, **kwargs 22 | ) 23 | self.media_type = media_type 24 | 25 | @cached_property 26 | def target_model(self): 27 | from wagtailmedia.models import get_media_model 28 | 29 | return get_media_model() 30 | 31 | @cached_property 32 | def field(self): 33 | if not self.media_type: 34 | return super().field 35 | 36 | return ModelChoiceField( 37 | queryset=self.target_model.objects.filter(type=self.media_type), 38 | widget=self.widget, 39 | required=self._required, 40 | validators=self._validators, 41 | help_text=self._help_text, 42 | ) 43 | 44 | @cached_property 45 | def widget(self): 46 | from wagtailmedia.widgets import AdminMediaChooser 47 | 48 | return AdminMediaChooser() 49 | 50 | def render_basic(self, value, context=None): 51 | raise NotImplementedError( 52 | f"You need to implement {self.__class__.__name__}.render_basic" 53 | ) 54 | 55 | def get_comparison_class(self) -> type["MediaChooserBlockComparison"]: 56 | return MediaChooserBlockComparison 57 | 58 | 59 | class MediaChooserBlockComparison(BlockComparison): 60 | def htmlvalue(self, value) -> str: 61 | return render_to_string( 62 | "wagtailmedia/widgets/compare.html", 63 | { 64 | "media_item_a": self.block.render_basic(value), 65 | "media_item_b": self.block.render_basic(value), 66 | }, 67 | ) 68 | 69 | def htmldiff(self) -> str: 70 | return render_to_string( 71 | "wagtailmedia/widgets/compare.html", 72 | { 73 | "media_item_a": self.block.render_basic(self.val_a), 74 | "media_item_b": self.block.render_basic(self.val_b), 75 | }, 76 | ) 77 | 78 | 79 | class AudioChooserBlock(AbstractMediaChooserBlock): 80 | def __init__(self, required=True, help_text=None, validators=(), **kwargs): 81 | super().__init__( 82 | required=required, 83 | help_text=help_text, 84 | validators=validators, 85 | media_type="audio", 86 | **kwargs, 87 | ) 88 | 89 | @cached_property 90 | def widget(self) -> "AdminAudioChooser": 91 | from wagtailmedia.widgets import AdminAudioChooser 92 | 93 | return AdminAudioChooser() 94 | 95 | def render_basic(self, value, context=None) -> str: 96 | if not value: 97 | return "" 98 | 99 | if value.type != self.media_type: 100 | return "" 101 | 102 | return format_audio_html(value) 103 | 104 | class Meta: 105 | icon = "wagtailmedia-audio" 106 | 107 | 108 | class VideoChooserBlock(AbstractMediaChooserBlock): 109 | def __init__(self, required=True, help_text=None, validators=(), **kwargs): 110 | super().__init__( 111 | required=required, 112 | help_text=help_text, 113 | validators=validators, 114 | media_type="video", 115 | **kwargs, 116 | ) 117 | 118 | @cached_property 119 | def widget(self) -> "AdminVideoChooser": 120 | from wagtailmedia.widgets import AdminVideoChooser 121 | 122 | return AdminVideoChooser() 123 | 124 | def render_basic(self, value, context=None) -> str: 125 | if not value: 126 | return "" 127 | 128 | if value.type != self.media_type: 129 | return "" 130 | 131 | return format_video_html(value) 132 | 133 | class Meta: 134 | icon = "wagtailmedia-video" 135 | -------------------------------------------------------------------------------- /src/wagtailmedia/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | The wagtailmedia settings are namespaced in the WAGTAILMEDIA setting. 3 | For example your project's `settings.py` file might look like this: 4 | WAGTAILMEDIA = { 5 | "MEDIA_MODEL": "mymedia.CustomMedia", 6 | # ... 7 | } 8 | This module provides the `wagtailmedia_settings` object, that is used to access 9 | the settings. It checks for user settings first, with fallback to defaults. 10 | """ 11 | 12 | import warnings 13 | 14 | from django.conf import settings 15 | from django.test.signals import setting_changed 16 | 17 | 18 | DEFAULTS = { 19 | "MEDIA_MODEL": "wagtailmedia.Media", 20 | "MEDIA_FORM_BASE": "", 21 | "AUDIO_EXTENSIONS": ["aac", "aiff", "flac", "m4a", "m4b", "mp3", "ogg", "wav"], 22 | "VIDEO_EXTENSIONS": [ 23 | "avi", 24 | "h264", 25 | "m4v", 26 | "mkv", 27 | "mov", 28 | "mp4", 29 | "mpeg", 30 | "mpg", 31 | "ogv", 32 | "webm", 33 | ], 34 | } 35 | 36 | # List of settings that have been deprecated 37 | DEPRECATED_SETTINGS = [] 38 | 39 | # List of settings that have been removed 40 | # note: use a tuple of (setting, deprecation warning from deprecation.py) 41 | REMOVED_SETTINGS = [] 42 | 43 | 44 | class WagtailMediaSettings: 45 | """ 46 | A settings object that allows the wagtailmedia settings to be accessed as 47 | properties. For example: 48 | from wagtailmedia.settings import wagtailmedia_settings 49 | print(wagtailmedia_settings.MEDIA_MODEL) 50 | Note: 51 | This is an internal class that is only compatible with settings namespaced 52 | under the WAGTAILMEDIA name. It is not intended to be used by 3rd-party 53 | apps, and test helpers like `override_settings` may not work as expected. 54 | """ 55 | 56 | def __init__(self, user_settings=None, defaults=None): 57 | if user_settings: 58 | self._user_settings = self.__check_user_settings(user_settings) 59 | self.defaults = defaults or DEFAULTS 60 | self._cached_attrs = set() 61 | 62 | @property 63 | def user_settings(self): 64 | if not hasattr(self, "_user_settings"): 65 | self._user_settings = self.__check_user_settings( 66 | getattr(settings, "WAGTAILMEDIA", {}) 67 | ) 68 | return self._user_settings 69 | 70 | def __getattr__(self, attr): 71 | if attr not in self.defaults: 72 | raise AttributeError(f"Invalid wagtailmedia setting: '{attr}'") 73 | 74 | try: 75 | # Check if present in user settings 76 | val = self.user_settings[attr] 77 | except KeyError: 78 | # Fall back to defaults 79 | val = self.defaults[attr] 80 | 81 | # Cache the result 82 | self._cached_attrs.add(attr) 83 | setattr(self, attr, val) 84 | return val 85 | 86 | def __check_user_settings(self, user_settings): 87 | for setting, category in DEPRECATED_SETTINGS: 88 | if setting in user_settings or hasattr(settings, setting): 89 | new_setting = setting.replace("WAGTAILMEDIA_", "") 90 | warnings.warn( 91 | f"The '{setting}' setting is deprecated and will be removed in the next release, " 92 | f'use WAGTAILMEDIA["{new_setting}"] instead.', 93 | category=category, 94 | stacklevel=2, 95 | ) 96 | user_settings[new_setting] = user_settings[setting] 97 | for setting in REMOVED_SETTINGS: 98 | if setting in user_settings: 99 | raise RuntimeError( 100 | f"The '{setting}' setting has been removed. " 101 | f"Please refer to the wagtailmedia documentation for available settings." 102 | ) 103 | return user_settings 104 | 105 | def reload(self): 106 | for attr in self._cached_attrs: 107 | delattr(self, attr) 108 | self._cached_attrs.clear() 109 | if hasattr(self, "_user_settings"): 110 | delattr(self, "_user_settings") 111 | 112 | 113 | wagtailmedia_settings = WagtailMediaSettings(None, DEFAULTS) 114 | 115 | 116 | def reload_wagtailmedia_settings(*args, **kwargs): 117 | setting = kwargs["setting"] 118 | if setting == "WAGTAILMEDIA": 119 | wagtailmedia_settings.reload() 120 | 121 | 122 | setting_changed.connect(reload_wagtailmedia_settings) 123 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.forms.utils import flatatt 3 | from django.utils.html import format_html, format_html_join 4 | from modelcluster.fields import ParentalKey 5 | from wagtail import blocks 6 | from wagtail.admin.panels import FieldPanel, InlinePanel 7 | from wagtail.fields import RichTextField, StreamField 8 | from wagtail.models import Orderable, Page 9 | 10 | from wagtailmedia.blocks import ( 11 | AbstractMediaChooserBlock, 12 | AudioChooserBlock, 13 | VideoChooserBlock, 14 | ) 15 | from wagtailmedia.edit_handlers import MediaChooserPanel 16 | from wagtailmedia.models import AbstractMedia, Media 17 | 18 | 19 | class CustomMedia(AbstractMedia): 20 | fancy_caption = RichTextField(blank=True) 21 | 22 | admin_form_fields = Media.admin_form_fields + ("fancy_caption",) 23 | 24 | 25 | class EventPageRelatedMedia(Orderable): 26 | page = ParentalKey( 27 | "wagtailmedia_tests.EventPage", 28 | related_name="related_media", 29 | on_delete=models.CASCADE, 30 | ) 31 | title = models.CharField(max_length=255, help_text="Link title") 32 | link_media = models.ForeignKey( 33 | "wagtailmedia.Media", 34 | null=True, 35 | blank=True, 36 | related_name="+", 37 | on_delete=models.CASCADE, 38 | ) 39 | 40 | @property 41 | def link(self): 42 | return self.link_media.url 43 | 44 | panels = [ 45 | FieldPanel("title"), 46 | MediaChooserPanel("link_media"), 47 | ] 48 | 49 | 50 | class EventPage(Page): 51 | date_from = models.DateField("Start date", null=True) 52 | date_to = models.DateField( 53 | "End date", 54 | null=True, 55 | blank=True, 56 | help_text="Not required if event is on a single day", 57 | ) 58 | time_from = models.TimeField("Start time", null=True, blank=True) 59 | time_to = models.TimeField("End time", null=True, blank=True) 60 | location = models.CharField(max_length=255) 61 | body = RichTextField(blank=True) 62 | cost = models.CharField(max_length=255) 63 | signup_link = models.URLField(blank=True) 64 | 65 | 66 | EventPage.content_panels = Page.content_panels + [ 67 | FieldPanel("date_from"), 68 | FieldPanel("date_to"), 69 | FieldPanel("time_from"), 70 | FieldPanel("time_to"), 71 | FieldPanel("location"), 72 | FieldPanel("cost"), 73 | FieldPanel("signup_link"), 74 | FieldPanel("body"), 75 | InlinePanel("related_media", heading="Related media", label="Media item"), 76 | ] 77 | 78 | 79 | class TestMediaBlock(AbstractMediaChooserBlock): 80 | def render_basic(self, value, context=None): 81 | if not value: 82 | return "" 83 | 84 | if value.type == "video": 85 | player_code = """ 86 |
    87 | 91 |
    92 | """ 93 | else: 94 | player_code = """ 95 |
    96 | 100 |
    101 | """ 102 | 103 | return format_html( 104 | player_code, 105 | format_html_join( 106 | "\n", "", [[flatatt(s)] for s in value.sources] 107 | ), 108 | ) 109 | 110 | 111 | class BlogStreamPage(Page): 112 | author = models.CharField(max_length=255) 113 | date = models.DateField("Post date") 114 | 115 | body = StreamField( 116 | [ 117 | ("heading", blocks.CharBlock(classname="title", icon="title")), 118 | ("paragraph", blocks.RichTextBlock(icon="pilcrow")), 119 | ("media", TestMediaBlock(icon="media")), 120 | ("video", VideoChooserBlock(icon="media")), 121 | ("audio", AudioChooserBlock(icon="media")), 122 | ], 123 | use_json_field=True, 124 | ) 125 | 126 | featured_media = models.ForeignKey( 127 | "wagtailmedia.Media", on_delete=models.PROTECT, related_name="+" 128 | ) 129 | 130 | content_panels = Page.content_panels + [ 131 | FieldPanel("author"), 132 | FieldPanel("date"), 133 | FieldPanel("body"), 134 | MediaChooserPanel("featured_media"), 135 | # the following are left here for local testing convenience 136 | # MediaChooserPanel("featured_media", media_type="audio"), 137 | # MediaChooserPanel("featured_media", media_type="video"), 138 | ] 139 | -------------------------------------------------------------------------------- /src/wagtailmedia/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path, reverse 2 | from django.utils.html import format_html 3 | from django.utils.translation import gettext_lazy as _ 4 | from django.utils.translation import ngettext 5 | from wagtail import hooks 6 | from wagtail.admin.admin_url_finder import ( 7 | ModelAdminURLFinder, 8 | register_admin_url_finder, 9 | ) 10 | from wagtail.admin.menu import MenuItem 11 | from wagtail.admin.navigation import get_site_for_user 12 | from wagtail.admin.search import SearchArea 13 | from wagtail.admin.site_summary import SummaryItem 14 | from wagtail.admin.staticfiles import versioned_static 15 | 16 | from wagtailmedia import admin_urls 17 | from wagtailmedia.forms import GroupMediaPermissionFormSet 18 | from wagtailmedia.models import get_media_model 19 | from wagtailmedia.permissions import permission_policy 20 | 21 | 22 | @hooks.register("register_admin_urls") 23 | def register_admin_urls(): 24 | return [ 25 | path("media/", include((admin_urls, "wagtailmedia"), namespace="wagtailmedia")), 26 | ] 27 | 28 | 29 | class MediaMenuItem(MenuItem): 30 | def is_shown(self, request): 31 | return permission_policy.user_has_any_permission( 32 | request.user, ["add", "change", "delete"] 33 | ) 34 | 35 | 36 | @hooks.register("register_admin_menu_item") 37 | def register_media_menu_item(): 38 | return MediaMenuItem( 39 | _("Media"), 40 | reverse("wagtailmedia:index"), 41 | name="media", 42 | icon_name="media", 43 | order=300, 44 | ) 45 | 46 | 47 | class MediaSummaryItem(SummaryItem): 48 | order = 300 49 | template_name = "wagtailmedia/homepage/site_summary_media.html" 50 | 51 | def get_context_data(self, parent_context): 52 | site_name = get_site_for_user(self.request.user)["site_name"] 53 | return { 54 | "total_media": permission_policy.instances_user_has_any_permission_for( 55 | self.request.user, {"add", "change", "delete", "choose"} 56 | ).count(), 57 | "site_name": site_name, 58 | } 59 | 60 | def is_shown(self): 61 | return permission_policy.user_has_any_permission( 62 | self.request.user, ["add", "change", "delete"] 63 | ) 64 | 65 | 66 | @hooks.register("construct_homepage_summary_items") 67 | def add_media_summary_item(request, items): 68 | items.append(MediaSummaryItem(request)) 69 | 70 | 71 | class MediaSearchArea(SearchArea): 72 | def is_shown(self, request): 73 | return permission_policy.user_has_any_permission( 74 | request.user, ["add", "change", "delete"] 75 | ) 76 | 77 | 78 | @hooks.register("register_admin_search_area") 79 | def register_media_search_area(): 80 | return MediaSearchArea( 81 | _("Media"), 82 | reverse("wagtailmedia:index"), 83 | name="media", 84 | icon_name="media", 85 | order=400, 86 | ) 87 | 88 | 89 | @hooks.register("register_group_permission_panel") 90 | def register_media_permissions_panel(): 91 | return GroupMediaPermissionFormSet 92 | 93 | 94 | @hooks.register("describe_collection_contents") 95 | def describe_collection_media(collection): 96 | media_count = get_media_model().objects.filter(collection=collection).count() 97 | if media_count: 98 | url = reverse("wagtailmedia:index") + f"?collection_id={collection.id}" 99 | return { 100 | "count": media_count, 101 | "count_text": ngettext( 102 | "%(count)s media file", "%(count)s media files", media_count 103 | ) 104 | % {"count": media_count}, 105 | "url": url, 106 | } 107 | 108 | 109 | class MediaAdminURLFinder(ModelAdminURLFinder): 110 | permission_policy = permission_policy 111 | edit_url_name = "wagtailmedia:edit" 112 | 113 | 114 | register_admin_url_finder(get_media_model(), MediaAdminURLFinder) 115 | 116 | 117 | @hooks.register("insert_global_admin_css") 118 | def add_media_css_tweaks(): 119 | return format_html( 120 | '', 121 | versioned_static("wagtailmedia/css/wagtailmedia.css"), 122 | ) 123 | 124 | 125 | @hooks.register("insert_global_admin_css") 126 | def add_media_comparison_css(): 127 | return format_html( 128 | '', 129 | versioned_static("wagtailmedia/css/wagtailmedia-comparison.css"), 130 | ) 131 | 132 | 133 | @hooks.register("register_icons") 134 | def register_icons(icons): 135 | return icons + [ 136 | "wagtailmedia/icons/wagtailmedia-audio.svg", # {% icon "wagtailmedia-audio" %} 137 | "wagtailmedia/icons/wagtailmedia-video.svg", # {% icon "wagtailmedia-video" %} 138 | ] 139 | -------------------------------------------------------------------------------- /tests/test_blocks.py: -------------------------------------------------------------------------------- 1 | from django.core.files.base import ContentFile 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from wagtailmedia.blocks import ( 6 | AbstractMediaChooserBlock, 7 | AudioChooserBlock, 8 | MediaChooserBlockComparison, 9 | VideoChooserBlock, 10 | ) 11 | from wagtailmedia.models import Media 12 | 13 | 14 | class BlockTests(TestCase): 15 | @classmethod 16 | def setUpTestData(cls): 17 | cls.audio = Media.objects.create( 18 | title="Test audio", 19 | duration=1000, 20 | file=ContentFile("Test", name="test.mp3"), 21 | type="audio", 22 | ) 23 | 24 | cls.video = Media.objects.create( 25 | title="Test video", 26 | duration=1024, 27 | file=ContentFile("Test", name="test.mp4"), 28 | type="video", 29 | ) 30 | 31 | def test_abstract_render_raises_not_implemented_error(self): 32 | block = AbstractMediaChooserBlock() 33 | with self.assertRaises(NotImplementedError): 34 | block.render(self.audio) 35 | 36 | def test_render_calls_render_basic(self): 37 | class TestMediaChooserBlock(AbstractMediaChooserBlock): 38 | def render_basic(self, value, context=None): 39 | return value.file.name 40 | 41 | block = TestMediaChooserBlock() 42 | self.assertRegex(block.render(self.audio), r"media/test(_\w{7})?.mp3") 43 | 44 | def test_media_block_get_form_state(self): 45 | block = AbstractMediaChooserBlock() 46 | form_state = block.get_form_state(self.audio.id) 47 | self.assertEqual(self.audio.id, form_state["id"]) 48 | self.assertEqual(self.audio.title, form_state["title"]) 49 | edit_link = reverse("wagtailmedia:edit", args=(self.audio.id,)) 50 | self.assertEqual(edit_link, form_state["edit_url"]) 51 | 52 | def test_abstract_media_block_queryset(self): 53 | block = AbstractMediaChooserBlock() 54 | 55 | self.assertQuerySetEqual( 56 | block.field.queryset.order_by("pk"), 57 | Media.objects.order_by("pk"), 58 | ) 59 | 60 | block = AbstractMediaChooserBlock(media_type="audio") 61 | self.assertQuerySetEqual( 62 | block.field.queryset.order_by("pk"), 63 | Media.objects.filter(type="audio").order_by("pk"), 64 | ) 65 | 66 | block = AbstractMediaChooserBlock(media_type="subspace-transmission") 67 | self.assertQuerySetEqual(block.field.queryset, Media.objects.none()) 68 | 69 | def test_audio_chooser_block_type(self): 70 | block = AudioChooserBlock() 71 | self.assertEqual(block.media_type, "audio") 72 | 73 | def test_audio_chooser_block_field_queryset(self): 74 | block = AudioChooserBlock() 75 | self.assertListEqual( 76 | list(block.field.queryset.values_list("pk", flat=True)), 77 | list(Media.objects.filter(type="audio").values_list("pk", flat=True)), 78 | ) 79 | 80 | def test_audio_chooser_block_rendering(self): 81 | block = AudioChooserBlock() 82 | self.assertEqual( 83 | block.render(self.audio), 84 | f'", 86 | ) 87 | 88 | # will return an empty value if trying to render with the wrong media type 89 | self.assertEqual(block.render(self.video), "") 90 | 91 | def test_video_chooser_block_type(self): 92 | block = VideoChooserBlock() 93 | self.assertEqual(block.media_type, "video") 94 | 95 | def test_video_chooser_block_field_queryset(self): 96 | block = VideoChooserBlock() 97 | self.assertListEqual( 98 | list(block.field.queryset.values_list("pk", flat=True)), 99 | list(Media.objects.filter(type="video").values_list("pk", flat=True)), 100 | ) 101 | 102 | def test_video_chooser_block_rendering(self): 103 | block = VideoChooserBlock() 104 | self.assertEqual( 105 | block.render(self.video), 106 | f'", 108 | ) 109 | 110 | # will return an empty value if trying to render with the wrong media type 111 | self.assertEqual(block.render(self.audio), "") 112 | 113 | def test_comparison_class(self): 114 | self.assertIs( 115 | AbstractMediaChooserBlock().get_comparison_class(), 116 | MediaChooserBlockComparison, 117 | ) 118 | self.assertIs( 119 | AudioChooserBlock().get_comparison_class(), MediaChooserBlockComparison 120 | ) 121 | self.assertIs( 122 | VideoChooserBlock().get_comparison_class(), MediaChooserBlockComparison 123 | ) 124 | -------------------------------------------------------------------------------- /src/wagtailmedia/templates/wagtailmedia/chooser/chooser.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | {% include "wagtailadmin/shared/header.html" with title=title icon=icon merged=1 %} 3 | 4 | {# TODO: drop data-wmtabs and the tabs.js include once we drop support for Wagtail < 7.1 #} 5 |
    8 | {% if uploadforms %} 9 | {# Both auth and video forms are powered by the same media form, so use one of them #} 10 | {% if uploadforms.video %} 11 | {{ uploadforms.video.media.js }} 12 | {{ uploadforms.video.media.css }} 13 | {% else %} 14 | {{ uploadforms.audio.media.js }} 15 | {{ uploadforms.audio.media.css }} 16 | {% endif %} 17 | 18 |
    19 | {# Using nice-padding and full width class until the modal header is restyled #} 20 |
    24 | {% trans "Search" as search_text %} 25 | {% include 'wagtailadmin/shared/tabs/tab_nav_link.html' with tab_id='search' title=search_text %} 26 | {% if uploadforms.audio %} 27 | {% trans "Upload Audio" as upload_audio_text %} 28 | {% if uploadforms.audio.errors and media_type == 'audio' %} 29 | {% include 'wagtailadmin/shared/tabs/tab_nav_link.html' with tab_id='upload-audio' title=upload_audio_text errors_count=uploadforms.audio.errors|length %} 30 | {% else %} 31 | {% include 'wagtailadmin/shared/tabs/tab_nav_link.html' with tab_id='upload-audio' title=upload_audio_text %} 32 | {% endif %} 33 | {% endif %} 34 | {% if uploadforms.video %} 35 | {% trans "Upload Video" as upload_video_text %} 36 | {% if uploadforms.video.errors and media_type == 'video' %} 37 | {% include 'wagtailadmin/shared/tabs/tab_nav_link.html' with tab_id='upload-video' title=upload_video_text errors_count=uploadforms.video.errors|length %} 38 | {% else %} 39 | {% include 'wagtailadmin/shared/tabs/tab_nav_link.html' with tab_id='upload-video' title=upload_video_text %} 40 | {% endif %} 41 | {% endif %} 42 |
    43 |
    44 | {% endif %} 45 | 46 |
    47 | 71 | {% if uploadforms %} 72 | {% for form_type, uploadform in uploadforms.items %} 73 | 100 | {% endfor %} 101 | {% endif %} 102 |
    103 |
    104 | -------------------------------------------------------------------------------- /src/wagtailmedia/models.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os.path 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.core.validators import FileExtensionValidator, MinValueValidator 7 | from django.db import models 8 | from django.dispatch import Signal 9 | from django.urls import reverse 10 | from django.utils.translation import gettext_lazy as _ 11 | from taggit.managers import TaggableManager 12 | from wagtail.models import CollectionMember, ReferenceIndex 13 | from wagtail.search import index 14 | from wagtail.search.queryset import SearchableQuerySetMixin 15 | 16 | from wagtailmedia.settings import wagtailmedia_settings 17 | 18 | 19 | ALLOWED_EXTENSIONS_THUMBNAIL = ["gif", "jpg", "jpeg", "png", "webp"] 20 | 21 | 22 | class MediaType(models.TextChoices): 23 | AUDIO = "audio", _("Audio file") 24 | VIDEO = "video", _("Video file") 25 | 26 | 27 | class MediaQuerySet(SearchableQuerySetMixin, models.QuerySet): 28 | pass 29 | 30 | 31 | class AbstractMedia(CollectionMember, index.Indexed, models.Model): 32 | title = models.CharField(max_length=255, verbose_name=_("title")) 33 | file = models.FileField(upload_to="media", verbose_name=_("file")) 34 | 35 | type = models.CharField( 36 | choices=MediaType.choices, max_length=255, blank=False, null=False 37 | ) 38 | duration = models.FloatField( 39 | blank=True, 40 | default=0, 41 | validators=[MinValueValidator(0)], 42 | verbose_name=_("duration"), 43 | help_text=_("Duration in seconds"), 44 | ) 45 | width = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("width")) 46 | height = models.PositiveIntegerField( 47 | null=True, blank=True, verbose_name=_("height") 48 | ) 49 | thumbnail = models.FileField( 50 | upload_to="media_thumbnails", blank=True, verbose_name=_("thumbnail") 51 | ) 52 | 53 | created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True) 54 | uploaded_by_user = models.ForeignKey( 55 | settings.AUTH_USER_MODEL, 56 | verbose_name=_("uploaded by user"), 57 | null=True, 58 | blank=True, 59 | editable=False, 60 | on_delete=models.SET_NULL, 61 | ) 62 | 63 | tags = TaggableManager(help_text=None, blank=True, verbose_name=_("tags")) 64 | 65 | objects = MediaQuerySet.as_manager() 66 | 67 | search_fields = CollectionMember.search_fields + [ 68 | index.SearchField("title", boost=10), 69 | index.AutocompleteField("title", boost=10), 70 | index.FilterField("title"), 71 | index.RelatedFields( 72 | "tags", 73 | [ 74 | index.SearchField("name", boost=10), 75 | index.AutocompleteField("name", boost=10), 76 | ], 77 | ), 78 | index.FilterField("uploaded_by_user"), 79 | index.FilterField("type"), 80 | ] 81 | 82 | admin_form_fields = ( 83 | "title", 84 | "file", 85 | "collection", 86 | "duration", 87 | "width", 88 | "height", 89 | "thumbnail", 90 | "tags", 91 | ) 92 | 93 | def __str__(self): 94 | return self.title 95 | 96 | @property 97 | def icon(self): 98 | return f"wagtailmedia-{self.type}" 99 | 100 | @property 101 | def filename(self): 102 | return os.path.basename(self.file.name) 103 | 104 | @property 105 | def thumbnail_filename(self) -> str: 106 | return os.path.basename(self.thumbnail.name) if self.thumbnail else "" 107 | 108 | @property 109 | def file_extension(self): 110 | return os.path.splitext(self.filename)[1][1:] 111 | 112 | @property 113 | def url(self): 114 | return self.file.url 115 | 116 | @property 117 | def sources(self): 118 | return [ 119 | { 120 | "src": self.url, 121 | "type": mimetypes.guess_type(self.filename)[0] 122 | or "application/octet-stream", 123 | } 124 | ] 125 | 126 | def get_usage(self): 127 | return ReferenceIndex.get_references_to(self).group_by_source_object() 128 | 129 | @property 130 | def usage_url(self): 131 | return reverse("wagtailmedia:media_usage", args=(self.id,)) 132 | 133 | def is_editable_by_user(self, user): 134 | from wagtailmedia.permissions import permission_policy 135 | 136 | return permission_policy.user_has_permission_for_instance(user, "change", self) 137 | 138 | def clean(self, *args, **kwargs): 139 | super().clean(*args, **kwargs) 140 | if not self.duration: 141 | self.duration = 0 142 | 143 | if self.thumbnail: 144 | validate = FileExtensionValidator(ALLOWED_EXTENSIONS_THUMBNAIL) 145 | validate(self.thumbnail) 146 | 147 | if self.type == "audio" and wagtailmedia_settings.AUDIO_EXTENSIONS: 148 | validate = FileExtensionValidator(wagtailmedia_settings.AUDIO_EXTENSIONS) 149 | validate(self.file) 150 | elif self.type == "video" and wagtailmedia_settings.VIDEO_EXTENSIONS: 151 | validate = FileExtensionValidator(wagtailmedia_settings.VIDEO_EXTENSIONS) 152 | validate(self.file) 153 | 154 | class Meta: 155 | abstract = True 156 | verbose_name = _("media") 157 | verbose_name_plural = _("media items") 158 | 159 | 160 | class Media(AbstractMedia): 161 | pass 162 | 163 | 164 | def get_media_model(): 165 | from django.apps import apps 166 | 167 | from wagtailmedia.settings import wagtailmedia_settings 168 | 169 | try: 170 | app_label, model_name = wagtailmedia_settings.MEDIA_MODEL.split(".") 171 | except AttributeError: 172 | return Media 173 | except ValueError as err: 174 | raise ImproperlyConfigured( 175 | "WAGTAILMEDIA[\"MEDIA_MODEL\"] must be of the form 'app_label.model_name'" 176 | ) from err 177 | 178 | media_model = apps.get_model(app_label, model_name) 179 | if media_model is None: 180 | raise ImproperlyConfigured( 181 | f"WAGTAILMEDIA[\"MEDIA_MODEL\"] refers to model '{wagtailmedia_settings.MEDIA_MODEL}' that has not been installed" 182 | ) 183 | 184 | return media_model 185 | 186 | 187 | # Provides `request` as an argument 188 | media_served = Signal() 189 | -------------------------------------------------------------------------------- /tests/test_compare.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.utils.safestring import SafeString 3 | from testapp.models import BlogStreamPage 4 | 5 | from wagtailmedia.blocks import ( 6 | AudioChooserBlock, 7 | MediaChooserBlockComparison, 8 | VideoChooserBlock, 9 | ) 10 | from wagtailmedia.edit_handlers import MediaFieldComparison 11 | from wagtailmedia.models import get_media_model 12 | from wagtailmedia.utils import format_audio_html, format_video_html 13 | 14 | from .utils import create_audio, create_video 15 | 16 | 17 | Media = get_media_model() 18 | 19 | 20 | class MediaBlockComparisonTestCase(TestCase): 21 | @classmethod 22 | def setUpTestData(cls): 23 | cls.audio_a = create_audio("Test audio 1", duration=1000) 24 | cls.audio_b = create_audio("Test audio 2", duration=100) 25 | cls.video_a = create_video("Test video 1", duration=1024) 26 | cls.video_b = create_video("Test video 2", duration=1024) 27 | 28 | def tearDown(self): 29 | for m in [self.audio_a, self.audio_b, self.video_a, self.video_b]: 30 | m.delete() 31 | 32 | 33 | class MediaFieldComparisonTest(MediaBlockComparisonTestCase): 34 | comparison_class = MediaFieldComparison 35 | 36 | def test_hasnt_changed(self): 37 | comparison = self.comparison_class( 38 | BlogStreamPage._meta.get_field("featured_media"), 39 | BlogStreamPage(featured_media=self.audio_a), 40 | BlogStreamPage(featured_media=self.audio_a), 41 | ) 42 | 43 | self.assertTrue(comparison.is_field) 44 | self.assertFalse(comparison.is_child_relation) 45 | self.assertEqual(comparison.field_label(), "Featured media") 46 | self.assertFalse(comparison.has_changed()) 47 | 48 | diff = comparison.htmldiff() 49 | self.assertHTMLEqual( 50 | diff, 51 | f'
    {format_audio_html(self.audio_a)}
    ', 52 | ) 53 | self.assertIsInstance(diff, SafeString) 54 | 55 | comparison = self.comparison_class( 56 | BlogStreamPage._meta.get_field("featured_media"), 57 | BlogStreamPage(featured_media=self.video_a), 58 | BlogStreamPage(featured_media=self.video_a), 59 | ) 60 | self.assertFalse(comparison.has_changed()) 61 | 62 | self.assertHTMLEqual( 63 | comparison.htmldiff(), 64 | f'
    {format_video_html(self.video_a)}
    ', 65 | ) 66 | 67 | def test_has_changed(self): 68 | comparison = self.comparison_class( 69 | BlogStreamPage._meta.get_field("featured_media"), 70 | BlogStreamPage(featured_media=self.audio_a), 71 | BlogStreamPage(featured_media=self.audio_b), 72 | ) 73 | 74 | self.assertTrue(comparison.has_changed()) 75 | diff = comparison.htmldiff() 76 | self.assertHTMLEqual( 77 | diff, 78 | f'
    {format_audio_html(self.audio_a)}
    ' 79 | f'
    {format_audio_html(self.audio_b)}
    ', 80 | ) 81 | self.assertIsInstance(diff, SafeString) 82 | 83 | comparison = self.comparison_class( 84 | BlogStreamPage._meta.get_field("featured_media"), 85 | BlogStreamPage(featured_media=self.audio_a), 86 | BlogStreamPage(featured_media=self.video_a), 87 | ) 88 | self.assertTrue(comparison.has_changed()) 89 | self.assertHTMLEqual( 90 | comparison.htmldiff(), 91 | f'
    {format_audio_html(self.audio_a)}
    ' 92 | f'
    {format_video_html(self.video_a)}
    ', 93 | ) 94 | 95 | comparison = self.comparison_class( 96 | BlogStreamPage._meta.get_field("featured_media"), 97 | BlogStreamPage(featured_media=self.video_a), 98 | BlogStreamPage(featured_media=self.video_b), 99 | ) 100 | self.assertTrue(comparison.has_changed()) 101 | self.assertHTMLEqual( 102 | comparison.htmldiff(), 103 | f'
    {format_video_html(self.video_a)}
    ' 104 | f'
    {format_video_html(self.video_b)}
    ', 105 | ) 106 | 107 | def test_empty_compare(self): 108 | comparison = self.comparison_class( 109 | BlogStreamPage._meta.get_field("featured_media"), 110 | BlogStreamPage(featured_media=None), 111 | BlogStreamPage(featured_media=None), 112 | ) 113 | 114 | self.assertTrue(comparison.is_field) 115 | self.assertFalse(comparison.is_child_relation) 116 | self.assertEqual(comparison.field_label(), "Featured media") 117 | self.assertFalse(comparison.has_changed()) 118 | 119 | self.assertHTMLEqual(comparison.htmldiff(), "") 120 | 121 | def test_our_class_in_comparison_class_registry(self): 122 | from wagtail.admin.compare import comparison_class_registry 123 | 124 | self.assertIn(Media, comparison_class_registry.values_by_fk_related_model) 125 | self.assertIs( 126 | comparison_class_registry.values_by_fk_related_model[Media], 127 | MediaFieldComparison, 128 | ) 129 | 130 | 131 | class MediaBlockComparisonTest(MediaBlockComparisonTestCase): 132 | comparison_class = MediaChooserBlockComparison 133 | 134 | def test_comparison_hasnt_changed(self): 135 | comparison = self.comparison_class( 136 | AudioChooserBlock(), True, True, self.audio_a, self.audio_a 137 | ) 138 | self.assertFalse(comparison.has_changed()) 139 | diff = comparison.htmldiff() 140 | self.assertHTMLEqual( 141 | diff, 142 | f'
    {format_audio_html(self.audio_a)}
    ', 143 | ) 144 | self.assertIsInstance(diff, SafeString) 145 | 146 | comparison = self.comparison_class( 147 | VideoChooserBlock(), True, True, self.video_a, self.video_a 148 | ) 149 | self.assertFalse(comparison.has_changed()) 150 | self.assertHTMLEqual( 151 | comparison.htmldiff(), 152 | f'
    {format_video_html(self.video_a)}
    ', 153 | ) 154 | 155 | def test_comparison_has_changed(self): 156 | comparison = self.comparison_class( 157 | AudioChooserBlock(), True, True, self.audio_a, self.audio_b 158 | ) 159 | self.assertTrue(comparison.has_changed()) 160 | diff = comparison.htmldiff() 161 | self.assertHTMLEqual( 162 | diff, 163 | f'
    {format_audio_html(self.audio_a)}
    ' 164 | f'
    {format_audio_html(self.audio_b)}
    ', 165 | ) 166 | 167 | comparison = self.comparison_class( 168 | VideoChooserBlock(), True, True, self.video_a, self.video_b 169 | ) 170 | self.assertTrue(comparison.has_changed()) 171 | self.assertHTMLEqual( 172 | comparison.htmldiff(), 173 | f'
    {format_video_html(self.video_a)}
    ' 174 | f'
    {format_video_html(self.video_b)}
    ', 175 | ) 176 | -------------------------------------------------------------------------------- /src/wagtailmedia/static/wagtailmedia/js/media-chooser-modal.js: -------------------------------------------------------------------------------- 1 | MEDIA_CHOOSER_MODAL_ONLOAD_HANDLERS = { 2 | 'chooser': function(modal, jsonData) { 3 | const searchUrl = $('form.media-search', modal.body).attr('action'); 4 | const searchInput = $('#id_q', modal.body); 5 | const resultsContainer = $('#search-results', modal.body); 6 | const collectionChooser = $('#collection_chooser_collection_id', modal.body); 7 | /* save initial page browser HTML, so that we can restore it if the search box gets cleared */ 8 | const initialPageResultsHtml = resultsContainer.html(); 9 | 10 | /* currentTag stores the tag currently being filtered on, so that we can 11 | preserve this when paginating */ 12 | let currentTag; 13 | 14 | let request; 15 | 16 | function ajaxifyLinks (context) { 17 | $('a.media-choice', context).on('click', function(e) { 18 | modal.loadUrl(this.href); 19 | e.preventDefault(); 20 | }); 21 | 22 | $('.pagination a', context).on('click', function(e) { 23 | let params = { 24 | collection_id: collectionChooser.val() 25 | }; 26 | 27 | if (this.hasAttribute("data-page")) { 28 | params['p'] = this.getAttribute("data-page"); 29 | } 30 | else if (this.parentElement.classList.contains("prev") || this.parentElement.classList.contains("next")) { 31 | const href = new URL(this.href); 32 | params = Object.fromEntries(href.searchParams.entries()); 33 | } 34 | 35 | const query = searchInput.val(); 36 | if (query.length) { 37 | params['q'] = query; 38 | } 39 | if (currentTag) { 40 | params['tag'] = currentTag; 41 | } 42 | 43 | request = fetchResults(params); 44 | e.preventDefault(); 45 | }); 46 | 47 | $('a[data-ordering]', context).on('click', function(e) { 48 | request = fetchResults({ 49 | q: searchInput.val(), 50 | collection_id: collectionChooser.val(), 51 | ordering: this.dataset["ordering"] 52 | }); 53 | e.preventDefault(); 54 | }); 55 | } 56 | 57 | function fetchResults(requestData) { 58 | return $.ajax({ 59 | url: searchUrl, 60 | data: requestData, 61 | success(data) { 62 | request = null; 63 | resultsContainer.html(data); 64 | ajaxifyLinks(resultsContainer); 65 | }, 66 | error() { 67 | request = null; 68 | }, 69 | }); 70 | } 71 | 72 | function search() { 73 | const query = searchInput.val(); 74 | const collection_id = collectionChooser.val() 75 | if (query !== '' || collection_id !== '') { 76 | /* Searching causes currentTag to be cleared - otherwise there's 77 | no way to de-select a tag */ 78 | currentTag = null; 79 | request = fetchResults({ 80 | q: query, 81 | collection_id: collection_id 82 | }); 83 | } 84 | else { 85 | /* search box is empty - restore original page browser HTML */ 86 | resultsContainer.html(initialPageResultsHtml); 87 | ajaxifyLinks(); 88 | } 89 | return false; 90 | } 91 | 92 | ajaxifyLinks(modal.body); 93 | if (typeof initWMTabs !== "undefined") { 94 | initWMTabs(); 95 | } 96 | 97 | $('form.media-upload', modal.body).on('submit', function() { 98 | var formdata = new FormData(this); 99 | 100 | // Get the title field of the submitted form, not the first in the modal. 101 | const input = this.querySelector('#id_media-chooser-upload-title'); 102 | if (!input.value) { 103 | if (!input.hasAttribute('aria-invalid')) { 104 | input.setAttribute('aria-invalid', 'true'); 105 | const field = input.closest('[data-field]'); 106 | field.classList.add('w-field--error'); 107 | const errors = field.querySelector('[data-field-errors]'); 108 | const icon = errors.querySelector('.icon'); 109 | if (icon) { 110 | icon.removeAttribute('hidden'); 111 | } 112 | const errorElement = document.createElement('p'); 113 | errorElement.classList.add('error-message'); 114 | // Global function provided by Wagtail. 115 | errorElement.innerHTML = gettext('This field is required.'); 116 | errors.appendChild(errorElement); 117 | } 118 | setTimeout(cancelSpinner, 500); 119 | } else { 120 | $.ajax({ 121 | url: this.action, 122 | data: formdata, 123 | processData: false, 124 | contentType: false, 125 | type: 'POST', 126 | dataType: 'text', 127 | success: modal.loadResponseText, 128 | error: function(response, textStatus, errorThrown) { 129 | message = jsonData['error_message'] + '
    ' + errorThrown + ' - ' + response.status; 130 | $('#upload').append( 131 | '
    ' + 132 | '' + jsonData['error_label'] + ': ' + message + '
    '); 133 | } 134 | }); 135 | } 136 | 137 | return false; 138 | }); 139 | 140 | $('form.media-search', modal.body).on('submit', search); 141 | 142 | searchInput.on('input', function() { 143 | if (request) { 144 | request.abort(); 145 | } 146 | clearTimeout($.data(this, 'timer')); 147 | var wait = setTimeout(search, 200); 148 | $(this).data('timer', wait); 149 | }); 150 | collectionChooser.on('change', search); 151 | $('a.suggested-tag').on('click', function() { 152 | currentTag = $(this).text(); 153 | searchInput.val(''); 154 | request = fetchResults({ 155 | 'tag': currentTag, 156 | collection_id: collectionChooser.val() 157 | }); 158 | return false; 159 | }); 160 | 161 | // Note: There are two inputs with `#id_title` on the page. 162 | // The page title and media title. Select the input inside the modal body. 163 | $('[name="media-chooser-upload-file"]', modal.body).each(function() { 164 | const fileWidget = $(this); 165 | fileWidget.on('change', function () { 166 | let titleWidget = $('#id_media-chooser-upload-title', fileWidget.closest('form')); 167 | if (titleWidget.val() === '') { 168 | // The file widget value example: `C:\fakepath\media.jpg` 169 | const parts = fileWidget.val().split('\\'); 170 | const filename = parts[parts.length - 1]; 171 | titleWidget.val(filename.replace(/\.[^.]+$/, '')); 172 | } 173 | }); 174 | }); 175 | 176 | /* Add tag entry interface (with autocompletion) to the tag field of the media upload form */ 177 | $('[name="media-chooser-upload-tags"]', modal.body).each(function() { 178 | $(this).tagit({ 179 | autocomplete: {source: jsonData['tag_autocomplete_url']} 180 | }); 181 | }); 182 | }, 183 | 'media_chosen': function(modal, jsonData) { 184 | modal.respond('mediaChosen', jsonData['result']); 185 | modal.close(); 186 | }, 187 | 'select_format': function(modal) { 188 | $('form', modal.body).on('submit', function() { 189 | var formdata = new FormData(this); 190 | 191 | $.post(this.action, $(this).serialize(), modal.loadResponseText, 'text'); 192 | 193 | return false; 194 | }); 195 | } 196 | }; 197 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import Group, Permission 3 | from django.core.files.base import ContentFile 4 | from django.test import TestCase 5 | from django.urls import reverse 6 | from wagtail.models import Collection, GroupCollectionPermission 7 | from wagtail.test.utils import WagtailTestUtils 8 | 9 | from wagtailmedia import models 10 | 11 | 12 | class TestMediaPermissions(TestCase): 13 | @classmethod 14 | def setUpTestData(cls): 15 | # Create some user accounts for testing permissions 16 | User = get_user_model() 17 | cls.user = User.objects.create_user( 18 | username="user", email="user@email.com", password="password" 19 | ) 20 | cls.owner = User.objects.create_user( 21 | username="owner", email="owner@email.com", password="password" 22 | ) 23 | cls.editor = User.objects.create_user( 24 | username="editor", email="editor@email.com", password="password" 25 | ) 26 | cls.editor.groups.add(Group.objects.get(name="Editors")) 27 | cls.administrator = User.objects.create_superuser( 28 | username="administrator", 29 | email="administrator@email.com", 30 | password="password", 31 | ) 32 | 33 | # Owner user must have the add_media permission 34 | cls.adders_group = Group.objects.create(name="Media adders") 35 | GroupCollectionPermission.objects.create( 36 | group=cls.adders_group, 37 | collection=Collection.get_first_root_node(), 38 | permission=Permission.objects.get(codename="add_media"), 39 | ) 40 | cls.owner.groups.add(cls.adders_group) 41 | 42 | # Create a media for running tests on 43 | cls.media = models.Media.objects.create( 44 | title="Test media", duration=100, uploaded_by_user=cls.owner 45 | ) 46 | 47 | def test_administrator_can_edit(self): 48 | self.assertTrue(self.media.is_editable_by_user(self.administrator)) 49 | 50 | def test_editor_can_edit(self): 51 | self.assertTrue(self.media.is_editable_by_user(self.editor)) 52 | 53 | def test_owner_can_edit(self): 54 | self.assertTrue(self.media.is_editable_by_user(self.owner)) 55 | 56 | def test_user_cant_edit(self): 57 | self.assertFalse(self.media.is_editable_by_user(self.user)) 58 | 59 | 60 | class TestEditOnlyPermissions(TestCase, WagtailTestUtils): 61 | @classmethod 62 | def setUpTestData(cls): 63 | cls.root_collection = Collection.get_first_root_node() 64 | cls.evil_plans_collection = cls.root_collection.add_child(name="Evil plans") 65 | cls.nice_plans_collection = cls.root_collection.add_child(name="Nice plans") 66 | 67 | # Create a media to edit 68 | cls.media = models.Media.objects.create( 69 | title="Test media", 70 | file=ContentFile("A boring example song", name="song.mp3"), 71 | collection=cls.nice_plans_collection, 72 | duration=100, 73 | ) 74 | 75 | # Create a user with change_media permission but not add_media 76 | cls.user = get_user_model().objects.create_user( 77 | username="changeonly", email="changeonly@example.com", password="password" 78 | ) 79 | change_permission = Permission.objects.get( 80 | content_type__app_label="wagtailmedia", codename="change_media" 81 | ) 82 | admin_permission = Permission.objects.get( 83 | content_type__app_label="wagtailadmin", codename="access_admin" 84 | ) 85 | cls.changers_group = Group.objects.create(name="Media changers") 86 | GroupCollectionPermission.objects.create( 87 | group=cls.changers_group, 88 | collection=cls.root_collection, 89 | permission=change_permission, 90 | ) 91 | cls.user.groups.add(cls.changers_group) 92 | 93 | cls.user.user_permissions.add(admin_permission) 94 | 95 | cls.collection_label_tag = '