Add permissions editing to the edit moderators page.

This commit is contained in:
Logan Hanks
2013-01-23 14:15:31 -08:00
parent 4aed1baea9
commit 5106508ed6
16 changed files with 513 additions and 30 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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()])

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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 = $('<input type="checkbox">')
.attr("name", perm)
.prop("checked", this.original_perms[perm])
var $label = $('<label>')
.append($input)
.click(function(e) { e.stopPropagation() })
if (perm == "all") {
$input.change(function() {
var disabled = $input.is(":checked")
$label.siblings()
.toggleClass("disabled", disabled)
.find('input[type="checkbox"]').prop("disabled", disabled)
update()
})
$label.append(
document.createTextNode(r.config.permissions.all_msg))
} else if (info) {
$input.change(update)
$label.append(document.createTextNode(info.title))
$label.attr("title", info.description)
}
return $label
},
show: function(e) {
close_menus(e)
this.$menu = $('<div class="permission-selector drop-choices">')
this.$menu.append(this._makeMenuLabel("all"))
for (var i in this.sorted_perm_keys) {
this.$menu.append(this._makeMenuLabel(this.sorted_perm_keys[i]))
}
this.$menu
.on("close_menu", $.proxy(this, "hide"))
.find("input").first().change().end()
if (!this.embedded) {
var $form = this.$el.find("form").clone()
$form.attr("id", this.form_id)
$form.click(function(e) { e.stopPropagation() })
this.$menu.append('<hr>', $form)
this.$permissions_field =
this.$menu.find('input[name="permissions"]')
}
this.$menu_controller.parent().append(this.$menu)
open_menu(this.$menu_controller[0])
return false
},
hide: function() {
if (this.$menu) {
this.$menu.remove()
this.$menu = null
this.updateSummary()
}
},
_renderBit: function(perm) {
var info = this.permission_info[perm]
var text
if (perm == "all") {
text = r.config.permissions.all_msg
} else if (info) {
text = info.title
} else {
text = perm
}
var $span = $('<span class="permission-bit"/>').text(text)
if (info) {
$span.attr("title", info.description)
}
return $span
},
updateSummary: function() {
var new_perms = this._getNewPerms()
var spans = []
if (new_perms && new_perms.all) {
spans.push(this._renderBit("all")
.toggleClass("added", this.original_perms.all != true))
} else {
if (this.original_perms.all) {
if (!this.embedded || !new_perms) {
spans.push(this._renderBit("all")
.toggleClass("removed",
!this.embedded && new_perms != null))
}
} else {
for (var perm in this.original_perms) {
if (this.original_perms[perm]) {
if (this.embedded && !(new_perms && !new_perms[perm])) {
spans.push(this._renderBit(perm))
}
if (!this.embedded) {
spans.push(this._renderBit(perm)
.toggleClass("removed",
new_perms != null
&& !new_perms[perm]))
}
}
}
}
if (new_perms) {
for (var perm in new_perms) {
if (this.permission_info[perm] && new_perms[perm]
&& !this.original_perms[perm]) {
spans.push(this._renderBit(perm)
.toggleClass("added", !this.embedded))
}
}
}
}
if (!spans.length) {
spans.push($('<span class="permission-bit">')
.text(r.config.permissions.none_msg)
.addClass("none"))
}
var $new_summary = $('<div class="permission-summary">')
for (var i = 0; i < spans.length; i++) {
if (i > 0) {
$new_summary.append(", ")
}
$new_summary.append(spans[i])
}
$new_summary.toggleClass("edited", this.$menu != null)
this.$el.find(".permission-summary").replaceWith($new_summary)
if (new_perms && this.$permissions_field) {
this.$permissions_field.val(this._serializePerms(new_perms))
}
},
onCommit: function(perms) {
this.$el.find('input[name="permissions"]').val(perms)
this.original_perms = this._parsePerms(perms)
this.hide()
}
})
r.ui.init = function() {
r.ui.HelpBubble.init()
r.ui.PermissionEditor.init()
}

View File

@@ -0,0 +1,57 @@
## The contents of this file are subject to the Common Public Attribution
## License Version 1.0. (the "License"); you may not use this file except in
## compliance with the License. You may obtain a copy of the License at
## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
## License Version 1.1, but Sections 14 and 15 have been added to cover use of
## software over a computer network and provide for limited attribution for the
## Original Developer. In addition, Exhibit A has been modified to be
## consistent with Exhibit B.
##
## Software distributed under the License is distributed on an "AS IS" basis,
## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
## the specific language governing rights and limitations under the License.
##
## The Original Code is reddit.
##
## The Original Developer is the Initial Developer. The Initial Developer of
## the Original Code is reddit Inc.
##
## All portions of the code written by reddit are Copyright (c) 2006-2012
## reddit Inc. All Rights Reserved.
###############################################################################
<%namespace file="utils.html" import="error_field"/>
<%def name="form_content()">
%if not thing.embedded:
<input type="hidden" name="name"
value="${thing.user.name if thing.user else ''}" />
%endif
<input type="hidden" name="type" value="${thing.permissions_type}">
<input type="hidden" name="permissions"
value="${thing.permissions.dumps() if thing.permissions else '+all'}">
%if not thing.embedded:
${error_field("USER_DOESNT_EXIST", "name")}
${error_field("NO_USER", "name")}
%endif
${error_field("INVALID_PERMISSION_TYPE", "type")}
${error_field("INVALID_PERMISSIONS", "permissions")}
%if not thing.embedded:
<span class="status"></span>
%endif
</%def>
<div class="permissions">
%if thing.embedded:
${form_content()}
%else:
<form action="/post/setpermissions" method="post"
class="setpermissions pretty-form medium-text"
onsubmit="return post_form(this, 'setpermissions')">
${form_content()}
<button type="submit">${_('save')}</button>
</form>
%endif
<div class="permission-summary">
</div>
</div>

View File

@@ -24,6 +24,8 @@
<% from r2.lib.template_helpers import static %>
<%def name="add_form(title, dest, add_type, container_name, verb=None)">
<% from r2.models import ModeratorPermissionSet %>
<% from r2.lib.pages import ModeratorPermissions %>
<form action="/post/${dest}"
method="post" class="pretty-form medium-text"
onsubmit="return post_form(this, '${dest}')"
@@ -34,6 +36,15 @@
<input type="hidden" name="container" value="${container_name}">
<input type="hidden" name="type" value="${add_type}">
<input type="text" name="name" id="name">
%if add_type == "moderator_invite":
${ModeratorPermissions(None, 'moderator',
ModeratorPermissionSet(all=True),
editable=True, embedded=True)}
&#32;
<span class="permissions-edit">
(<a href="javascript:void(0)">${_('change')}</a>)
</span>
%endif
<button class="btn" type="submit">${verb or _("add")}</button>
<span class="status"></span>
${error_field("USER_DOESNT_EXIST", "name")}

View File

@@ -75,5 +75,13 @@
<span title="${thing.rel._date.strftime('%Y-%m-%d %H:%M:%S')}">
${timesince(thing.rel._date)}
</span>
%elif thing.name == "permissions":
${thing.rel}
%elif thing.name == "permissionsctl":
%if thing.editable:
<span class="permissions-edit">
(<a href="javascript:void(0)">${_('change')}</a>)
</span>
%endif
%endif
</%def>