Skip to content

Commit e4872d9

Browse files
committed
Re-work moderation of submitted items
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.
1 parent 8fbc977 commit e4872d9

37 files changed

+929
-321
lines changed

media/css/admin_pgweb.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,58 @@
1+
a.admbutton {
2+
padding: 10px 15px;
3+
}
4+
5+
div.modadmfield input,
6+
div.modadmfield select,
7+
div.modadmfield textarea {
8+
width: 500px;
9+
}
10+
11+
.moderation-form-row div {
12+
display: inline-block;
13+
vertical-align: top;
14+
}
15+
16+
.moderation-form-row div.txtpreview {
17+
border: 1px solid gray;
18+
padding: 5px;
19+
border-radius: 5px;
20+
white-space: pre;
21+
width: 500px;
22+
overflow-x: auto;
23+
margin-right: 20px;
24+
}
25+
26+
.moderation-form-row iframe.mdpreview {
27+
border: 1px solid gray;
28+
padding: 5px;
29+
border-radius: 5px;
30+
width: 500px;
31+
overflow-x: auto;
32+
}
33+
34+
.moderation-form-row div.mdpreview-data {
35+
display: none;
36+
}
37+
38+
.moderation-form-row div.simplepreview {
39+
max-width: 800px;
40+
}
41+
42+
.moderror {
43+
color: red !important;
44+
}
45+
46+
div.modhelp {
47+
display: block;
48+
color: #999;
49+
font-size: 11px;
50+
}
51+
152
#new_notification {
253
width: 400px;
354
}
55+
56+
.wspre {
57+
white-space: pre;
58+
}

media/css/main.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ p, ul, ol, dl, table {
197197
padding: 1em 2em;
198198
}
199199

200+
/* Utility */
201+
.ws-pre {
202+
white-space: pre;
203+
}
204+
200205
/* #BLOCKQUOTE */
201206

