├── tests ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── models.py ├── templates │ └── tests │ │ └── simple_page.html ├── urls.py ├── fixtures │ └── test.json ├── test_models.py ├── settings.py ├── test_frontend.py └── test_admin.py ├── wagtail_review ├── __init__.py ├── views │ ├── __init__.py │ ├── frontend.py │ ├── annotations_api.py │ └── admin.py ├── migrations │ ├── __init__.py │ ├── 0003_response.py │ ├── 0002_annotation_annotationrange.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ ├── wagtailreview_admin_tags.py │ └── wagtailreview_tags.py ├── templates │ └── wagtail_review │ │ ├── email │ │ ├── request_review_subject.txt │ │ ├── response_received_subject.txt │ │ ├── response_received.txt │ │ └── request_review.txt │ │ ├── submit_for_review_menu_item.html │ │ ├── response_form_fields.html │ │ ├── admin │ │ ├── dashboard.html │ │ └── audit_trail.html │ │ ├── create_review.html │ │ └── annotate.html ├── apps.py ├── static │ └── wagtail_review │ │ ├── css │ │ ├── audit_trail.css │ │ ├── create_review.css │ │ ├── respond.css │ │ └── annotator.css │ │ └── js │ │ ├── annotator-extensions.js │ │ └── submit.js ├── text.py ├── urls.py ├── admin_urls.py ├── forms.py ├── wagtail_hooks.py └── models.py ├── .gitignore ├── MANIFEST.in ├── runtests.py ├── .github ├── report_nightly_build_failure.py └── workflows │ ├── nightly-tests.yml │ └── test.yml ├── LICENSE ├── setup.py ├── CHANGELOG.txt └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_review/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_review/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_review/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wagtail_review/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /wagtail_review.egg-info 2 | __pycache__ 3 | /dist 4 | /build 5 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from wagtail.models import Page 2 | 3 | 4 | class SimplePage(Page): 5 | pass 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.rst *.txt *.md 2 | recursive-include wagtail_review * 3 | global-exclude __pycache__ 4 | global-exclude *.py[co] 5 | global-exclude .DS_Store 6 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/email/request_review_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% blocktrans with page_title=page.title|safe %}Review request: {{ page_title }}{% endblocktrans %} 4 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/email/response_received_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% blocktrans with reviewer=reviewer.get_name|safe page_title=page.title|safe %}Response received from {{ reviewer }} on: {{ page_title }}{% endblocktrans %} 4 | -------------------------------------------------------------------------------- /wagtail_review/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WagtailReviewAppConfig(AppConfig): 5 | label = "wagtail_review" 6 | name = "wagtail_review" 7 | verbose_name = "wagtail-review" 8 | default_auto_field = "django.db.models.AutoField" 9 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | 6 | from django.core.management import execute_from_command_line 7 | 8 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 9 | execute_from_command_line([sys.argv[0], 'test'] + sys.argv[1:]) 10 | -------------------------------------------------------------------------------- /tests/templates/tests/simple_page.html: -------------------------------------------------------------------------------- 1 | {% load wagtailreview_tags %} 2 | 3 | 4 | {{ page.title }} 5 | 6 | 7 |

{{ page.title }}

