diff options
27 files changed, 765 insertions, 326 deletions
diff --git a/askbot/__init__.py b/askbot/__init__.py index 0e4ba506..3515cdba 100644 --- a/askbot/__init__.py +++ b/askbot/__init__.py @@ -38,6 +38,7 @@ REQUIREMENTS = { 'longerusername': 'longerusername', 'bs4': 'beautifulsoup4', 'picklefield': 'django-picklefield==0.3.0', + #'stopforumspam': 'stopforumspam' } if platform.system() != 'Windows': diff --git a/askbot/conf/moderation.py b/askbot/conf/moderation.py index b537663f..f0e6d681 100644 --- a/askbot/conf/moderation.py +++ b/askbot/conf/moderation.py @@ -4,6 +4,7 @@ from askbot.conf.settings_wrapper import settings from askbot.conf.super_groups import DATA_AND_FORMATTING from askbot.deps.livesettings import ConfigurationGroup from askbot.deps.livesettings import BooleanValue +from askbot.deps.livesettings import StringValue from django.core.cache import cache from django.utils.translation import ugettext_lazy as _ @@ -17,16 +18,24 @@ def empty_cache_callback(old_value, new_value): MODERATION = ConfigurationGroup( 'MODERATION', _('Content moderation'), - super_group = DATA_AND_FORMATTING + super_group=DATA_AND_FORMATTING ) +CONTENT_MODERATION_MODE_CHOICES = ( + ('flags', _('audit flagged posts')), + ('audit', _('audit flagged posts and watched users')), + ('premoderation', _('pre-moderate watched users and audit flagged posts')), +) + settings.register( - BooleanValue( + StringValue( MODERATION, - 'ENABLE_CONTENT_MODERATION', - default = False, - description = _('Enable content moderation'), - update_callback = empty_cache_callback + 'CONTENT_MODERATION_MODE', + choices=CONTENT_MODERATION_MODE_CHOICES, + default='flags', + description=_('Content moderation method'), + update_callback=empty_cache_callback, + help_text=_("Audit is made after the posts are published, pre-moderation prevents publishing before moderator's decision.") ) ) @@ -34,9 +43,9 @@ settings.register( BooleanValue( MODERATION, 'ENABLE_TAG_MODERATION', - default = False, - description = _('Enable tag moderation'), - help_text = _( + default=False, + description=_('Enable tag moderation'), + help_text=_( 'If enabled, any new tags will not be applied ' 'to the questions, but emailed to the moderators. ' 'To use this feature, tags must be optional.' diff --git a/askbot/context.py b/askbot/context.py index 7de6cf0d..b0855b0b 100644 --- a/askbot/context.py +++ b/askbot/context.py @@ -37,6 +37,7 @@ def application_settings(request): settings.ASKBOT_ALLOWED_UPLOAD_FILE_TYPES my_settings['ASKBOT_URL'] = settings.ASKBOT_URL my_settings['STATIC_URL'] = settings.STATIC_URL + my_settings['IP_MODERATION_ENABLED'] = getattr(settings, 'ASKBOT_IP_MODERATION_ENABLED', False) my_settings['ASKBOT_CSS_DEVEL'] = getattr( settings, 'ASKBOT_CSS_DEVEL', diff --git a/askbot/management/commands/send_email_alerts.py b/askbot/management/commands/send_email_alerts.py index 1f04690f..b7b594ef 100644 --- a/askbot/management/commands/send_email_alerts.py +++ b/askbot/management/commands/send_email_alerts.py @@ -143,7 +143,7 @@ class Command(NoArgsCommand): thread__closed=True ).order_by('-thread__last_activity_at') - if askbot_settings.ENABLE_CONTENT_MODERATION: + if askbot_settings.CONTENT_MODERATION_MODE == 'premoderation': base_qs = base_qs.filter(approved = True) #todo: for some reason filter on did not work as expected ~Q(viewed__who=user) | # Q(viewed__who=user,viewed__when__lt=F('thread__last_activity_at')) diff --git a/askbot/media/js/post.js b/askbot/media/js/post.js index d077ce9e..2efe0bf8 100644 --- a/askbot/media/js/post.js +++ b/askbot/media/js/post.js @@ -539,8 +539,6 @@ var Vote = function(){ var subscribeAnonymousMessage = gettext('anonymous users cannot subscribe to questions') + pleaseLogin; var voteAnonymousMessage = gettext('anonymous users cannot vote') + pleaseLogin; //there were a couple of more messages... - var offensiveConfirmation = gettext('please confirm offensive'); - var removeOffensiveConfirmation = gettext('please confirm removal of offensive flag'); var offensiveAnonymousMessage = gettext('anonymous users cannot flag offensive posts') + pleaseLogin; var removeConfirmation = gettext('confirm delete'); var removeAnonymousMessage = gettext('anonymous users cannot delete/undelete') + pleaseLogin; @@ -1069,10 +1067,8 @@ var Vote = function(){ ); return false; } - if (confirm(offensiveConfirmation)){ - postId = object.id.substr(object.id.lastIndexOf('-') + 1); - submit(object, voteType, callback_offensive); - } + postId = object.id.substr(object.id.lastIndexOf('-') + 1); + submit(object, voteType, callback_offensive); }, //remove flag offensive remove_offensive: function(object, voteType){ @@ -1089,10 +1085,8 @@ var Vote = function(){ ); return false; } - if (confirm(removeOffensiveConfirmation)){ - postId = object.id.substr(object.id.lastIndexOf('-') + 1); - submit(object, voteType, callback_remove_offensive); - } + postId = object.id.substr(object.id.lastIndexOf('-') + 1); + submit(object, voteType, callback_remove_offensive); }, remove_all_offensive: function(object, voteType){ if (!currentUserId || currentUserId.toUpperCase() == "NONE"){ @@ -1108,10 +1102,8 @@ var Vote = function(){ ); return false; } - if (confirm(removeOffensiveConfirmation)){ - postId = object.id.substr(object.id.lastIndexOf('-') + 1); - submit(object, voteType, callback_remove_all_offensive); - } + postId = object.id.substr(object.id.lastIndexOf('-') + 1); + submit(object, voteType, callback_remove_all_offensive); }, //delete question or answer (comments are deleted separately) remove: function(object, voteType){ diff --git a/askbot/media/js/user.js b/askbot/media/js/user.js index 90f35f2f..90ca2a7b 100644 --- a/askbot/media/js/user.js +++ b/askbot/media/js/user.js @@ -1,4 +1,5 @@ //todo: refactor this into "Inbox" object or more specialized +/* var setup_inbox = function(){ var getSelected = function(){ @@ -113,20 +114,6 @@ var setup_inbox = function(){ } ); - var rejectPostDialog = new RejectPostDialog(); - rejectPostDialog.decorate($('#reject-edit-modal')); - rejectPostDialog.setSelectedEditDataReader(function(){ - return getSelected(); - }); - setupButtonEventHandlers( - $('#re_delete_post'), - function(){ - if (rejectPostDialog.readSelectedEditData()) { - rejectPostDialog.show(); - } - } - ); - if ($('body').hasClass('inbox-flags')) { var responses = $('.response-parent'); responses.each(function(idx, response) { @@ -138,6 +125,14 @@ var setup_inbox = function(){ }); } }; +*/ +var setup_inbox = function(){ + var page = $('.inbox-flags'); + if (page.length) { + var modControls = new PostModerationControls(); + modControls.decorate(page); + } +}; var setup_badge_details_toggle = function(){ $('.badge-context-toggle').each(function(idx, elem){ @@ -157,52 +152,34 @@ var setup_badge_details_toggle = function(){ }); }; -var PostModerationControls = function() { +/** +* response notifications +*/ +var Inbox = function() { WrappedElement.call(this); }; -inherits(PostModerationControls, WrappedElement); - -PostModerationControls.prototype.setParent = function(parent_element) { - this._parent_element = parent_element; -}; +inherits(Inbox, WrappedElement); -PostModerationControls.prototype.setReasonsDialog = function(dialog) { - this._reasonsDialog = dialog; -}; - -PostModerationControls.prototype.getMemoId = function() { +Inbox.prototype.getMemoId = function() { return this._parent_element.data('responseId'); }; -PostModerationControls.prototype.getMemoElement = function() { +Inbox.prototype.getMemoElement = function() { var reId = this.getMemoId(); return $('#re_' + reId); }; -PostModerationControls.prototype.removeMemo = function() { +Inbox.prototype.removeMemo = function() { this.getMemoElement().remove(); }; -PostModerationControls.prototype.markMemo = function() { +Inbox.prototype.markMemo = function() { var memo = this.getMemoElement(); var checkbox = memo.find('input[type="checkbox"]'); checkbox.attr('checked', true); }; -PostModerationControls.prototype.addReason = function(id, title) { - var li = this.makeElement('li'); - var anchor = this.makeElement('a'); - anchor.html(title); - anchor.data('postId', id); - li.append(anchor); - var adderLink = this._reasonList.children().last(); - adderLink.before(li); - //attach event handler - var me = this; - setupButtonEventHandlers(anchor, function() { me.moderatePost(id, 'delete_post') }); -}; - -PostModerationControls.prototype.moderatePost = function(reasonId, actionType){ +Inbox.prototype.moderatePost = function(reasonId, actionType){ var me = this; var data = { reject_reason_id: reasonId, @@ -232,68 +209,205 @@ PostModerationControls.prototype.moderatePost = function(reasonId, actionType){ }; -PostModerationControls.prototype.createDom = function() { - var toolbar = this.makeElement('div'); - toolbar.addClass('btn-toolbar post-moderation-controls'); - this._element = toolbar; +/** +* the dropdown menu with selection of reasons +* to reject posts and a button that starts menu to +* manage the list of reasons +*/ +var DeclineAndExplainMenu = function() { + WrappedElement.call(this); +}; +inherits(DeclineAndExplainMenu, WrappedElement); + +DeclineAndExplainMenu.prototype.setupDeclinePostHandler = function(button) { + var me = this; + var reasonId = button.data('reasonId'); + var controls = this.getControls(); + var handler = controls.getModHandler('decline-with-reason', ['posts'], reasonId); + setupButtonEventHandlers(button, handler); +}; + +DeclineAndExplainMenu.prototype.addReason = function(id, title) { + var li = this.makeElement('li'); + var button = this.makeElement('a'); + li.append(button); + button.html(title); + button.data('reasonId', id); + this._addReasonBtn.parent().before(li); - var div = this.makeElement('div'); - div.addClass('btn-group'); - toolbar.append(div); + this.setupDeclinePostHandler(button); +}; - var acceptBtn = this.makeElement('a'); - acceptBtn.addClass('btn save-reason'); - acceptBtn.html(gettext('Accept')); - div.append(acceptBtn); +DeclineAndExplainMenu.prototype.setControls = function(controls) { + this._controls = controls; +}; - div = this.makeElement('div'); - div.addClass('btn-group dropdown'); - toolbar.append(div); +DeclineAndExplainMenu.prototype.getControls = function() { + return this._controls; +}; - var toggle = this.makeElement('button'); - toggle.addClass('btn btn-danger dropdown-toggle'); - toggle.append($('<span>' + gettext('Reject') + '</span>')); - toggle.append($('<span class="caret"></span>')); - div.append(toggle); +DeclineAndExplainMenu.prototype.decorate = function(element) { + this._element = element; + //activate dropdown menu + element.dropdown(); - toggle.dropdown(); + var declineBtns = element.find('.decline-with-reason'); + var me = this; + declineBtns.each(function(idx, elem) { + me.setupDeclinePostHandler($(elem)); + }); - var ul = this.makeElement('ul'); - ul.addClass('dropdown-menu'); - div.append(ul); + this._reasonList = element.find('ul'); - this._reasonList = ul; + var addReasonBtn = element.find('.manage-reasons'); + this._addReasonBtn = addReasonBtn; - //reason adder - var li = this.makeElement('li'); - var anchor = this.makeElement('a'); - anchor.html(gettext('add new reject reason')); - li.append(anchor); - ul.append(li); + var manageReasonsDialog = new ManageRejectReasonsDialog(); + manageReasonsDialog.decorate($('#manage-reject-reasons-modal')); + this._manageReasonsDialog = manageReasonsDialog; + manageReasonsDialog.setMenu(this); +}; + +/** +* Buttons to moderate posts +* and the list of edits +*/ +var PostModerationControls = function() { + WrappedElement.call(this); +}; +inherits(PostModerationControls, WrappedElement); + +/** +* displays feedback message +*/ +PostModerationControls.prototype.showMessage = function(message) { + this._notification.html(message); + this._notification.parent().fadeIn('fast'); +}; - //append menu items +PostModerationControls.prototype.hideMessage = function() { + this._notification.parent().hide(); +}; + +/** +* removes entries from the moderation screen +*/ +PostModerationControls.prototype.removeEntries = function(entryIds) { + for (var i = 0; i < entryIds.length; i++) { + var id = entryIds[i]; + var elem = this._element.find('.message[data-message-id="' + id + '"]'); + if (elem.length) { + elem.fadeOut('fast'); + } + } +}; + +PostModerationControls.prototype.getCheckBoxes = function() { + return this._element.find('.messages input[type="checkbox"]'); +}; + +PostModerationControls.prototype.getSelectedEditIds = function() { + var checkBoxes = this.getCheckBoxes(); + var num = checkBoxes.length; + var idList = []; + for (var i = 0; i < num; i++) { + var cb = $(checkBoxes[i]); + if (cb.is(':checked')) { + var msg = cb.closest('.message-details'); + var msgId = msg.data('messageId'); + idList.push(msgId); + } + } + return idList; +}; + +/** +* action - one of 'decline-with-reason', 'approve', 'block' +* items - a list of items ['posts', 'users', 'ips'] +* not all combinations of action and items are supported +* optReason must be used with 'decline-with-reason' action +*/ +PostModerationControls.prototype.getModHandler = function(action, items, optReason) { var me = this; - $.each(askbot['data']['postRejectReasons'], function(idx, reason) { - me.addReason(reason['id'], reason['title']); - }); + return function() { + var selectedEditIds = me.getSelectedEditIds(); + if (selectedEditIds.length == 0) { + me.showMessage(gettext('Please select at least one item')); + return; + } + //@todo: implement undo + var postData = { + 'edit_ids': selectedEditIds,//revision ids + 'action': action, + 'items': items,//affected items - users, posts, ips + 'reason': optReason || 'none' + }; + $.ajax({ + type: 'POST', + cache: false, + dataType: 'json', + data: JSON.stringify(postData), + url: askbot['urls']['moderatePostEdits'], + success: function(response_data){ + if (response_data['success'] == true){ + me.removeEntries(selectedEditIds); + } + if (response_data['message']) { + me.showMessage(response_data['message']); + } + } + }); + }; +}; - var reasonsDlg = this._reasonsDialog; - setupButtonEventHandlers(anchor, function() { - me.markMemo();//mark current post - reasonsDlg.readSelectedEditData();//read data of selected edits - reasonsDlg.show();//open the "big" dialog - }); - setupButtonEventHandlers(acceptBtn, function() { - me.moderatePost(null, 'remove_flag'); - }); +PostModerationControls.prototype.getSelectAllHandler = function(selected) { + var me = this; + return function() { + var cb = me.getCheckBoxes(); + cb.prop('checked', selected); + }; }; +PostModerationControls.prototype.decorate = function(element) { + this._element = element; + this._notification = element.find('.action-status span'); + this.hideMessage(); + //approve posts button + var button = $('.approve-posts'); + setupButtonEventHandlers(button, this.getModHandler('approve', ['posts'])); + + //approve posts and users + button = $('.approve-posts-users'); + setupButtonEventHandlers(button, this.getModHandler('approve', ['posts', 'users'])); + + //decline and explain why + var reasonsMenuElem = $('.decline-reasons-menu'); + var declineAndExplainMenu = new DeclineAndExplainMenu(); + declineAndExplainMenu.setControls(this); + declineAndExplainMenu.decorate(reasonsMenuElem); + + //delete posts and block users + button = element.find('.decline-block-users'); + setupButtonEventHandlers(button, this.getModHandler('block', ['posts', 'users'])); + + //delete posts, block users and ips + button = element.find('.decline-block-users-ips'); + setupButtonEventHandlers(button, this.getModHandler('block', ['posts', 'users', 'ips'])); + + button = element.find('.sel-all'); + setupButtonEventHandlers(button, this.getSelectAllHandler(true)); + + button = element.find('.sel-none'); + setupButtonEventHandlers(button, this.getSelectAllHandler(false)); +}; + + /** * @constructor * manages post/edit reject reasons * in the post moderation view */ -var RejectPostDialog = function(){ +var ManageRejectReasonsDialog = function(){ WrappedElement.call(this); this._selected_edit_ids = null; this._selected_reason_id = null; @@ -301,27 +415,31 @@ var RejectPostDialog = function(){ this._postModerationControls = []; this._selectedEditDataReader = undefined; }; -inherits(RejectPostDialog, WrappedElement); +inherits(ManageRejectReasonsDialog, WrappedElement); + +ManageRejectReasonsDialog.prototype.setMenu = function(menu) { + this._reasonsMenu = menu; +}; -RejectPostDialog.prototype.setSelectedEditDataReader = function(func) { +ManageRejectReasonsDialog.prototype.setSelectedEditDataReader = function(func) { this._selectedEditDataReader = func; }; -RejectPostDialog.prototype.readSelectedEditData = function() { +ManageRejectReasonsDialog.prototype.readSelectedEditData = function() { var data = this._selectedEditDataReader(); this.setSelectedEditData(data); return data['id_list'].length > 0; }; -RejectPostDialog.prototype.setSelectedEditData = function(data){ +ManageRejectReasonsDialog.prototype.setSelectedEditData = function(data){ this._selected_edit_data = data; }; -RejectPostDialog.prototype.addPostModerationControl = function(control) { +ManageRejectReasonsDialog.prototype.addPostModerationControl = function(control) { this._postModerationControls.push(control); }; -RejectPostDialog.prototype.setState = function(state){ +ManageRejectReasonsDialog.prototype.setState = function(state){ this._state = state; this.clearErrors(); if (this._element){ @@ -338,15 +456,15 @@ RejectPostDialog.prototype.setState = function(state){ } }; -RejectPostDialog.prototype.show = function(){ +ManageRejectReasonsDialog.prototype.show = function(){ $(this._element).modal('show'); }; -RejectPostDialog.prototype.hide = function(){ +ManageRejectReasonsDialog.prototype.hide = function(){ $(this._element).modal('hide'); }; -RejectPostDialog.prototype.resetInputs = function(){ +ManageRejectReasonsDialog.prototype.resetInputs = function(){ if (this._title_input){ this._title_input.reset(); } @@ -357,12 +475,12 @@ RejectPostDialog.prototype.resetInputs = function(){ selected.removeClass('selected'); }; -RejectPostDialog.prototype.clearErrors = function(){ +ManageRejectReasonsDialog.prototype.clearErrors = function(){ var error = this._element.find('.alert'); error.remove(); }; -RejectPostDialog.prototype.makeAlertBox = function(errors){ +ManageRejectReasonsDialog.prototype.makeAlertBox = function(errors){ //construct the alert box var alert_box = new AlertBox(); alert_box.setClass('alert-error'); @@ -393,7 +511,7 @@ RejectPostDialog.prototype.makeAlertBox = function(errors){ return alert_box; }; -RejectPostDialog.prototype.setAdderErrors = function(errors){ +ManageRejectReasonsDialog.prototype.setAdderErrors = function(errors){ //clear previous errors this.clearErrors(); var alert_box = this.makeAlertBox(errors); @@ -402,7 +520,7 @@ RejectPostDialog.prototype.setAdderErrors = function(errors){ .prepend(alert_box.getElement()); }; -RejectPostDialog.prototype.setSelectorErrors = function(errors){ +ManageRejectReasonsDialog.prototype.setSelectorErrors = function(errors){ this.clearErrors(); var alert_box = this.makeAlertBox(errors); this._element @@ -410,7 +528,7 @@ RejectPostDialog.prototype.setSelectorErrors = function(errors){ .prepend(alert_box.getElement()); }; -RejectPostDialog.prototype.setErrors = function(errors){ +ManageRejectReasonsDialog.prototype.setErrors = function(errors){ this.clearErrors(); var alert_box = this.makeAlertBox(errors); var current_state = this._state; @@ -419,7 +537,7 @@ RejectPostDialog.prototype.setErrors = function(errors){ .prepend(alert_box.getElement()); }; -RejectPostDialog.prototype.addSelectableReason = function(data){ +ManageRejectReasonsDialog.prototype.addSelectableReason = function(data){ var id = data['reason_id']; var title = data['title']; var details = data['details']; @@ -433,7 +551,7 @@ RejectPostDialog.prototype.addSelectableReason = function(data){ }); }; -RejectPostDialog.prototype.startSavingReason = function(callback){ +ManageRejectReasonsDialog.prototype.startSavingReason = function(callback){ var title_input = this._title_input; var details_input = this._details_input; @@ -483,7 +601,7 @@ RejectPostDialog.prototype.startSavingReason = function(callback){ }); }; -RejectPostDialog.prototype.rejectPost = function(reason_id){ +ManageRejectReasonsDialog.prototype.rejectPost = function(reason_id){ var me = this; var memos = this._selected_edit_data['elements']; var memo_ids = this._selected_edit_data['id_list']; @@ -513,13 +631,13 @@ RejectPostDialog.prototype.rejectPost = function(reason_id){ }); }; -RejectPostDialog.prototype.setPreviewerData = function(data){ +ManageRejectReasonsDialog.prototype.setPreviewerData = function(data){ this._selected_reason_id = data['id']; this._element.find('.selected-reason-title').html(data['title']); this._element.find('.selected-reason-details').html(data['details']); }; -RejectPostDialog.prototype.startEditingReason = function(){ +ManageRejectReasonsDialog.prototype.startEditingReason = function(){ var title = this._element.find('.selected-reason-title').html(); var details = this._element.find('.selected-reason-details').html(); this._title_input.setVal(title); @@ -527,15 +645,15 @@ RejectPostDialog.prototype.startEditingReason = function(){ this.setState('add-new'); }; -RejectPostDialog.prototype.resetSelectedReasonId = function(){ +ManageRejectReasonsDialog.prototype.resetSelectedReasonId = function(){ this._selected_reason_id = null; }; -RejectPostDialog.prototype.getSelectedReasonId = function(){ +ManageRejectReasonsDialog.prototype.getSelectedReasonId = function(){ return this._selected_reason_id; }; -RejectPostDialog.prototype.startDeletingReason = function(){ +ManageRejectReasonsDialog.prototype.startDeletingReason = function(){ var select_box = this._select_box; var data = select_box.getSelectedItemData(); var reason_id = data['id']; @@ -562,7 +680,7 @@ RejectPostDialog.prototype.startDeletingReason = function(){ } }; -RejectPostDialog.prototype.decorate = function(element){ +ManageRejectReasonsDialog.prototype.decorate = function(element){ this._element = element; //set default state according to the # of available reasons this._selector = $(element).find('#reject-edit-modal-select'); diff --git a/askbot/media/style/style.css b/askbot/media/style/style.css index 33df2b0a..7d1cd135 100644 --- a/askbot/media/style/style.css +++ b/askbot/media/style/style.css @@ -2871,17 +2871,49 @@ ul#related-tags li { .user-profile-page.inbox-group-join-requests td { padding-right: 10px; } -.inbox-flags.user-profile-page .re { +.user-profile-page.inbox-forum h2, +.user-profile-page.inbox-flags h2 { + line-height: 24px; + padding-bottom: 6px; +} +.user-profile-page.inbox-forum input[type="checkbox"] { + display: none; +} +.user-profile-page.inbox-forum .new { + background: #FFF8C6; +} +.reject-reason-title { + margin-bottom: 12px; +} +.user-profile-page.inbox-flags .re { width: 810px; } -.inbox-flags.user-profile-page .post-moderation-controls { +.user-profile-page.inbox-flags .post-moderation-controls { float: left; width: 150px; margin-top: 23px; text-align: right; } -.inbox-flags.user-profile-page .dropdown:hover ul.dropdown-menu { +.user-profile-page.inbox-flags .dropdown { + display: -moz-inline-stack; + display: inline-block; + height: 17px; +} +.user-profile-page.inbox-flags .dropdown:hover ul.dropdown-menu { display: block; + margin-top: 9px; +} +.user-profile-page.inbox-flags .highlight { + background: transparent; +} +.user-profile-page.inbox-flags .messages { + margin-bottom: 14px; +} +.user-profile-page.inbox-flags .select-items { + margin-bottom: 10px; +} +.user-profile-page.inbox-flags #responses div.face { + display: none; } .openid-signin form { margin-bottom: 5px; @@ -3699,6 +3731,10 @@ button::-moz-focus-inner { font-size: 12px; padding: 0; } +.inbox-flags .action-status { + line-height: 38px; + height: 24px; +} .action-status span { padding: 3px 5px 3px 5px; background-color: #fff380; @@ -3861,6 +3897,7 @@ p.signup_p { margin-bottom: 15px; } #responses h2 { + line-height: 24px; margin: 0; padding: 0; } diff --git a/askbot/media/style/style.less b/askbot/media/style/style.less index f4ac2dfd..55f237ae 100644 --- a/askbot/media/style/style.less +++ b/askbot/media/style/style.less @@ -2993,7 +2993,28 @@ ul#related-tags li { } } -.inbox-flags.user-profile-page { +.user-profile-page.inbox-forum, +.user-profile-page.inbox-flags { + h2 { + line-height: 24px; + padding-bottom: 6px; + } +} + +.user-profile-page.inbox-forum { + input[type="checkbox"] { + display: none; + } + .new { + background: #FFF8C6; + } +} + +.reject-reason-title { + margin-bottom: 12px; +} + +.user-profile-page.inbox-flags { .re { width: 810px; } @@ -3003,11 +3024,29 @@ ul#related-tags li { margin-top: 23px; text-align: right; } + .dropdown { + display: -moz-inline-stack; + display: inline-block; + height: 17px; + } .dropdown:hover { ul.dropdown-menu { display: block; + margin-top: 9px; } } + .highlight { + background: transparent; + } + .messages { + margin-bottom: 14px; + } + .select-items { + margin-bottom: 10px; + } + #responses div.face { + display: none; + } } .openid-signin form { @@ -3929,6 +3968,11 @@ button::-moz-focus-inner { padding: 0; } +.inbox-flags .action-status { + line-height: 38px; + height: 24px; +} + .action-status span { padding: 3px 5px 3px 5px; background-color: #fff380; /* nice yellow */ @@ -4122,6 +4166,7 @@ p.signup_p { line-height:18px; margin-bottom:15px; h2 { + line-height: 24px; margin: 0; padding: 0; } diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index 5eb79c2f..c80c57b5 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -558,9 +558,9 @@ def get_or_create_anonymous_user(): return user def user_needs_moderation(self): - """True, if user needs moderation""" - if askbot_settings.ENABLE_CONTENT_MODERATION: - return not (self.is_administrator_or_moderator() or self.is_approved()) + if self.status not in ('a', 'm', 'd'): + choices = ('audit', 'premoderation') + return askbot_settings.CONTENT_MODERATION_MODE in choices return False def user_notify_users( @@ -2720,24 +2720,34 @@ def user_approve_post_revision(user, post_revision, timestamp = None): post = post_revision.post - assert(post_revision.revision == 0) - post_revision.revision = post.get_latest_revision_number() + 1 + #approval of unpublished revision + if post_revision.revision == 0: + post_revision.revision = post.get_latest_revision_number() + 1 - post_revision.save() + post_revision.save() - post.approved = True - post.save() + if post.approved == False: + if post.is_comment(): + post.parent.comment_count += 1 + post.parent.save() + elif post.is_answer(): + post.thread.answer_count += 1 + post.thread.save() - if post_revision.post.post_type == 'question': - thread = post.thread - thread.approved = True - thread.save() - post.thread.invalidate_cached_data() + post.approved = True + post.save() - #send the signal of published revision - signals.post_revision_published.send( - None, revision = post_revision, was_approved = True - ) + if post_revision.post.post_type == 'question': + thread = post.thread + thread.approved = True + thread.save() + + post.thread.invalidate_cached_data() + + #send the signal of published revision + signals.post_revision_published.send( + None, revision = post_revision, was_approved = True + ) @auto_now_timestamp def flag_post( diff --git a/askbot/models/post.py b/askbot/models/post.py index f1178473..94b96370 100644 --- a/askbot/models/post.py +++ b/askbot/models/post.py @@ -235,6 +235,14 @@ class PostManager(BaseQuerySetManager): parse_results = post.parse_and_save(author=author, is_private=is_private) + post.add_revision( + author = author, + revised_at = added_at, + text = text, + comment = unicode(const.POST_STATUS['default_version']), + by_email = by_email + ) + from askbot.models import signals signals.post_updated.send( post=post, @@ -246,13 +254,6 @@ class PostManager(BaseQuerySetManager): sender=post.__class__ ) - post.add_revision( - author = author, - revised_at = added_at, - text = text, - comment = unicode(const.POST_STATUS['default_version']), - by_email = by_email - ) return post @@ -288,9 +289,10 @@ class PostManager(BaseQuerySetManager): #update thread data #todo: this totally belongs to some `Thread` class method - thread.answer_count += 1 - thread.save() - thread.set_last_activity(last_activity_at=added_at, last_activity_by=author) # this should be here because it regenerates cached thread summary html + if answer.is_approved(): + thread.answer_count += 1 + thread.save() + thread.set_last_activity(last_activity_at=added_at, last_activity_by=author) # this should be here because it regenerates cached thread summary html return answer @@ -794,13 +796,16 @@ class Post(models.Model): """``False`` only when moderation is ``True`` and post ``self.approved is False`` """ - if askbot_settings.ENABLE_CONTENT_MODERATION: - return self.approved + if askbot_settings.CONTENT_MODERATION_MODE == 'premoderation': + if self.approved: + return True + if self.revisions.filter(revision=0).count() == 1: + return False return True def needs_moderation(self): #todo: do we need this, can't we just use is_approved()? - return self.approved is False + return self.is_approved() is False def get_absolute_url(self, no_slug = False, question_post=None, thread=None): from askbot.utils.slug import slugify @@ -1052,8 +1057,9 @@ class Post(models.Model): by_email=by_email, ip_addr=ip_addr, ) - self.comment_count = self.comment_count + 1 - self.save() + if comment_post.is_approved(): + self.comment_count = self.comment_count + 1 + self.save() #tried to add this to bump updated question #in most active list, but it did not work @@ -2110,16 +2116,22 @@ class PostRevisionManager(models.Manager): author = kwargs['author'] moderate_email = False - if kwargs.get('email') and kwargs.get('email'): + if kwargs.get('email'): from askbot.models.reply_by_email import emailed_content_needs_moderation moderate_email = emailed_content_needs_moderation(kwargs['email']) - #in the moderate_or_publish() we determine the revision number - #0 revision belongs to the moderation queue - if author.needs_moderation() or moderate_email: - kwargs['revision'] = 0 + needs_moderation = author.needs_moderation() or moderate_email + + #0 revision is not shown to the users + if askbot_settings.CONTENT_MODERATION_MODE == 'premoderation' and needs_moderation: + kwargs.update({ + 'approved': False, + 'approved_by': None, + 'approved_at': None, + 'revision': 0, + 'summary': kwargs['summary'] or _('Suggested edit') + }) revision = super(PostRevisionManager, self).create(*args, **kwargs) - revision.place_on_moderation_queue() else: post = kwargs['post'] kwargs['revision'] = post.get_latest_revision_number() + 1 @@ -2136,6 +2148,10 @@ class PostRevisionManager(models.Manager): from askbot.models import signals signals.post_revision_published.send(None, revision=revision) + #audit or pre-moderation modes require placement of the post on the moderation queue + if needs_moderation: + revision.place_on_moderation_queue() + return revision class PostRevision(models.Model): @@ -2194,16 +2210,6 @@ class PostRevision(models.Model): This allows us to moderate every revision """ - - #moderated revisions have number 0 - self.revision = 0 - self.approved = False #todo: we probably don't need this field any more - self.approved_by = None - self.approved_at = None - if self.summary == '': - self.summary = _('Suggested edit') - self.save() - #this is run on "post-save" so for a new post #we'll have just one revision if self.post.revisions.count() == 1: @@ -2243,6 +2249,7 @@ class PostRevision(models.Model): #In this case, use different activity type, but perhaps there is no real need activity_type = const.TYPE_ACTIVITY_MODERATED_POST_EDIT + #Activity instance is the actual queue item from askbot.models import Activity activity = Activity( user = self.author, @@ -2251,7 +2258,6 @@ class PostRevision(models.Model): question = self.get_origin_post() ) activity.save() - activity.add_recipients(self.post.get_moderators()) def should_notify_author_about_publishing(self, was_approved = False): diff --git a/askbot/models/question.py b/askbot/models/question.py index 17899285..8336ac5d 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -289,7 +289,7 @@ class ThreadManager(BaseQuerySetManager): # TODO: add a possibility to see deleted questions qs = self.filter(**primary_filter) - if askbot_settings.ENABLE_CONTENT_MODERATION: + if askbot_settings.CONTENT_MODERATION_MODE == 'premoderation': qs = qs.filter(approved = True) #if groups feature is enabled, filter out threads diff --git a/askbot/models/reply_by_email.py b/askbot/models/reply_by_email.py index 5e36a347..309743cb 100644 --- a/askbot/models/reply_by_email.py +++ b/askbot/models/reply_by_email.py @@ -15,7 +15,7 @@ def emailed_content_needs_moderation(email): is marked for moderation todo: maybe this belongs to a separate "moderation" module """ - if askbot_settings.ENABLE_CONTENT_MODERATION: + if askbot_settings.CONTENT_MODERATION_MODE == 'premoderation': group_name = email.split('@')[0] from askbot.models.user import Group try: diff --git a/askbot/sitemap.py b/askbot/sitemap.py index c50c64dc..d12d3cf2 100644 --- a/askbot/sitemap.py +++ b/askbot/sitemap.py @@ -5,7 +5,10 @@ class QuestionsSitemap(Sitemap): changefreq = 'daily' priority = 0.5 def items(self): - return Post.objects.get_questions().exclude(deleted=True) + questions = Post.objects.get_questions() + questions = questions.exclude(deleted=True) + questions = questions.exclude(approved=False) + return questions.select_related('thread__title', 'thread__last_activity_at') def lastmod(self, obj): return obj.thread.last_activity_at diff --git a/askbot/templates/macros.html b/askbot/templates/macros.html index b314255c..b6f64401 100644 --- a/askbot/templates/macros.html +++ b/askbot/templates/macros.html @@ -6,31 +6,15 @@ >{% if icon == False %}{% if site_label %}{{ site_label }}{% else %}{{ site }}{% endif %}{% endif %}</a> {%- endmacro -%} -{%- macro inbox_post_snippet(response, inbox_section) -%} -<div id="re_{{response.id}}" class="re{% if response.is_new %} new highlight{% else %} seen{% endif %}"> +{%- macro inbox_message_snippet(message) -%} +<div class="message-details" data-message-id="{{ message.id }}"> <input type="checkbox" /> - <div class="face"> - {{ gravatar(response.user, 48) }} - </div> - <div class="content"> - <a - style="font-size:12px" - href="{{ response.user.get_absolute_url() }}" - >{{ response.user.username|escape }}</a> - <a style="text-decoration:none;" href="{{ response.response_url }}"> - {{ response.response_type }} - ({{ timeago(response.timestamp) }}):<br/> - {% if inbox_section != 'flags' %} - {{ response.response_snippet }} - {% endif %} - </a> - {% if inbox_section == 'flags' %} - <a class="re_expand" href="{{ response.response_url }}"> - <!--div class="re_snippet">{{ response.response_snippet|escape }}</div--> - <div class="re_content">{{ response.response_content }}</div> - </a> - {% endif %} - </div> + {{ gravatar(message.user, 24) }} + <a class="username" href="{{ message.user.get_absolute_url() }}">{{ message.user.username|escape }}</a> + <a class="forum-post-link" href="{{ message.url }}">{{ message.message_type }}</a> + ({{ timeago(message.timestamp) }}) + {#<div class="snippet">{{ message.snippet }}</div>#} + <div class="post-content">{{ message.content }}</div> </div> {%- endmacro -%} diff --git a/askbot/templates/moderation/queue.html b/askbot/templates/moderation/queue.html new file mode 100644 index 00000000..e204b86d --- /dev/null +++ b/askbot/templates/moderation/queue.html @@ -0,0 +1,56 @@ +{% extends "user_inbox/base.html" %} +{% import "macros.html" as macros %} +{% block profilesection %} + {% trans %}moderation queue{% endtrans %} +{% endblock %} +{% block inbox_content %} + <div class="tools"> + <div class="select-items"> + <strong>{% trans %}Select:{% endtrans %}</strong> + <a class="sel-all">{% trans %}all{% endtrans %}</a> | + <a class="sel-none">{% trans %}none{% endtrans %}</a> + </div> + <a class="btn approve-posts">{% trans %}approve posts{% endtrans %}</a> + <a class="btn approve-posts-users" id="re_approve_posts_users">{% trans %}approve posts and users{% endtrans %}</a> + <!--div class="btn-group dropdown decline-reasons-menu"> + <span class="btn btn-info dropdown-toggle"> + <span>{% trans %}decline and explain why{% endtrans %}</span> + <span class="caret"></span> + </span> + <ul class="dropdown-menu"> + {% for reason in post_reject_reasons %} + <li> + <a class="decline-with-reason" data-reason-id="{{ reason.id }}">{{ reason.title|escape }}</a> + </li> + {% endfor %} + <li> + <a class="manage-reasons">{% trans %}add/manage reject reasons{% endtrans %}</a> + </li> + </ul> + </div--> + <a class="btn btn-danger decline-block-users">{% trans %}delete posts and block users{% endtrans %}</a> + {% if settings.IP_MODERATION_ENABLED %} + <a class="btn btn-danger decline-block-users-ips">{% trans %}delete posts, block users and IPs{% endtrans %}</a> + {% endif %} + </div> + <p style="margin-top: 12px;">Attention: approval of users removes them from the queue and approves ALL of their posts, blocking of the users + DELETES ALL OF THEIR POSTS. There is no easy undo at the moment.</p> + {% include "moderation/reject_post_dialog.html" %} + <div class="action-status"><span></span></div> + <div class="messages"> + {% for message in messages %}{# messages are grouped by question, using the "nested_messages" #} + <div + class="message{% if message.is_new %} highlight new{% else %} seen{% endif %}" + data-message-id="{{ message.id }}" + > + {#<h2>"{{ message.title.strip()|escape}}"</h2>#} + {{ macros.inbox_message_snippet(message) }} + {# "nested" messages are further response messages to the same question #} + {% for followup_message in message.followup_messages %} + {{ macros.inbox_message_snippet(followup_message) }} + {% endfor %} + </div> + <div class="clearfix"></div> + {% endfor %} + </div> +{% endblock %} diff --git a/askbot/templates/user_profile/reject_post_dialog.html b/askbot/templates/moderation/reject_post_dialog.html index 3483e83e..24c75769 100644 --- a/askbot/templates/user_profile/reject_post_dialog.html +++ b/askbot/templates/moderation/reject_post_dialog.html @@ -1,4 +1,4 @@ -<div class="modal" style="display:none" id="reject-edit-modal"> +<div class="modal" style="display:none" id="manage-reject-reasons-modal"> <div class="modal-header"> <a class="close" data-dismiss="modal">x</a> <h3>{% trans %}Reject the post(s)?{% endtrans %}</h3> diff --git a/askbot/templates/user_inbox/base.html b/askbot/templates/user_inbox/base.html index db657b23..0bfbf5e4 100644 --- a/askbot/templates/user_inbox/base.html +++ b/askbot/templates/user_inbox/base.html @@ -10,35 +10,37 @@ {% set re_count = request.user.new_response_count + request.user.seen_response_count %} - <div id="re_sections"> - {% trans %}Sections:{% endtrans %} - {% set sep = joiner('|') %} - {#{{ sep() }} - <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=messages" - {% if inbox_section == 'messages' %}class="on"{% endif %} - >{% trans %}messages{% endtrans %}</a>#} - {% if re_count > 0 %}{{ sep() }} - <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=forum" - {% if inbox_section == 'forum' %}class="on"{% endif %} + {% if need_inbox_sections_nav %} + <div id="re_sections"> + {% trans %}Sections:{% endtrans %} + {% set sep = joiner('|') %} + {#{{ sep() }} + <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=messages" + {% if inbox_section == 'messages' %}class="on"{% endif %} + >{% trans %}messages{% endtrans %}</a>#} + {% if re_count > 0 %}{{ sep() }} + <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=forum" + {% if inbox_section == 'forum' %}class="on"{% endif %} + > + {% trans %}forum responses ({{re_count}}){% endtrans -%} + </a> + {% endif %} + {% if flags_count > 0 %}{{ sep() }} + <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=flags" + {% if inbox_section == 'flags' %}class="on"{% endif %} + > + {% trans %}flagged items ({{flags_count}}){% endtrans %} + </a> + {% endif %} + {% if group_join_requests_count %}{{ sep() }} + <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=join_requests" + {% if inbox_section == 'join_requests' %}class="on"{% endif %} > - {% trans %}forum responses ({{re_count}}){% endtrans -%} + {% trans %}group join requests{% endtrans %} </a> - {% endif %} - {% if flags_count > 0 %}{{ sep() }} - <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=flags" - {% if inbox_section == 'flags' %}class="on"{% endif %} - > - {% trans %}flagged items ({{flags_count}}){% endtrans %} - </a> - {% endif %} - {% if group_join_requests_count %}{{ sep() }} - <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=join_requests" - {% if inbox_section == 'join_requests' %}class="on"{% endif %} - > - {% trans %}group join requests{% endtrans %} - </a> - {% endif %} - </div> + {% endif %} + </div> + {% endif %} {% block inbox_content %} {% endblock %} </div> @@ -48,6 +50,7 @@ var askbot = askbot || {}; askbot['urls'] = askbot['urls'] || {}; askbot['urls']['manageInbox'] = '{% url manage_inbox %}'; + askbot['urls']['moderatePostEdits'] = '{% url moderate_post_edits %}'; askbot['urls']['save_post_reject_reason'] = '{% url save_post_reject_reason %}'; askbot['urls']['delete_post_reject_reason'] = '{% url delete_post_reject_reason %}'; {% if request.user.is_administrator_or_moderator() %} diff --git a/askbot/templates/user_inbox/responses.html b/askbot/templates/user_inbox/responses.html new file mode 100644 index 00000000..57f4de00 --- /dev/null +++ b/askbot/templates/user_inbox/responses.html @@ -0,0 +1,23 @@ +{% extends "user_inbox/base.html" %} +{% import "macros.html" as macros %} +{% block profilesection %} + {% trans %}inbox - moderation queue{% endtrans %} +{% endblock %} +{% block inbox_content %} + <div class="messages"> + {% for message in messages %}{# messages are grouped by question, using the "nested_messages" #} + <div + class="message{% if message.is_new %} highlight new{% else %} seen{% endif %}" + data-message-id="{{ message.id }}" + > + <h2>"{{ message.title.strip()|escape}}"</h2> + {{ macros.inbox_message_snippet(message) }} + {# "nested" messages are further response messages to the same question #} + {% for followup_message in message.followup_messages %} + {{ macros.inbox_message_snippet(followup_message) }} + {% endfor %} + </div> + <div class="clearfix"></div> + {% endfor %} + </div> +{% endblock %} diff --git a/askbot/templates/user_inbox/responses_and_flags.html b/askbot/templates/user_inbox/responses_and_flags.html deleted file mode 100644 index 16599c1d..00000000 --- a/askbot/templates/user_inbox/responses_and_flags.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "user_inbox/base.html" %} -{% import "macros.html" as macros %} -{% block profilesection %} - {% trans %}inbox - responses{% endtrans %} -{% endblock %} -{% block inbox_content %} - <div id="re_tools"> - <strong>{% trans %}select:{% endtrans %}</strong> - <a id="sel_all">{% trans %}all{% endtrans %}</a> | - <a id="sel_seen">{% trans %}seen{% endtrans %}</a> | - <a id="sel_new">{% trans %}new{% endtrans %}</a> | - <a id="sel_none">{% trans %}none{% endtrans %}</a><br /> - <div class="btn-group"> - {% if inbox_section == 'forum' %} - <a class="btn" id="re_mark_seen">{% trans %}mark as seen{% endtrans %}</a> - <a class="btn" id="re_mark_new">{% trans %}mark as new{% endtrans %}</a> - <a class="btn" id="re_dismiss">{% trans %}dismiss{% endtrans %}</a> - {% else %} - <a class="btn" id="re_remove_flag">{% trans %}remove flags/approve{% endtrans %}</a> - <a - class="btn" - id="re_delete_post" - >{% trans %}delete post{% endtrans %}</a> - {% endif %} - </div> - </div> - {% include "user_profile/reject_post_dialog.html" %} - <div id="responses"> - {% for response in responses %} - <div class="response-parent" data-response-id="{{response.id}}"> - <h2>"{{ response.response_title.strip()|escape}}"</h2> - {{ macros.inbox_post_snippet(response, inbox_section) }} - {% for nested_response in response.nested_responses %} - {{ macros.inbox_post_snippet(nested_response, inbox_section) }} - {%endfor%} - </div> - <div class="clearfix"></div> - {% endfor %} - </div> - </div> -{% endblock %} diff --git a/askbot/tests/email_alert_tests.py b/askbot/tests/email_alert_tests.py index ecd1dc54..4c541395 100644 --- a/askbot/tests/email_alert_tests.py +++ b/askbot/tests/email_alert_tests.py @@ -1054,9 +1054,9 @@ class PostApprovalTests(utils.AskbotTestCase): def setUp(self): self.reply_by_email = askbot_settings.REPLY_BY_EMAIL askbot_settings.update('REPLY_BY_EMAIL', True) - self.enable_content_moderation = \ - askbot_settings.ENABLE_CONTENT_MODERATION - askbot_settings.update('ENABLE_CONTENT_MODERATION', True) + self.content_moderation_mode = \ + askbot_settings.CONTENT_MODERATION_MODE + askbot_settings.update('CONTENT_MODERATION_MODE', 'premoderation') self.self_notify_when = \ askbot_settings.SELF_NOTIFY_EMAILED_POST_AUTHOR_WHEN when = const.FOR_FIRST_REVISION @@ -1070,8 +1070,8 @@ class PostApprovalTests(utils.AskbotTestCase): 'REPLY_BY_EMAIL', self.reply_by_email ) askbot_settings.update( - 'ENABLE_CONTENT_MODERATION', - self.enable_content_moderation + 'CONTENT_MODERATION_MODE', + self.content_moderation_mode ) askbot_settings.update( 'SELF_NOTIFY_EMAILED_POST_AUTHOR_WHEN', diff --git a/askbot/tests/page_load_tests.py b/askbot/tests/page_load_tests.py index 1143d056..9b16f58b 100644 --- a/askbot/tests/page_load_tests.py +++ b/askbot/tests/page_load_tests.py @@ -563,7 +563,7 @@ class PageLoadTestCase(AskbotTestCase): 'user_profile', kwargs={'id': asker.id, 'slug': slugify(asker.username)}, data={'sort':'inbox'}, - template='user_inbox/responses_and_flags.html', + template='user_inbox/responses.html', ) @with_settings(GROUPS_ENABLED=True) diff --git a/askbot/urls.py b/askbot/urls.py index 799ef0f5..85f9675f 100644 --- a/askbot/urls.py +++ b/askbot/urls.py @@ -206,6 +206,11 @@ urlpatterns = patterns('', name='moderate_group_join_request' ), service_url( + r'^moderate-post-edits/', + views.moderation.moderate_post_edits, + name='moderate_post_edits' + ), + service_url( r'^save-draft-question/', views.commands.save_draft_question, name = 'save_draft_question' diff --git a/askbot/views/__init__.py b/askbot/views/__init__.py index 481ca8ab..461c80df 100644 --- a/askbot/views/__init__.py +++ b/askbot/views/__init__.py @@ -9,6 +9,7 @@ from askbot.views import meta from askbot.views import sharing from askbot.views import widgets from askbot.views import api_v1 +from askbot.views import moderation from django.conf import settings if 'avatar' in settings.INSTALLED_APPS: from askbot.views import avatar_views diff --git a/askbot/views/commands.py b/askbot/views/commands.py index 934f5479..d9866db9 100644 --- a/askbot/views/commands.py +++ b/askbot/views/commands.py @@ -24,9 +24,11 @@ from django.shortcuts import render from django.template.loader import get_template from django.views.decorators import csrf from django.utils import simplejson -from django.utils.html import escape from django.utils import translation +from django.utils.encoding import force_text +from django.utils.html import escape from django.utils.translation import ugettext as _ +from django.utils.translation import ungettext from django.utils.translation import string_concat from askbot.utils.slug import slugify from askbot import models @@ -52,7 +54,6 @@ def manage_inbox(request): request data is memo_list - list of integer id's of the ActivityAuditStatus items and action_type - string - one of delete|mark_new|mark_seen """ - response_data = dict() try: if request.is_ajax(): diff --git a/askbot/views/context.py b/askbot/views/context.py index eda8f6a7..339832ba 100644 --- a/askbot/views/context.py +++ b/askbot/views/context.py @@ -32,12 +32,11 @@ def get_for_inbox(user): return None #get flags count - flag_activity_types = (const.TYPE_ACTIVITY_MARK_OFFENSIVE,) - if askbot_settings.ENABLE_CONTENT_MODERATION: - flag_activity_types += ( - const.TYPE_ACTIVITY_MODERATED_NEW_POST, - const.TYPE_ACTIVITY_MODERATED_POST_EDIT - ) + flag_activity_types = ( + const.TYPE_ACTIVITY_MARK_OFFENSIVE, + const.TYPE_ACTIVITY_MODERATED_NEW_POST, + const.TYPE_ACTIVITY_MODERATED_POST_EDIT + ) #get group_join_requests_count group_join_requests_count = 0 @@ -48,10 +47,13 @@ def get_for_inbox(user): ) group_join_requests_count = pending_memberships.count() + re_count = user.new_response_count + user.seen_response_count + flags_count = user.get_notifications(flag_activity_types).count() return { - 're_count': user.new_response_count + user.seen_response_count, - 'flags_count': user.get_notifications(flag_activity_types).count(), - 'group_join_requests_count': group_join_requests_count + 're_count': re_count, + 'flags_count': flags_count, + 'group_join_requests_count': group_join_requests_count, + 'need_inbox_sections_nav': int(re_count > 0) + int(flags_count > 0) + int(group_join_requests_count) > 1 } def get_extra(context_module_setting, request, data): diff --git a/askbot/views/moderation.py b/askbot/views/moderation.py new file mode 100644 index 00000000..94561348 --- /dev/null +++ b/askbot/views/moderation.py @@ -0,0 +1,180 @@ +from askbot.utils import decorators +from askbot import const +from askbot import models +from askbot import mail +from datetime import datetime +from django.utils.translation import string_concat +from django.utils.translation import ungettext +from django.utils.translation import ugettext as _ +from django.template.loader import get_template +from django.conf import settings as django_settings +from django.contrib.contenttypes.models import ContentType +from django.utils.encoding import force_text +from django.template import RequestContext +from django.views.decorators import csrf +from django.utils.encoding import force_text +from django.core import exceptions +from django.utils import simplejson + +#some utility functions +def get_object(memo): + content_object = memo.activity.content_object + if isinstance(content_object, models.PostRevision): + return content_object.post + else: + return content_object + + +def get_editors(memo_set, exclude=None): + editors = set() + for memo in memo_set: + post = get_object(memo) + editors.add(post.author) + + if exclude in editors: + editors.remove(exclude)#make sure not to block yourself + return editors + +def concat_messages(message1, message2): + if message1: + message = string_concat(message1, ', ') + return string_concat(message, message2) + else: + return message2 + + +@csrf.csrf_exempt +@decorators.post_only +@decorators.ajax_only +def moderate_post_edits(request): + if request.user.is_anonymous(): + raise exceptions.PermissionDenied() + if not request.user.is_administrator_or_moderator(): + raise exceptions.PermissionDenied() + + post_data = simplejson.loads(request.raw_post_data) + #{'action': 'decline-with-reason', 'items': ['posts'], 'reason': 1, 'edit_ids': [827]} + + memo_set = models.ActivityAuditStatus.objects.filter( + id__in=post_data['edit_ids'] + ).select_related('activity') + result = {'message': ''} + + if post_data['action'] == 'decline-with-reason': + num_posts = 0 + for memo in memo_set: + post = get_object(memo) + request.user.delete_post(post) + reject_reason = models.PostFlagReason.objects.get(id=post_data['reason']) + template = get_template('email/rejected_post.html') + data = { + 'post': post.html, + 'reject_reason': reject_reason.details.html + } + body_text = template.render(RequestContext(request, data)) + mail.send_mail( + subject_line = _('your post was not accepted'), + body_text = unicode(body_text), + recipient_list = [post.author.email,] + ) + num_posts += 1 + + if num_posts: + posts_message = ungettext('%d post deleted', '%d posts deleted', num_posts) % num_posts + result['message'] = concat_messages(result['message'], posts_message) + + if post_data['action'] == 'approve': + num_posts = 0 + if 'posts' in post_data['items']: + for memo in memo_set: + if memo.activity.activity_type == const.TYPE_ACTIVITY_MARK_OFFENSIVE: + #unflag the post + content_object = memo.activity.content_object + request.user.flag_post(content_object, cancel_all=True, force=True) + num_posts += 1 + else: + revision = memo.activity.content_object + if isinstance(revision, models.PostRevision): + request.user.approve_post_revision(revision) + num_posts += 1 + + + if 'users' in post_data['items']: + editors = get_editors(memo_set) + for editor in editors: + editor.set_status('a') + + num_editors = len(editors) + if num_editors: + users_message = ungettext('%d user approved', '%d users approved', num_editors) % num_editors + result['message'] = concat_messages(result['message'], users_message) + + #approve revisions by the authors + revisions = models.PostRevision.objects.filter(author__in=editors) + now = datetime.now() + revisions.update(approved=True, approved_at=now, approved_by=request.user) + ct = ContentType.objects.get_for_model(models.PostRevision) + mod_activity_types = ( + const.TYPE_ACTIVITY_MARK_OFFENSIVE, + const.TYPE_ACTIVITY_MODERATED_NEW_POST, + const.TYPE_ACTIVITY_MODERATED_POST_EDIT + ) + items = models.Activity.objects.filter( + content_type=ct, + object_id__in=revisions.values_list('id', flat=True), + activity_type__in=mod_activity_types + ) + num_posts = items.count() + items.delete() + + if num_posts > 0: + posts_message = ungettext('%d post approved', '%d posts approved', num_posts) % num_posts + result['message'] = concat_messages(result['message'], posts_message) + + if 'users' in post_data['items'] and post_data['action'] == 'block': + editors = get_editors(memo_set, exclude=request.user) + num_posts = 0 + for editor in editors: + editor.set_status('b') + num_posts += request.user.delete_all_content_authored_by_user(editor) + + if num_posts: + posts_message = ungettext('%d post deleted', '%d posts deleted', num_posts) % num_posts + result['message'] = concat_messages(result['message'], posts_message) + + num_editors = len(editors) + if num_editors: + users_message = ungettext('%d user blocked', '%d users blocked', num_editors) % num_editors + result['message'] = concat_messages(result['message'], users_message) + + moderate_ips = getattr(django_settings, 'ASKBOT_IP_MODERATION_ENABLED', False) + if moderate_ips and 'ips' in post_data and post_data['action'] == 'block': + for memo in memo_set: + obj = memo.activity.content_object + if isinstance(obj, models.PostRevision): + ips.add(obj.ip_addr) + + #to make sure to not block the admin and + #in case REMOTE_ADDR is a proxy server - not + #block access to the site + ips.remove(request.META['REMOTE_ADDR']) + + from stopforumspam.models import Cache + already_blocked = Cache.objects.filter(ip__in=ips) + already_blocked.update(permanent=True) + already_blocked_ips = already_blocked.values_list('ip', flat=True) + ips = ips - set(already_blocked_ips) + for ip in ips: + cache = Cache(ip=ip, permanent=True) + cache.save() + + num_ips = len(ips) + if num_ips: + ips_message = ungettext('%d ip blocked', '%d ips blocked', num_ips) % num_ips + result['message'] = concat_messages(result['message'], ips_message) + + memo_set.delete() + request.user.update_response_counts() + if result['message']: + result['message'] = force_text(result['message']) + return result diff --git a/askbot/views/users.py b/askbot/views/users.py index 32203998..504e654b 100644 --- a/askbot/views/users.py +++ b/askbot/views/users.py @@ -90,11 +90,10 @@ def show_users(request, by_group=False, group_id=None, group_slug=None): else: try: group = models.Group.objects.get(id = group_id) - group_email_moderation_enabled = \ - ( - askbot_settings.GROUP_EMAIL_ADDRESSES_ENABLED \ - and askbot_settings.ENABLE_CONTENT_MODERATION - ) + group_email_moderation_enabled = ( + askbot_settings.GROUP_EMAIL_ADDRESSES_ENABLED \ + and askbot_settings.CONTENT_MODERATION_MODE == 'premoderation' + ) user_acceptance_level = group.get_openness_level_for_user( request.user ) @@ -396,7 +395,7 @@ def user_stats(request, user, context): if request.user != user: question_filter['is_anonymous'] = False - if askbot_settings.ENABLE_CONTENT_MODERATION: + if askbot_settings.CONTENT_MODERATION_MODE == 'premoderation': question_filter['approved'] = True # @@ -731,7 +730,7 @@ def user_responses(request, user, context): activity_types += (const.TYPE_ACTIVITY_MENTION,) elif section == 'flags': activity_types = (const.TYPE_ACTIVITY_MARK_OFFENSIVE,) - if askbot_settings.ENABLE_CONTENT_MODERATION: + if askbot_settings.CONTENT_MODERATION_MODE in ('premoderation', 'audit'): activity_types += ( const.TYPE_ACTIVITY_MODERATED_NEW_POST, const.TYPE_ACTIVITY_MODERATED_POST_EDIT @@ -795,34 +794,34 @@ def user_responses(request, user, context): 'timestamp': memo.activity.active_at, 'user': memo.activity.user, 'is_new': memo.is_new(), - 'response_url': memo.activity.get_absolute_url(), - 'response_snippet': memo.activity.get_snippet(), - 'response_title': memo.activity.question.thread.title, - 'response_type': memo.activity.get_activity_type_display(), - 'response_id': memo.activity.question.id, - 'nested_responses': [], - 'response_content': memo.activity.content_object.html, + 'url': memo.activity.get_absolute_url(), + 'snippet': memo.activity.get_snippet(), + 'title': memo.activity.question.thread.title, + 'message_type': memo.activity.get_activity_type_display(), + 'question_id': memo.activity.question.id, + 'followup_messages': list(), + 'content': memo.activity.content_object.html, } response_list.append(response) #4) sort by response id - response_list.sort(lambda x,y: cmp(y['response_id'], x['response_id'])) + response_list.sort(lambda x,y: cmp(y['question_id'], x['question_id'])) #5) group responses by thread (response_id is really the question post id) - last_response_id = None #flag to know if the response id is different - filtered_response_list = list() - for i, response in enumerate(response_list): + last_question_id = None #flag to know if the question id is different + filtered_message_list = list() + for message in response_list: #todo: group responses by the user as well - if response['response_id'] == last_response_id: - original_response = dict.copy(filtered_response_list[len(filtered_response_list)-1]) - original_response['nested_responses'].append(response) - filtered_response_list[len(filtered_response_list)-1] = original_response + if message['question_id'] == last_question_id: + original_message = dict.copy(filtered_message_list[-1]) + original_message['followup_messages'].append(message) + filtered_message_list[-1] = original_message else: - filtered_response_list.append(response) - last_response_id = response['response_id'] + filtered_message_list.append(message) + last_question_id = message['question_id'] #6) sort responses by time - filtered_response_list.sort(lambda x,y: cmp(y['timestamp'], x['timestamp'])) + filtered_message_list.sort(lambda x,y: cmp(y['timestamp'], x['timestamp'])) reject_reasons = models.PostFlagReason.objects.all().order_by('title') data = { @@ -832,10 +831,14 @@ def user_responses(request, user, context): 'inbox_section': section, 'page_title' : _('profile - responses'), 'post_reject_reasons': reject_reasons, - 'responses' : filtered_response_list, + 'messages' : filtered_message_list, } context.update(data) - return render(request, 'user_inbox/responses_and_flags.html', context) + if section == 'flags': + template = 'moderation/queue.html' + else: + template = 'user_inbox/responses.html' + return render(request, template, context) def user_network(request, user, context): if 'followit' not in django_settings.INSTALLED_APPS: |