Re-work moderation of submitted items
authorMagnus Hagander <magnus@hagander.net>
Thu, 10 Sep 2020 12:52:41 +0000 (14:52 +0200)
committerMagnus Hagander <magnus@hagander.net>
Thu, 10 Sep 2020 12:52:41 +0000 (14:52 +0200)
This includes a number of new features:

* Move some moderation functionality into shared places, so we don't
  keep re-inventing the wheel.
* Implement three-state moderation, where the submitter can edit their
  item and then explicitly say "i'm done, please moderate this now".
  This is currently only implemented for News, but done in a reusable
  way.
* Move moderation workflow to it's own set of URLs instead of
  overloading it on the general admin interface. Admin interface remains
  for editing things, but these are now separated out into separate
  things.
* Do proper stylesheet clearing for moderation of markdown fields, using
  a dynamic sandboxed iframe, so it's not ruined by the /admin/ css.
* Move moderation email notification into dedicated moderation code,
  thereby simplifying the admin subclassing we did which was in some
  places quite fragile.
* Reset date of news postings to the date of their approval, when
  approved. This avoids some annoying ordering issues.

38 files changed:
media/css/admin_pgweb.css
media/css/main.css
media/js/admin_pgweb.js
pgweb/account/forms.py
pgweb/account/urls.py
pgweb/account/views.py
pgweb/core/forms.py
pgweb/core/models.py
pgweb/core/views.py
pgweb/downloads/forms.py
pgweb/downloads/models.py
pgweb/downloads/views.py
pgweb/events/models.py
pgweb/events/views.py
pgweb/news/admin.py
pgweb/news/feeds.py
pgweb/news/forms.py
pgweb/news/management/commands/twitter_post.py
pgweb/news/migrations/0005_modstate.py [new file with mode: 0644]
pgweb/news/models.py
pgweb/news/struct.py
pgweb/news/views.py
pgweb/profserv/forms.py
pgweb/profserv/models.py
pgweb/profserv/views.py
pgweb/urls.py
pgweb/util/admin.py
pgweb/util/helpers.py
pgweb/util/moderation.py
pgweb/util/signals.py
templates/account/index.html
templates/account/objectlist.html
templates/account/submit_form.html [new file with mode: 0644]
templates/account/submit_preview.html [new file with mode: 0644]
templates/admin/change_form_pgweb.html
templates/admin/news/newsarticle/change_form.html [deleted file]
templates/core/admin_moderation_form.html [new file with mode: 0644]
templates/core/admin_pending.html

index 3b91f3a6acc70b11abe7972841a22ccab5303e69..ba63f5ab15f4745ab57d6acc1032d92336caeedf 100644 (file)
@@ -1,3 +1,62 @@
+a.admbutton {
+    padding: 10px 15px;
+}
+
+div.modadmfield input,
+div.modadmfield select,
+div.modadmfield textarea {
+    width: 500px;
+}
+
+.moderation-form-row div {
+    display: inline-block;
+    vertical-align: top;
+}
+
+.moderation-form-row div.txtpreview {
+    border: 1px solid gray;
+    padding: 5px;
+    border-radius: 5px;
+    white-space: pre;
+    width: 500px;
+    overflow-x: auto;
+    margin-right: 20px;
+}
+
+.moderation-form-row iframe.mdpreview {
+    border: 1px solid gray;
+    padding: 5px;
+    border-radius: 5px;
+    width: 500px;
+    overflow-x: auto;
+}
+
+.moderation-form-row div.mdpreview-data {
+    display: none;
+}
+
+.moderation-form-row div.simplepreview {
+    max-width: 800px;
+}
+
+.moderror {
+    color: red !important;
+}
+
+div.modhelp {
+    display: block;
+    color: #999;
+    font-size: 11px;
+}
+
 #new_notification {
     width: 400px;
 }
+
+.wspre {
+    white-space: pre;
+}
+
+.nowrap {
+    white-space: nowrap;
+}
index 1dc828a97370f6b26fb23c6aef2a5b9cf4356b7d..db18a09290d738a8601b655cebee9d5296f9e50e 100644 (file)
@@ -197,6 +197,11 @@ p, ul, ol, dl, table {
   padding: 1em 2em;
 }
 
