+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;
+}
padding: 1em 2em;
}
+/* Utility */
+.ws-pre {
+ white-space: pre;
+}
+
/* #BLOCKQUOTE */
blockquote {
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';
+ }
}
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)
# 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),
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
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
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,
},
}
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),
})
})
+@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,
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
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
import base64
+from pgweb.util.moderation import TwostateModerateModel
+
TESTING_CHOICES = (
(0, 'Release'),
(1, 'Release candidate'),
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)
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):
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
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(
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: *
})
+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)
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)
from django.db import models
from pgweb.core.models import Organisation
+from pgweb.util.moderation import TwostateModerateModel
class Category(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)
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)
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
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
#######
})
-@login_required
-def productform(request, itemid):
- return simple_form(Product, itemid, request, ProductForm,
- redirect='/account/edit/products/')
-
-
#######
# Stackbuilder
#######
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")
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
class Meta:
ordering = ('-startdate', '-enddate', )
+
+ @classmethod
+ def get_formclass(self):
+ from pgweb.events.forms import EventForm
+ return EventForm
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):
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/')
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):
from django.contrib.syndication.views import Feed
+from pgweb.util.moderation import ModerationState
from .models import NewsArticle
from datetime import datetime, time
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
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
class Meta:
model = NewsArticle
- exclude = ('submitter', 'approved', 'tweeted')
+ exclude = ('date', 'submitter', 'modstate', 'tweeted', 'firstmoderator')
widgets = {
'tags': forms.CheckboxSelectMultiple,
}
from datetime import datetime, timedelta
import time
+from pgweb.util.moderation import ModerationState
from pgweb.news.models import NewsArticle
import requests_oauthlib
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
--- /dev/null
+# -*- 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),
+ ),
+ ]
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):
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
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)
from datetime import date, timedelta
from .models import NewsArticle
+from pgweb.util.moderation import ModerationState
+
def get_struct():
now = date.today()
# 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
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
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,
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,
'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/')
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)
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",
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
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'),
'regionname': regname,
'services': services,
})
-
-
-# Forms to edit
-@login_required
-def profservform(request, itemid):
- return simple_form(ProfessionalService, itemid, request, ProfessionalServiceForm,
- redirect='/account/edit/services/')
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),
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):
* 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'
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):
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,
- )
from django.conf import settings
from pgweb.util.contexts import render_pgweb
+from pgweb.util.moderation import ModerationState
import io
import re
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
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)
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()
'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={}):
-# 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))
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
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):
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))
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%}
{%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 %}
--- /dev/null
+{%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%}
--- /dev/null
+{%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%}
{% 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 }}
<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 %}
+++ /dev/null
-{% 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 %}
--- /dev/null
+{%extends "admin/base_site.html"%}
+{%load pgfilters%}
+
+{%block breadcrumbs%}
+<div class="breadcrumbs"><a href="/admin/">Home</a> › <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%}
<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>