8 | {% wagtailreview %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/submit_for_review_menu_item.html: -------------------------------------------------------------------------------- 1 | {% load wagtailadmin_tags %} 2 | 3 | -------------------------------------------------------------------------------- /wagtail_review/static/wagtail_review/css/audit_trail.css: -------------------------------------------------------------------------------- 1 | .review { 2 | clear: both; 3 | } 4 | 5 | ul.actions { 6 | list-style-type: none; 7 | margin-top: -.5em; 8 | margin-bottom: 7em; 9 | padding: 0; 10 | } 11 | 12 | ul.actions li { 13 | float: left; 14 | padding: 0 0.5em 0 0; 15 | margin: 0 0 0.5em; 16 | } 17 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.conf.urls import include 4 | from django.urls import path 5 | 6 | from wagtail.admin import urls as wagtailadmin_urls 7 | from wagtail import urls as wagtail_urls 8 | 9 | from wagtail_review import urls as wagtailreview_urls 10 | 11 | 12 | urlpatterns = [ 13 | path(r'admin/', include(wagtailadmin_urls)), 14 | path(r'review/', include(wagtailreview_urls)), 15 | 16 | # For anything not caught by a more specific rule above, hand over to 17 | # Wagtail's serving mechanism 18 | path('', include(wagtail_urls)), 19 | ] 20 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/response_form_fields.html: -------------------------------------------------------------------------------- 1 |
2 | Your review 3 | 4 |
5 | {% for radio in response_form.result %} 6 | {{ radio }} 7 | {% endfor %} 8 |
9 | 10 |
11 | 12 | {{ response_form.comment }} 13 |
14 | 15 |
16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /wagtail_review/templatetags/wagtailreview_admin_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.contrib.contenttypes.models import ContentType 3 | from wagtail.models import Page 4 | 5 | import swapper 6 | 7 | from wagtail_review.text import user_display_name 8 | 9 | Review = swapper.load_model('wagtail_review', 'Review') 10 | 11 | register = template.Library() 12 | 13 | 14 | @register.simple_tag 15 | def page_has_open_review(page): 16 | return bool(Review.objects.filter( 17 | page_revision__object_id=str(page.pk), page_revision__base_content_type=ContentType.objects.get_for_model(Page), status='open' 18 | )) 19 | 20 | 21 | register.filter(user_display_name) 22 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/email/response_received.txt: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailreview_admin_tags %} 2 | 3 | {% blocktrans with full_name=submitter|user_display_name|safe %}Dear {{ full_name }},{% endblocktrans %} 4 | {% if response.result == 'approve' %} 5 | {% blocktrans with reviewer=reviewer.get_name|safe page_title=page.title|safe %}{{ reviewer }} has approved the page "{{ page_title }}".{% endblocktrans %} 6 | {% else %} 7 | {% blocktrans with reviewer=reviewer.get_name|safe page_title=page.title|safe %}{{ reviewer }} has commented on the page "{{ page_title }}".{% endblocktrans %} 8 | {% endif %} 9 | {% if response.comment %}{% trans "Comment:" %} 10 | {{ response.comment|safe }} 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /wagtail_review/text.py: -------------------------------------------------------------------------------- 1 | def user_display_name(user): 2 | """ 3 | Returns the preferred display name for the given user object: the result of 4 | user.get_full_name() if implemented and non-empty, or user.get_username() otherwise. 5 | """ 6 | try: 7 | full_name = user.get_full_name().strip() 8 | if full_name: 9 | return full_name 10 | except AttributeError: 11 | pass 12 | 13 | try: 14 | return user.get_username() 15 | except AttributeError: 16 | # we were passed None or something else that isn't a valid user object; return 17 | # empty string to replicate the behaviour of {{ user.get_full_name|default:user.get_username }} 18 | return '' 19 | -------------------------------------------------------------------------------- /wagtail_review/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.urls import path 4 | from wagtail_review.views import frontend, annotations_api 5 | 6 | app_name = 'wagtail_review' 7 | 8 | urlpatterns = [ 9 | path('view///', frontend.view, name='view'), 10 | path('respond///', frontend.respond, name='respond'), 11 | path('api/', annotations_api.root, name='annotations_api_root'), 12 | path('api/search/', annotations_api.search, name='annotations_api_search'), 13 | path('api/annotations/', annotations_api.index, name='annotations_api_index'), 14 | path('api/annotations//', annotations_api.item, name='annotations_api_item'), 15 | ] 16 | -------------------------------------------------------------------------------- /wagtail_review/static/wagtail_review/css/create_review.css: -------------------------------------------------------------------------------- 1 | input.reviewer-autocomplete { 2 | margin-right: 8px; 3 | vertical-align: middle; 4 | width: 90%; 5 | } 6 | 7 | .wagtailreview-reviewer-list { 8 | display: flex; 9 | flex-direction: row; 10 | flex-wrap: wrap; 11 | } 12 | 13 | .wagtailreview-reviewer-list li { 14 | margin: 6px 0; 15 | } 16 | 17 | .wagtailreview-reviewer-list li span { 18 | cursor: default; 19 | font-size: 14px; 20 | font-weight: 600; 21 | } 22 | 23 | .wagtailreview-reviewer-list li:not(:last-child) { 24 | margin-right: 16px; 25 | } 26 | 27 | .wagtailreview-reviewer-list .icon { 28 | color: #d9d9d9; 29 | font-size: 16px; 30 | } 31 | 32 | .wagtailreview-reviewer-list .icon:hover, 33 | .wagtailreview-reviewer-list .icon:focus { 34 | color: #cd3238; 35 | } 36 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2018-09-26 19:01 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('wagtailcore', '0040_page_draft_title'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='SimplePage', 20 | fields=[ 21 | ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | bases=('wagtailcore.page',), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/email/request_review.txt: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailreview_admin_tags %} 2 | 3 | {% if user %}{% blocktrans with full_name=user|user_display_name|safe %}Dear {{ full_name }},{% endblocktrans %}{% endif %} 4 | {% blocktrans with full_name=submitter|user_display_name|safe page_title=page.title|safe %} 5 | {{ full_name }} has requested a review from you on the page "{{ page_title }}". To review the page and provide your feedback, please visit this link:{% endblocktrans %} 6 | 7 | {{ respond_url|safe }} 8 | 9 | {% blocktrans %}Please do not share this link - it is personal to you, and anyone else with the link will be able to respond to the review under your name. If you wish to share the page with colleagues, please use the following link, which will allow them to view the page and any existing review comments, but not respond to the review themselves:{% endblocktrans %} 10 | 11 | {{ view_url|safe }} 12 | -------------------------------------------------------------------------------- /.github/report_nightly_build_failure.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Called by GitHub Action when the nightly build fails. 4 | This reports an error to the #nightly-build-failures Slack channel. 5 | """ 6 | import os 7 | import requests 8 | 9 | if "SLACK_WEBHOOK_URL" in os.environ: 10 | # https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables 11 | repository = os.environ["GITHUB_REPOSITORY"] 12 | run_id = os.environ["GITHUB_RUN_ID"] 13 | url = f"https://github.com/{repository}/actions/runs/{run_id}" 14 | 15 | print("Reporting to #nightly-build-failures slack channel") 16 | response = requests.post( 17 | os.environ["SLACK_WEBHOOK_URL"], 18 | json={ 19 | "text": f"A Nightly build failed. See {url}", 20 | }, 21 | ) 22 | 23 | print(f"Slack responded with: {response}") 24 | 25 | else: 26 | print( 27 | "Unable to report to #nightly-build-failures slack channel because SLACK_WEBHOOK_URL is not set" 28 | ) 29 | -------------------------------------------------------------------------------- /wagtail_review/migrations/0003_response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2018-10-02 14:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('wagtail_review', '0002_annotation_annotationrange'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Response', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('result', models.CharField(choices=[('approve', 'Approved'), ('comment', 'Comment')], default=None, max_length=10)), 21 | ('comment', models.TextField()), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='wagtail_review.Reviewer')), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /wagtail_review/admin_urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from django.urls import path 4 | from django.views.generic import RedirectView 5 | from wagtail_review.views import admin as admin_views 6 | 7 | app_name = 'wagtail_review' 8 | 9 | urlpatterns = [ 10 | path('', RedirectView.as_view(pattern_name='wagtail_review_admin:dashboard')), 11 | path('create_review/', admin_views.create_review, name='create_review'), 12 | path('autocomplete_users/', admin_views.autocomplete_users, name='autocomplete_users'), 13 | path('reviews/', admin_views.DashboardView.as_view(), name='dashboard'), 14 | path('reviews//', admin_views.AuditTrailView.as_view(), name='audit_trail'), 15 | path('reviews//view/', admin_views.view_review_page, name='view_review_page'), 16 | path('reviews//close/', admin_views.close_review, name='close_review'), 17 | path('reviews//close_and_publish/', admin_views.close_and_publish, name='close_and_publish'), 18 | path('reviews//reopen/', admin_views.reopen_review, name='reopen_review'), 19 | ] 20 | -------------------------------------------------------------------------------- /wagtail_review/templatetags/wagtailreview_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from wagtail_review.forms import ResponseForm 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.inclusion_tag('wagtail_review/annotate.html', takes_context=True) 9 | def wagtailreview(context): 10 | request = context['request'] 11 | review_mode = getattr(request, 'wagtailreview_mode', None) 12 | reviewer = getattr(request, 'wagtailreview_reviewer', None) 13 | 14 | if review_mode == 'respond' or review_mode == 'comment': 15 | return { 16 | 'mode': review_mode, 17 | 'allow_annotations': (reviewer.review.status != 'closed'), 18 | 'show_closed': (reviewer.review.status == 'closed'), 19 | 'allow_responses': (review_mode == 'respond' and reviewer.review.status != 'closed'), 20 | 'reviewer': reviewer, 21 | 'token': reviewer.response_token, 22 | 'response_form': ResponseForm() 23 | } 24 | elif review_mode == 'view': 25 | return { 26 | 'mode': review_mode, 27 | 'show_closed': False, 28 | 'allow_annotations': False, 29 | 'allow_responses': False, 30 | 'reviewer': reviewer, 31 | 'token': reviewer.view_token 32 | } 33 | 34 | else: 35 | return {'mode': None} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Torchbox 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name='wagtail-review', 7 | version='0.5', 8 | description="Review workflow for Wagtail", 9 | author='Matthew Westcott', 10 | author_email='matthew.westcott@torchbox.com', 11 | url='https://github.com/wagtail-nest/wagtail-review', 12 | packages=find_packages(), 13 | include_package_data=True, 14 | install_requires=[ 15 | 'swapper>=1.1,<1.2', 16 | ], 17 | python_requires=">=3.8", 18 | license='BSD', 19 | long_description="An extension for Wagtail allowing pages to be submitted for review (including to non-Wagtail users) prior to publication", 20 | classifiers=[ 21 | 'Development Status :: 3 - Alpha', 22 | 'Environment :: Web Environment', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Programming Language :: Python :: 3.9', 30 | 'Programming Language :: Python :: 3.10', 31 | 'Programming Language :: Python :: 3.11', 32 | 'Programming Language :: Python :: 3.12', 33 | 'Framework :: Django', 34 | 'Framework :: Django :: 4.2', 35 | 'Framework :: Django :: 5.0', 36 | 'Framework :: Django :: 5.1', 37 | 'Framework :: Wagtail', 38 | 'Framework :: Wagtail :: 4', 39 | 'Framework :: Wagtail :: 5', 40 | 'Framework :: Wagtail :: 6', 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /wagtail_review/migrations/0002_annotation_annotationrange.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2018-10-02 09:37 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('wagtail_review', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Annotation', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('quote', models.TextField(blank=True)), 21 | ('text', models.TextField(blank=True)), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='annotations', to='wagtail_review.Reviewer')), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='AnnotationRange', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('start', models.TextField()), 32 | ('start_offset', models.IntegerField()), 33 | ('end', models.TextField()), 34 | ('end_offset', models.IntegerField()), 35 | ('annotation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ranges', to='wagtail_review.Annotation')), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /.github/workflows/nightly-tests.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Nightly Wagtail test 3 | 4 | on: 5 | schedule: 6 | - cron: "45 0 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | nightly-test: 12 | # Cannot check the existence of secrets, so limiting to repository name to prevent all forks to run nightly. 13 | # See: https://github.com/actions/runner/issues/520 14 | if: ${{ github.repository == 'wagtail-nest/wagtail-review' }} 15 | runs-on: ubuntu-latest 16 | 17 | services: 18 | postgres: 19 | image: postgres:14 20 | env: 21 | POSTGRES_PASSWORD: postgres 22 | ports: 23 | - 5432:5432 24 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | - name: Set up Python 3.12 29 | uses: actions/setup-python@v4 30 | with: 31 | python-version: "3.12" 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install "psycopg>=3.2" 36 | pip install "Django>=5.1,<5.2" 37 | pip install "git+https://github.com/wagtail/wagtail.git@main#egg=wagtail" 38 | pip install -e . 39 | - name: Test 40 | id: test 41 | continue-on-error: true 42 | run: ./runtests.py 43 | env: 44 | DATABASE_ENGINE: django.db.backends.postgresql 45 | DATABASE_HOST: localhost 46 | DATABASE_USER: postgres 47 | DATABASE_PASS: postgres 48 | - name: Send Slack notification on failure 49 | if: steps.test.outcome == 'failure' 50 | run: | 51 | python .github/report_nightly_build_failure.py 52 | env: 53 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 54 | -------------------------------------------------------------------------------- /wagtail_review/static/wagtail_review/js/annotator-extensions.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | function renderWithUsername(annotation) { 3 | if (annotation.text) { 4 | var author = '

' + annotator.util.escapeHtml(annotation.user.name) + '

' 5 | return author + annotator.util.escapeHtml(annotation.text); 6 | } else { 7 | return "" + _t('No comment') + ""; 8 | } 9 | } 10 | 11 | window.annotatorExt = { 12 | 'viewerWithUsernames': function(viewer) { 13 | viewer.setRenderer(renderWithUsername); 14 | }, 15 | 'viewerModeUi': function() { 16 | /* Read-only view of annotations. Taken from https://github.com/openannotation/annotator/issues/580#issuecomment-254772752 */ 17 | var element = document.body; // Or whatever is your selector/element when you initialize the annotator 18 | var ui = {}; 19 | 20 | return { 21 | start: function () { 22 | ui.highlighter = new annotator.ui.highlighter.Highlighter(element); 23 | ui.viewer = new annotator.ui.viewer.Viewer({ 24 | permitEdit: function (ann) { return false; }, 25 | permitDelete: function (ann) { return false; }, 26 | autoViewHighlights: element 27 | }); 28 | ui.viewer.setRenderer(renderWithUsername); 29 | ui.viewer.attach(); 30 | }, 31 | destroy: function () { 32 | ui.highlighter.destroy(); 33 | ui.viewer.destroy(); 34 | }, 35 | annotationsLoaded: function (anns) { 36 | ui.highlighter.drawAll(anns); 37 | } 38 | }; 39 | } 40 | }; 41 | })(); 42 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/admin/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/generic/index.html" %} 2 | {% load i18n wagtailreview_admin_tags %} 3 | 4 | {% block listing %} 5 |
6 |
7 | 8 | 9 | 10 | 18 | 19 | 20 | 21 | 22 | 23 | {% for page in pages %} 24 | 25 | 30 | 33 | 37 | 38 | {% endfor %} 39 | 40 |
11 | {% trans "Page" %} 12 | {% if ordering == "page" %} 13 | 14 | {% else %} 15 | 16 | {% endif %} 17 | {% trans "Last review requested at" %}{% trans "Status" %}
26 |

27 | {{ page.get_admin_display_title }} 28 |

29 |
31 | {{ page.last_review_requested_at }} 32 | 34 | {% page_has_open_review page as is_open %} 35 | {% if is_open %}{% trans "Open" %}{% else %}{% trans "Closed" %}{% endif %} 36 |
41 | 42 |
43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /tests/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": "Home", 26 | "numchild": 0, 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 | { 38 | "pk": 1, 39 | "model": "wagtailcore.site", 40 | "fields": { 41 | "root_page": 2, 42 | "hostname": "localhost", 43 | "port": 80, 44 | "is_default_site": true 45 | } 46 | }, 47 | 48 | { 49 | "pk": 1, 50 | "model": "auth.user", 51 | "fields": { 52 | "username": "spongebob", 53 | "first_name": "Spongebob", 54 | "last_name": "Squarepants", 55 | "is_active": true, 56 | "is_superuser": false, 57 | "is_staff": true, 58 | "groups": [ 59 | ], 60 | "user_permissions": [], 61 | "password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22", 62 | "email": "spongebob@example.com" 63 | } 64 | }, 65 | { 66 | "pk": 2, 67 | "model": "auth.user", 68 | "fields": { 69 | "username": "homer", 70 | "first_name": "Homer", 71 | "last_name": "Simpson", 72 | "is_active": true, 73 | "is_superuser": false, 74 | "is_staff": true, 75 | "groups": [ 76 | ], 77 | "user_permissions": [], 78 | "password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22", 79 | "email": "homer@example.com" 80 | } 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /wagtail_review/views/frontend.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.core.exceptions import PermissionDenied 3 | from django.http import HttpResponse 4 | from django.middleware.csrf import get_token as get_csrf_token 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.urls import reverse 7 | 8 | from wagtail_review.forms import ResponseForm 9 | from wagtail_review.models import Response, Reviewer 10 | 11 | SUCCESS_RESPONSE_MESSAGE = "Thank you, your review has been received." 12 | 13 | 14 | def view(request, reviewer_id, token): 15 | reviewer = get_object_or_404(Reviewer, id=reviewer_id) 16 | if token != reviewer.view_token: 17 | raise PermissionDenied 18 | 19 | page = reviewer.review.page_revision.as_object() 20 | return page.make_preview_request( 21 | original_request=request, 22 | extra_request_attrs={ 23 | 'wagtailreview_mode': 'view', 24 | 'wagtailreview_reviewer': reviewer, 25 | } 26 | ) 27 | 28 | 29 | def respond(request, reviewer_id, token): 30 | reviewer = get_object_or_404(Reviewer, id=reviewer_id) 31 | if token != reviewer.response_token: 32 | raise PermissionDenied 33 | 34 | if request.method == 'POST': 35 | response = Response(reviewer=reviewer) 36 | form = ResponseForm(request.POST, instance=response) 37 | if form.is_valid() and reviewer.review.status != 'closed': 38 | form.save() 39 | response.send_notification_to_submitter() 40 | if request.user.has_perm('wagtailadmin.access_admin'): 41 | messages.success(request, SUCCESS_RESPONSE_MESSAGE) 42 | return redirect(reverse('wagtail_review_admin:dashboard')) 43 | return HttpResponse(SUCCESS_RESPONSE_MESSAGE) 44 | 45 | else: 46 | page = reviewer.review.page_revision.as_object() 47 | # Fetch the CSRF token so that Django will return a set-cookie header in the case that this is 48 | # the user's first request, and ensure that the dummy request (where the submit-review form is 49 | # rendered) is using the same token 50 | get_csrf_token(request) 51 | 52 | return page.make_preview_request( 53 | original_request=request, 54 | extra_request_attrs={ 55 | 'wagtailreview_mode': 'respond', 56 | 'wagtailreview_reviewer': reviewer, 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | 0.5 (2024-08-19) 2 | ---------------- 3 | 4 | * Fixes for Wagtail 6.x (Matt Westcott) 5 | * Dropped support for Wagtail <5.2, Django <4.2 6 | 7 | 8 | 0.4 (2023-08-01) 9 | ---------------- 10 | 11 | * Fixes for Wagtail 5.x (Matt Westcott) 12 | * Dropped support for Wagtail <4.1, Django <3.2, Python <3.8 13 | 14 | 15 | 0.3.1 (2022-01-04) 16 | ------------------ 17 | 18 | * Fixes for Django 4.x (Adrien Lemaire) 19 | * Fixes for Wagtail 3.x and 4.x (Matt Westcott, Dan Braghis, Sage Abdullah) 20 | 21 | 22 | 0.3 (2021-03-01) 23 | ---------------- 24 | 25 | * Roll back to 0.1.x codebase and apply compatibility fixes up to Wagtail 2.12 26 | * Logged-in users are now redirected back to the Wagtail admin after submitting a review (Maylon Pedroso) 27 | * Fix: CSRF token is now set correctly when the 'respond to review' view is the user's first request (Matt Westcott) 28 | * Fix: Prevent URLs in emails from being wrongly HTML-escaped (Karl Hobley, Matt Westcott) 29 | * Fix: Display username in place of full name where full name is blank or not implemented by a custom user model (Matt Westcott) 30 | 31 | 32 | 0.2.1 (2021-03-01) 33 | ------------------ 34 | 35 | NOTE: The 0.2.x branch is no longer recommended for use. 0.2 was a major rewrite of wagtail-review designed to integrate with Wagtail 2.10's moderation workflow features, but as of March 2021 the resources to complete this work have not been available, and various compatibility issues exist in the current release. We hope to release a workflow-enabled version of wagtail-review in future, as a new package. 36 | 37 | To upgrade to wagtail-review 0.3.x, first upgrade to 0.2.1 then run `./manage.py migrate wagtail_review 0003`. This will delete any existing review data. 38 | 39 | * Fix error on page creation (Matt Westcott) 40 | * Fix: Prevent share/comment tabs from showing on pages other than the edit page view (Matt Westcott) 41 | * Fix: Reinstate missing submitter name on notification email (Matt Westcott) 42 | * Fix: Allow reversing migrations to facilitate upgrading to 0.3.x (Matt Westcott) 43 | 44 | 45 | 0.2 (2020-12-11) 46 | ---------------- 47 | 48 | * Logged-in users are now redirected back to the Wagtail admin after submitting a review (Maylon Pedroso) 49 | * Fix: CSRF token is now set correctly when the 'respond to review' view is the user's first request (Matt Westcott) 50 | 51 | 52 | 0.1.1 (2018-10-17) 53 | ------------------ 54 | 55 | * Fixes for Django 2.x 56 | 57 | 58 | 0.1 (2018-10-10) 59 | ---------------- 60 | 61 | * Initial release 62 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.exceptions import ValidationError 3 | from django.test import TestCase 4 | 5 | from wagtail.models import Page 6 | 7 | from wagtail_review.models import Review, Reviewer 8 | 9 | 10 | class TestReviewerModel(TestCase): 11 | fixtures = ['test.json'] 12 | 13 | def setUp(self): 14 | self.homepage = Page.objects.get(url_path='/home/').specific 15 | self.revision = self.homepage.save_revision() 16 | self.review = Review.objects.create(page_revision=self.revision, submitter=User.objects.first()) 17 | 18 | def test_tokens_are_assigned(self): 19 | """Test that response_token and view_token are populated on save""" 20 | reviewer = Reviewer.objects.create(review=self.review, email='bob@example.com') 21 | self.assertRegex(reviewer.response_token, r'^\w{16}$') 22 | self.assertRegex(reviewer.view_token, r'^\w{16}$') 23 | 24 | def test_validate_email_or_user_required(self): 25 | reviewer = Reviewer(review=self.review) 26 | with self.assertRaises(ValidationError): 27 | reviewer.full_clean() 28 | 29 | def test_get_email_address(self): 30 | reviewer1 = Reviewer(review=self.review, email='bob@example.com') 31 | reviewer2 = Reviewer(review=self.review, user=User.objects.get(username='spongebob')) 32 | self.assertEqual(reviewer1.get_email_address(), 'bob@example.com') 33 | self.assertEqual(reviewer2.get_email_address(), 'spongebob@example.com') 34 | 35 | def test_get_respond_url(self): 36 | reviewer = Reviewer.objects.create(review=self.review, email='bob@example.com') 37 | self.assertEqual( 38 | reviewer.get_respond_url(), 39 | '/review/respond/%d/%s/' % (reviewer.id, reviewer.response_token) 40 | ) 41 | self.assertEqual( 42 | reviewer.get_respond_url(absolute=True), 43 | 'http://test.local/review/respond/%d/%s/' % (reviewer.id, reviewer.response_token) 44 | ) 45 | 46 | def test_get_view_url(self): 47 | reviewer = Reviewer.objects.create(review=self.review, email='bob@example.com') 48 | self.assertEqual( 49 | reviewer.get_view_url(), 50 | '/review/view/%d/%s/' % (reviewer.id, reviewer.view_token) 51 | ) 52 | self.assertEqual( 53 | reviewer.get_view_url(absolute=True), 54 | 'http://test.local/review/view/%d/%s/' % (reviewer.id, reviewer.view_token) 55 | ) 56 | -------------------------------------------------------------------------------- /wagtail_review/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured, ValidationError 4 | from django.forms.formsets import DELETION_FIELD_NAME 5 | from django.utils.module_loading import import_string 6 | from django.utils.translation import gettext 7 | 8 | import swapper 9 | 10 | from wagtail_review.models import Reviewer, Response 11 | 12 | Review = swapper.load_model('wagtail_review', 'Review') 13 | 14 | 15 | class CreateReviewForm(forms.ModelForm): 16 | class Meta: 17 | model = Review 18 | fields = [] 19 | 20 | 21 | def get_review_form_class(): 22 | """ 23 | Get the review form class from the ``WAGTAILREVIEW_REVIEW_FORM`` setting. 24 | """ 25 | form_class_name = getattr(settings, 'WAGTAILREVIEW_REVIEW_FORM', 'wagtail_review.forms.CreateReviewForm') 26 | try: 27 | return import_string(form_class_name) 28 | except ImportError: 29 | raise ImproperlyConfigured( 30 | "WAGTAILREVIEW_REVIEW_FORM refers to a form '%s' that is not available" % form_class_name 31 | ) 32 | 33 | 34 | class BaseReviewerFormSet(forms.BaseInlineFormSet): 35 | def add_fields(self, form, index): 36 | super().add_fields(form, index) 37 | form.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput() 38 | 39 | def clean(self): 40 | # Confirm that at least one reviewer has been specified. 41 | # Do this as a custom validation step (rather than passing min_num=1 / 42 | # validate_min=True to inlineformset_factory) so that we can have a 43 | # custom error message. 44 | if (self.total_form_count() - len(self.deleted_forms) < 1): 45 | raise ValidationError( 46 | gettext("Please select one or more reviewers."), 47 | code='too_few_forms' 48 | ) 49 | 50 | 51 | ReviewerFormSet = forms.inlineformset_factory( 52 | Review, Reviewer, 53 | fields=['user', 'email'], 54 | formset=BaseReviewerFormSet, 55 | extra=0, 56 | widgets={ 57 | 'user': forms.HiddenInput, 58 | 'email': forms.HiddenInput, 59 | } 60 | ) 61 | 62 | 63 | class ResponseForm(forms.ModelForm): 64 | class Meta: 65 | model = Response 66 | fields = ['result', 'comment'] 67 | widgets = { 68 | 'result': forms.RadioSelect, 69 | 'comment': forms.Textarea(attrs={ 70 | 'placeholder': 'Enter your comments', 71 | }), 72 | } 73 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import os 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': os.environ.get('DATABASE_ENGINE', 'django.db.backends.sqlite3'), 8 | 'NAME': os.environ.get('DATABASE_NAME', 'wagtail_review'), 9 | 'USER': os.environ.get('DATABASE_USER', None), 10 | 'PASSWORD': os.environ.get('DATABASE_PASS', None), 11 | 'HOST': os.environ.get('DATABASE_HOST', None), 12 | 13 | 'TEST': { 14 | 'NAME': os.environ.get('DATABASE_NAME', None), 15 | } 16 | } 17 | } 18 | 19 | 20 | SECRET_KEY = 'not needed' 21 | 22 | ROOT_URLCONF = 'tests.urls' 23 | 24 | STATIC_URL = '/static/' 25 | 26 | STATICFILES_FINDERS = ( 27 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 28 | ) 29 | 30 | USE_TZ = True 31 | 32 | TEMPLATES = [ 33 | { 34 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 35 | 'DIRS': [], 36 | 'APP_DIRS': True, 37 | 'OPTIONS': { 38 | 'context_processors': [ 39 | 'django.template.context_processors.debug', 40 | 'django.template.context_processors.request', 41 | 'django.contrib.auth.context_processors.auth', 42 | 'django.contrib.messages.context_processors.messages', 43 | 'django.template.context_processors.request', 44 | ], 45 | 'debug': True, 46 | }, 47 | }, 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.contrib.sessions.middleware.SessionMiddleware', 53 | 'django.middleware.csrf.CsrfViewMiddleware', 54 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 55 | 'django.contrib.messages.middleware.MessageMiddleware', 56 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 57 | ] 58 | 59 | INSTALLED_APPS = ( 60 | 'wagtail_review', 61 | 'tests', 62 | 63 | 'wagtail.search', 64 | 'wagtail.sites', 65 | 'wagtail.users', 66 | 'wagtail.images', 67 | 'wagtail.documents', 68 | 'wagtail.admin', 69 | 'wagtail', 70 | 71 | 'taggit', 72 | 73 | 'django.contrib.admin', 74 | 'django.contrib.auth', 75 | 'django.contrib.contenttypes', 76 | 'django.contrib.sessions', 77 | 'django.contrib.messages', 78 | 'django.contrib.staticfiles', 79 | ) 80 | 81 | PASSWORD_HASHERS = ( 82 | 'django.contrib.auth.hashers.MD5PasswordHasher', # don't use the intentionally slow default password hasher 83 | ) 84 | 85 | WAGTAIL_SITE_NAME = 'wagtail-review test' 86 | WAGTAILADMIN_BASE_URL = 'http://test.local' 87 | ALLOWED_HOSTS = ['localhost', 'testserver'] 88 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/create_review.html: -------------------------------------------------------------------------------- 1 | {% load i18n wagtailadmin_tags %} 2 | {% trans "Submit for review" as title_str %} 3 | {% include "wagtailadmin/shared/header.html" with title=title_str icon='doc-empty-inverse' %} 4 | 5 |
6 |
7 | {% csrf_token %} 8 |
    9 | {% for field in form %} 10 | {% if field.is_hidden %} 11 | {{ field }} 12 | {% else %} 13 | {% include "wagtailadmin/shared/field_as_li.html" with field=field %} 14 | {% endif %} 15 | {% endfor %} 16 | 17 |
  • 18 |
    19 | 20 |
    21 | {% for err in reviewer_formset.non_form_errors %} 22 |

    {{ err }}

    23 | {% endfor %} 24 |
    25 | 26 | 27 |
    28 |

    Enter a user name or email address

    29 | 30 | {{ reviewer_formset.management_form }} 31 |
      32 |
    33 |
    34 |
    35 | 43 |
  • 44 | 45 |
  • 46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI 3 | 4 | on: 5 | push: 6 | pull_request: 7 | 8 | # Current configuration: 9 | # - django 4.2, python 3.8, wagtail 5.2, sqlite 10 | # - django 4.2, python 3.9, wagtail 6.0, postgresql 11 | # - django 5.0, python 3.11, wagtail 6.1, sqlite 12 | # - django 5.1, python 3.12, wagtail 6.2, postgresql 13 | # - django 5.1, python 3.12, wagtail main, postgres (allow failures) 14 | jobs: 15 | test: 16 | runs-on: ubuntu-20.04 17 | continue-on-error: ${{ matrix.experimental }} 18 | strategy: 19 | matrix: 20 | include: 21 | - python: "3.8" 22 | django: "Django>=4.2,<4.3" 23 | wagtail: "wagtail>=5.2,<5.3" 24 | database: "sqlite3" 25 | experimental: false 26 | - python: "3.9" 27 | django: "Django>=4.2,<4.3" 28 | wagtail: "wagtail>=6.0,<6.1" 29 | database: "postgresql" 30 | psycopg: "psycopg>=3.2" 31 | experimental: false 32 | - python: "3.11" 33 | django: "Django>=5.0,<5.1" 34 | wagtail: "wagtail>=6.1,<6.2" 35 | database: "sqlite3" 36 | experimental: false 37 | - python: "3.12" 38 | django: "Django>=5.1,<5.2" 39 | wagtail: "wagtail>=6.2,<6.3" 40 | database: "postgresql" 41 | psycopg: "psycopg>=3.2" 42 | experimental: false 43 | - python: "3.12" 44 | django: "Django>=5.1,<5.2" 45 | wagtail: "git+https://github.com/wagtail/wagtail.git@main#egg=wagtail" 46 | database: "postgresql" 47 | psycopg: "psycopg>=3.2" 48 | experimental: true 49 | 50 | services: 51 | postgres: 52 | image: postgres:14 53 | env: 54 | POSTGRES_PASSWORD: postgres 55 | ports: 56 | - 5432:5432 57 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 58 | 59 | steps: 60 | - uses: actions/checkout@v3 61 | - name: Set up Python ${{ matrix.python }} 62 | uses: actions/setup-python@v4 63 | with: 64 | python-version: ${{ matrix.python }} 65 | - name: Install dependencies 66 | run: | 67 | python -m pip install --upgrade pip 68 | pip install "${{ matrix.django }}" 69 | pip install "${{ matrix.wagtail }}" 70 | - name: Install psycopg 71 | if: matrix.psycopg 72 | run: | 73 | pip install "${{ matrix.psycopg }}" 74 | - name: Install package 75 | run: | 76 | pip install -e .[testing] 77 | - name: Test 78 | run: ./runtests.py 79 | env: 80 | DATABASE_ENGINE: django.db.backends.${{ matrix.database }} 81 | DATABASE_HOST: localhost 82 | DATABASE_USER: postgres 83 | DATABASE_PASS: postgres 84 | -------------------------------------------------------------------------------- /wagtail_review/static/wagtail_review/css/respond.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:400,600&display=swap'); 2 | 3 | .wagtailreview-respond { 4 | bottom: 35px; 5 | font-family: 'Open Sans', sans-serif; 6 | font-size: 13px; 7 | line-height: 18px; 8 | position: fixed; 9 | right: 35px; 10 | z-index: 9999; 11 | } 12 | 13 | .wagtailreview-respond .button-review-closed, 14 | .wagtailreview-respond .js-show-submit { 15 | align-items: center; 16 | background-color: #fff; 17 | border-radius: 4px; 18 | border: 0; 19 | box-shadow: 0 0 8px 0 rgba(0,125,126,0.43); 20 | color: #007d7e; 21 | cursor: pointer; 22 | display: flex; 23 | flex-direction: row; 24 | font-size: 13px; 25 | font-weight: 600; 26 | justify-content: space-between; 27 | line-height: 18px; 28 | outline: 0; 29 | padding: 8px 6px 8px 16px; 30 | text-decoration: none; 31 | text-transform: uppercase; 32 | } 33 | 34 | .wagtailreview-respond .button-review-closed { 35 | cursor: default; 36 | padding: 8px 16px; 37 | } 38 | 39 | .wagtailreview-respond .js-show-submit .wagtail-icon { 40 | color: #000; 41 | font-size: 32px; 42 | height: 32px; 43 | margin-left: 6px; 44 | width: 32px; 45 | } 46 | 47 | .wagtailreview-respond-form { 48 | background-color: #333; 49 | border-radius: 4px; 50 | bottom: 70px; 51 | box-shadow: 0 0 15px 0 rgba(0,0,0,0.28); 52 | color: #fff; 53 | padding: 24px; 54 | position: absolute; 55 | right: 0; 56 | width: 370px; 57 | } 58 | 59 | .wagtailreview-respond-form::after { 60 | border-left: 20px solid transparent; 61 | border-right: 20px solid transparent; 62 | border-top: 20px solid #333; 63 | bottom: -10px; 64 | content: ""; 65 | height: 0; 66 | position: absolute; 67 | right: 10px; 68 | width: 0; 69 | } 70 | 71 | .wagtailreview-respond-form fieldset { 72 | border: 0; 73 | margin: 0; 74 | padding: 0; 75 | } 76 | 77 | .wagtailreview-respond-form legend { 78 | font-size: 21px; 79 | font-weight: 600; 80 | line-height: 28px; 81 | } 82 | 83 | .wagtailreview-respond-form__radios { 84 | display: flex; 85 | flex-direction: row; 86 | margin: 24px 0; 87 | } 88 | 89 | .wagtailreview-respond-form__radios label:not(:last-child) { 90 | margin-right: 24px; 91 | } 92 | 93 | .wagtailreview-respond-form__comment label { 94 | font-weight: 600; 95 | } 96 | 97 | .wagtailreview-respond-form__comment textarea { 98 | background-color: rgba(255, 255, 255, 0.2); 99 | border-radius: 4px; 100 | border: 0; 101 | color: #fff; 102 | font-style: normal; 103 | margin: 12px 0 24px; 104 | outline: 0; 105 | padding: 10px; 106 | } 107 | 108 | .wagtailreview-respond-form__submit input { 109 | background-color: #007d7e; 110 | border-radius: 4px; 111 | border: 0; 112 | color: #fff; 113 | cursor: pointer; 114 | font-size: 13px; 115 | font-weight: 600; 116 | line-height: 18px; 117 | outline: 0; 118 | padding: 8px 16px; 119 | text-decoration: none; 120 | text-transform: uppercase; 121 | } 122 | -------------------------------------------------------------------------------- /wagtail_review/views/annotations_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import HttpResponseNotAllowed, JsonResponse 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.views.decorators.cache import never_cache 7 | 8 | from wagtail_review.models import Annotation, Reviewer 9 | 10 | 11 | def _check_reviewer_credentials(request): 12 | try: 13 | mode = request.META.get('HTTP_X_WAGTAILREVIEW_MODE') or request.GET['mode'] 14 | reviewer_id = request.META.get('HTTP_X_WAGTAILREVIEW_REVIEWER') or request.GET['reviewer'] 15 | token = request.META.get('HTTP_X_WAGTAILREVIEW_TOKEN') or request.GET['token'] 16 | reviewer = Reviewer.objects.get(id=reviewer_id) 17 | except (KeyError, Reviewer.DoesNotExist): 18 | raise PermissionDenied 19 | 20 | if (mode == 'respond' or mode == 'comment') and token == reviewer.response_token: 21 | pass 22 | elif mode == 'view' and token == reviewer.view_token: 23 | pass 24 | else: 25 | raise PermissionDenied 26 | 27 | return (reviewer, mode) 28 | 29 | 30 | def root(request): 31 | return JsonResponse({ 32 | "name": "Annotator Store API", 33 | "version": "2.0.0" 34 | }) 35 | 36 | 37 | @never_cache 38 | def index(request): 39 | reviewer, mode = _check_reviewer_credentials(request) 40 | 41 | if request.method == 'GET': 42 | results = [ 43 | annotation.as_json_data() 44 | for annotation in reviewer.review.get_annotations() 45 | ] 46 | return JsonResponse(results, safe=False) 47 | 48 | elif request.method == 'POST': 49 | if mode not in ('respond', 'comment'): 50 | raise PermissionDenied 51 | 52 | if reviewer.review.status == 'closed': 53 | raise PermissionDenied 54 | 55 | data = json.loads(request.body) 56 | 57 | annotation = reviewer.annotations.create(quote=data['quote'], text=data['text']) 58 | for r in data['ranges']: 59 | annotation.ranges.create( 60 | start=r['start'], start_offset=r['startOffset'], end=r['end'], end_offset=r['endOffset'] 61 | ) 62 | 63 | return redirect('wagtail_review:annotations_api_item', annotation.id) 64 | else: 65 | return HttpResponseNotAllowed(['GET', 'POST'], "Method not allowed") 66 | 67 | 68 | @never_cache 69 | def item(request, id): 70 | reviewer, mode = _check_reviewer_credentials(request) 71 | 72 | if request.method == 'GET': 73 | annotation = get_object_or_404(Annotation, id=id) 74 | 75 | # only allow retrieving annotations within the same review as the current user's credentials 76 | if reviewer.review != annotation.reviewer.review: 77 | raise PermissionDenied 78 | 79 | return JsonResponse(annotation.as_json_data()) 80 | 81 | else: 82 | return HttpResponseNotAllowed(['GET'], "Method not allowed") 83 | 84 | 85 | @never_cache 86 | def search(request): 87 | reviewer, mode = _check_reviewer_credentials(request) 88 | 89 | results = [ 90 | annotation.as_json_data() 91 | for annotation in reviewer.review.get_annotations() 92 | ] 93 | return JsonResponse({ 94 | 'total': len(results), 95 | 'rows': results 96 | }) 97 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/annotate.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | {% if mode %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% if allow_responses %} 11 |
12 | 13 | Submit your review 14 | 15 | 16 |
17 | {% csrf_token %} 18 | {% include "wagtail_review/response_form_fields.html" %} 19 |
20 |
21 | {% elif allow_annotations %} 22 | {# provide an empty respond form so that we can pull the CSRF token from it #} 23 |
{% csrf_token %}
24 | {% elif show_closed %} 25 |
26 | This review is now closed 27 |
28 | {% endif %} 29 | 30 | 82 | {% endif %} 83 | -------------------------------------------------------------------------------- /wagtail_review/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.urls import re_path 3 | from django.contrib import messages as django_messages 4 | from django.templatetags.static import static 5 | from django.shortcuts import redirect 6 | from django.urls import reverse 7 | from django.utils.html import format_html 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | import swapper 11 | 12 | from wagtail.admin import messages 13 | from wagtail.admin.action_menu import ActionMenuItem 14 | from wagtail.admin.menu import MenuItem 15 | from wagtail import hooks 16 | 17 | from wagtail_review import admin_urls 18 | from wagtail_review.forms import get_review_form_class, ReviewerFormSet 19 | 20 | Review = swapper.load_model('wagtail_review', 'Review') 21 | 22 | 23 | @hooks.register('register_admin_urls') 24 | def register_admin_urls(): 25 | return [ 26 | re_path(r'^wagtail_review/', include(admin_urls, namespace='wagtail_review_admin')), 27 | ] 28 | 29 | 30 | # Replace 'submit for moderation' action with 'submit for review' 31 | 32 | class SubmitForReviewMenuItem(ActionMenuItem): 33 | label = _("Submit for review") 34 | name = 'action-submit-for-review' 35 | template_name = 'wagtail_review/submit_for_review_menu_item.html' 36 | icon_name = 'resubmit' 37 | 38 | class Media: 39 | js = ['wagtail_review/js/submit.js'] 40 | css = { 41 | 'all': ['wagtail_review/css/create_review.css'] 42 | } 43 | 44 | @hooks.register('construct_page_action_menu') 45 | def remove_submit_to_moderator_option(menu_items, request, context): 46 | for (i, menu_item) in enumerate(menu_items): 47 | if menu_item.name == 'action-submit': 48 | menu_items[i] = SubmitForReviewMenuItem() 49 | 50 | 51 | def handle_submit_for_review(request, page): 52 | if 'action-submit-for-review' in request.POST: 53 | ReviewForm = get_review_form_class() 54 | 55 | review = Review(page_revision=page.get_latest_revision(), submitter=request.user) 56 | form = ReviewForm(request.POST, instance=review, prefix='create_review') 57 | reviewer_formset = ReviewerFormSet(request.POST, instance=review, prefix='create_review_reviewers') 58 | 59 | # forms should already have been validated at the point of submission, so treat validation failures 60 | # at this point as a hard error 61 | if not form.is_valid(): 62 | raise Exception("Review form failed validation") 63 | if not reviewer_formset.is_valid(): 64 | raise Exception("Reviewer formset failed validation") 65 | 66 | form.save() 67 | reviewer_formset.save() 68 | 69 | # create a reviewer record for the current user 70 | review.reviewers.create(user=review.submitter) 71 | 72 | review.send_request_emails() 73 | 74 | # clear original confirmation message as set by the create/edit view, 75 | # so that we can replace it with our own 76 | list(django_messages.get_messages(request)) 77 | 78 | message = _( 79 | "Page '{0}' has been submitted for review." 80 | ).format( 81 | page.get_admin_display_title() 82 | ) 83 | 84 | messages.success(request, message) 85 | 86 | # redirect back to the explorer 87 | return redirect('wagtailadmin_explore', page.get_parent().id) 88 | 89 | hooks.register('after_create_page', handle_submit_for_review) 90 | hooks.register('after_edit_page', handle_submit_for_review) 91 | 92 | 93 | class ReviewsMenuItem(MenuItem): 94 | def is_shown(self, request): 95 | return bool(Review.get_pages_with_reviews_for_user(request.user)) 96 | 97 | 98 | @hooks.register('register_admin_menu_item') 99 | def register_images_menu_item(): 100 | return ReviewsMenuItem( 101 | _('Reviews'), reverse('wagtail_review_admin:dashboard'), 102 | name='reviews', icon_name='tick-inverse', order=1000 103 | ) 104 | -------------------------------------------------------------------------------- /tests/test_frontend.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | 4 | from wagtail.models import Page, Site 5 | 6 | from wagtail_review.models import Review, Reviewer 7 | from tests.models import SimplePage 8 | 9 | 10 | class TestFrontendViews(TestCase): 11 | fixtures = ['test.json'] 12 | 13 | def setUp(self): 14 | self.admin_user = User.objects.create_superuser( 15 | username='admin', email='admin@example.com', password='password' 16 | ) 17 | 18 | self.homepage = Page.objects.get(url_path='/home/').specific 19 | self.page = SimplePage(title="Simple page original", slug="simple-page") 20 | self.homepage.add_child(instance=self.page) 21 | 22 | self.page.title = "Simple page submitted" 23 | submitted_revision = self.page.save_revision() 24 | self.review = Review.objects.create(page_revision=submitted_revision, submitter=self.admin_user) 25 | self.reviewer = Reviewer.objects.create(review=self.review, user=User.objects.get(username='spongebob')) 26 | 27 | self.page.title = "Simple page with draft edit" 28 | self.page.save_revision() 29 | 30 | # Need to update site record so the hostname matches what Django will send to the view 31 | # This prevents a 400 (Bad Request) error when the preview is generated 32 | Site.objects.update(hostname="testserver") 33 | 34 | def test_view_token_must_match(self): 35 | response = self.client.get('/review/view/%d/xxxxx/' % self.reviewer.id) 36 | self.assertEqual(response.status_code, 403) 37 | 38 | def test_view(self): 39 | response = self.client.get('/review/view/%d/%s/' % (self.reviewer.id, self.reviewer.view_token)) 40 | self.assertEqual(response.status_code, 200) 41 | self.assertContains(response, "

Simple page submitted

") 42 | self.assertContains(response, "var app = new annotator.App();") 43 | self.assertContains(response, "app.include(annotatorExt.viewerModeUi);") 44 | 45 | def test_response_token_must_match(self): 46 | response = self.client.get('/review/respond/%d/xxxxx/' % self.reviewer.id) 47 | self.assertEqual(response.status_code, 403) 48 | 49 | def test_respond_view(self): 50 | response = self.client.get('/review/respond/%d/%s/' % (self.reviewer.id, self.reviewer.response_token)) 51 | self.assertEqual(response.status_code, 200) 52 | self.assertContains(response, "

Simple page submitted

") 53 | self.assertContains(response, "var app = new annotator.App();") 54 | self.assertContains(response, "app.include(annotator.ui.main,") 55 | 56 | def test_respond_view_post_not_authenticated_user(self): 57 | response = self.client.post('/review/respond/%d/%s/' % (self.reviewer.id, self.reviewer.response_token), 58 | data={'result': 'approve', 'comment': 'comment'}) 59 | self.assertEqual(response.status_code, 200) 60 | review_response = self.reviewer.review.get_responses().last() 61 | self.assertEqual(review_response.result, 'approve') 62 | self.assertEqual(review_response.comment, 'comment') 63 | 64 | def test_respond_view_post_authenticated_user(self): 65 | self.client.login(username='admin', password='password') 66 | response = self.client.post('/review/respond/%d/%s/' % (self.reviewer.id, self.reviewer.response_token), 67 | data={'result': 'approve', 'comment': 'comment'}) 68 | self.client.logout() 69 | self.assertEqual(response.status_code, 302) 70 | self.assertEqual(response.url, '/admin/wagtail_review/reviews/') 71 | review_response = self.reviewer.review.get_responses().last() 72 | self.assertEqual(review_response.result, 'approve') 73 | self.assertEqual(review_response.comment, 'comment') 74 | 75 | def test_live_page_has_no_annotator_js(self): 76 | response = self.client.get('/simple-page/') 77 | self.assertEqual(response.status_code, 200) 78 | self.assertContains(response, "

Simple page original

") 79 | self.assertNotContains(response, "var app = new annotator.App();") 80 | -------------------------------------------------------------------------------- /wagtail_review/static/wagtail_review/css/annotator.css: -------------------------------------------------------------------------------- 1 | .annotator-filter *, 2 | .annotator-notice, 3 | .annotator-widget * { 4 | font-family: 'Open Sans', sans-serif; 5 | } 6 | 7 | .annotator-widget { 8 | background-color: #333; 9 | border-radius: 4px; 10 | padding: 10px; 11 | } 12 | 13 | .annotator-widget::after { 14 | border-left: 20px solid transparent; 15 | border-right: 20px solid transparent; 16 | border-top: 20px solid #333; 17 | height: 0; 18 | width: 0; 19 | } 20 | 21 | .annotator-hl { 22 | background-color: rgba(0, 177, 179, 0.4); 23 | } 24 | 25 | .annotator-viewer div:first-of-type { 26 | color: rgba(255, 255, 255, 0.8); 27 | font-size: 13px; 28 | font-style: normal; 29 | line-height: 18px; 30 | padding: 5px; 31 | } 32 | 33 | .annotator-viewer p { 34 | color: #fff; 35 | font-size: 13px; 36 | font-style: italic; 37 | font-weight: 700; 38 | line-height: 18px; 39 | margin-bottom: 3px; 40 | } 41 | 42 | .annotator-adder { 43 | background-image: url("data:image/svg+xml,%3Csvg width='36' height='44' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M26.8 36L18 44l-8.8-8H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h28a4 4 0 0 1 4 4v28a4 4 0 0 1-4 4h-5.2z' fill='%23333' fill-rule='evenodd'/%3E%3C/svg%3E%0A"); 44 | background-position: center; 45 | background-repeat: no-repeat; 46 | background-size: contain; 47 | height: 44px; 48 | width: 36px; 49 | } 50 | 51 | .annotator-adder:hover { 52 | background-position: center; 53 | } 54 | 55 | .annotator-adder::before { 56 | -moz-box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.44); 57 | -webkit-box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.44); 58 | border-radius: 4px; 59 | box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.44); 60 | content: ""; 61 | height: 36px; 62 | left: 0; 63 | pointer-events: none; 64 | position: absolute; 65 | top: 0; 66 | width: 36px; 67 | } 68 | 69 | .annotator-adder::after { 70 | content: ''; 71 | /* From FontAwesome: https://fontawesome.com/icons/pen?style=solid , https://fontawesome.com/license */ 72 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='white' d='M290.74 93.24l128.02 128.02-277.99 277.99-114.14 12.6C11.35 513.54-1.56 500.62.14 485.34l12.7-114.22 277.9-277.88zm207.2-19.06l-60.11-60.11c-18.75-18.75-49.16-18.75-67.91 0l-56.55 56.55 128.02 128.02 56.55-56.55c18.75-18.76 18.75-49.16 0-67.91z'/%3E%3C/svg%3E%0A"); 73 | background-repeat: no-repeat; 74 | pointer-events: none; 75 | position: absolute; 76 | left: 9px; 77 | top: 7px; 78 | width: 20px; 79 | height: 100%; 80 | } 81 | 82 | .annotator-editor .annotator-item:first-child textarea { 83 | background-color: rgba(255, 255, 255, 0.2); 84 | border-radius: 4px; 85 | color: #fff; 86 | font-style: normal; 87 | margin-bottom: 10px; 88 | padding: 10px; 89 | } 90 | 91 | .annotator-editor .annotator-item:first-child textarea:focus { 92 | background-color: rgba(255, 255, 255, 0.2); 93 | } 94 | 95 | .annotator-editor .annotator-controls { 96 | background: none; 97 | border: 0; 98 | box-shadow: none; 99 | } 100 | 101 | .annotator-editor a, 102 | .annotator-editor a.annotator-save, 103 | .annotator-editor a.annotator-save, 104 | .annotator-editor a:focus, 105 | .annotator-editor a:hover, 106 | .annotator-filter .annotator-filter-active label, 107 | .annotator-filter .annotator-filter-navigation button:hover { 108 | background: none; 109 | border-radius: 4px; 110 | border: 1px solid #626262; 111 | box-shadow: none; 112 | color: #fff; 113 | font-size: 13px; 114 | font-weight: 400; 115 | line-height: 18px; 116 | padding: 4px 8px; 117 | text-shadow: none; 118 | text-transform: uppercase; 119 | } 120 | 121 | .annotator-editor a.annotator-save { 122 | background-color: #007d7e; 123 | border: 0; 124 | } 125 | 126 | .annotator-editor a.annotator-save:hover, 127 | .annotator-editor a.annotator-save:focus { 128 | background-color: #00676a; 129 | } 130 | 131 | .annotator-editor a:after { 132 | display: none; 133 | } 134 | 135 | .annotator-editor a:not(:last-child) { 136 | margin-right: 7px; 137 | } 138 | 139 | .annotator-editor a:hover, 140 | .annotator-editor a:focus { 141 | background: none; 142 | border-color: #626262; 143 | } 144 | -------------------------------------------------------------------------------- /wagtail_review/templates/wagtail_review/admin/audit_trail.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/base.html" %} 2 | {% load i18n static wagtailreview_admin_tags %} 3 | 4 | {% block titletag %}{{ view.page_title }} {{ view.get_page_subtitle }}{% endblock %} 5 | 6 | {% block extra_css %} 7 | {{ block.super }} 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 | {% include "wagtailadmin/shared/header.html" with title=view.page_title subtitle=page.get_admin_display_title icon=view.header_icon %} 14 | 15 |
16 | {% for review in reviews %} 17 |
18 |

19 | {% blocktrans with submitter=review.submitter|user_display_name datetime=review.created_at|date:"H:i j M Y" %}Review requested by {{ submitter }} at {{ datetime }}{% endblocktrans %} - {{ review.get_status_display }} 20 |

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for response in review.get_responses %} 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% endfor %} 39 | {% for reviewer in review.get_non_responding_reviewers %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% endfor %} 47 | 48 |
{% trans "Reviewer" %}{% trans "Response" %}{% trans "Date" %}{% trans "Comments" %}
{{ response.reviewer.get_name }}{{ response.get_result_display }}{{ response.created_at|date:"H:i j M Y" }}{{ response.comment }}
{{ reviewer.get_name }}{% trans "Awaiting response" %}
49 | 50 |
    51 |
  • 52 | {% trans "View page" %} 53 |
  • 54 |
  • 55 | {% trans "Edit page" %} 56 |
  • 57 | {% if review.status == 'closed' %} 58 |
  • 59 |
    60 | {% csrf_token %} 61 | 62 |
    63 |
  • 64 | {% else %} 65 |
  • 66 |
    67 | {% csrf_token %} 68 | 69 |
    70 |
  • 71 | {% if page_permissions.can_publish %} 72 |
  • 73 |
    74 | {% csrf_token %} 75 | 76 |
    77 |
  • 78 | {% endif %} 79 | {% endif %} 80 |
81 |
82 | {% endfor %} 83 |
84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /wagtail_review/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2018-09-26 13:00 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | from django.db.migrations.recorder import MigrationRecorder 8 | import django.db.models.deletion 9 | from wagtail import VERSION as WAGTAIL_VERSION 10 | 11 | 12 | def get_run_before_and_revision_model(): 13 | # The return value of this function is used in the Migration class 14 | # definition, so everything in this check happens at module load time 15 | # (i.e. at the start of the `migrate` command). 16 | 17 | # Changing the core migration dependency potentially breaks existing 18 | # users as it can cause an InconsistentMigrationHistory error. 19 | 20 | # Based on the dependencies, this migration can be run both before or 21 | # after the PageRevision model is renamed to Revision. As a result, 22 | # we cannot accurately determine the revision_model to use. 23 | 24 | # What we can do instead is keep pointing to the old PageRevision name, 25 | # but use run_before to make sure that this migration is run before the 26 | # core migration that renames the PageRevision model. 27 | run_before = [("wagtailcore", "0070_rename_pagerevision_revision")] 28 | revision_model = "wagtailcore.PageRevision" 29 | 30 | try: 31 | if MigrationRecorder.Migration.objects.filter( 32 | app="wagtailcore", name="0070_rename_pagerevision_revision" 33 | ).exists(): 34 | # However, if the core migration has already been applied in a 35 | # previous `migrate` run, we should unset run_before to avoid an 36 | # InconsistentMigrationHistory error. 37 | 38 | # This might be the case if the core migration was run 39 | # separately and an earlier version of wagtail-localize were 40 | # already installed where we did not ensure this migration was 41 | # run before the core migration. 42 | run_before = [] 43 | 44 | # In any case, it should be safe to point to the new Revision 45 | # model name as the core migration has already been applied. 46 | revision_model = "wagtailcore.Revision" 47 | 48 | except (django.db.utils.OperationalError, django.db.utils.ProgrammingError): 49 | # Normally happens when running tests 50 | pass 51 | 52 | return run_before, revision_model 53 | 54 | 55 | class Migration(migrations.Migration): 56 | 57 | initial = True 58 | 59 | dependencies = [ 60 | ('wagtailcore', '0040_page_draft_title'), 61 | migrations.swappable_dependency(settings.WAGTAILREVIEW_REVIEW_MODEL), 62 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 63 | ] 64 | 65 | run_before, revision_model = get_run_before_and_revision_model() 66 | 67 | operations = [ 68 | migrations.CreateModel( 69 | name='Review', 70 | fields=[ 71 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 72 | ('status', models.CharField(choices=[('open', 'Open'), ('closed', 'Closed')], default='open', editable=False, max_length=30)), 73 | ('created_at', models.DateTimeField(auto_now_add=True)), 74 | ('page_revision', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=revision_model)), 75 | ('submitter', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 76 | ], 77 | options={ 78 | 'swappable': 'WAGTAILREVIEW_REVIEW_MODEL', 79 | }, 80 | ), 81 | migrations.CreateModel( 82 | name='Reviewer', 83 | fields=[ 84 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 85 | ('email', models.EmailField(blank=True, max_length=254)), 86 | ('response_token', models.CharField(editable=False, help_text='Secret token this user must supply to be allowed to respond to the review', max_length=32)), 87 | ('view_token', models.CharField(editable=False, help_text='Secret token this user must supply to be allowed to view the page revision being reviewed', max_length=32)), 88 | ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.WAGTAILREVIEW_REVIEW_MODEL, related_name='reviewers')), 89 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 90 | ], 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wagtail-review 2 | 3 | An extension for Wagtail allowing pages to be submitted for review (including to non-Wagtail users) prior to publication 4 | 5 | ![Screencast demo](https://tom.s3.amazonaws.com/wagtail-review.gif) 6 | 7 | ## Requirements 8 | 9 | Wagtail 5.2 or higher 10 | 11 | ## Installation 12 | 13 | Install the package from PyPI: 14 | 15 | pip install wagtail-review 16 | 17 | Add to your project's `INSTALLED_APPS`: 18 | 19 | 'wagtail_review', 20 | 21 | Add to your project's URL config: 22 | 23 | from wagtail_review import urls as wagtailreview_urls 24 | 25 | # Somewhere above the include(wagtail_urls) line: 26 | path("review/", include(wagtailreview_urls)), 27 | 28 | Add a `{% wagtailreview %}` tag to your project's base template(s), towards the bottom of the document ``: 29 | 30 | {% load wagtailreview_tags %} 31 | 32 | {% wagtailreview %} 33 | 34 | 35 | ## Custom notification emails 36 | 37 | To customise the notification email sent to reviewers, override the templates `wagtail_review/email/request_review_subject.txt` (for the subject line) and `wagtail_review/email/request_review.txt` (for the email content). This needs to be done in an app which appears above `wagtail_review` in the `INSTALLED_APPS` list. 38 | 39 | The following context variables are available within the templates: 40 | 41 | * `email`: the reviewer's email address 42 | * `user`: the reviewer's user object (`None` if the reviewer was specified as an email address only, rather than a user account) 43 | * `review`: The review object (probably only useful when a custom review model is in use - see below) 44 | * `page`: Page object corresponding to the page revision to be reviewed 45 | * `submitter`: user object of the Wagtail user submitting the page for review 46 | * `respond_url`: Personalised URL (including domain) for this reviewer intended to be kept private, allowing them to respond to the review 47 | * `view_url`: Personalised URL (including domain) for this reviewer intended to be shared with colleagues, allowing them to view the page under review 48 | 49 | 50 | To customise the notification email sent to the review submitter when a reviewer responds, 51 | override the templates `wagtail_review/email/response_received_subject.txt` (for the subject line) and `wagtail_review/email/response_received.txt` (for the email content). The following context variables are available: 52 | 53 | * `submitter`: The user object of the Wagtail user who submitted the page for review 54 | * `reviewer`: Reviewer object for the person responding to the review 55 | * `review`: The review object (probably only useful when a custom review model is in use - see below) 56 | * `page`: Page object corresponding to the page revision being reviewed 57 | * `response`: Object representing the reviewer's response, including fields 'result' (equal to 'approve' or 'comment') and 'comment' 58 | 59 | 60 | ## Custom review models 61 | 62 | To define a custom review model: 63 | 64 | # my_project/my_app/models.py 65 | 66 | from wagtail_review.models import BaseReview 67 | 68 | REVIEW_TYPE_CHOICES = [ 69 | ('clinical', "Clinical review"), 70 | ('editorial', "Editorial review"), 71 | ] 72 | 73 | class Review(BaseReview): 74 | review_type = models.CharField(max_length=255, choices=REVIEW_TYPE_CHOICES) 75 | 76 | 77 | # my_project/my_app/forms.py 78 | 79 | from wagtail_review.forms import CreateReviewForm as BaseCreateReviewForm 80 | 81 | class CreateReviewForm(BaseCreateReviewForm): 82 | class Meta(BaseCreateReviewForm.Meta): 83 | fields = ['review_type'] 84 | 85 | 86 | # my_project/settings.py 87 | 88 | WAGTAILREVIEW_REVIEW_MODEL = 'my_app.Review' # appname.ModelName identifier for model 89 | WAGTAILREVIEW_REVIEW_FORM = 'my_project.my_app.forms.CreateReviewForm' # dotted path to form class 90 | 91 | 92 | ## Custom response form 93 | 94 | The form for responding to reviews can be customised by overriding the template `wagtail_review/response_form_fields.html`; this needs to be done in an app which appears above `wagtail_review` in the `INSTALLED_APPS` list. The HTML for the default form is: 95 | 96 |
97 | Submit your review 98 | 99 | {% for radio in response_form.result %} 100 |
101 | {{ radio }} 102 |
103 | {% endfor %} 104 | 105 |
106 | 107 | {{ response_form.comment }} 108 |
109 | 110 |
111 | 112 |
113 | 114 |
115 | -------------------------------------------------------------------------------- /wagtail_review/static/wagtail_review/js/submit.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | 3 | function createReviewOnload(modal, jsonData) { 4 | /* onload behaviours for the create review form */ 5 | 6 | var reviewerList = $('#id_create_review-reviewer_form_container'); 7 | var totalFormsInput = $('#id_create_review_reviewers-TOTAL_FORMS'); 8 | var formCount = parseInt(totalFormsInput.val(), 10); 9 | var emptyFormTemplate = document.getElementById('id_create_review_reviewers-EMPTY_FORM_TEMPLATE'); 10 | if (emptyFormTemplate.innerText) { 11 | emptyFormTemplate = emptyFormTemplate.innerText; 12 | } else if (emptyFormTemplate.textContent) { 13 | emptyFormTemplate = emptyFormTemplate.textContent; 14 | } 15 | 16 | function initReviewerDeleteLink(li, formIndex) { 17 | $('#id_create_review_reviewers-' + formIndex + '-delete_link').click(function() { 18 | $('#id_create_review_reviewers-' + formIndex + '-DELETE').val('1'); 19 | li.fadeOut('fast'); 20 | return false; 21 | }); 22 | } 23 | 24 | function addReviewer(id, email, label) { 25 | var newFormHtml = $(emptyFormTemplate 26 | .replace(/__prefix__/g, formCount) 27 | .replace(/<-(-*)\/script>/g, '<$1/script>')); 28 | reviewerList.append(newFormHtml); 29 | $('#id_create_review_reviewers-' + formCount + '-user').val(id); 30 | $('#id_create_review_reviewers-' + formCount + '-email').val(email); 31 | $('#id_create_review_reviewers-' + formCount + '-label').text(label); 32 | initReviewerDeleteLink(newFormHtml, formCount); 33 | 34 | formCount++; 35 | totalFormsInput.val(formCount); 36 | } 37 | 38 | var autocompleteField = $('#id_create_review-reviewer_autocomplete', modal.body); 39 | 40 | var autocompleteErrorMessage = $('

Please enter an email address, or select a user from the dropdown

'); 41 | autocompleteField.closest('.field-content').append(autocompleteErrorMessage); 42 | autocompleteErrorMessage.hide(); 43 | 44 | var autocompleteUrl = autocompleteField.data('autocomplete-url'); 45 | autocompleteField.autocomplete({ 46 | 'minLength': 2, 47 | 'source': function(request, response) { 48 | $.getJSON(autocompleteUrl, {'q': request.term}, function(jsonResponse) { 49 | var results = []; 50 | for (var i = 0; i < jsonResponse.results.length; i++) { 51 | results[i] = { 52 | 'value': jsonResponse.results[i]['id'], 53 | 'label': jsonResponse.results[i]['full_name'] 54 | }; 55 | } 56 | response(results); 57 | }); 58 | }, 59 | 'focus': function( event, ui ) { 60 | /* prevent populating the input box with the ID */ 61 | return false; 62 | }, 63 | 'select': function(event, ui) { 64 | addReviewer(ui.item.value, null, ui.item.label); 65 | autocompleteField.val(''); 66 | autocompleteErrorMessage.hide(); 67 | return false; 68 | } 69 | }); 70 | 71 | function addReviewerIfEmail() { 72 | /* add the value of autocompleteField to the reviewer list if it looks like an email address */ 73 | var val = autocompleteField.val(); 74 | if (/^[^\@]+\@[^\@]+\.[^\@]+$/.test(val)) { 75 | addReviewer(null, val, val); 76 | autocompleteField.val(''); 77 | autocompleteErrorMessage.hide(); 78 | } else { 79 | autocompleteErrorMessage.show(); 80 | } 81 | } 82 | $('#id_create_review-reviewer_autocomplete_add', modal.body).click(addReviewerIfEmail); 83 | autocompleteField.keypress(function(e) { 84 | if (e.keyCode == 13) { 85 | addReviewerIfEmail(); 86 | return false; 87 | } 88 | }); 89 | 90 | $('form', modal.body).on('submit', function() { 91 | modal.postForm(this.action, $(this).serialize()); 92 | return false; 93 | }); 94 | 95 | } 96 | 97 | function onValidateOK(modal, jsonData) { 98 | /* transfer create-review form contents to the real page-edit form */ 99 | var formFields = $('form', modal.body).serializeArray(); 100 | var editForm = $('form#page-edit-form'); 101 | for (var i = 0; i < formFields.length; i++) { 102 | var input = $('').attr({ 103 | 'name': formFields[i].name, 'value': formFields[i].value 104 | }); 105 | editForm.append(input); 106 | } 107 | /* Add a hidden field to substitute for clicking the 'submit for review' button, 108 | so that we know this was a submit-for-review action when intercepting the form post */ 109 | editForm.append(''); 110 | editForm.submit(); 111 | } 112 | 113 | /* behaviour for the submit-for-review menu item */ 114 | $('input[name="action-submit-for-review"],button[name="action-submit-for-review"]').click(function() { 115 | var createReviewUrl = $(this).data('url'); 116 | ModalWorkflow({ 117 | url: createReviewUrl, 118 | onload: { 119 | 'form': createReviewOnload, 120 | 'done': onValidateOK 121 | } 122 | }); 123 | return false; 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /wagtail_review/views/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.core.exceptions import PermissionDenied 4 | from django.db.models import Q 5 | from django.http import JsonResponse 6 | from django.shortcuts import get_object_or_404, redirect 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.views.decorators.http import require_POST 9 | from django.views.generic.detail import DetailView 10 | 11 | import swapper 12 | 13 | from wagtail.admin import messages 14 | from wagtail.admin.modal_workflow import render_modal_workflow 15 | from wagtail.admin.views import generic 16 | from wagtail.models import Page 17 | 18 | from wagtail_review.forms import get_review_form_class, ReviewerFormSet 19 | from wagtail_review.models import Reviewer 20 | from wagtail_review.text import user_display_name 21 | 22 | 23 | Review = swapper.load_model('wagtail_review', 'Review') 24 | User = get_user_model() 25 | 26 | 27 | def create_review(request): 28 | ReviewForm = get_review_form_class() 29 | 30 | if request.method == 'GET': 31 | form = ReviewForm(prefix='create_review') 32 | reviewer_formset = ReviewerFormSet(prefix='create_review_reviewers') 33 | else: 34 | form = ReviewForm(request.POST, prefix='create_review') 35 | reviewer_formset = ReviewerFormSet(request.POST, prefix='create_review_reviewers') 36 | 37 | form_is_valid = form.is_valid() 38 | reviewer_formset_is_valid = reviewer_formset.is_valid() 39 | 40 | if not (form_is_valid and reviewer_formset_is_valid): 41 | return render_modal_workflow( 42 | request, 'wagtail_review/create_review.html', None, { 43 | 'form': form, 44 | 'reviewer_formset': reviewer_formset, 45 | }, json_data={'step': 'form'} 46 | ) 47 | else: 48 | return render_modal_workflow( 49 | request, None, None, {}, json_data={'step': 'done'} 50 | ) 51 | 52 | return render_modal_workflow( 53 | request, 'wagtail_review/create_review.html', None, { 54 | 'form': form, 55 | 'reviewer_formset': reviewer_formset, 56 | }, json_data={'step': 'form'} 57 | ) 58 | 59 | 60 | def autocomplete_users(request): 61 | q = request.GET.get('q', '') 62 | 63 | terms = q.split() 64 | if terms: 65 | conditions = Q() 66 | 67 | model_fields = [f.name for f in User._meta.get_fields()] 68 | 69 | for term in terms: 70 | if 'username' in model_fields: 71 | conditions |= Q(username__icontains=term) 72 | 73 | if 'first_name' in model_fields: 74 | conditions |= Q(first_name__icontains=term) 75 | 76 | if 'last_name' in model_fields: 77 | conditions |= Q(last_name__icontains=term) 78 | 79 | if 'email' in model_fields: 80 | conditions |= Q(email__icontains=term) 81 | 82 | users = User.objects.filter(conditions) 83 | else: 84 | users = User.objects.none() 85 | 86 | result_data = [ 87 | { 88 | 'id': user.pk, 89 | 'full_name': user_display_name(user), 90 | 'username': user.get_username(), 91 | } 92 | for user in users 93 | ] 94 | 95 | return JsonResponse({'results': result_data}) 96 | 97 | 98 | class DashboardView(generic.IndexView): 99 | template_name = 'wagtail_review/admin/dashboard.html' 100 | page_title = _("Review dashboard") 101 | context_object_name = 'pages' 102 | 103 | def get_queryset(self): 104 | return Review.get_pages_with_reviews_for_user(self.request.user) 105 | 106 | 107 | class AuditTrailView(DetailView): 108 | template_name = 'wagtail_review/admin/audit_trail.html' 109 | page_title = _("Audit trail") 110 | header_icon = 'doc-empty-inverse' 111 | context_object_name = 'page' 112 | 113 | def get_queryset(self): 114 | return Review.get_pages_with_reviews_for_user(self.request.user) 115 | 116 | def get_object(self): 117 | return super().get_object().specific 118 | 119 | def get_context_data(self, **kwargs): 120 | context = super().get_context_data(**kwargs) 121 | 122 | context['reviews'] = Review.objects.filter( 123 | page_revision__object_id=str(self.object.pk), 124 | page_revision__base_content_type=ContentType.objects.get_for_model(Page) 125 | ).order_by('created_at').select_related('submitter').prefetch_related('reviewers__responses') 126 | context['page_permissions'] = self.object.permissions_for_user(self.request.user) 127 | 128 | return context 129 | 130 | 131 | def view_review_page(request, review_id=None): 132 | review = get_object_or_404(Review, id=review_id) 133 | 134 | # find a reviewer record corresponding to the current user 135 | # (the submitter of the review should always have one) 136 | try: 137 | reviewer = review.reviewers.get(user=request.user) 138 | except Reviewer.DoesNotExist: 139 | # current user is not participating in the review; 140 | # if they have edit access to the page, give them the submitter's 141 | # read-only credentials so that they can see annotations 142 | 143 | page = review.page_revision.as_object() 144 | perms = page.permissions_for_user(request.user) 145 | 146 | if not (perms.can_edit() or perms.can_publish()): 147 | raise PermissionDenied 148 | 149 | try: 150 | reviewer = review.reviewers.get(user=review.submitter) 151 | except Reviewer.DoesNotExist: 152 | raise PermissionDenied 153 | 154 | page = review.page_revision.as_object() 155 | if reviewer.user == request.user: 156 | review_mode = 'comment' 157 | else: 158 | review_mode = 'view' 159 | 160 | return page.make_preview_request( 161 | original_request=request, 162 | extra_request_attrs={ 163 | 'wagtailreview_reviewer': reviewer, 164 | 'wagtailreview_mode': review_mode, 165 | } 166 | ) 167 | 168 | 169 | @require_POST 170 | def close_review(request, review_id=None): 171 | review = get_object_or_404(Review, id=review_id) 172 | page = review.page_revision.as_object() 173 | perms = page.permissions_for_user(request.user) 174 | 175 | if not (perms.can_edit() or perms.can_publish()): 176 | raise PermissionDenied 177 | 178 | review.status = 'closed' 179 | review.save() 180 | 181 | messages.success(request, _("The review has been closed.")) 182 | 183 | return redirect('wagtail_review_admin:audit_trail', page.id) 184 | 185 | 186 | @require_POST 187 | def close_and_publish(request, review_id=None): 188 | review = get_object_or_404(Review, id=review_id) 189 | page = review.page_revision.as_object() 190 | perms = page.permissions_for_user(request.user) 191 | if not perms.can_publish(): 192 | raise PermissionDenied 193 | 194 | review.status = 'closed' 195 | review.save() 196 | review.page_revision.publish() 197 | 198 | messages.success(request, _("The review has been closed and the page published.")) 199 | 200 | return redirect('wagtail_review_admin:audit_trail', page.id) 201 | 202 | 203 | @require_POST 204 | def reopen_review(request, review_id=None): 205 | review = get_object_or_404(Review, id=review_id) 206 | page = review.page_revision.as_object() 207 | perms = page.permissions_for_user(request.user) 208 | 209 | if not (perms.can_edit() or perms.can_publish()): 210 | raise PermissionDenied 211 | 212 | review.status = 'open' 213 | review.save() 214 | 215 | messages.success(request, _("The review has been reopened.")) 216 | 217 | return redirect('wagtail_review_admin:audit_trail', page.id) 218 | -------------------------------------------------------------------------------- /wagtail_review/models.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from django.db import models 7 | from django.db.models import Case, Value, When 8 | from django.template.loader import render_to_string 9 | from django.urls import reverse 10 | from django.utils.functional import cached_property 11 | from django.utils.translation import gettext_lazy as _ 12 | 13 | import swapper 14 | 15 | from wagtail import VERSION as WAGTAIL_VERSION 16 | from wagtail.admin.mail import send_mail 17 | 18 | if WAGTAIL_VERSION >= (5, 1): 19 | from wagtail.permission_policies.pages import PagePermissionPolicy 20 | else: 21 | from wagtail.models import UserPagePermissionsProxy 22 | 23 | from wagtail_review.text import user_display_name 24 | 25 | 26 | # make the setting name WAGTAILREVIEW_REVIEW_MODEL rather than WAGTAIL_REVIEW_REVIEW_MODEL 27 | swapper.set_app_prefix('wagtail_review', 'wagtailreview') 28 | 29 | 30 | REVIEW_STATUS_CHOICES = [ 31 | ('open', _("Open")), 32 | ('closed', _("Closed")), 33 | ] 34 | 35 | 36 | revision_model = "wagtailcore.Revision" 37 | revision_page_fk_relation = "page_revision__object_id" 38 | 39 | class BaseReview(models.Model): 40 | """ 41 | Abstract base class for Review models. Can be subclassed to specify application-specific fields, e.g. review type 42 | """ 43 | page_revision = models.ForeignKey(revision_model, related_name='+', on_delete=models.CASCADE, editable=False) 44 | status = models.CharField(max_length=30, default='open', choices=REVIEW_STATUS_CHOICES, editable=False) 45 | submitter = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='+', editable=False) 46 | created_at = models.DateTimeField(auto_now_add=True) 47 | 48 | def send_request_emails(self): 49 | # send request emails to all reviewers except the reviewer record for the user submitting the request 50 | for reviewer in self.reviewers.exclude(user=self.submitter): 51 | reviewer.send_request_email() 52 | 53 | @cached_property 54 | def revision_as_page(self): 55 | return self.page_revision.as_object() 56 | 57 | def get_annotations(self): 58 | return Annotation.objects.filter(reviewer__review=self).prefetch_related('ranges') 59 | 60 | def get_responses(self): 61 | return Response.objects.filter(reviewer__review=self).order_by('created_at').select_related('reviewer') 62 | 63 | def get_non_responding_reviewers(self): 64 | return self.reviewers.filter(responses__isnull=True).exclude(user=self.submitter) 65 | 66 | @classmethod 67 | def get_pages_with_reviews_for_user(cls, user): 68 | """ 69 | Return a queryset of pages which have reviews, for which the user has edit permission 70 | """ 71 | if WAGTAIL_VERSION >= (5, 1): 72 | editable_pages = PagePermissionPolicy().instances_user_has_permission_for(user, "change") 73 | else: 74 | editable_pages = UserPagePermissionsProxy(user).editable_pages() 75 | 76 | reviewed_pages = ( 77 | cls.objects 78 | .order_by('-created_at') 79 | .values_list(revision_page_fk_relation, 'created_at') 80 | ) 81 | # Annotate datetime when a review was last created for this page 82 | last_review_requested_at = Case( 83 | *[ 84 | When(pk=pk, then=Value(created_at)) 85 | for pk, created_at in reviewed_pages 86 | ], 87 | output_field=models.DateTimeField(), 88 | ) 89 | return ( 90 | editable_pages 91 | .filter(pk__in=(page[0] for page in reviewed_pages)) 92 | .annotate(last_review_requested_at=last_review_requested_at) 93 | .order_by('-last_review_requested_at') 94 | ) 95 | 96 | class Meta: 97 | abstract = True 98 | 99 | 100 | class Review(BaseReview): 101 | class Meta: 102 | swappable = swapper.swappable_setting('wagtail_review', 'Review') 103 | 104 | 105 | def generate_token(): 106 | return ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(16)) 107 | 108 | 109 | class Reviewer(models.Model): 110 | review = models.ForeignKey(swapper.get_model_name('wagtail_review', 'Review'), related_name='reviewers', on_delete=models.CASCADE) 111 | user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE, related_name='+') 112 | email = models.EmailField(blank=True) 113 | response_token = models.CharField( 114 | max_length=32, editable=False, 115 | help_text="Secret token this user must supply to be allowed to respond to the review" 116 | ) 117 | view_token = models.CharField( 118 | max_length=32, editable=False, 119 | help_text="Secret token this user must supply to be allowed to view the page revision being reviewed" 120 | ) 121 | 122 | def clean(self): 123 | if self.user is None and not self.email: 124 | raise ValidationError("A reviewer must have either an email address or a user account") 125 | 126 | def get_email_address(self): 127 | return self.email or self.user.email 128 | 129 | def get_name(self): 130 | return user_display_name(self.user) if self.user else self.email 131 | 132 | def save(self, **kwargs): 133 | if not self.response_token: 134 | self.response_token = generate_token() 135 | if not self.view_token: 136 | self.view_token = generate_token() 137 | 138 | super().save(**kwargs) 139 | 140 | def get_respond_url(self, absolute=False): 141 | url = reverse('wagtail_review:respond', args=[self.id, self.response_token]) 142 | if absolute: 143 | url = settings.WAGTAILADMIN_BASE_URL + url 144 | return url 145 | 146 | def get_view_url(self, absolute=False): 147 | url = reverse('wagtail_review:view', args=[self.id, self.view_token]) 148 | if absolute: 149 | url = settings.WAGTAILADMIN_BASE_URL + url 150 | return url 151 | 152 | def send_request_email(self): 153 | email_address = self.get_email_address() 154 | 155 | context = { 156 | 'email': email_address, 157 | 'user': self.user, 158 | 'review': self.review, 159 | 'page': self.review.revision_as_page, 160 | 'submitter': self.review.submitter, 161 | 'respond_url': self.get_respond_url(absolute=True), 162 | 'view_url': self.get_view_url(absolute=True), 163 | } 164 | 165 | email_subject = render_to_string('wagtail_review/email/request_review_subject.txt', context).strip() 166 | email_content = render_to_string('wagtail_review/email/request_review.txt', context).strip() 167 | 168 | send_mail(email_subject, email_content, [email_address]) 169 | 170 | 171 | class Annotation(models.Model): 172 | reviewer = models.ForeignKey(Reviewer, related_name='annotations', on_delete=models.CASCADE) 173 | quote = models.TextField(blank=True) 174 | text = models.TextField(blank=True) 175 | created_at = models.DateTimeField(auto_now_add=True) 176 | updated_at = models.DateTimeField(auto_now=True) 177 | 178 | def as_json_data(self): 179 | return { 180 | 'id': self.id, 181 | 'annotator_schema_version': 'v1.0', 182 | 'created': self.created_at.isoformat(), 183 | 'updated': self.updated_at.isoformat(), 184 | 'text': self.text, 185 | 'quote': self.quote, 186 | 'user': { 187 | 'id': self.reviewer.id, 188 | 'name': self.reviewer.get_name(), 189 | }, 190 | 'ranges': [r.as_json_data() for r in self.ranges.all()], 191 | } 192 | 193 | 194 | class AnnotationRange(models.Model): 195 | annotation = models.ForeignKey(Annotation, related_name='ranges', on_delete=models.CASCADE) 196 | start = models.TextField() 197 | start_offset = models.IntegerField() 198 | end = models.TextField() 199 | end_offset = models.IntegerField() 200 | 201 | def as_json_data(self): 202 | return { 203 | 'start': self.start, 204 | 'startOffset': self.start_offset, 205 | 'end': self.end, 206 | 'endOffset': self.end_offset, 207 | } 208 | 209 | 210 | RESULT_CHOICES = ( 211 | ('approve', 'Approved'), 212 | ('comment', 'Comment'), 213 | ) 214 | 215 | 216 | class Response(models.Model): 217 | reviewer = models.ForeignKey(Reviewer, related_name='responses', on_delete=models.CASCADE) 218 | result = models.CharField(choices=RESULT_CHOICES, max_length=10, blank=False, default=None) 219 | comment = models.TextField() 220 | created_at = models.DateTimeField(auto_now_add=True) 221 | 222 | def send_notification_to_submitter(self): 223 | submitter = self.reviewer.review.submitter 224 | if submitter.email: 225 | 226 | context = { 227 | 'submitter': submitter, 228 | 'reviewer': self.reviewer, 229 | 'review': self.reviewer.review, 230 | 'page': self.reviewer.review.revision_as_page, 231 | 'response': self, 232 | } 233 | 234 | email_subject = render_to_string('wagtail_review/email/response_received_subject.txt', context).strip() 235 | email_content = render_to_string('wagtail_review/email/response_received.txt', context).strip() 236 | 237 | send_mail(email_subject, email_content, [submitter.email]) 238 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.contrib.auth.models import User 4 | from django.core import mail 5 | from django.test import TestCase 6 | 7 | from wagtail.models import Page 8 | 9 | from wagtail_review.models import Review 10 | 11 | 12 | class TestAdminViews(TestCase): 13 | fixtures = ['test.json'] 14 | 15 | def setUp(self): 16 | self.admin_user = User.objects.create_superuser( 17 | username='admin', email='admin@example.com', password='password' 18 | ) 19 | self.assertTrue( 20 | self.client.login(username='admin', password='password') 21 | ) 22 | self.homepage = Page.objects.get(url_path='/home/').specific 23 | 24 | def test_submit_for_review_action(self): 25 | """Test that 'submit for review' appears in the page action menu""" 26 | response = self.client.get('/admin/pages/%d/edit/' % self.homepage.pk) 27 | self.assertEqual(response.status_code, 200) 28 | 29 | self.assertContains(response, "Submit for review") 30 | # check that the action button has a data-url attribute 31 | self.assertContains(response, 'data-url="/admin/wagtail_review/create_review/"') 32 | # check that JS is imported 33 | self.assertContains(response, "wagtail_review/js/submit.js") 34 | 35 | def test_submit_for_review_action_on_create(self): 36 | """Test that 'submit for review' appears in the page action menu""" 37 | response = self.client.get('/admin/pages/add/tests/simplepage/%d/' % self.homepage.pk) 38 | self.assertEqual(response.status_code, 200) 39 | 40 | self.assertContains(response, "Submit for review") 41 | # check that the action button has a data-url attribute 42 | self.assertContains(response, 'data-url="/admin/wagtail_review/create_review/"') 43 | # check that JS is imported 44 | self.assertContains(response, "wagtail_review/js/submit.js") 45 | 46 | def test_create_review(self): 47 | response = self.client.get('/admin/wagtail_review/create_review/') 48 | self.assertEqual(response.status_code, 200) 49 | response_json = json.loads(response.content) 50 | self.assertEqual(response_json['step'], 'form') 51 | 52 | def test_user_autocomplete(self): 53 | response = self.client.get('/admin/wagtail_review/autocomplete_users/?q=homer') 54 | self.assertEqual(response.status_code, 200) 55 | data = json.loads(response.content) 56 | self.assertEqual(data['results'], [ 57 | {'id': 2, 'full_name': 'Homer Simpson', 'username': 'homer'} 58 | ]) 59 | 60 | response = self.client.get('/admin/wagtail_review/autocomplete_users/?q=pants') 61 | self.assertEqual(response.status_code, 200) 62 | data = json.loads(response.content) 63 | self.assertEqual(data['results'], [ 64 | {'id': 1, 'full_name': 'Spongebob Squarepants', 'username': 'spongebob'} 65 | ]) 66 | 67 | def test_validate_reviewers_required(self): 68 | # reject a completely empty formset 69 | response = self.client.post('/admin/wagtail_review/create_review/', { 70 | 'create_review_reviewers-TOTAL_FORMS': 0, 71 | 'create_review_reviewers-INITIAL_FORMS': 0, 72 | 'create_review_reviewers-MIN_NUM_FORMS': 0, 73 | 'create_review_reviewers-MAX_NUM_FORMS': 1000, 74 | }) 75 | self.assertEqual(response.status_code, 200) 76 | response_json = json.loads(response.content) 77 | self.assertEqual(response_json['step'], 'form') 78 | self.assertFormSetError(response.context['reviewer_formset'], None, None, "Please select one or more reviewers.") 79 | 80 | # reject a formset with only deleted items 81 | response = self.client.post('/admin/wagtail_review/create_review/', { 82 | 'create_review_reviewers-TOTAL_FORMS': 1, 83 | 'create_review_reviewers-INITIAL_FORMS': 0, 84 | 'create_review_reviewers-MIN_NUM_FORMS': 0, 85 | 'create_review_reviewers-MAX_NUM_FORMS': 1000, 86 | 87 | 'create_review_reviewers-0-user': '', 88 | 'create_review_reviewers-0-email': 'someone@example.com', 89 | 'create_review_reviewers-0-DELETE': '1', 90 | }) 91 | self.assertEqual(response.status_code, 200) 92 | response_json = json.loads(response.content) 93 | self.assertEqual(response_json['step'], 'form') 94 | self.assertFormSetError(response.context['reviewer_formset'], None, None, "Please select one or more reviewers.") 95 | 96 | def test_validate_ok(self): 97 | response = self.client.post('/admin/wagtail_review/create_review/', { 98 | 'create_review_reviewers-TOTAL_FORMS': 1, 99 | 'create_review_reviewers-INITIAL_FORMS': 0, 100 | 'create_review_reviewers-MIN_NUM_FORMS': 0, 101 | 'create_review_reviewers-MAX_NUM_FORMS': 1000, 102 | 103 | 'create_review_reviewers-0-user': '', 104 | 'create_review_reviewers-0-email': 'someone@example.com', 105 | 'create_review_reviewers-0-DELETE': '', 106 | }) 107 | self.assertEqual(response.status_code, 200) 108 | response_json = json.loads(response.content) 109 | self.assertEqual(response_json['step'], 'done') 110 | 111 | def test_post_edit_form(self): 112 | response = self.client.post('/admin/pages/2/edit/', { 113 | 'title': "Home submitted", 114 | 'slug': 'title', 115 | 116 | 'create_review_reviewers-TOTAL_FORMS': 2, 117 | 'create_review_reviewers-INITIAL_FORMS': 0, 118 | 'create_review_reviewers-MIN_NUM_FORMS': 0, 119 | 'create_review_reviewers-MAX_NUM_FORMS': 1000, 120 | 121 | 'create_review_reviewers-0-user': '', 122 | 'create_review_reviewers-0-email': 'someone@example.com', 123 | 'create_review_reviewers-0-DELETE': '', 124 | 125 | 'create_review_reviewers-1-user': User.objects.get(username='spongebob').pk, 126 | 'create_review_reviewers-1-email': '', 127 | 'create_review_reviewers-1-DELETE': '', 128 | 129 | 'action-submit-for-review': '1', 130 | }) 131 | 132 | self.assertRedirects(response, '/admin/pages/1/') 133 | 134 | self.homepage.refresh_from_db() 135 | revision = self.homepage.get_latest_revision() 136 | review = Review.objects.get(page_revision=revision) 137 | self.assertEqual(review.reviewers.count(), 3) 138 | 139 | reviewer_emails = set(reviewer.get_email_address() for reviewer in review.reviewers.all()) 140 | self.assertEqual(reviewer_emails, {'admin@example.com', 'someone@example.com', 'spongebob@example.com'}) 141 | 142 | self.assertEqual(len(mail.outbox), 2) 143 | email_recipients = set(email.to[0] for email in mail.outbox) 144 | self.assertEqual(email_recipients, {'someone@example.com', 'spongebob@example.com'}) 145 | 146 | def test_post_create_form(self): 147 | response = self.client.post('/admin/pages/add/tests/simplepage/2/', { 148 | 'title': "Subpage submitted", 149 | 'slug': 'subpage-submitted', 150 | 151 | 'create_review_reviewers-TOTAL_FORMS': 2, 152 | 'create_review_reviewers-INITIAL_FORMS': 0, 153 | 'create_review_reviewers-MIN_NUM_FORMS': 0, 154 | 'create_review_reviewers-MAX_NUM_FORMS': 1000, 155 | 156 | 'create_review_reviewers-0-user': '', 157 | 'create_review_reviewers-0-email': 'someone@example.com', 158 | 'create_review_reviewers-0-DELETE': '', 159 | 160 | 'create_review_reviewers-1-user': User.objects.get(username='spongebob').pk, 161 | 'create_review_reviewers-1-email': '', 162 | 'create_review_reviewers-1-DELETE': '', 163 | 164 | 'action-submit-for-review': '1', 165 | }) 166 | 167 | self.assertRedirects(response, '/admin/pages/2/') 168 | 169 | revision = Page.objects.get(slug='subpage-submitted').get_latest_revision() 170 | review = Review.objects.get(page_revision=revision) 171 | self.assertEqual(review.reviewers.count(), 3) 172 | 173 | reviewer_emails = set(reviewer.get_email_address() for reviewer in review.reviewers.all()) 174 | self.assertEqual(reviewer_emails, {'admin@example.com', 'someone@example.com', 'spongebob@example.com'}) 175 | 176 | self.assertEqual(len(mail.outbox), 2) 177 | email_recipients = set(email.to[0] for email in mail.outbox) 178 | self.assertEqual(email_recipients, {'someone@example.com', 'spongebob@example.com'}) 179 | 180 | def test_reviews_index(self): 181 | revision = self.homepage.save_revision() 182 | review = Review.objects.create(page_revision=revision, submitter=self.admin_user) 183 | review.reviewers.create(user=self.admin_user) 184 | review.reviewers.create(user=User.objects.get(username='spongebob')) 185 | response = self.client.get('/admin/wagtail_review/reviews/') 186 | self.assertEqual(response.status_code, 200) 187 | self.assertContains(response, 'Home' % self.homepage.pk, html=True) 188 | self.assertContains(response, 'Open', html=True) 189 | 190 | def test_review_audit_trail(self): 191 | revision = self.homepage.save_revision() 192 | review = Review.objects.create(page_revision=revision, submitter=self.admin_user) 193 | review.reviewers.create(user=self.admin_user) 194 | review.reviewers.create(user=User.objects.get(username='spongebob')) 195 | response = self.client.get('/admin/wagtail_review/reviews/%d/' % self.homepage.pk) 196 | self.assertEqual(response.status_code, 200) 197 | self.assertContains(response, 'Review requested by admin') 198 | self.assertContains(response, 'Spongebob Squarepants') 199 | self.assertContains(response, 'Awaiting response') 200 | 201 | def test_view_review(self): 202 | revision = self.homepage.save_revision() 203 | review = Review.objects.create(page_revision=revision, submitter=self.admin_user) 204 | review.reviewers.create(user=self.admin_user) 205 | review.reviewers.create(user=User.objects.get(username='spongebob')) 206 | response = self.client.get('/admin/wagtail_review/reviews/%d/view/' % review.pk) 207 | self.assertEqual(response.status_code, 200) 208 | --------------------------------------------------------------------------------