+/* Utility */
+.ws-pre {
+  white-space: pre;
+}
+
 /* #BLOCKQUOTE */
 
 blockquote {
index cf83ee45ecd8be9d6ed8e350de840d2968069b15..c920b5f162026a07376bf49e1205b0932f12a89d 100644 (file)
@@ -1,8 +1,28 @@
 window.onload = function() {
-    tael = document.getElementsByTagName('textarea');
-    for (i = 0; i < tael.length; i++) {
+    /* Preview in the pure admin views */
+    let tael = document.getElementsByTagName('textarea');
+    for (let i = 0; i < tael.length; i++) {
         if (tael[i].className.indexOf('markdown_preview') >= 0) {
             attach_showdown_preview(tael[i].id, 1);
         }
     }
+
+    /* Preview in the moderation view */
+    let previews = document.getElementsByClassName('mdpreview');
+    for (let i = 0; i < previews.length; i++) {
+        let iframe = previews[i];
+        let textdiv = iframe.previousElementSibling;
+        let hiddendiv = iframe.nextElementSibling;
+
+        /* Copy the HTML into the iframe */
+        iframe.srcdoc = hiddendiv.innerHTML;
+
+        /* Maybe we should apply *some* stylesheet here? */
+
+        /* Resize the height to to be the same */
+        if (textdiv.offsetHeight > iframe.offsetHeight)
+            iframe.style.height = textdiv.offsetHeight + 'px';
+        if (iframe.offsetHeight > textdiv.offsetHeight)
+            textdiv.style.height = iframe.offsetHeight + 'px';
+    }
 }
index 609101e23f69ef0935b9a7d790db6b396007f1b4..433216962b5359b4ceb34ac3238e6eb64da41f12 100644 (file)
@@ -192,3 +192,11 @@ class AddEmailForm(forms.Form):
 
 class PgwebPasswordResetForm(forms.Form):
     email = forms.EmailField()
+
+
+class ConfirmSubmitForm(forms.Form):
+    confirm = forms.BooleanField(required=True, help_text='Confirm')
+
+    def __init__(self, objtype, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['confirm'].help_text = 'Confirm that you are ready to submit this {}.'.format(objtype)
index 74d348b6b479de4a6fdf896610bb94c7ec8dd43b..3bd0c68f5c879017016d16d7903afc9213987ec2 100644 (file)
@@ -23,20 +23,13 @@ urlpatterns = [
     # List of items to edit
     url(r'^edit/(.*)/$', pgweb.account.views.listobjects),
 
-    # News & Events
-    url(r'^news/(.*)/$', pgweb.news.views.form),
-    url(r'^events/(.*)/$', pgweb.events.views.form),
-
-    # Software catalogue
-    url(r'^organisations/(.*)/$', pgweb.core.views.organisationform),
-    url(r'^products/(.*)/$', pgweb.downloads.views.productform),
+    # Submitted items
+    url(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
+    url(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
 
     # Organisation information
     url(r'^orglist/$', pgweb.account.views.orglist),
 
-    # Professional services
-    url(r'^services/(.*)/$', pgweb.profserv.views.profservform),
-
     # Docs comments
     url(r'^comments/(new)/([^/]+)/([^/]+)/$', pgweb.docs.views.commentform),
     url(r'^comments/(new)/([^/]+)/([^/]+)/done/$', pgweb.docs.views.commentform_done),
index 70b33c17e4fe5c6cc941d34392b211db506c7c9b..14d4bd7b2a0b0c49c95a102ece5e4f88a95eb584 100644 (file)
@@ -2,8 +2,9 @@ from django.contrib.auth.models import User
 from django.contrib.auth import login as django_login
 import django.contrib.auth.views as authviews
 from django.http import HttpResponseRedirect, Http404, HttpResponse
+from django.core.exceptions import PermissionDenied
 from django.shortcuts import get_object_or_404
-from pgweb.util.decorators import login_required, script_sources, frame_sources
+from pgweb.util.decorators import login_required, script_sources, frame_sources, content_sources
 from django.utils.encoding import force_bytes
 from django.utils.http import urlsafe_base64_encode
 from django.contrib.auth.tokens import default_token_generator
@@ -23,23 +24,28 @@ import itertools
 
 from pgweb.util.contexts import render_pgweb
 from pgweb.util.misc import send_template_mail, generate_random_token, get_client_ip
-from pgweb.util.helpers import HttpSimpleResponse
+from pgweb.util.helpers import HttpSimpleResponse, simple_form
+from pgweb.util.moderation import ModerationState
 
 from pgweb.news.models import NewsArticle
 from pgweb.events.models import Event
-from pgweb.core.models import Organisation, UserProfile
+from pgweb.core.models import Organisation, UserProfile, ModerationNotification
 from pgweb.contributors.models import Contributor
 from pgweb.downloads.models import Product
 from pgweb.profserv.models import ProfessionalService
 
 from .models import CommunityAuthSite, CommunityAuthConsent, SecondaryEmail
-from .forms import PgwebAuthenticationForm
+from .forms import PgwebAuthenticationForm, ConfirmSubmitForm
 from .forms import CommunityAuthConsentForm
 from .forms import SignupForm, SignupOauthForm
 from .forms import UserForm, UserProfileForm, ContributorForm
 from .forms import AddEmailForm, PgwebPasswordResetForm
 
 import logging
+
+from pgweb.util.moderation import get_moderation_model_from_suburl
+from pgweb.mailqueue.util import send_simple_mail
+
 log = logging.getLogger(__name__)
 
 # The value we store in user.password for oauth logins. This is
@@ -47,43 +53,69 @@ log = logging.getLogger(__name__)
 OAUTH_PASSWORD_STORE = 'oauth_signin_account_no_password'
 
 
+def _modobjs(qs):
+    l = list(qs)
+    if l:
+        return {
+            'title': l[0]._meta.verbose_name_plural.capitalize(),
+            'objects': l,
+            'editurl': l[0].account_edit_suburl,
+        }
+    else:
+        return None
+
+
 @login_required
 def home(request):
-    myarticles = NewsArticle.objects.filter(org__managers=request.user, approved=False)
-    myevents = Event.objects.filter(org__managers=request.user, approved=False)
-    myorgs = Organisation.objects.filter(managers=request.user, approved=False)
-    myproducts = Product.objects.filter(org__managers=request.user, approved=False)
-    myprofservs = ProfessionalService.objects.filter(org__managers=request.user, approved=False)
     return render_pgweb(request, 'account', 'account/index.html', {
-        'newsarticles': myarticles,
-        'events': myevents,
-        'organisations': myorgs,
-        'products': myproducts,
-        'profservs': myprofservs,
+        'modobjects': [
+            {
+                'title': 'not submitted yet',
+                'objects': [
+                    _modobjs(NewsArticle.objects.filter(org__managers=request.user, modstate=ModerationState.CREATED)),
+                ],
+            },
+            {
+                'title': 'waiting for moderator approval',
+                'objects': [
+                    _modobjs(NewsArticle.objects.filter(org__managers=request.user, modstate=ModerationState.PENDING)),
+                    _modobjs(Event.objects.filter(org__managers=request.user, approved=False)),
+                    _modobjs(Organisation.objects.filter(managers=request.user, approved=False)),
+                    _modobjs(Product.objects.filter(org__managers=request.user, approved=False)),
+                    _modobjs(ProfessionalService.objects.filter(org__managers=request.user, approved=False))
+                ],
+            },
+        ],
     })
 
 
 objtypes = {
     'news': {
-        'title': 'News Article',
+        'title': 'news article',
         'objects': lambda u: NewsArticle.objects.filter(org__managers=u),
+        'tristate': True,
+        'editapproved': False,
     },
     'events': {
-        'title': 'Event',
+        'title': 'event',
         'objects': lambda u: Event.objects.filter(org__managers=u),
+        'editapproved': True,
     },
     'products': {
-        'title': 'Product',
+        'title': 'product',
         'objects': lambda u: Product.objects.filter(org__managers=u),
+        'editapproved': True,
     },
     'services': {
-        'title': 'Professional Service',
+        'title': 'professional service',
         'objects': lambda u: ProfessionalService.objects.filter(org__managers=u),
+        'editapproved': True,
     },
     'organisations': {
-        'title': 'Organisation',
+        'title': 'organisation',
         'objects': lambda u: Organisation.objects.filter(managers=u),
         'submit_header': 'Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
+        'editapproved': True,
     },
 }
 
@@ -193,14 +225,25 @@ def listobjects(request, objtype):
         raise Http404("Object type not found")
     o = objtypes[objtype]
 
-    return render_pgweb(request, 'account', 'account/objectlist.html', {
-        'objects': {
+    if o.get('tristate', False):
+        objects = {
+            'approved': o['objects'](request.user).filter(modstate=ModerationState.APPROVED),
+            'unapproved': o['objects'](request.user).filter(modstate=ModerationState.PENDING),
+            'inprogress': o['objects'](request.user).filter(modstate=ModerationState.CREATED),
+        }
+    else:
+        objects = {
             'approved': o['objects'](request.user).filter(approved=True),
             'unapproved': o['objects'](request.user).filter(approved=False),
-        },
+        }
+
+    return render_pgweb(request, 'account', 'account/objectlist.html', {
+        'objects': objects,
         'title': o['title'],
+        'editapproved': o['editapproved'],
         'submit_header': o.get('submit_header', None),
         'suburl': objtype,
+        'tristate': o.get('tristate', False),
     })
 
 
@@ -213,6 +256,105 @@ def orglist(request):
     })
 
 
+@login_required
+@transaction.atomic
+def submitted_item_form(request, objtype, item):
+    model = get_moderation_model_from_suburl(objtype)
+
+    if item == 'new':
+        extracontext = {}
+    else:
+        extracontext = {
+            'notices': ModerationNotification.objects.filter(
+                objecttype=model.__name__,
+                objectid=item,
+            ).order_by('-date')
+        }
+
+    return simple_form(model, item, request, model.get_formclass(),
+                       redirect='/account/edit/{}/'.format(objtype),
+                       formtemplate='account/submit_form.html',
+                       extracontext=extracontext)
+
+
+@content_sources('style', "'unsafe-inline'")
+def _submitted_item_submit(request, objtype, model, obj):
+    if obj.modstate != ModerationState.CREATED:
+        # Can only submit if state is created
+        return HttpResponseRedirect("/account/edit/{}/".format(objtype))
+
+    if request.method == 'POST':
+        form = ConfirmSubmitForm(obj._meta.verbose_name, data=request.POST)
+        if form.is_valid():
+            with transaction.atomic():
+                obj.modstate = ModerationState.PENDING
+                obj.send_notification = False
+                obj.save()
+
+                send_simple_mail(settings.NOTIFICATION_FROM,
+                                 settings.NOTIFICATION_EMAIL,
+                                 "{} {} submitted".format(obj._meta.verbose_name.capitalize(), obj.id),
+                                 "{} {} with title '{}' submitted for moderation by {}".format(
+                                     obj._meta.verbose_name.capitalize(),
+                                     obj.id,
+                                     obj.title,
+                                     request.user.username
+                                 ),
+                                 )
+                return HttpResponseRedirect("/account/edit/{}/".format(objtype))
+    else:
+        form = ConfirmSubmitForm(obj._meta.verbose_name)
+
+    return render_pgweb(request, 'account', 'account/submit_preview.html', {
+        'obj': obj,
+        'form': form,
+        'objtype': obj._meta.verbose_name,
+        'preview': obj.get_preview_fields(),
+    })
+
+
+def _submitted_item_withdraw(request, objtype, model, obj):
+    if obj.modstate != ModerationState.PENDING:
+        # Can only withdraw if it's in pending state
+        return HttpResponseRedirect("/account/edit/{}/".format(objtype))
+
+    obj.modstate = ModerationState.CREATED
+    obj.send_notification = False
+    if obj.twomoderators:
+        obj.firstmoderator = None
+        obj.save(update_fields=['modstate', 'firstmoderator'])
+    else:
+        obj.save(update_fields=['modstate', ])
+
+    send_simple_mail(
+        settings.NOTIFICATION_FROM,
+        settings.NOTIFICATION_EMAIL,
+        "{} {} withdrawn from moderation".format(model._meta.verbose_name.capitalize(), obj.id),
+        "{} {} with title {} withdrawn from moderation by {}".format(
+            model._meta.verbose_name.capitalize(),
+            obj.id,
+            obj.title,
+            request.user.username
+        ),
+    )
+    return HttpResponseRedirect("/account/edit/{}/".format(objtype))
+
+
+@login_required
+@transaction.atomic
+def submitted_item_submitwithdraw(request, objtype, item, what):
+    model = get_moderation_model_from_suburl(objtype)
+
+    obj = get_object_or_404(model, pk=item)
+    if not obj.verify_submitter(request.user):
+        raise PermissionDenied("You are not the owner of this item!")
+
+    if what == 'submit':
+        return _submitted_item_submit(request, objtype, model, obj)
+    else:
+        return _submitted_item_withdraw(request, objtype, model, obj)
+
+
 def login(request):
     return authviews.LoginView.as_view(template_name='account/login.html',
                                        authentication_form=PgwebAuthenticationForm,
index 65d1026d9518186e2b3efe15edf982ad09d26335..30468a5e3c1fd8affadc76065c70632ae74084e4 100644 (file)
@@ -6,6 +6,7 @@ from .models import Organisation
 from django.contrib.auth.models import User
 
 from pgweb.util.middleware import get_current_user
+from pgweb.util.moderation import ModerationState
 from pgweb.mailqueue.util import send_simple_mail
 
 
@@ -79,3 +80,42 @@ class MergeOrgsForm(forms.Form):
         if self.cleaned_data['merge_into'] == self.cleaned_data['merge_from']:
             raise ValidationError("The two organisations selected must be different!")
         return self.cleaned_data
+
+
+class ModerationForm(forms.Form):
+    modnote = forms.CharField(label='Moderation notice', widget=forms.Textarea, required=False,
+                              help_text="This note will be sent to the creator of the object regardless of if the moderation state has changed.")
+    oldmodstate = forms.CharField(label='Current moderation state', disabled=True)
+    modstate = forms.ChoiceField(label='New moderation status', choices=ModerationState.CHOICES + (
+        (ModerationState.REJECTED, 'Reject and delete'),
+    ))
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user')
+        self.obj = kwargs.pop('obj')
+        self.twostate = hasattr(self.obj, 'approved')
+
+        super().__init__(*args, **kwargs)
+        if self.twostate:
+            self.fields['modstate'].choices = [(k, v) for k, v in self.fields['modstate'].choices if int(k) != 1]
+        if self.obj.twomoderators:
+            if self.obj.firstmoderator:
+                self.fields['modstate'].help_text = 'This object requires approval from two moderators. It has already been approved by {}.'.format(self.obj.firstmoderator)
+            else:
+                self.fields['modstate'].help_text = 'This object requires approval from two moderators.'
+
+    def clean_modstate(self):
+        state = int(self.cleaned_data['modstate'])
+        if state == ModerationState.APPROVED and self.obj.twomoderators and self.obj.firstmoderator == self.user:
+            raise ValidationError("You already moderated this object, waiting for a *different* moderator")
+        return state
+
+    def clean(self):
+        cleaned_data = super().clean()
+
+        note = cleaned_data['modnote']
+
+        if note and int(cleaned_data['modstate']) == ModerationState.APPROVED and self.obj.twomoderators and not self.obj.firstmoderator:
+            self.add_error('modnote', ("Moderation notices cannot be sent on first-moderator approvals for objects that require two moderators."))
+
+        return cleaned_data
index c4f8a4cda4384a3b345b254e68b1d460a6e3aff1..63b70a7a2ac88292a55be7f1f020422982bb6934 100644 (file)
@@ -5,6 +5,8 @@ from pgweb.util.misc import varnish_purge
 
 import base64
 
+from pgweb.util.moderation import TwostateModerateModel
+
 TESTING_CHOICES = (
     (0, 'Release'),
     (1, 'Release candidate'),
@@ -121,9 +123,8 @@ class OrganisationType(models.Model):
         return self.typename
 
 
-class Organisation(models.Model):
+class Organisation(TwostateModerateModel):
     name = models.CharField(max_length=100, null=False, blank=False, unique=True)
-    approved = models.BooleanField(null=False, default=False)
     address = models.TextField(null=False, blank=True)
     url = models.URLField(null=False, blank=False)
     email = models.EmailField(null=False, blank=True)
@@ -132,15 +133,24 @@ class Organisation(models.Model):
     managers = models.ManyToManyField(User, blank=False)
     lastconfirmed = models.DateTimeField(null=False, blank=False, auto_now_add=True)
 
-    send_notification = True
-    send_m2m_notification = True
+    account_edit_suburl = 'organisations'
+    moderation_fields = ['address', 'url', 'email', 'phone', 'orgtype', 'managers']
 
     def __str__(self):
         return self.name
 
+    @property
+    def title(self):
+        return self.name
+
     class Meta:
         ordering = ('name',)
 
+    @classmethod
+    def get_formclass(self):
+        from pgweb.core.forms import OrganisationForm
+        return OrganisationForm
+
 
 # Basic classes for importing external RSS feeds, such as planet
 class ImportedRSSFeed(models.Model):
index a835cadb472e680a6302abeacc9b256b8ddb1059..15c1a756bfb75bc60e94153ddd141b4f18cc412c 100644 (file)
@@ -4,7 +4,7 @@ from django.http import HttpResponseNotModified
 from django.core.exceptions import PermissionDenied
 from django.template import TemplateDoesNotExist, loader
 from django.contrib.auth.decorators import user_passes_test
-from pgweb.util.decorators import login_required
+from pgweb.util.decorators import login_required, content_sources
 from django.contrib import messages
 from django.views.decorators.csrf import csrf_exempt
 from django.db import connection, transaction
@@ -20,28 +20,29 @@ import urllib.parse
 from pgweb.util.decorators import cache, nocache
 from pgweb.util.contexts import render_pgweb, get_nav_menu, PGWebContextProcessor
 from pgweb.util.helpers import simple_form, PgXmlHelper
-from pgweb.util.moderation import get_all_pending_moderations
+from pgweb.util.moderation import get_all_pending_moderations, get_moderation_model, ModerationState
 from pgweb.util.misc import get_client_ip, varnish_purge, varnish_purge_expr, varnish_purge_xkey
 from pgweb.util.sitestruct import get_all_pages_struct
+from pgweb.mailqueue.util import send_simple_mail
 
 # models needed for the pieces on the frontpage
 from pgweb.news.models import NewsArticle, NewsTag
 from pgweb.events.models import Event
 from pgweb.quotes.models import Quote
-from .models import Version, ImportedRSSItem
+from .models import Version, ImportedRSSItem, ModerationNotification
 
 # models needed for the pieces on the community page
 from pgweb.survey.models import Survey
 
 # models and forms needed for core objects
 from .models import Organisation
-from .forms import OrganisationForm, MergeOrgsForm
+from .forms import MergeOrgsForm, ModerationForm
 
 
 # Front page view
 @cache(minutes=10)
 def home(request):
-    news = NewsArticle.objects.filter(approved=True)[:5]
+    news = NewsArticle.objects.filter(modstate=ModerationState.APPROVED)[:5]
     today = date.today()
     # get up to seven events to display on the homepage
     event_base_queryset = Event.objects.select_related('country').filter(
@@ -136,16 +137,6 @@ def fallback(request, url):
     return HttpResponse(t.render(c))
 
 
-# Edit-forms for core objects
-@login_required
-def organisationform(request, itemid):
-    if itemid != 'new':
-        get_object_or_404(Organisation, pk=itemid, managers=request.user)
-
-    return simple_form(Organisation, itemid, request, OrganisationForm,
-                       redirect='/account/edit/organisations/')
-
-
 # robots.txt
 def robots(request):
     return HttpResponse("""User-agent: *
@@ -288,6 +279,187 @@ def admin_pending(request):
     })
 
 
+def _send_moderation_message(request, obj, message, notice, what):
+    if message and notice:
+        msg = "{}\n\nThe following further information was provided:\n{}".format(message, notice)
+    elif notice:
+        msg = notice
+    else:
+        msg = message
+
+    n = ModerationNotification(
+        objectid=obj.id,
+        objecttype=type(obj).__name__,
+        text=msg,
+        author=request.user,
+    )
+    n.save()
+
+    # In the email, add a link back to the item in the bottom
+    msg += "\n\nYou can view your {} by going to\n{}/account/edit/{}/".format(
+        obj._meta.verbose_name,
+        settings.SITE_ROOT,
+        obj.account_edit_suburl,
+    )
+
+    # Send message to org admin
+    if isinstance(obj, Organisation):
+        orgemail = obj.email
+    else:
+        orgemail = obj.org.email
+
+    send_simple_mail(
+        settings.NOTIFICATION_FROM,
+        orgemail,
+        "Your submitted {} with title {}".format(obj._meta.verbose_name, obj.title),
+        msg,
+        suppress_auto_replies=False,
+    )
+
+    # Send notification to admins
+    if what:
+        admmsg = message
+        if obj.is_approved:
+            admmsg += "\n\nNOTE! This {} was previously approved!!".format(obj._meta.verbose_name)
+
+        if notice:
+            admmsg += "\n\nModeration notice:\n{}".format(notice)
+
+        admmsg += "\n\nEdit at: {}/admin/_moderate/{}/{}/\n".format(settings.SITE_ROOT, obj._meta.model_name, obj.id)
+
+        if obj.twomoderators:
+            modname = "{} and {}".format(obj.firstmoderator, request.user)
+        else:
+            modname = request.user
+
+        send_simple_mail(settings.NOTIFICATION_FROM,
+                         settings.NOTIFICATION_EMAIL,
+                         "{} {} by {}".format(obj._meta.verbose_name.capitalize(), what, modname),
+                         admmsg)
+
+
+# Moderate a single item
+@login_required
+@user_passes_test(lambda u: u.groups.filter(name='pgweb moderators').exists())
+@transaction.atomic
+@content_sources('style', "'unsafe-inline'")
+def admin_moderate(request, objtype, objid):
+    model = get_moderation_model(objtype)
+    obj = get_object_or_404(model, pk=objid)
+
+    initdata = {
+        'oldmodstate': obj.modstate_string,
+        'modstate': obj.modstate,
+    }
+    # Else deal with it as a form
+    if request.method == 'POST':
+        form = ModerationForm(request.POST, user=request.user, obj=obj, initial=initdata)
+        if form.is_valid():
+            # Ok, do something!
+            modstate = int(form.cleaned_data['modstate'])
+            modnote = form.cleaned_data['modnote']
+            savefields = []
+
+            if modstate == obj.modstate:
+                # No change in moderation state, but did we want to send a message?
+                if modnote:
+                    _send_moderation_message(request, obj, None, modnote, None)
+                    messages.info(request, "Moderation message sent, no state changed.")
+                    return HttpResponseRedirect("/admin/pending/")
+                else:
+                    messages.warning(request, "Moderation state not changed and no moderation note added.")
+                    return HttpResponseRedirect(".")
+
+            # Ok, we have a moderation state change!
+            if modstate == ModerationState.CREATED:
+                # Returned to editing again (for two-state, this means de-moderated)
+                _send_moderation_message(request,
+                                         obj,
+                                         "The {} with title {}\nhas been returned for further editing.\nPlease re-submit when you have adjusted it.".format(
+                                             obj._meta.verbose_name,
+                                             obj.title
+                                         ),
+                                         modnote,
+                                         "returned")
+            elif modstate == ModerationState.PENDING:
+                # Pending moderation should never happen if we actually *change* the value
+                messages.warning(request, "Cannot change state to 'pending moderation'")
+                return HttpResponseRedirect(".")
+            elif modstate == ModerationState.APPROVED:
+                # Object requires two moderators
+                if obj.twomoderators:
+                    # Do we already have a moderator who approved it?
+                    if not obj.firstmoderator:
+                        # Nope. That means we record ourselves as the first moderator, and wait for a second moderator.
+                        obj.firstmoderator = request.user
+                        obj.save(update_fields=['firstmoderator', ])
+                        messages.info(request, "{} approved, waiting for second moderator.".format(obj._meta.verbose_name))
+                        return HttpResponseRedirect("/admin/pending")
+                    elif obj.firstmoderator == request.user:
+                        # Already approved by *us*
+                        messages.warning(request, "{} was already approved by you, waiting for a second *different* moderator.".format(obj._meta.verbose_name))
+                        return HttpResponseRedirect("/admin/pending")
+                    # Else we fall through and approve it, as if only a single moderator was required
+
+                _send_moderation_message(request,
+                                         obj,
+                                         "The {} with title {}\nhas been approved and is now published.".format(obj._meta.verbose_name, obj.title),
+                                         modnote,
+                                         "approved")
+
+                # If there is a field called 'date', reset it to today so that it gets slotted into the correct place in lists
+                if hasattr(obj, 'date') and isinstance(obj.date, date):
+                    obj.date = date.today()
+                    savefields.append('date')
+
+            elif modstate == ModerationState.REJECTED:
+                _send_moderation_message(request,
+                                         obj,
+                                         "The {} with title {}\nhas been rejected and is now deleted.".format(obj._meta.verbose_name, obj.title),
+                                         modnote,
+                                         "rejected")
+                messages.info(request, "{} rejected and deleted".format(obj._meta.verbose_name))
+                obj.send_notification = False
+                obj.delete()
+                return HttpResponseRedirect("/admin/pending")
+            else:
+                raise Exception("Can't happen.")
+
+            if hasattr(obj, 'approved'):
+                # This is a two-state one!
+                obj.approved = (modstate == ModerationState.APPROVED)
+                savefields.append('approved')
+            else:
+                # Three-state moderation
+                obj.modstate = modstate
+                savefields.append('modstate')
+
+            if modstate != ModerationState.APPROVED and obj.twomoderators:
+                # If changing to anything other than approved, we need to clear the moderator field, so things can start over
+                obj.firstmoderator = None
+                savefields.append('firstmoderator')
+
+            # Suppress notifications as we're sending our own
+            obj.send_notification = False
+            obj.save(update_fields=savefields)
+            messages.info(request, "Moderation state changed to {}".format(obj.modstate_string))
+            return HttpResponseRedirect("/admin/pending/")
+    else:
+        form = ModerationForm(obj=obj, user=request.user, initial=initdata)
+
+    return render(request, 'core/admin_moderation_form.html', {
+        'obj': obj,
+        'form': form,
+        'app': obj._meta.app_label,
+        'model': obj._meta.model_name,
+        'itemtype': obj._meta.verbose_name,
+        'itemtypeplural': obj._meta.verbose_name_plural,
+        'notices': ModerationNotification.objects.filter(objectid=obj.id, objecttype=type(obj).__name__).order_by('date'),
+        'previous': hasattr(obj, 'org') and type(obj).objects.filter(org=obj.org).exclude(id=obj.id).order_by('-id')[:10] or None,
+        'object_fields': obj.get_moderation_preview_fields(),
+    })
+
+
 # Purge objects from varnish, for the admin pages
 @login_required
 @user_passes_test(lambda u: u.is_staff)
index 4f6ea15ce42e6948602d84f97c30d77b6c124f8d..1f3d71132d27e94c51326a9b4faa883f95b749f6 100644 (file)
@@ -5,9 +5,6 @@ from .models import Product
 
 
 class ProductForm(forms.ModelForm):
-    form_intro = """Note that in order to register a new product, you must first register an organisation.
-If you have not done so, use <a href="/account/organisations/new/">this form</a>."""
-
     def __init__(self, *args, **kwargs):
         super(ProductForm, self).__init__(*args, **kwargs)
 
index 37176259aec0166ca48bf2679d24f68f5bcd7ef6..1e3999fea0636944ae4cc35ddc70522505975ced 100644 (file)
@@ -1,6 +1,7 @@
 from django.db import models
 
 from pgweb.core.models import Organisation
+from pgweb.util.moderation import TwostateModerateModel
 
 
 class Category(models.Model):
@@ -24,9 +25,8 @@ class LicenceType(models.Model):
         ordering = ('typename',)
 
 
-class Product(models.Model):
+class Product(TwostateModerateModel):
     name = models.CharField(max_length=100, null=False, blank=False, unique=True)
-    approved = models.BooleanField(null=False, default=False)
     org = models.ForeignKey(Organisation, db_column="publisher_id", null=False, verbose_name="Organisation", on_delete=models.CASCADE)
     url = models.URLField(null=False, blank=False)
     category = models.ForeignKey(Category, null=False, on_delete=models.CASCADE)
@@ -35,18 +35,28 @@ class Product(models.Model):
     price = models.CharField(max_length=200, null=False, blank=True)
     lastconfirmed = models.DateTimeField(null=False, blank=False, auto_now_add=True)
 
-    send_notification = True
+    account_edit_suburl = 'products'
     markdown_fields = ('description', )
+    moderation_fields = ('org', 'url', 'category', 'licencetype', 'description', 'price')
 
     def __str__(self):
         return self.name
 
+    @property
+    def title(self):
+        return self.name
+
     def verify_submitter(self, user):
         return (len(self.org.managers.filter(pk=user.pk)) == 1)
 
     class Meta:
         ordering = ('name',)
 
+    @classmethod
+    def get_formclass(self):
+        from pgweb.downloads.forms import ProductForm
+        return ProductForm
+
 
 class StackBuilderApp(models.Model):
     textid = models.CharField(max_length=100, null=False, blank=False)
index 3d507658578053d2cf7f3c32c9ebc9f2e0ec05a8..7370193cba1f4d43e390b82e2897f7b776a71442 100644 (file)
@@ -1,7 +1,6 @@
 from django.shortcuts import render, get_object_or_404
 from django.http import HttpResponse, Http404, HttpResponseRedirect
 from django.core.exceptions import PermissionDenied
-from pgweb.util.decorators import login_required
 from django.views.decorators.csrf import csrf_exempt
 from django.conf import settings
 
@@ -11,12 +10,11 @@ import json
 
 from pgweb.util.decorators import nocache
 from pgweb.util.contexts import render_pgweb
-from pgweb.util.helpers import simple_form, PgXmlHelper, HttpServerError
+from pgweb.util.helpers import PgXmlHelper, HttpServerError
 from pgweb.util.misc import varnish_purge, version_sort
 
 from pgweb.core.models import Version
 from .models import Category, Product, StackBuilderApp
-from .forms import ProductForm
 
 
 #######
@@ -224,12 +222,6 @@ def productlist(request, catid, junk=None):
     })
 
 
-@login_required
-def productform(request, itemid):
-    return simple_form(Product, itemid, request, ProductForm,
-                       redirect='/account/edit/products/')
-
-
 #######
 # Stackbuilder
 #######
index 92168c7e77e24f9dd09b890c31f0197695621a98..185a6a7ca475117d49b133f07299c5ea1d925c2b 100644 (file)
@@ -1,11 +1,10 @@
 from django.db import models
 
 from pgweb.core.models import Country, Language, Organisation
+from pgweb.util.moderation import TwostateModerateModel
 
 
-class Event(models.Model):
-    approved = models.BooleanField(null=False, blank=False, default=False)
-
+class Event(TwostateModerateModel):
     org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text="If no organisations are listed, please check the <a href=\"/account/orglist/\">organisation list</a> and contact the organisation manager or <a href=\"mailto:webmaster@postgresql.org\">webmaster@postgresql.org</a> if none are listed.", on_delete=models.CASCADE)
     title = models.CharField(max_length=100, null=False, blank=False)
     isonline = models.BooleanField(null=False, default=False, verbose_name="Online event")
@@ -22,8 +21,9 @@ class Event(models.Model):
     summary = models.TextField(blank=False, null=False, help_text="A short introduction (shown on the events listing page)")
     details = models.TextField(blank=False, null=False, help_text="Complete event description")
 
-    send_notification = True
+    account_edit_suburl = 'events'
     markdown_fields = ('details', 'summary', )
+    moderation_fields = ['org', 'title', 'isonline', 'city', 'state', 'country', 'language', 'badged', 'description_for_badged', 'startdate', 'enddate', 'summary', 'details']
 
     def purge_urls(self):
         yield '/about/event/%s/' % self.pk
@@ -69,3 +69,8 @@ class Event(models.Model):
 
     class Meta:
         ordering = ('-startdate', '-enddate', )
+
+    @classmethod
+    def get_formclass(self):
+        from pgweb.events.forms import EventForm
+        return EventForm
index e7b8d703858de4ad7453deda8b9a5a865381871c..57579eb179b8503996ca06b3ca7f3ecd68e9deff 100644 (file)
@@ -1,14 +1,11 @@
 from django.shortcuts import get_object_or_404
 from django.http import Http404
-from pgweb.util.decorators import login_required
 
 from datetime import date
 
 from pgweb.util.contexts import render_pgweb
-from pgweb.util.helpers import simple_form
 
 from .models import Event
-from .forms import EventForm
 
 
 def main(request):
@@ -39,9 +36,3 @@ def item(request, itemid, throwaway=None):
     return render_pgweb(request, 'about', 'events/item.html', {
         'obj': event,
     })
-
-
-@login_required
-def form(request, itemid):
-    return simple_form(Event, itemid, request, EventForm,
-                       redirect='/account/edit/events/')
index 5b1f5512e3f83bfa057e0225c0347c449db60fc9..c68bd71996c7d87f982af22d2259ee6260e58eac 100644 (file)
@@ -5,18 +5,11 @@ from .models import NewsArticle, NewsTag
 
 
 class NewsArticleAdmin(PgwebAdmin):
-    list_display = ('title', 'org', 'date', 'approved', )
-    list_filter = ('approved', )
+    list_display = ('title', 'org', 'date', 'modstate', )
+    list_filter = ('modstate', )
     filter_horizontal = ('tags', )
     search_fields = ('content', 'title', )
-    change_form_template = 'admin/news/newsarticle/change_form.html'
-
-    def change_view(self, request, object_id, extra_context=None):
-        newsarticle = NewsArticle.objects.get(pk=object_id)
-        my_context = {
-            'latest': NewsArticle.objects.filter(org=newsarticle.org)[:10]
-        }
-        return super(NewsArticleAdmin, self).change_view(request, object_id, extra_context=my_context)
+    exclude = ('modstate', 'firstmoderator', )
 
 
 class NewsTagAdmin(PgwebAdmin):
index b6766b49a7d25dc578f118d4b54b34cd404bb670..0904d2f640d5710a42ef16c86250b93207f28a5d 100644 (file)
@@ -1,5 +1,6 @@
 from django.contrib.syndication.views import Feed
 
+from pgweb.util.moderation import ModerationState
 from .models import NewsArticle
 
 from datetime import datetime, time
@@ -17,9 +18,9 @@ class NewsFeed(Feed):
 
     def items(self, obj):
         if obj:
-            return NewsArticle.objects.filter(approved=True, tags__urlname=obj)[:10]
+            return NewsArticle.objects.filter(modstate=ModerationState.APPROVED, tags__urlname=obj)[:10]
         else:
-            return NewsArticle.objects.filter(approved=True)[:10]
+            return NewsArticle.objects.filter(modstate=ModerationState.APPROVED)[:10]
 
     def item_link(self, obj):
         return "https://www.postgresql.org/about/news/%s/" % obj.id
index 648bf4115a4bfa329eea4ca3ce06eea8bbc6df3c..3d24bfbd499c25073e2a29b61c35093cdfa4f137 100644 (file)
@@ -1,22 +1,19 @@
 from django import forms
 from django.forms import ValidationError
 
+from pgweb.util.moderation import ModerationState
 from pgweb.core.models import Organisation
 from .models import NewsArticle, NewsTag
 
 
 class NewsArticleForm(forms.ModelForm):
-    def __init__(self, *args, **kwargs):
-        super(NewsArticleForm, self).__init__(*args, **kwargs)
-        self.fields['date'].help_text = 'Use format YYYY-MM-DD'
-
     def filter_by_user(self, user):
         self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
 
     def clean_date(self):
-        if self.instance.pk and self.instance.approved:
+        if self.instance.pk and self.instance.modstate != ModerationState.CREATED:
             if self.cleaned_data['date'] != self.instance.date:
-                raise ValidationError("You cannot change the date on an article that has been approved")
+                raise ValidationError("You cannot change the date on an article that has been submitted or approved")
         return self.cleaned_data['date']
 
     @property
@@ -45,7 +42,7 @@ class NewsArticleForm(forms.ModelForm):
 
     class Meta:
         model = NewsArticle
-        exclude = ('submitter', 'approved', 'tweeted')
+        exclude = ('date', 'submitter', 'modstate', 'tweeted', 'firstmoderator')
         widgets = {
             'tags': forms.CheckboxSelectMultiple,
         }
index 655966ae945ec98698e81c03edf89ef765966169..d3a1ba7d8a696b8536595f82a8bd3cd65f065144 100644 (file)
@@ -11,6 +11,7 @@ from django.conf import settings
 from datetime import datetime, timedelta
 import time
 
+from pgweb.util.moderation import ModerationState
 from pgweb.news.models import NewsArticle
 
 import requests_oauthlib
@@ -25,7 +26,7 @@ class Command(BaseCommand):
         if not curs.fetchall()[0][0]:
             raise CommandError("Failed to get advisory lock, existing twitter_post process stuck?")
 
-        articles = list(NewsArticle.objects.filter(tweeted=False, approved=True, date__gt=datetime.now() - timedelta(days=7)).order_by('date'))
+        articles = list(NewsArticle.objects.filter(tweeted=False, modstate=ModerationState.APPROVED, date__gt=datetime.now() - timedelta(days=7)).order_by('date'))
         if not len(articles):
             return
 
diff --git a/pgweb/news/migrations/0005_modstate.py b/pgweb/news/migrations/0005_modstate.py
new file mode 100644 (file)
index 0000000..c3d0358
--- /dev/null
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.27 on 2020-07-02 12:41
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('news', '0004_tag_permissions'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='newsarticle',
+            name='modstate',
+            field=models.IntegerField(choices=[(0, 'Created (submitter edits)'), (1, 'Pending moderation'), (2, 'Approved and published')], default=0, verbose_name='Moderation state'),
+        ),
+        migrations.RunSQL(
+            "UPDATE news_newsarticle SET modstate=CASE WHEN approved THEN 2 ELSE 0 END",
+            "UPDATE news_newsarticle SET approved=(modstate = 2)",
+        ),
+        migrations.RemoveField(
+            model_name='newsarticle',
+            name='approved',
+        ),
+        migrations.AddField(
+            model_name='newsarticle',
+            name='firstmoderator',
+            field=models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL),
+        ),
+    ]
index c246621d01ad501c1ce5f8eaf22e989c83b75086..a0b28f87bdd1eb55d757f9e010f50dcd9a8bcd3c 100644 (file)
@@ -1,6 +1,7 @@
 from django.db import models
 from datetime import date
 from pgweb.core.models import Organisation
+from pgweb.util.moderation import TristateModerateModel, ModerationState, TwoModeratorsMixin
 
 
 class NewsTag(models.Model):
@@ -21,18 +22,19 @@ class NewsTag(models.Model):
         ordering = ('sortkey', 'urlname', )
 
 
-class NewsArticle(models.Model):
+class NewsArticle(TwoModeratorsMixin, TristateModerateModel):
     org = models.ForeignKey(Organisation, null=False, blank=False, verbose_name="Organisation", help_text="If no organisations are listed, please check the <a href=\"/account/orglist/\">organisation list</a> and contact the organisation manager or <a href=\"mailto:webmaster@postgresql.org\">webmaster@postgresql.org</a> if none are listed.", on_delete=models.CASCADE)
-    approved = models.BooleanField(null=False, blank=False, default=False)
     date = models.DateField(null=False, blank=False, default=date.today)
     title = models.CharField(max_length=200, null=False, blank=False)
     content = models.TextField(null=False, blank=False)
     tweeted = models.BooleanField(null=False, blank=False, default=False)
     tags = models.ManyToManyField(NewsTag, blank=False, help_text="Select the tags appropriate for this post")
 
-    send_notification = True
-    send_m2m_notification = True
+    account_edit_suburl = 'news'
     markdown_fields = ('content',)
+    moderation_fields = ('org', 'date', 'title', 'content', 'taglist')
+    preview_fields = ('title', 'content', 'taglist')
+    extramodnotice = "In particular, note that news articles will be sent by email to subscribers, and therefor cannot be recalled in any way once sent."
 
     def purge_urls(self):
         yield '/about/news/%s/' % self.pk
@@ -53,9 +55,23 @@ class NewsArticle(models.Model):
             return True
         return False
 
+    @property
+    def taglist(self):
+        return ", ".join([t.name for t in self.tags.all()])
+
     @property
     def displaydate(self):
         return self.date.strftime("%Y-%m-%d")
 
     class Meta:
         ordering = ('-date',)
+
+    @classmethod
+    def get_formclass(self):
+        from pgweb.news.forms import NewsArticleForm
+        return NewsArticleForm
+
+    @property
+    def block_edit(self):
+        # Don't allow editing of news articles that have been published
+        return self.modstate in (ModerationState.PENDING, ModerationState.APPROVED)
index b67c8d0f1ff3508d8094ac20a0492a372b844dc6..6af63dc3bcd57307e1777a5c33f9de950459c8ff 100644 (file)
@@ -1,6 +1,8 @@
 from datetime import date, timedelta
 from .models import NewsArticle
 
+from pgweb.util.moderation import ModerationState
+
 
 def get_struct():
     now = date.today()
@@ -10,7 +12,7 @@ def get_struct():
     # since we don't care about getting it indexed.
     # Also, don't bother indexing anything > 4 years old
 
-    for n in NewsArticle.objects.filter(approved=True, date__gt=fouryearsago):
+    for n in NewsArticle.objects.filter(modstate=ModerationState.APPROVED, date__gt=fouryearsago):
         yearsold = (now - n.date).days / 365
         if yearsold > 4:
             yearsold = 4
index 0a3dbe553600e5f1a904a249706952d4fd05e52c..163d3f2e9debff43fd501103ebc7176c3ec23fc3 100644 (file)
@@ -1,12 +1,10 @@
 from django.shortcuts import get_object_or_404
 from django.http import HttpResponse, Http404
-from pgweb.util.decorators import login_required
 
 from pgweb.util.contexts import render_pgweb
-from pgweb.util.helpers import simple_form
+from pgweb.util.moderation import ModerationState
 
 from .models import NewsArticle, NewsTag
-from .forms import NewsArticleForm
 
 import json
 
@@ -14,10 +12,10 @@ import json
 def archive(request, tag=None, paging=None):
     if tag:
         tag = get_object_or_404(NewsTag, urlname=tag.strip('/'))
-        news = NewsArticle.objects.select_related('org').filter(approved=True, tags=tag)
+        news = NewsArticle.objects.select_related('org').filter(modstate=ModerationState.APPROVED, tags=tag)
     else:
         tag = None
-        news = NewsArticle.objects.select_related('org').filter(approved=True)
+        news = NewsArticle.objects.select_related('org').filter(modstate=ModerationState.APPROVED)
     return render_pgweb(request, 'about', 'news/newsarchive.html', {
         'news': news,
         'tag': tag,
@@ -27,7 +25,7 @@ def archive(request, tag=None, paging=None):
 
 def item(request, itemid, throwaway=None):
     news = get_object_or_404(NewsArticle, pk=itemid)
-    if not news.approved:
+    if news.modstate != ModerationState.APPROVED:
         raise Http404
     return render_pgweb(request, 'about', 'news/item.html', {
         'obj': news,
@@ -44,9 +42,3 @@ def taglist_json(request):
             'sortkey': t.sortkey,
         } for t in NewsTag.objects.order_by('urlname').distinct('urlname')],
     }), content_type='application/json')
-
-
-@login_required
-def form(request, itemid):
-    return simple_form(NewsArticle, itemid, request, NewsArticleForm,
-                       redirect='/account/edit/news/')
index 05d44a5894dcd96423dcada72cacb18004c4a107..53b1a44f4f1095c5aab74812ded08dad0796cf72 100644 (file)
@@ -5,9 +5,6 @@ from .models import ProfessionalService
 
 
 class ProfessionalServiceForm(forms.ModelForm):
-    form_intro = """Note that in order to register a new professional service, you must first register an organisation.
-If you have not done so, use <a href="/account/organisations/new/">this form</a>."""
-
     def __init__(self, *args, **kwargs):
         super(ProfessionalServiceForm, self).__init__(*args, **kwargs)
 
index ad32ddba219c4498ce350b449b64fee4b4e8d4a3..016b4d74a4e5be08978677dcfce7050881aa56d5 100644 (file)
@@ -1,11 +1,10 @@
 from django.db import models
 
 from pgweb.core.models import Organisation
+from pgweb.util.moderation import TwostateModerateModel
 
 
-class ProfessionalService(models.Model):
-    approved = models.BooleanField(null=False, blank=False, default=False)
-
+class ProfessionalService(TwostateModerateModel):
     org = models.OneToOneField(Organisation, null=False, blank=False,
                                db_column="organisation_id", on_delete=models.CASCADE,
                                verbose_name="organisation",
@@ -29,15 +28,26 @@ class ProfessionalService(models.Model):
     provides_hosting = models.BooleanField(null=False, default=False)
     interfaces = models.CharField(max_length=512, null=True, blank=True, verbose_name="Interfaces (for hosting)")
 
+    account_edit_suburl = 'services'
+    moderation_fields = ('org', 'description', 'employees', 'locations', 'region_africa', 'region_asia', 'region_europe',
+                         'region_northamerica', 'region_oceania', 'region_southamerica', 'hours', 'languages',
+                         'customerexample', 'experience', 'contact', 'url', 'provides_support', 'provides_hosting', 'interfaces')
     purge_urls = ('/support/professional_', )
 
-    send_notification = True
-
     def verify_submitter(self, user):
         return (len(self.org.managers.filter(pk=user.pk)) == 1)
 
     def __str__(self):
         return self.org.name
 
+    @property
+    def title(self):
+        return self.org.name
+
     class Meta:
         ordering = ('org__name',)
+
+    @classmethod
+    def get_formclass(self):
+        from pgweb.profserv.forms import ProfessionalServiceForm
+        return ProfessionalServiceForm
index ff7684858446f3662c50f139a8756f2c7e9631e8..509bccc0471b51fe6c76bdee78e71431b81e5a58 100644 (file)
@@ -1,11 +1,8 @@
 from django.http import Http404
-from pgweb.util.decorators import login_required
 
 from pgweb.util.contexts import render_pgweb
-from pgweb.util.helpers import simple_form
 
 from .models import ProfessionalService
-from .forms import ProfessionalServiceForm
 
 regions = (
     ('africa', 'Africa'),
@@ -52,10 +49,3 @@ def region(request, servtype, regionname):
         'regionname': regname,
         'services': services,
     })
-
-
-# Forms to edit
-@login_required
-def profservform(request, itemid):
-    return simple_form(ProfessionalService, itemid, request, ProfessionalServiceForm,
-                       redirect='/account/edit/services/')
index 0fd54b419206c2441493c85847b14431ef5e7aae..1f119a62a6c7ab42a2851ebf4bc052b1e72d2167 100644 (file)
@@ -148,6 +148,7 @@ urlpatterns = [
     url(r'^admin/pending/$', pgweb.core.views.admin_pending),
     url(r'^admin/purge/$', pgweb.core.views.admin_purge),
     url(r'^admin/mergeorg/$', pgweb.core.views.admin_mergeorg),
+    url(r'^admin/_moderate/(\w+)/(\d+)/$', pgweb.core.views.admin_moderate),
 
     # Uncomment the next line to enable the admin:
     url(r'^admin/', admin.site.urls),
index 9e02eddb5a36d60622ca8f2af1d97b471fc20288..31cbaf8ebc84b53e69e9631ba2e44371a38750a3 100644 (file)
@@ -1,8 +1,4 @@
 from django.contrib import admin
-from django.conf import settings
-
-from pgweb.core.models import ModerationNotification
-from pgweb.mailqueue.util import send_simple_mail
 
 
 class PgwebAdmin(admin.ModelAdmin):
@@ -11,8 +7,6 @@ class PgwebAdmin(admin.ModelAdmin):
     * Markdown preview for markdown capable textfields (specified by
       including them in a class variable named markdown_capable that is a tuple
       of field names)
-    * Add an admin field for "notification", that can be sent to the submitter
-      of an item to inform them of moderation issues.
     """
 
     change_form_template = 'admin/change_form_pgweb.html'
@@ -25,15 +19,6 @@ class PgwebAdmin(admin.ModelAdmin):
                 fld.widget.attrs['class'] = fld.widget.attrs['class'] + ' markdown_preview'
         return fld
 
-    def change_view(self, request, object_id, form_url='', extra_context=None):
-        if hasattr(self.model, 'send_notification') and self.model.send_notification:
-            # Anything that sends notification supports manual notifications
-            if extra_context is None:
-                extra_context = dict()
-            extra_context['notifications'] = ModerationNotification.objects.filter(objecttype=self.model.__name__, objectid=object_id).order_by('date')
-
-        return super(PgwebAdmin, self).change_view(request, object_id, form_url, extra_context)
-
     # Remove the builtin delete_selected action, so it doesn't
     # conflict with the custom one.
     def get_actions(self, request):
@@ -53,75 +38,6 @@ class PgwebAdmin(admin.ModelAdmin):
     custom_delete_selected.short_description = "Delete selected items"
     actions = ['custom_delete_selected']
 
-    def save_model(self, request, obj, form, change):
-        if change and hasattr(self.model, 'send_notification') and self.model.send_notification:
-            # We only do processing if something changed, not when adding
-            # a new object.
-            if 'new_notification' in request.POST and request.POST['new_notification']:
-                # Need to send off a new notification. We'll also store
-                # it in the database for future reference, of course.
-                if not obj.org.email:
-                    # Should not happen because we remove the form field. Thus
-                    # a hard exception is ok.
-                    raise Exception("Organisation does not have an email, cannot send notification!")
-                n = ModerationNotification()
-                n.objecttype = obj.__class__.__name__
-                n.objectid = obj.id
-                n.text = request.POST['new_notification']
-                n.author = request.user.username
-                n.save()
-
-                # Now send an email too
-                msgstr = _get_notification_text(obj,
-                                                request.POST['new_notification'])
-
-                send_simple_mail(settings.NOTIFICATION_FROM,
-                                 obj.org.email,
-                                 "postgresql.org moderation notification",
-                                 msgstr,
-                                 suppress_auto_replies=False)
-
-                # Also generate a mail to the moderators
-                send_simple_mail(
-                    settings.NOTIFICATION_FROM,
-                    settings.NOTIFICATION_EMAIL,
-                    "Moderation comment on %s %s" % (obj.__class__._meta.verbose_name, obj.id),
-                    _get_moderator_notification_text(
-                        obj,
-                        request.POST['new_notification'],
-                        request.user.username
-                    )
-                )
-
-        # Either no notifications, or done with notifications
-        super(PgwebAdmin, self).save_model(request, obj, form, change)
-
 
 def register_pgwebadmin(model):
     admin.site.register(model, PgwebAdmin)
-
-
-def _get_notification_text(obj, txt):
-    objtype = obj.__class__._meta.verbose_name
-    return """You recently submitted a %s to postgresql.org.
-
-During moderation, this item has received comments that need to be
-addressed before it can be approved. The comment given by the moderator is:
-
-%s
-
-Please go to https://www.postgresql.org/account/ and make any changes
-request, and your submission will be re-moderated.
-""" % (objtype, txt)
-
-
-def _get_moderator_notification_text(obj, txt, moderator):
-    return """Moderator %s made a comment to a pending object:
-Object type: %s
-Object id: %s
-Comment: %s
-""" % (moderator,
-       obj.__class__._meta.verbose_name,
-       obj.id,
-       txt,
-       )
index d2d71dd059f01ddbb342b9ead9b34a47308d831d..f594ea164b02d248fb2eb0cbbf89d6972f7194b0 100644 (file)
@@ -7,6 +7,7 @@ import django.utils.xmlutils
 from django.conf import settings
 
 from pgweb.util.contexts import render_pgweb
+from pgweb.util.moderation import ModerationState
 
 import io
 import re
@@ -34,7 +35,7 @@ def MarkdownValidator(val):
     return val
 
 
-def simple_form(instancetype, itemid, request, formclass, formtemplate='base/form.html', redirect='/account/', navsection='account', fixedfields=None, createifempty=False):
+def simple_form(instancetype, itemid, request, formclass, formtemplate='base/form.html', redirect='/account/', navsection='account', fixedfields=None, createifempty=False, extracontext={}):
     if itemid == 'new':
         instance = instancetype()
         is_new = True
@@ -56,6 +57,9 @@ def simple_form(instancetype, itemid, request, formclass, formtemplate='base/for
             if not instance.verify_submitter(request.user):
                 raise PermissionDenied("You are not the owner of this item!")
 
+        if getattr(instance, 'block_edit', False):
+            raise PermissionDenied("You cannot edit this item")
+
     if request.method == 'POST':
         # Process this form
         form = formclass(data=request.POST, instance=instance)
@@ -72,13 +76,18 @@ def simple_form(instancetype, itemid, request, formclass, formtemplate='base/for
             do_notify = getattr(instance, 'send_notification', False)
             instance.send_notification = False
 
-            if not getattr(instance, 'approved', True) and not is_new:
-                # If the object has an "approved" field and it's set to false, we don't
-                # bother notifying about the changes. But if it lacks this field, we notify
-                # about everything, as well as if the field exists and the item has already
-                # been approved.
-                # Newly added objects are always notified.
-                do_notify = False
+            # If the object has an "approved" field and it's set to false, we don't
+            # bother notifying about the changes. But if it lacks this field, we notify
+            # about everything, as well as if the field exists and the item has already
+            # been approved.
+            # Newly added objects are always notified.
+            if not is_new:
+                if hasattr(instance, 'approved'):
+                    if not getattr(instance, 'approved', True):
+                        do_notify = False
+                elif hasattr(instance, 'modstate'):
+                    if getattr(instance, 'modstate', None) == ModerationState.CREATED:
+                        do_notify = False
 
             notify = io.StringIO()
 
@@ -176,14 +185,17 @@ def simple_form(instancetype, itemid, request, formclass, formtemplate='base/for
             'class': 'toggle-checkbox',
         })
 
-    return render_pgweb(request, navsection, formtemplate, {
+    ctx = {
         'form': form,
         'formitemtype': instance._meta.verbose_name,
         'form_intro': hasattr(form, 'form_intro') and form.form_intro or None,
         'described_checkboxes': getattr(form, 'described_checkboxes', {}),
         'savebutton': (itemid == "new") and "Submit New" or "Save",
         'operation': (itemid == "new") and "New" or "Edit",
-    })
+    }
+    ctx.update(extracontext)
+
+    return render_pgweb(request, navsection, formtemplate, ctx)
 
 
 def template_to_string(templatename, attrs={}):
index 324a14880e67feb6b982562536ad01062d94e15c..74f90d2aef3734bc1e6ae070a840c1ab3ee49926 100644 (file)
-# models needed to generate unapproved list
-from pgweb.news.models import NewsArticle
-from pgweb.events.models import Event
-from pgweb.core.models import Organisation
-from pgweb.downloads.models import Product
-from pgweb.profserv.models import ProfessionalService
-from pgweb.quotes.models import Quote
+from django.db import models
+from django.contrib.auth.models import User
+
+import datetime
+
+import markdown
+
+
+class ModerateModel(models.Model):
+    def _get_field_data(self, k):
+        val = getattr(self, k)
+        yield k
+
+        try:
+            yield self._meta.get_field(k).verbose_name.capitalize()
+        except Exception:
+            yield k.capitalize()
+        yield val
+
+        if k in getattr(self, 'markdown_fields', []):
+            yield markdown.markdown(val)
+        else:
+            yield None
+
+        if k == 'date' and isinstance(val, datetime.date):
+            yield "Will be reset to today's date when this {} is approved".format(self._meta.verbose_name)
+        else:
+            yield None
+
+    def get_preview_fields(self):
+        if getattr(self, 'preview_fields', []):
+            return [list(self._get_field_data(k)) for k in self.preview_fields]
+        return self.get_moderation_preview_fields()
+
+    def get_moderation_preview_fields(self):
+        return [list(self._get_field_data(k)) for k in self.moderation_fields]
+
+    class Meta:
+        abstract = True
+
+    @property
+    def block_edit(self):
+        return False
+
+    @property
+    def twomoderators(self):
+        return hasattr(self, 'firstmoderator')
+
+    def twomoderators_string(self):
+        return None
+
+
+class ModerationState(object):
+    CREATED = 0
+    PENDING = 1
+    APPROVED = 2
+    REJECTED = -1  # Never stored, so not available as a choice
+
+    CHOICES = (
+        (CREATED, 'Created (submitter edits)'),
+        (PENDING, 'Pending moderation'),
+        (APPROVED, 'Approved and published'),
+    )
+
+    @classmethod
+    def get_string(cls, modstate):
+        return next(filter(lambda x: x[0] == modstate, cls.CHOICES))[1]
+
+
+class TristateModerateModel(ModerateModel):
+    modstate = models.IntegerField(null=False, blank=False, default=0, choices=ModerationState.CHOICES,
+                                   verbose_name="Moderation state")
+
+    send_notification = True
+    send_m2m_notification = True
+
+    class Meta:
+        abstract = True
+
+    @property
+    def modstate_string(self):
+        return ModerationState.get_string(self.modstate)
+
+    @property
+    def is_approved(self):
+        return self.modstate == ModerationState.APPROVED
+
+
+class TwostateModerateModel(ModerateModel):
+    approved = models.BooleanField(null=False, blank=False, default=False)
+
+    send_notification = True
+    send_m2m_notification = True
+
+    class Meta:
+        abstract = True
+
+    @property
+    def modstate_string(self):
+        return self.approved and 'Approved' or 'Created/Pending'
+
+    @property
+    def modstate(self):
+        return self.approved and ModerationState.APPROVED or ModerationState.CREATED
+
+    @property
+    def is_approved(self):
+        return self.approved
+
+
+class TwoModeratorsMixin(models.Model):
+    firstmoderator = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
+
+    class Meta:
+        abstract = True
+
+    def twomoderators_string(self):
+        if self.firstmoderator:
+            return "Already approved by {}, waiting for second moderator".format(self.firstmoderator)
+        return "Requires two moderators, not approved by anybody yet"
 
 
 # Pending moderation requests (including URLs for the admin interface))
 def _get_unapproved_list(objecttype):
-    objects = objecttype.objects.filter(approved=False)
+    if hasattr(objecttype, 'approved'):
+        objects = objecttype.objects.filter(approved=False)
+    else:
+        objects = objecttype.objects.filter(modstate=ModerationState.PENDING)
     if not len(objects):
         return None
     return {
         'name': objects[0]._meta.verbose_name_plural,
-        'entries': [{'url': '/admin/%s/%s/%s/' % (x._meta.app_label, x._meta.model_name, x.pk), 'title': str(x)} for x in objects]
+        'entries': [
+            {
+                'url': '/admin/_moderate/%s/%s/' % (x._meta.model_name, x.pk),
+                'title': str(x),
+                'twomoderators': x.twomoderators_string(),
+            } for x in objects]
     }
 
 
+def _modclasses():
+    from pgweb.news.models import NewsArticle
+    from pgweb.events.models import Event
+    from pgweb.core.models import Organisation
+    from pgweb.downloads.models import Product
+    from pgweb.profserv.models import ProfessionalService
+    return [NewsArticle, Event, Organisation, Product, ProfessionalService]
+
+
 def get_all_pending_moderations():
-    applist = [
-        _get_unapproved_list(NewsArticle),
-        _get_unapproved_list(Event),
-        _get_unapproved_list(Organisation),
-        _get_unapproved_list(Product),
-        _get_unapproved_list(ProfessionalService),
-        _get_unapproved_list(Quote),
-    ]
+    applist = [_get_unapproved_list(c) for c in _modclasses()]
     return [x for x in applist if x]
+
+
+def get_moderation_model(modelname):
+    return next((c for c in _modclasses() if c._meta.model_name == modelname))
+
+
+def get_moderation_model_from_suburl(suburl):
+    return next((c for c in _modclasses() if c.account_edit_suburl == suburl))
index 5f076c7667d21b37224c8bda0585232313d4ab1a..895cb45ef3ae7266c91595f60f79e95edf7e5a86 100644 (file)
@@ -6,6 +6,7 @@ import difflib
 
 from pgweb.util.middleware import get_current_user
 from pgweb.util.misc import varnish_purge
+from pgweb.util.moderation import ModerationState
 from pgweb.mailqueue.util import send_simple_mail
 
 
@@ -51,7 +52,7 @@ def _get_all_notification_fields(obj):
     else:
         # Include all field names except specified ones,
         # that are local to this model (not auto created)
-        return [f.name for f in obj._meta.get_fields() if f.name not in ('approved', 'submitter', 'id', ) and not f.auto_created]
+        return [f.name for f in obj._meta.get_fields() if f.name not in ('approved', 'modstate', 'submitter', 'id', ) and not f.auto_created]
 
 
 def _get_attr_value(obj, fieldname):
@@ -82,21 +83,29 @@ def _get_notification_text(obj):
         return ('A new {0} has been added'.format(obj._meta.verbose_name),
                 _get_full_text_representation(obj))
 
-    if hasattr(obj, 'approved'):
+    if hasattr(obj, 'approved') or hasattr(obj, 'modstate'):
             # This object has the capability to do approving. Apply the following logic:
             # 1. If object was unapproved, and is still unapproved, don't send notification
             # 2. If object was unapproved, and is now approved, send "object approved" notification
             # 3. If object was approved, and is no longer approved, send "object unapproved" notification
             # 4. (FIXME: configurable?) If object was approved and is still approved, send changes notification
-        if not obj.approved:
-            if not oldobj.approved:
+
+        if hasattr(obj, 'approved'):
+            approved = obj.approved
+            oldapproved = oldobj.approved
+        else:
+            approved = obj.modstate != ModerationState.CREATED
+            oldapproved = oldobj.modstate != ModerationState.CREATED
+
+        if not approved:
+            if not oldapproved:
                 # Was approved, still approved -> no notification
                 return (None, None)
             # From approved to unapproved
             return ('{0} id {1} has been unapproved'.format(obj._meta.verbose_name, obj.id),
                     _get_full_text_representation(obj))
         else:
-            if not oldobj.approved:
+            if not oldapproved:
                 # Object went from unapproved to approved
                 return ('{0} id {1} has been approved'.format(obj._meta.verbose_name, obj.id),
                         _get_full_text_representation(obj))
index c7c0e6980c90eb34e05f00293ad6ab365ee5c7da..dae8262a71294949393ebb2c8b22ad0421f77c48 100644 (file)
@@ -34,58 +34,18 @@ please contact <a href="webmaster@postgresql.org">webmaster@postgresql.org</a>
 and let us know which objects to connect together.
 </p>
 
-{%if newsarticles or events or organisations or products or profservs %}
-<h2>Submissions awaiting moderation</h2>
-<p>
-You have submitted the following objects that are still waiting moderator
-approval before they are published:
-</p>
-
-{%if newsarticles%}
-<h3>News articles</h3>
+{% for cat in modobjects %}
+<h2>Items {{cat.title}}</h2>
+{%for l in cat.objects %}
+{%if l %}
+<h3><a href="/account/edit/{{l.editurl}}/">{{l.title}}</a></h3>
+{%for o in l.objects %}
 <ul>
-{%for article in newsarticles%}
-<li><a href="/account/news/{{article.id}}/">{{article}}</a></li>
-{%endfor%}
+<li>{{o.title}}</li>
 </ul>
-{%endif%}
-
-{%if events%}
-<h3>Events</h3>
-<ul>
-{%for event in events%}
-<li><a href="/account/events/{{event.id}}/">{{event}}</a></li>
 {%endfor%}
-</ul>
 {%endif%}
-
-{%if organisations%}
-<h3>Organisations</h3>
-<ul>
-{%for org in organisations%}
-<li><a href="/account/organisations/{{org.id}}">{{org}}</a></li>
 {%endfor%}
-</ul>
-{%endif%}
-
-{%if products%}
-<h3>Products</h3>
-<ul>
-{%for product in products%}
-<li><a href="/account/products/{{product.id}}">{{product}}</a></li>
 {%endfor%}
-</ul>
-{%endif%}
-
-{%if profservs%}
-<h3>Professional Services</h3>
-<ul>
-{%for profserv in profservs%}
-<li><a href="/account/services/{{profserv.id}}">{{profserv}}</a></li>
-{%endfor%}
-</ul>
-{%endif%}
-
-{%endif%}
 
 {%endblock%}
index fa617ce3db12cf24e675b09866c2993e638440c3..fcd9e9ac2b6433db9961eeefbbaefd0d4a996966 100644 (file)
@@ -1,27 +1,67 @@
 {%extends "base/page.html"%}
 {%block title%}Your account{%endblock%}
 {%block contents%}
-<h1>{{title}}s <i class="fas fa-th-list"></i></h1>
+<h1>{{title|title}}s <i class="fas fa-th-list"></i></h1>
 <p>
-Objects that are awaiting moderator approval are listed in their own category.
-Note that modifying anything that was previously approved might result in
-additional moderation based upon what has changed in the content.
+  The following {{title}}s are associated with an organisation you are a manager for.
 </p>
 
+{%if objects.inprogress %}
+<h3>Not submitted</h3>
+<p>
+  You can edit these {{title}}s an unlimited number of times, but they will not
+  be visible to anybody.
+<ul>
+  {%for o in objects.inprogress %}
+    <li><a href="/account/{{suburl}}/{{o.id}}/">{{o}}</a> (<a href="/account/{{suburl}}/{{o.id}}/submit/">Submit for moderation</a>)</li>
+  {%endfor%}
+</ul>
+{%endif%}
+
 {% if objects.unapproved %}
-<h3>Awaiting Moderation</h3>
+<h3>Waiting for moderator approval</h3>
+<p>
+  These {{title}}s are pending moderator approval. As soon as a moderator has reviewed them,
+  they will be published.
+{%if not tristate%}
+  You can make further changes to them while you wait for moderator approval.
+{%else%}
+  If you withdraw a submission, it will return to <i>Not submitted</i> status and you can make
+  further changes.
+{%endif%}
+</p>
 <ul>
   {%for o in objects.unapproved %}
+{%if tristate%}
+    <li>{{o}} (<a href="/account/{{suburl}}/{{o.id}}/withdraw/">Withdraw</a>)</li>
+{%else%}{# one-step approval allows editing in unapproved state #}
     <li><a href="/account/{{suburl}}/{{o.id}}/">{{o}}</a></li>
+{%endif%}
   {%endfor%}
 </ul>
 {% endif %}
 
 {% if objects.approved %}
 <h3>Approved</h3>
+{%if not editapproved%}
+<p>
+  These {{title}}s are approved and published, and can no longer be edited. If you need to make
+  any changes to these objects, please contact
+  <a href="mailto:webmaster@postgresql.org">webmaster@postgresql.org</a>.
+</p>
+{%else%}
+<p>
+  These objects are approved and published, but you can still edit them. Any changes you make
+  will notify moderators, who may decide to reject the object based on the changes.
+</p>
+{%endif%}
 <ul>
   {%for o in objects.approved %}
+{%if editapproved%}
     <li><a href="/account/{{suburl}}/{{o.id}}/">{{o}}</a></li>
+{%else%}
+    <li>{{o}}</li>
+{%endif%}
   {%endfor%}
 </ul>
 {% endif %}
diff --git a/templates/account/submit_form.html b/templates/account/submit_form.html
new file mode 100644 (file)
index 0000000..6cda851
--- /dev/null
@@ -0,0 +1,22 @@
+{%extends "base/form.html"%}
+{%block post_form%}
+{%if notices%}
+<h2>Moderation notices</h2>
+<p>
+  This {{formitemtype}} has previously received the following moderation notices:
+</p>
+<table>
+  <tr>
+    <th>Date</th>
+    <th>Note</th>
+  </tr>
+{%for n in notices%}
+  <tr valign="top">
+    <td>{{n.date}}</td>
+    <td class="ws-pre">{{n.text}}</td>
+  </tr>
+{%endfor%}
+</table>
+{%endif%}
+
+{%endblock%}
diff --git a/templates/account/submit_preview.html b/templates/account/submit_preview.html
new file mode 100644 (file)
index 0000000..eed65e4
--- /dev/null
@@ -0,0 +1,28 @@
+{%extends "base/form.html"%}
+{%load markup%}
+{%block title%}Confirm {{objtype}} submission{%endblock%}
+{%block contents%}
+<h1>Confirm {{objtype}} submission</h1>
+
+<p>
+  You are about to submit the following {{objtype}} for moderation. Note that once submitted,
+  the contents can no longer be changed.
+</p>
+{%if obj.extramodnotice %}
+<p>
+  {{obj.extramodnotice}}
+</p>
+{%endif%}
+
+<h2>Your {{objtype}}</h2>
+{%for fld, title, contents, mdcontents, note in preview %}
+<div class="row">
+  <div class="col-sm-2 col-form-label"><strong>{{title}}</strong></div>
+  <div class="col-sm-10">{%if mdcontents%}{{mdcontents|safe}}{%else%}{{contents}}{%endif%}</div>
+</div>
+{%endfor%}
+
+<h2>Confirm</h2>
+{%include "base/form_contents.html" with savebutton="Submit for moderation"%}
+
+{%endblock%}
index c891f7b4a2df3a79f1d5011d5d22e821ed63c3c2..456b275ca081b17b97a143152fc22f286350da67 100644 (file)
@@ -1,10 +1,4 @@
 {% extends "admin/change_form.html" %}
-{% block form_top %}
-<p>
-Note that the summary field can use
-<a href="http://en.wikipedia.org/wiki/Markdown">markdown</a> markup.
-</p>
-{%endblock%}
 
 {% block extrahead %}
 {{ block.super }}
@@ -14,25 +8,25 @@ Note that the summary field can use
 <script type="text/javascript" src="/media/js/admin_pgweb.js"></script>
 {%endblock%}
 
-{%if notifications%}
-{%block after_field_sets%}
-<h4>Notifications sent for this item</h4>
-<ul>
- {%for n in notifications%}
- <li>{{n.text}} by {{n.author}} sent at {{n.date}}</li>
- {%empty%}
- <li>No notifications sent for this item</li>
- {%endfor%}
-</ul>
-<p>
-{%if original.org.email%}
-New notification: <input type="text" name="new_notification" id="new_notification" /> (<strong>Note!</strong> This comment is emailed to the organisation!)<br/>
-To send a notification on rejection, first add the notification above and hit
-"Save and continue editing". Then as a separate step, delete the record.
-{%else%}
-Organisation has <strong>no email</strong>, so cannot send notifications to it!
+{%block form_top%}
+{%if original.is_approved%}
+<div class="module aligned">
+  <h2 class="moderror">This {{opts.verbose_name}} has already been approved! Be very careful with editing!</h2>
+</div>
 {%endif%}
+<p>
+  <a href="/admin/_moderate/{{opts.model_name}}/{{original.pk}}/" class="button">Moderate this {{opts.verbose_name}}</a>
 </p>
-<hr/>
 {%endblock%}
+
+{% block after_field_sets %}
+<p>
+  <a href="/admin/_moderate/{{opts.model_name}}/{{original.pk}}/" class="button">Moderate this {{opts.verbose_name}}</a>
+</p>
+{%if original.is_approved%}
+
+<div class="module aligned">
+  <h2 class="moderror">This {{opts.verbose_name}} has already been approved! Be very careful with editing!</h2>
+</div>
 {%endif%}
+{% endblock %}
diff --git a/templates/admin/news/newsarticle/change_form.html b/templates/admin/news/newsarticle/change_form.html
deleted file mode 100644 (file)
index 6956556..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-{% extends "admin/change_form_pgweb.html" %}
-{% block after_field_sets %}
-{{block.super}}
-<h4>Previous 10 posts by this organization:</h4>
-<ul>
-{%for p in latest %}
-<li>{{p.date}}: {{p.title}}</li>
-{%endfor%}
-</ul>
-{% endblock %}
diff --git a/templates/core/admin_moderation_form.html b/templates/core/admin_moderation_form.html
new file mode 100644 (file)
index 0000000..85cb0b5
--- /dev/null
@@ -0,0 +1,114 @@
+{%extends "admin/base_site.html"%}
+{%load pgfilters%}
+
+{%block breadcrumbs%}
+<div class="breadcrumbs"><a href="/admin/">Home</a> &rsaquo; <a href="/admin/pending">Pending moderation</a></div>
+{%endblock%}
+
+{% block extrahead %}
+{{ block.super }}
+<link rel="stylesheet" type="text/css" href="/media/admin/css/forms.css" />
+<script type="text/javascript" src="/media/js/admin_pgweb.js"></script>
+{% endblock %}
+
+{% block coltype %}colM{% endblock %}
+
+
+{%block content%}
+<h1>Pending moderation</h1>
+
+<div id="content-main">
+  <form method="post" action=".">{%csrf_token%}
+    <div>
+{% if errors %}
+    <p class="errornote">
+    {% if errors|length == 1 %}Please correct the error below.{% else %}Please correct the errors below{% endif %}
+    </p>
+    {{ form.non_field_errors }}
+{% endif %}
+    </div>
+
+{%if obj.is_approved%}
+    <div class="module aligned">
+      <h2 class="moderror">This {{itemtype}} has already been approved!</h2>
+    </div>
+{%endif%}
+    <div class="module aligned">
+      <h2>{{itemtype|capfirst}}</h2>
+{%for fld, title, contents, mdcontents, note in object_fields %}
+      <div class="form-row moderation-form-row">
+        <div style="width: 200px;">{{title}}</div>
+{%if mdcontents%}
+          <div class="txtpreview">{{contents}}</div>
+         <iframe class="mdpreview" src="about:blank" sandbox="allow-same-origin"></iframe>
+          <div class="mdpreview-data">{{mdcontents|safe}}</div>
+{%else%}
+          <div class="simplepreview">{{contents}}
+{%if note%}<div class="modhelp">{{note}}</div>{%endif%}
+          </div>
+{%endif%}
+
+      </div>
+{%endfor%}
+{%if user.is_staff %}
+      <a class="button admbutton" href="/admin/{{app}}/{{model}}/{{obj.id}}/">Edit {{itemtype}} in admin view</a>
+{%endif%}
+    </div>
+
+{%if previous %}
+    <div class="module aligned">
+        <h2>Previous {{itemtypeplural}}</h2>
+        <p>These are the latest {{itemtypeplural}} from this organisation:</p>
+        <table>
+            {%for p in previous %}
+                <tr>
+                    <td>{{p.date}}</td>
+                    <td>{{p.modstate_string}}</td>
+                    <td>{{p.title}}</td>
+                </tr>
+            {%endfor%}
+        </table>
+    </div>
+{%endif%}
+
+{%if notices%}
+    <div class="module aligned">
+        <h2>Moderation notices</h2>
+        <p>These moderation notices have previously been sent for this item:</p>
+        <table>
+            {%for n in notices %}
+                <tr>
+                    <td>{{n.date}}</td>
+                    <td>{{n.author}}</td>
+                    <td class="ws-pre">{{n.text}}</td>
+                </tr>
+            {%endfor%}
+        </table>
+    </div>
+{%endif%}
+
+    <div class="module aligned">
+      <h2>Moderation</h2>
+{%if obj.is_approved%}
+<h3 class="moderror">This {{itemtype}} has already been approved!</h3>
+<p>
+Be careful if you unapprove it!
+</p>
+{%endif%}
+{% for field in form %}
+      <div class="form-row{% if field.errors %} errors{% endif %}">
+        <div class="modadmfield">
+{%if field.errors%}{{field.errors}}<br/>{%endif%}
+          {{ field.label_tag }}
+          {{ field }}
+          {%if field.field.help_text %}
+          <div class="help">{{ field.help_text|safe }}</div>
+          {%endif%}
+        </div>
+      </div>
+{% endfor %}
+    </div>
+      <input type="submit" value="Moderate this {{itemtype}}">
+  </form>
+</div>
+{%endblock%}
index 35da83ad95fd5b0aa6ca6b1fc3a8fadb81622083..5d71c2e92fae97b72daf78e8dfcf9adfd8da29a6 100644 (file)
       <caption><a class="section">Pending {{app.name}}</a></caption>
       {%for entry in app.entries%}
       <tr class="row{%cycle '1' '2'%}">
-       <td><a href="{{entry.url}}">{{entry.title}}</a></td>
+       <td class="nowrap"><a href="{{entry.url}}">{{entry.title}}</a></td>
+{%if entry.twomoderators%}
+       <td width="100%">{{entry.twomoderators}}</td>
+{%endif%}
       </tr>
       {%endfor%}
     </table>