202207
blockquote {

media/js/admin_pgweb.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
11
window.onload = function() {
2-
tael = document.getElementsByTagName('textarea');
3-
for (i = 0; i < tael.length; i++) {
2+
/* Preview in the pure admin views */
3+
let tael = document.getElementsByTagName('textarea');
4+
for (let i = 0; i < tael.length; i++) {
45
if (tael[i].className.indexOf('markdown_preview') >= 0) {
56
attach_showdown_preview(tael[i].id, 1);
67
}
78
}
9+
10+
/* Preview in the moderation view */
11+
let previews = document.getElementsByClassName('mdpreview');
12+
for (let i = 0; i < previews.length; i++) {
13+
let iframe = previews[i];
14+
let textdiv = iframe.previousElementSibling;
15+
let hiddendiv = iframe.nextElementSibling;
16+
17+
/* Copy the HTML into the iframe */
18+
iframe.srcdoc = hiddendiv.innerHTML;
19+
20+
/* Maybe we should apply *some* stylesheet here? */
21+
22+
/* Resize the height to to be the same */
23+
if (textdiv.offsetHeight > iframe.offsetHeight)
24+
iframe.style.height = textdiv.offsetHeight + 'px';
25+
if (iframe.offsetHeight > textdiv.offsetHeight)
26+
textdiv.style.height = iframe.offsetHeight + 'px';
27+
}
828
}

pgweb/account/forms.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,11 @@ def clean_email2(self):
171171

172172
class PgwebPasswordResetForm(forms.Form):
173173
email = forms.EmailField()
174+
175+
176+
class ConfirmSubmitForm(forms.Form):
177+
confirm = forms.BooleanField(required=True, help_text='Confirm')
178+
179+
def __init__(self, objtype, *args, **kwargs):
180+
super().__init__(*args, **kwargs)
181+
self.fields['confirm'].help_text = 'Confirm that you are ready to submit this {}.'.format(objtype)

pgweb/account/urls.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,14 @@
2424
# List of items to edit
2525
url(r'^edit/(.*)/$', pgweb.account.views.listobjects),
2626

27-
# News & Events
28-
url(r'^news/(.*)/$', pgweb.news.views.form),
29-
url(r'^events/(.*)/$', pgweb.events.views.form),
30-
31-
# Software catalogue
27+
# Submitted items
28+
url(r'^(?P<objtype>news)/(?P<item>\d+)/(?P<what>submit|withdraw)/$', pgweb.account.views.submitted_item_submitwithdraw),
29+
url(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
3230
url(r'^organisations/(.*)/$', pgweb.core.views.organisationform),
33-
url(r'^products/(.*)/$', pgweb.downloads.views.productform),
3431

3532
# Organisation information
3633
url(r'^orglist/$', pgweb.account.views.orglist),
3734

38-
# Professional services
39-
url(r'^services/(.*)/$', pgweb.profserv.views.profservform),
40-
4135
# Docs comments
4236
url(r'^comments/(new)/([^/]+)/([^/]+)/$', pgweb.docs.views.commentform),
4337
url(r'^comments/(new)/([^/]+)/([^/]+)/done/$', pgweb.docs.views.commentform_done),

pgweb/account/views.py

Lines changed: 158 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.contrib.auth import login as django_login
33
import django.contrib.auth.views as authviews
44
from django.http import HttpResponseRedirect, Http404, HttpResponse
5+
from django.core.exceptions import PermissionDenied
56
from django.shortcuts import get_object_or_404
67
from pgweb.util.decorators import login_required, script_sources, frame_sources
78
from django.utils.encoding import force_bytes
@@ -23,67 +24,98 @@
2324

2425
from pgweb.util.contexts import render_pgweb
2526
from pgweb.util.misc import send_template_mail, generate_random_token, get_client_ip
26-
from pgweb.util.helpers import HttpSimpleResponse
27+
from pgweb.util.helpers import HttpSimpleResponse, simple_form
28+
from pgweb.util.moderation import ModerationState
2729

2830
from pgweb.news.models import NewsArticle
2931
from pgweb.events.models import Event
30-
from pgweb.core.models import Organisation, UserProfile
32+
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
3133
from pgweb.contributors.models import Contributor
3234
from pgweb.downloads.models import Product
3335
from pgweb.profserv.models import ProfessionalService
3436

3537
from .models import CommunityAuthSite, CommunityAuthConsent, EmailChangeToken
36-
from .forms import PgwebAuthenticationForm
38+
from .forms import PgwebAuthenticationForm, ConfirmSubmitForm
3739
from .forms import CommunityAuthConsentForm
3840
from .forms import SignupForm, SignupOauthForm
3941
from .forms import UserForm, UserProfileForm, ContributorForm
4042
from .forms import ChangeEmailForm, PgwebPasswordResetForm
4143

4244
import logging
45+
46+
from pgweb.util.moderation import get_moderation_model_from_suburl
47+
from pgweb.mailqueue.util import send_simple_mail
48+
4349
log = logging.getLogger(__name__)
4450

4551
# The value we store in user.password for oauth logins. This is
4652
# a value that must not match any hashers.
4753
OAUTH_PASSWORD_STORE = 'oauth_signin_account_no_password'
4854

4955

56+
def _modobjs(qs):
57+
l = list(qs)
58+
if l:
59+
return {
60+
'title': l[0]._meta.verbose_name_plural.capitalize(),
61+
'objects': l,
62+
'editurl': l[0].account_edit_suburl,
63+
}
64+
else:
65+
return None
66+
67+
5068
@login_required
5169
def home(request):
52-
myarticles = NewsArticle.objects.filter(org__managers=request.user, approved=False)
53-
myevents = Event.objects.filter(org__managers=request.user, approved=False)
54-
myorgs = Organisation.objects.filter(managers=request.user, approved=False)
55-
myproducts = Product.objects.filter(org__managers=request.user, approved=False)
56-
myprofservs = ProfessionalService.objects.filter(org__managers=request.user, approved=False)
5770
return render_pgweb(request, 'account', 'account/index.html', {
58-
'newsarticles': myarticles,
59-
'events': myevents,
60-
'organisations': myorgs,
61-
'products': myproducts,
62-
'profservs': myprofservs,
71+
'modobjects': [
72+
{
73+
'title': 'not submitted yet',
74+
'objects': [
75+
_modobjs(NewsArticle.objects.filter(org__managers=request.user, modstate=ModerationState.CREATED)),
76+
],
77+
},
78+
{
79+
'title': 'waiting for moderator approval',
80+
'objects': [
81+
_modobjs(NewsArticle.objects.filter(org__managers=request.user, modstate=ModerationState.PENDING)),
82+
_modobjs(Event.objects.filter(org__managers=request.user, approved=False)),
83+
_modobjs(Organisation.objects.filter(managers=request.user, approved=False)),
84+
_modobjs(Product.objects.filter(org__managers=request.user, approved=False)),
85+
_modobjs(ProfessionalService.objects.filter(org__managers=request.user, approved=False))
86+
],
87+
},
88+
],
6389
})
6490

6591

6692
objtypes = {
6793
'news': {
68-
'title': 'News Article',
94+
'title': 'news article',
6995
'objects': lambda u: NewsArticle.objects.filter(org__managers=u),
96+
'tristate': True,
97+
'editapproved': False,
7098
},
7199
'events': {
72-
'title': 'Event',
100+
'title': 'event',
73101
'objects': lambda u: Event.objects.filter(org__managers=u),
102+
'editapproved': True,
74103
},
75104
'products': {
76-
'title': 'Product',
105+
'title': 'product',
77106
'objects': lambda u: Product.objects.filter(org__managers=u),
107+
'editapproved': True,
78108
},
79109
'services': {
80-
'title': 'Professional Service',
110+
'title': 'professional service',
81111
'objects': lambda u: ProfessionalService.objects.filter(org__managers=u),
112+
'editapproved': True,
82113
},
83114
'organisations': {
84-
'title': 'Organisation',
115+
'title': 'organisation',
85116
'objects': lambda u: Organisation.objects.filter(managers=u),
86117
'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.',
118+
'editapproved': True,
87119
},
88120
}
89121

@@ -208,14 +240,25 @@ def listobjects(request, objtype):
208240
raise Http404("Object type not found")
209241
o = objtypes[objtype]
210242

211-
return render_pgweb(request, 'account', 'account/objectlist.html', {
212-
'objects': {
243+
if o.get('tristate', False):
244+
objects = {
245+
'approved': o['objects'](request.user).filter(modstate=ModerationState.APPROVED),
246+
'unapproved': o['objects'](request.user).filter(modstate=ModerationState.PENDING),
247+
'inprogress': o['objects'](request.user).filter(modstate=ModerationState.CREATED),
248+
}
249+
else:
250+
objects = {
213251
'approved': o['objects'](request.user).filter(approved=True),
214252
'unapproved': o['objects'](request.user).filter(approved=False),
215-
},
253+
}
254+
255+
return render_pgweb(request, 'account', 'account/objectlist.html', {
256+
'objects': objects,
216257
'title': o['title'],
258+
'editapproved': o['editapproved'],
217259
'submit_header': o.get('submit_header', None),
218260
'suburl': objtype,
261+
'tristate': o.get('tristate', False),
219262
})
220263

221264

@@ -228,6 +271,100 @@ def orglist(request):
228271
})
229272

230273

274+
@login_required
275+
def submitted_item_form(request, objtype, item):
276+
model = get_moderation_model_from_suburl(objtype)
277+
278+
if item == 'new':
279+
extracontext = {}
280+
else:
281+
extracontext = {
282+
'notices': ModerationNotification.objects.filter(
283+
objecttype=model.__name__,
284+
objectid=item,
285+
).order_by('-date')
286+
}
287+
288+
return simple_form(model, item, request, model.get_formclass(),
289+
redirect='/account/edit/{}/'.format(objtype),
290+
formtemplate='account/submit_form.html',
291+
extracontext=extracontext)
292+
293+
294+
def _submitted_item_submit(request, objtype, model, obj):
295+
if obj.modstate != ModerationState.CREATED:
296+
# Can only submit if state is created
297+
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
298+
299+
if request.method == 'POST':
300+
form = ConfirmSubmitForm(obj._meta.verbose_name, data=request.POST)
301+
if form.is_valid():
302+
with transaction.atomic():
303+
obj.modstate = ModerationState.PENDING
304+
obj.send_notification = False
305+
obj.save()
306+
307+
send_simple_mail(settings.NOTIFICATION_FROM,
308+
settings.NOTIFICATION_EMAIL,
309+
"{} {} submitted".format(obj._meta.verbose_name.capitalize(), obj.id),
310+
"{} {} with title {} submitted for moderation by {}".format(
311+
obj._meta.verbose_name.capitalize(),
312+
obj.id,
313+
obj.title,
314+
request.user.username
315+
),
316+
)
317+
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
318+
else:
319+
form = ConfirmSubmitForm(obj._meta.verbose_name)
320+
321+
return render_pgweb(request, 'account', 'account/submit_preview.html', {
322+
'obj': obj,
323+
'form': form,
324+
'objtype': obj._meta.verbose_name,
325+
'preview': obj.get_preview_fields(),
326+
})
327+
328+
329+
def _submitted_item_withdraw(request, objtype, model, obj):
330+
# XXX: should we do a confirmation step? But it's easy enough to resubmit.
331+
if obj.modstate != ModerationState.PENDING:
332+
# Can only withdraw if it's in pending state
333+
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
334+
335+
obj.modstate = ModerationState.CREATED
336+
obj.send_notification = False
337+
obj.save()
338+
339+
send_simple_mail(
340+
settings.NOTIFICATION_FROM,
341+
settings.NOTIFICATION_EMAIL,
342+
"{} {} withdrawn from moderation".format(model._meta.verbose_name.capitalize(), obj.id),
343+
"{} {} with title {} withdrawn from moderation by {}".format(
344+
model._meta.verbose_name.capitalize(),
345+
obj.id,
346+
obj.title,
347+
request.user.username
348+
),
349+
)
350+
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
351+
352+
353+
@login_required
354+
@transaction.atomic
355+
def submitted_item_submitwithdraw(request, objtype, item, what):
356+
model = get_moderation_model_from_suburl(objtype)
357+
358+
obj = get_object_or_404(model, pk=item)
359+
if not obj.verify_submitter(request.user):
360+
raise PermissionDenied("You are not the owner of this item!")
361+
362+
if what == 'submit':
363+
return _submitted_item_submit(request, objtype, model, obj)
364+
else:
365+
return _submitted_item_withdraw(request, objtype, model, obj)
366+
367+
231368
def login(request):
232369
return authviews.LoginView.as_view(template_name='account/login.html',
233370
authentication_form=PgwebAuthenticationForm,

0 commit comments

Comments
 (0)