From 5106508ed6257b16f9ede91c0900a0d90745cfba Mon Sep 17 00:00:00 2001 From: Logan Hanks Date: Wed, 23 Jan 2013 14:15:31 -0800 Subject: [PATCH] Add permissions editing to the edit moderators page. --- r2/r2/controllers/api.py | 60 +++++- r2/r2/lib/db/userrel.py | 4 +- r2/r2/lib/errors.py | 2 + r2/r2/lib/pages/pages.py | 55 +++++- r2/r2/lib/template_helpers.py | 8 + r2/r2/lib/validator/validator.py | 21 +++ r2/r2/models/modaction.py | 13 +- r2/r2/models/subreddit.py | 40 ++-- r2/r2/public/static/css/reddit.css | 41 +++++ r2/r2/public/static/js/base.js | 2 +- r2/r2/public/static/js/jquery.reddit.js | 4 +- r2/r2/public/static/js/reddit.js | 2 +- r2/r2/public/static/js/ui.js | 215 ++++++++++++++++++++++ r2/r2/templates/moderatorpermissions.html | 57 ++++++ r2/r2/templates/userlist.html | 11 ++ r2/r2/templates/usertableitem.html | 8 + 16 files changed, 513 insertions(+), 30 deletions(-) create mode 100644 r2/r2/templates/moderatorpermissions.html diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 533e989cb..f55647d0d 100755 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -562,6 +562,11 @@ class ApiController(RedditController, OAuth2ResourceController): 'wikicontributor', ) + _sr_friend_types_with_permissions = ( + 'moderator', + 'moderator_invite', + ) + @noresponse(VUser(), VModhash(), nuser = VExistingUname('name'), @@ -613,16 +618,55 @@ class ApiController(RedditController, OAuth2ResourceController): if type == "friend" and c.user.gold: c.user.friend_rels_cache(_update=True) + @validatedForm(VSrModerator(), VModhash(), + target=VExistingUname('name'), + type_and_permissions=VPermissions('type', 'permissions')) + @api_doc(api_section.users) + def POST_setpermissions(self, form, jquery, target, type_and_permissions): + if form.has_errors('name', errors.USER_DOESNT_EXIST, errors.NO_USER): + return + if form.has_errors('type', errors.INVALID_PERMISSION_TYPE): + return + if form.has_errors('permissions', errors.INVALID_PERMISSIONS): + return + + type, permissions = type_and_permissions + update = None + + if type in ("moderator", "moderator_invite"): + if not c.user_is_admin: + if type == "moderator" and not c.site.can_demod(c.user, target): + abort(403, 'forbidden') + if (type == "moderator_invite" + and not c.site.is_unlimited_moderator(c.user)): + abort(403, 'forbidden') + if type == "moderator": + rel = c.site.get_moderator(target) + if type == "moderator_invite": + rel = c.site.get_moderator_invite(target) + rel.set_permissions(permissions) + rel._commit() + update = rel.encoded_permissions + ModAction.create(c.site, c.user, action='setpermissions', + target=target, details='permission_' + type, + description=update) + + if update: + row = form.closest('tr') + editor = row.find('.permissions').data('PermissionEditor') + editor.onCommit(update) + @validatedForm(VUser(), VModhash(), ip = ValidIP(), friend = VExistingUname('name'), container = nop('container'), type = VOneOf('type', ('friend',) + _sr_friend_types), + type_and_permissions = VPermissions('type', 'permissions'), note = VLength('note', 300)) @api_doc(api_section.users) def POST_friend(self, form, jquery, ip, friend, - container, type, note): + container, type, type_and_permissions, note): """ Complement to POST_unfriend: handles friending as well as privilege changes on subreddits. @@ -670,6 +714,14 @@ class ApiController(RedditController, OAuth2ResourceController): elif form.has_errors("name", errors.USER_DOESNT_EXIST, errors.NO_USER): return + if type in self._sr_friend_types_with_permissions: + if form.has_errors('type', errors.INVALID_PERMISSION_TYPE): + return + if form.has_errors('permissions', errors.INVALID_PERMISSIONS): + return + else: + permissions = None + if type == "moderator_invite" and container.is_moderator(friend): c.errors.add(errors.ALREADY_MODERATOR, field="name") form.set_error(errors.ALREADY_MODERATOR, "name") @@ -678,7 +730,7 @@ class ApiController(RedditController, OAuth2ResourceController): if type == "moderator": container.remove_moderator_invite(friend) - new = fn(friend) + new = fn(friend, permissions=type_and_permissions[1]) # Log this action if new and type in self._sr_friend_types: @@ -726,13 +778,15 @@ class ApiController(RedditController, OAuth2ResourceController): ip=ValidIP()) @api_doc(api_section.subreddits) def POST_accept_moderator_invite(self, form, jquery, ip): + rel = c.site.get_moderator_invite(c.user) if not c.site.remove_moderator_invite(c.user): c.errors.add(errors.NO_INVITE_FOUND) form.set_error(errors.NO_INVITE_FOUND, None) return + permissions = rel.get_permissions() ModAction.create(c.site, c.user, "acceptmoderatorinvite") - c.site.add_moderator(c.user) + c.site.add_moderator(c.user, permissions=rel.get_permissions()) notify_user_added("accept_moderator_invite", c.user, c.user, c.site) jquery.refresh() diff --git a/r2/r2/lib/db/userrel.py b/r2/r2/lib/db/userrel.py index 3346dfa3f..714160668 100644 --- a/r2/r2/lib/db/userrel.py +++ b/r2/r2/lib/db/userrel.py @@ -42,10 +42,12 @@ class UserRelManager(object): rel._permission_class = self.permission_class return rel - def add(self, thing, user, **attrs): + def add(self, thing, user, permissions=None, **attrs): if self.get(thing, user): return None r = self.relation(thing, user, self.name, **attrs) + if permissions is not None: + r.set_permissions(permissions) r._commit() r._permission_class = self.permission_class return r diff --git a/r2/r2/lib/errors.py b/r2/r2/lib/errors.py index 4d9342cc3..290e8f4cd 100644 --- a/r2/r2/lib/errors.py +++ b/r2/r2/lib/errors.py @@ -117,6 +117,8 @@ error_list = dict(( ('BID_LIVE', _('you cannot edit the bid of a live ad')), ('TOO_MANY_CAMPAIGNS', _('you have too many campaigns for that promotion')), ('BAD_JSONP_CALLBACK', _('that jsonp callback contains invalid characters')), + ('INVALID_PERMISSION_TYPE', _("permissions don't apply to that type of user")), + ('INVALID_PERMISSIONS', _('invalid permissions string')), )) errors = Storage([(e, e) for e in error_list.keys()]) diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 77bfee77d..b73f2f1bc 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -2968,6 +2968,16 @@ class ModList(UserList): invite_form_title = _('invite moderator') remove_self_title = _('you are a moderator of this subreddit. %(action)s') + def __init__(self, editable=True): + super(ModList, self).__init__(editable=editable) + self.perms_by_type = { + self.type: c.site.moderators_with_perms(), + self.invite_type: c.site.moderator_invites_with_perms(), + } + self.cells = ('user', 'permissions', 'permissionsctl', 'sendmessage') + if editable: + self.cells += ('remove',) + @property def table_title(self): return _("moderators of /r/%(reddit)s") % {"reddit": c.site.name} @@ -2990,24 +3000,50 @@ class ModList(UserList): def has_invite(self): return c.user_is_loggedin and c.site.is_moderator_invite(c.user) - def moderator_editable(self, user): + def moderator_editable(self, user, row_type): if not c.user_is_loggedin: return False elif c.user_is_admin: return True - else: + elif row_type == self.type: return c.site.can_demod(c.user, user) + elif row_type == self.invite_type: + return c.site.is_unlimited_moderator(c.user) + else: + return False + + def user_row(self, row_type, user, editable=True): + perms = ModeratorPermissions( + user, row_type, self.perms_by_type[row_type].get(user._id), + editable=editable and self.moderator_editable(user, row_type)) + return UserTableItem(user, row_type, self.cells, self.container_name, + editable, self.remove_action, rel=perms) @property def user_rows(self): - return self._user_rows(self.type, self.user_ids(), self.moderator_editable) + return self._user_rows( + self.type, self.user_ids(), + lambda u: self.moderator_editable(u, self.type)) @property def invited_user_rows(self): - return self._user_rows(self.invite_type, c.site.moderator_invite_ids()) + return self._user_rows( + self.invite_type, self.invited_user_ids(), + lambda u: self.moderator_editable(u, self.invite_type)) + + def _sort_user_ids(self, row_type): + for user_id, perms in self.perms_by_type[row_type].iteritems(): + if perms is None: + yield user_id + for user_id, perms in self.perms_by_type[row_type].iteritems(): + if perms is not None: + yield user_id def user_ids(self): - return c.site.moderators + return list(self._sort_user_ids(self.type)) + + def invited_user_ids(self): + return list(self._sort_user_ids(self.invite_type)) class BannedList(UserList): """List of users banned from a given reddit""" @@ -3865,7 +3901,6 @@ class GoldInfoPage(BoringPage): } BoringPage.__init__(self, *args, **kwargs) - class Goldvertisement(Templated): def __init__(self): Templated.__init__(self) @@ -3883,3 +3918,11 @@ class LinkCommentsSettings(Templated): self.can_edit = (c.user_is_loggedin and (c.user_is_admin or link.subreddit_slow.is_moderator(c.user))) + +class ModeratorPermissions(Templated): + def __init__(self, user, permissions_type, permissions, + editable=False, embedded=False): + self.user = user + self.permissions = permissions + Templated.__init__(self, permissions_type=permissions_type, + editable=editable, embedded=embedded) diff --git a/r2/r2/lib/template_helpers.py b/r2/r2/lib/template_helpers.py index e63577c40..75d5a3da2 100755 --- a/r2/r2/lib/template_helpers.py +++ b/r2/r2/lib/template_helpers.py @@ -144,6 +144,14 @@ def js_config(extra_config=None): "clicktracker_url": g.clicktracker_url, "uitracker_url": g.uitracker_url, "static_root": static(''), + "permissions": { + "info": { + "moderator": ModeratorPermissionSet.info, + "moderator_invite": ModeratorPermissionSet.info, + }, + "all_msg": _("full permissions"), + "none_msg": _("no permissions"), + }, } if extra_config: diff --git a/r2/r2/lib/validator/validator.py b/r2/r2/lib/validator/validator.py index 26e6724ee..a6f674bc7 100644 --- a/r2/r2/lib/validator/validator.py +++ b/r2/r2/lib/validator/validator.py @@ -2106,3 +2106,24 @@ class VOAuth2RefreshToken(Validator): return token else: return None + +class VPermissions(Validator): + types = dict( + moderator=ModeratorPermissionSet, + moderator_invite=ModeratorPermissionSet, + ) + + def __init__(self, type_param, permissions_param, *a, **kw): + Validator.__init__(self, (type_param, permissions_param), *a, **kw) + + def run(self, type, permissions): + permission_class = self.types.get(type) + if not permission_class: + self.set_error(errors.INVALID_PERMISSION_TYPE, field=self.param[0]) + return (None, None) + try: + perm_set = permission_class.loads(permissions, validate=True) + except ValueError: + self.set_error(errors.INVALID_PERMISSIONS, field=self.param[1]) + return (None, None) + return type, perm_set diff --git a/r2/r2/models/modaction.py b/r2/r2/models/modaction.py index c62b8e5ac..db84b302d 100644 --- a/r2/r2/models/modaction.py +++ b/r2/r2/models/modaction.py @@ -56,7 +56,7 @@ class ModAction(tdb_cassandra.UuidThing, Printable): 'editsettings', 'editflair', 'distinguish', 'marknsfw', 'wikibanned', 'wikicontributor', 'wikiunbanned', 'removewikicontributor', 'wikirevise', 'wikipermlevel', - 'ignorereports', 'unignorereports') + 'ignorereports', 'unignorereports', 'setpermissions') _menu = {'banuser': _('ban user'), 'unbanuser': _('unban user'), @@ -82,7 +82,8 @@ class ModAction(tdb_cassandra.UuidThing, Printable): 'wikirevise': _('wiki revise page'), 'wikipermlevel': _('wiki page permissions'), 'ignorereports': _('ignore reports'), - 'unignorereports': _('unignore reports')} + 'unignorereports': _('unignore reports'), + 'setpermissions': _('permissions')} _text = {'banuser': _('banned'), 'wikibanned': _('wiki banned'), @@ -108,7 +109,8 @@ class ModAction(tdb_cassandra.UuidThing, Printable): 'distinguish': _('distinguished'), 'marknsfw': _('marked nsfw'), 'ignorereports': _('ignored reports'), - 'unignorereports': _('unignored reports')} + 'unignorereports': _('unignored reports'), + 'setpermissions': _('changed permissions on')} _details_text = {# approve comment/link 'unspam': _('unspam'), @@ -152,7 +154,10 @@ class ModAction(tdb_cassandra.UuidThing, Printable): 'flair_clear_template': _('clear flair templates'), # distinguish/nsfw 'remove': _('remove'), - 'ignore_reports': _('ignore reports')} + 'ignore_reports': _('ignore reports'), + # permissions + 'permission_moderator': _('set permissions on moderator'), + 'permission_moderator_invite': _('set permissions on moderator invitation')} # This stuff won't change cache_ignore = set(['subreddit', 'target']).union(Printable.cache_ignore) diff --git a/r2/r2/models/subreddit.py b/r2/r2/models/subreddit.py index 7bb4f6dc5..a6e49fadd 100644 --- a/r2/r2/models/subreddit.py +++ b/r2/r2/models/subreddit.py @@ -23,6 +23,7 @@ from __future__ import with_statement import base64 +import collections import datetime import hashlib @@ -82,7 +83,7 @@ class PermissionSet(dict): return ','.join('-+'[bool(v)] + k for k, v in sorted(self.iteritems())) def is_superuser(self): - return super(PermissionSet, self).get(self.ALL) + return bool(super(PermissionSet, self).get(self.ALL)) def is_valid(self): if not self.info: @@ -295,6 +296,16 @@ class Subreddit(Thing, Printable): def moderators(self): return self.moderator_ids() + def moderators_with_perms(self): + return collections.OrderedDict( + (r._thing2_id, r.get_permissions()) + for r in self.each_moderator()) + + def moderator_invites_with_perms(self): + return collections.OrderedDict( + (r._thing2_id, r.get_permissions()) + for r in self.each_moderator_invite()) + @property def stylesheet_is_static(self): """Is the subreddit using the newer static file based stylesheets?""" @@ -520,13 +531,13 @@ class Subreddit(Thing, Printable): self.is_moderator_invite(user)) def can_demod(self, bully, victim): - # This works because the is_*() functions return the relation - # when True. So we can compare the dates on the relations. - bully_rel = self.is_moderator(bully) - victim_rel = self.is_moderator(victim) - if bully_rel is None or victim_rel is None: - return False - return bully_rel._date <= victim_rel._date + bully_rel = self.get_moderator(bully) + victim_rel = self.get_moderator(victim) + return ( + bully_rel is not None + and victim_rel is not None + and bully_rel.is_superuser() # limited mods can't demod + and bully_rel._date <= victim_rel._date) @classmethod def load_subreddits(cls, links, return_dict = True, stale=False): @@ -907,15 +918,18 @@ class Subreddit(Thing, Printable): def is_limited_moderator(self, user): rel = self.is_moderator(user) - return rel and rel.permissions is not None + return bool(rel and not rel.is_superuser()) + + def is_unlimited_moderator(self, user): + rel = self.is_moderator(user) + return bool(rel and rel.is_superuser()) def update_moderator_permissions(self, user, **kwargs): """Grants or denies permissions to this moderator. - Does nothing if the given user is not a moderator. - - Args are named parameters with bool or None values (use None to disable - granting or denying the permission). + Does nothing if the given user is not a moderator. Args are named + parameters with bool or None values (use None to all back to the default + for a permission). """ rel = self.get_moderator(user) if rel: diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index a79747f82..98f1a6dc4 100755 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -6410,3 +6410,44 @@ body.gold .buttons li.comment-save-button { display: inline; } width: 250px; font-size: small; } + +.permissions { + display: inline-block; + font-size: small; + text-align: right; + width: 44ex; +} +#moderator_invite .permissions { width: 30ex; } +.permissions > form { display: none; } + +.permission-summary { + display: inline-block; + font-size: small; + border: 1px solid white; +} +.permission-summary.edited { border: dashed 1px black; } +.permission-bit.added { font-weight: bold; } +.permission-bit.removed { text-decoration: line-through; } +.permission-bit.none { font-style: italic; } + +.permissions-edit { font-size: x-small; } + +.permission-selector { + border: 1px solid black; + background-color: white; + position: absolute; + width: 24ex; +} +.permission-selector.active { display: block; } +.permission-selector label { + display: block; text-align: left; + padding: 0px 2px 1px 2px; +} +.permission-selector label:first-child { border-bottom: 1px solid black; } +.permission-selector label:hover { background-color: #bbb; } +.permission-selector label.disabled { background-color: #ddd; } +.permission-selector form { text-align: right; } +.permission-selector .status, .permission-selector .error { + text-align: left; + white-space: normal; +} diff --git a/r2/r2/public/static/js/base.js b/r2/r2/public/static/js/base.js index 02dc8d7f1..8c7bb6acc 100644 --- a/r2/r2/public/static/js/base.js +++ b/r2/r2/public/static/js/base.js @@ -12,7 +12,7 @@ r.setup = function(config) { $(function() { r.login.ui.init() r.analytics.init() - r.ui.HelpBubble.init() + r.ui.init() r.interestbar.init() r.apps.init() r.wiki.init() diff --git a/r2/r2/public/static/js/jquery.reddit.js b/r2/r2/public/static/js/jquery.reddit.js index 19ba8737a..58f8906bc 100644 --- a/r2/r2/public/static/js/jquery.reddit.js +++ b/r2/r2/public/static/js/jquery.reddit.js @@ -565,8 +565,10 @@ $.fn.insert_table_rows = function(rows, index) { /* insert cells */ $.map(thing.cells, function(cell) { $(row.insertCell(row.cells.length)) - .html($.unsafe(cell)); + .html($.unsafe(cell)) + .trigger("insert-cell"); }); + $(row).trigger("insert-row"); /* reveal! */ $(row).fadeIn(); }); diff --git a/r2/r2/public/static/js/reddit.js b/r2/r2/public/static/js/reddit.js index e15e482a5..eb5399d79 100644 --- a/r2/r2/public/static/js/reddit.js +++ b/r2/r2/public/static/js/reddit.js @@ -12,7 +12,7 @@ function open_menu(menu) { function close_menus(event) { $(".drop-choices.inuse").not(".active") .removeClass("inuse"); - $(".drop-choices.active").removeClass("active"); + $(".drop-choices.active").removeClass("active").trigger("close_menu") // Clear any flairselectors that may have been opened. $(".flairselector").empty(); diff --git a/r2/r2/public/static/js/ui.js b/r2/r2/public/static/js/ui.js index e4327dd26..a1c110cec 100644 --- a/r2/r2/public/static/js/ui.js +++ b/r2/r2/public/static/js/ui.js @@ -202,3 +202,218 @@ r.ui.HelpBubble.prototype = $.extend(new r.ui.Base(), { this.timeout = setTimeout($.proxy(this, 'hide'), this.hideDelay) } }) + +r.ui.PermissionEditor = function(el) { + r.ui.Base.call(this, el) + var params = {} + this.$el.find('input[type="hidden"]').each(function(idx, el) { + params[el.name] = el.value + }) + var permission_type = params.type + var name = params.name + this.form_id = permission_type + "-permissions-" + name + this.permission_info = r.config.permissions.info[permission_type] + this.sorted_perm_keys = $.map(this.permission_info, + function(v, k) { return k }) + this.sorted_perm_keys.sort() + this.original_perms = this._parsePerms(params.permissions) + this.embedded = this.$el.find("form").length == 0 + this.$menu = null + if (this.embedded) { + this.$permissions_field = this.$el.find('input[name="permissions"]') + this.$menu_controller = this.$el.siblings('.permissions-edit') + } else { + this.$menu_controller = this.$el.closest('tr').find('.permissions-edit') + } + this.$menu_controller.find('a').click($.proxy(this, 'show')) + this.updateSummary() +} +r.ui.PermissionEditor.init = function() { + function activate(target) { + $(target).find('.permissions').each(function(idx, el) { + $(el).data('PermissionEditor', new r.ui.PermissionEditor(el)) + }) + } + activate('body') + for (var permission_type in r.config.permissions.info) { + $('.' + permission_type + '-table') + .on('insert-row', 'tr', function(e) { activate(this) }) + } +} +r.ui.PermissionEditor.prototype = $.extend(new r.ui.Base(), { + _parsePerms: function(permspec) { + var perms = {} + permspec.split(",").forEach(function(str) { + perms[str.substring(1)] = str[0] == "+" + }) + return perms.all ? {"all": true} : perms + }, + + _serializePerms: function(perms) { + if (perms.all) { + return "+all" + } else { + var parts = [] + for (var perm in perms) { + parts.push((perms[perm] ? "+" : "-") + perm) + } + return parts.join(",") + } + }, + + _getNewPerms: function() { + if (!this.$menu) { + return null + } + var perms = {} + this.$menu.find('input[type="checkbox"]').each(function(idx, el) { + perms[$(el).attr("name")] = $(el).prop("checked") + }) + return perms + }, + + _makeMenuLabel: function(perm) { + var update = $.proxy(this, "updateSummary") + var info = this.permission_info[perm] + var $input = $('') + .attr("name", perm) + .prop("checked", this.original_perms[perm]) + var $label = $('