diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index fefed3676..6a35fc650 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -50,6 +50,7 @@ def make_map(global_conf={}, app_conf={}): mc('/account-activity', controller='front', action='account_activity') mc('/about/message/:where', controller='message', action='listing') + mc('/about/log', controller='front', action='moderationlog') mc('/about/:location', controller='front', action='editreddit', location = 'about') diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 6c277cdfa..f0f2f91fa 100644 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -21,7 +21,7 @@ ################################################################################ from validator import * from pylons.i18n import _, ungettext -from reddit_base import RedditController, base_listing +from reddit_base import RedditController, base_listing, base_cassandra_listing from r2 import config from r2.models import * from r2.lib.pages import * @@ -362,6 +362,59 @@ class FrontController(RedditController): else: return self.abort404() + def _make_moderationlog(self, num, after, reverse, count, mod=None, action=None): + + if mod and action: + query = c.site.get_modactions(mod=mod, action=None) + + def keep_fn(ma): + return ma.action == action + else: + query = c.site.get_modactions(mod=mod, action=action) + + def keep_fn(ma): + return True + + builder = QueryBuilder(query, skip=True, num=num, after=after, + keep_fn=keep_fn, count=count, + reverse=reverse, + wrap=default_thing_wrapper()) + listing = ModActionListing(builder) + pane = listing.listing() + return pane + + @base_cassandra_listing + @validate(mod=VAccountByName('mod'), + action=VOneOf('type', ModAction.actions)) + def GET_moderationlog(self, num, after, reverse, count, mod, action): + + is_moderator = c.user_is_loggedin and c.site.is_moderator(c.user) or c.user_is_admin + + if not is_moderator: + return self.abort404() + + panes = PaneStack() + pane = self._make_moderationlog(num, after, reverse, count, + mod=mod, action=action) + panes.append(pane) + + action_buttons = [NavButton(_('all'), None, opt='type', css_class='primary')] + for a in ModAction.actions: + action_buttons.append(NavButton(ModAction._menu[a], a, opt='type')) + + mod_ids = c.site.moderators + mods = Account._byID(mod_ids) + mod_buttons = [NavButton(_('all'), None, opt='mod', css_class='primary')] + for mod_id in mod_ids: + mod = mods[mod_id] + mod_buttons.append(NavButton(mod.name, mod.name, opt='mod')) + base_path = request.path + menus = [NavMenu(action_buttons, base_path=base_path, + title=_('filter by action'), type='lightdrop', css_class='modaction-drop'), + NavMenu(mod_buttons, base_path=base_path, + title=_('filter by moderator'), type='lightdrop')] + return EditReddit(content=panes, nav_menus=menus, extension_handling=False).render() + def _make_spamlisting(self, location, num, after, reverse, count): if location == 'reports': query = c.site.get_reported() diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py index caec8b408..4f3284542 100644 --- a/r2/r2/lib/menus.py +++ b/r2/r2/lib/menus.py @@ -131,6 +131,7 @@ menu = MenuHandler(hot = _('hot'), banned = _("ban users"), banusers = _("ban users"), flair = _("edit user flair"), + log = _("moderation log"), popular = _("popular"), create = _("create"), diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index b78456b68..a43e8b212 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -26,6 +26,8 @@ from r2.models import Friends, All, Sub, NotFound, DomainSR, Random, Mod, Random from r2.models import Link, Printable, Trophy, bidding, PromotionWeights, Comment from r2.models import Flair, FlairTemplate, FlairTemplateBySubredditIndex from r2.models.oauth2 import OAuth2Client +from r2.models import ModAction +from r2.models import Thing from r2.config import cache from r2.lib.tracking import AdframeInfo from r2.lib.jsonresponse import json_respond @@ -203,6 +205,7 @@ class Reddit(Templated): NamedButton('spam', css_class = 'reddit-spam'), NamedButton('banned', css_class = 'reddit-ban'), NamedButton('flair', css_class = 'reddit-flair'), + NamedButton('log', css_class = 'reddit-moderationlog'), ]) return [NavMenu(buttons, type = "flat_vert", base_path = "/about/", css_class = "icon-menu", separator = '')] @@ -517,6 +520,7 @@ class SubredditInfoBar(CachedTemplate): NamedButton('traffic'), NavButton(menu.community_settings, 'edit'), NavButton(menu.flair, 'flair'), + NavButton(menu.modactions, 'modactions'), ]) return [NavMenu(buttons, type = "flat_vert", base_path = "/about/", separator = '')] diff --git a/r2/r2/models/builder.py b/r2/r2/models/builder.py index fc7132095..9049fd25a 100644 --- a/r2/r2/models/builder.py +++ b/r2/r2/models/builder.py @@ -45,7 +45,7 @@ EXTRA_FACTOR = 1.5 MAX_RECURSION = 10 class Builder(object): - def __init__(self, wrap = Wrapped, keep_fn = None, stale = True): + def __init__(self, wrap=Wrapped, keep_fn=None, stale=True): self.stale = stale self.wrap = wrap self.keep_fn = keep_fn @@ -272,9 +272,8 @@ class Builder(object): return True class QueryBuilder(Builder): - def __init__(self, query, wrap = Wrapped, keep_fn = None, - skip = False, **kw): - Builder.__init__(self, wrap, keep_fn) + def __init__(self, query, wrap=Wrapped, keep_fn=None, skip=False, **kw): + Builder.__init__(self, wrap=wrap, keep_fn=keep_fn) self.query = query self.skip = skip self.num = kw.get('num') diff --git a/r2/r2/models/listing.py b/r2/r2/models/listing.py index 755122410..fa764c0f0 100644 --- a/r2/r2/models/listing.py +++ b/r2/r2/models/listing.py @@ -89,6 +89,10 @@ class Listing(object): def __iter__(self): return iter(self.things) +class TableListing(Listing): pass + +class ModActionListing(TableListing): pass + class LinkListing(Listing): def __init__(self, *a, **kw): Listing.__init__(self, *a, **kw) diff --git a/r2/r2/models/modaction.py b/r2/r2/models/modaction.py index 09637621d..8cd47d37a 100644 --- a/r2/r2/models/modaction.py +++ b/r2/r2/models/modaction.py @@ -1,6 +1,6 @@ from r2.lib.db import tdb_cassandra from r2.lib.utils import tup -from r2.models import Account, Subreddit, Link, Printable +from r2.models import Account, Subreddit, Link, Comment, Printable from pycassa.system_manager import TIME_UUID_TYPE from uuid import UUID from pylons.i18n import _ @@ -86,7 +86,7 @@ class ModAction(tdb_cassandra.UuidThing, Printable): 'flair_clear_template': _('clear flair templates')} # This stuff won't change - cache_ignore = set(['subreddit']).union(Printable.cache_ignore) + cache_ignore = set(['subreddit', 'target']).union(Printable.cache_ignore) # Thing properties for Printable @property @@ -187,15 +187,54 @@ class ModAction(tdb_cassandra.UuidThing, Printable): from r2.lib.menus import NavButton from r2.lib.db.thing import Thing - from r2.lib.pages import WrappedUser, SimpleLinkDisplay + from r2.lib.pages import WrappedUser + from r2.lib.filters import _force_unicode + + TITLE_MAX_WIDTH = 50 request_path = request.path target_fullnames = [item.target_fullname for item in wrapped if hasattr(item, 'target_fullname')] - targets = Thing._by_fullname(target_fullnames) + targets = Thing._by_fullname(target_fullnames, data=True) + authors = Account._byID([t.author_id for t in targets.values() if hasattr(t, 'author_id')], data=True) + links = Link._byID([t.link_id for t in targets.values() if hasattr(t, 'link_id')], data=True) + subreddits = Subreddit._byID([t.sr_id for t in targets.values() if hasattr(t, 'sr_id')]) + + # Assemble target links + target_links = {} + target_accounts = {} + for fullname, target in targets.iteritems(): + if isinstance(target, Link): + author = authors[target.author_id] + title = _force_unicode(target.title) + if len(title) > TITLE_MAX_WIDTH: + short_title = title[:TITLE_MAX_WIDTH] + '...' + else: + short_title = title + text = '"%(title)s" %(by)s %(author)s' % {'title': short_title, + 'by': _('by'), + 'author': author.name} + path = target.make_permalink(subreddits[target.sr_id]) + target_links[fullname] = (text, path, title) + elif isinstance(target, Comment): + author = authors[target.author_id] + link = links[target.link_id] + title = _force_unicode(link.title) + if len(title) > TITLE_MAX_WIDTH: + short_title = title[:TITLE_MAX_WIDTH] + '...' + else: + short_title = title + text = '%(by)s %(author)s %(on)s "%(title)s"' % {'by': _('by'), + 'author': author.name, + 'on': _('on'), + 'title': short_title} + path = target.make_permalink(link, subreddits[target.sr_id]) + target_links[fullname] = (text, path, title) + elif isinstance(target, Account): + target_accounts[fullname] = WrappedUser(target) for item in wrapped: - # Can I move these buttons somewhere else? Does it make sense to do so? + # Can I move these buttons somewhere else? Not great to have request stuff in here css_class = 'modactions %s' % item.action item.button = NavButton('', item.action, opt='type', css_class=css_class) item.button.build(base_path=request_path) @@ -206,14 +245,13 @@ class ModAction(tdb_cassandra.UuidThing, Printable): item.text = ModAction._text.get(item.action, '') item.details = item.get_extra_text() - # Can extend default_thing_wrapper to also lookup the targets - if hasattr(item, 'target_fullname') and not item.target_fullname == None: + if hasattr(item, 'target_fullname') and item.target_fullname: target = targets[item.target_fullname] if isinstance(target, Account): - item.target = WrappedUser(target) - elif isinstance(target, Comment) or isinstance(target, Link): - item.target = SimpleLinkDisplay(target) - + item.target_wrapped_user = target_accounts[item.target_fullname] + elif isinstance(target, Link) or isinstance(target, Comment): + item.target_text, item.target_path, item.target_title = target_links[item.target_fullname] + Printable.add_props(user, wrapped) class ModActionBySR(tdb_cassandra.View): diff --git a/r2/r2/models/subreddit.py b/r2/r2/models/subreddit.py index ddb4664a4..cae856bae 100644 --- a/r2/r2/models/subreddit.py +++ b/r2/r2/models/subreddit.py @@ -38,7 +38,6 @@ from r2.lib.filters import _force_unicode from r2.lib.db import tdb_cassandra from r2.lib.cache import CL_ONE - import os.path import random @@ -333,6 +332,10 @@ class Subreddit(Thing, Printable): from r2.lib.db import queries return queries.get_sr_comments(self) + def get_modactions(self, mod=None, action=None): + # Get a query that will yield ModAction objects with mod and action + from r2.models import ModAction + return ModAction.get_actions(self, mod=mod, action=action) @classmethod def add_props(cls, user, wrapped): diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index 0a7aa653c..190871e6e 100644 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -146,7 +146,7 @@ label.disabled { color: gray; } .hover a:hover { text-decoration: underline } -.selected { font-weight: bold; } +.selected, .choice.primary { font-weight: bold; } .flat-list {list-style-type: none; display: inline;} .flat-list li, .flat-list form {display: inline; white-space: nowrap; } @@ -4250,6 +4250,7 @@ dd { margin-left: 20px; } .icon-menu .reddit-spam:before, .icon-menu .reddit-ban:before, .icon-menu .reddit-flair:before, +.icon-menu .reddit-moderationlog:before, .icon-menu .reddit-moderators:before, .icon-menu .moderator-mail:before, .icon-menu .reddit-contributors:before, @@ -4304,6 +4305,10 @@ dd { margin-left: 20px; } /* Work around a centering difference between this icon and reddit_ban.png */ margin-left: 1px; } +.icon-menu .reddit-moderationlog:before { + background-image: url(../reddit_moderationlog.png); /* SPRITE */ + margin-left: 1px; +} .icon-menu .reddit-moderators:before { background-image: url(../shield.png); /* SPRITE */ } @@ -4917,3 +4922,83 @@ tr.gold-accent + tr > td { background: #e4e4e4; box-shadow: inset 0px -1px 0px white; } + +.modactionlisting table { + margin: 0 15px; +} + +.modactionlisting td.timestamp { + white-space: nowrap; + padding-left: 0; + padding-right: 1.5em; +} + +.modactionlisting td.button { + padding-right: 0; + padding-left: 1.5em; +} + +.modactionlisting td.description em { + font-style: italic; +} + +.modactions td { + font-size: small; + text-align: left; + padding: 2px; +} +.modactions.banuser, +.modactions.unbanuser, +.modactions.removelink, +.modactions.approvelink, +.modactions.removecomment, +.modactions.approvecomment, +.modactions.addmoderator, +.modactions.removemoderator, +.modactions.addcontributor, +.modactions.removecontributor, +.modactions.editsettings, +.modactions.editflair { + height: 16px; + width: 16px; + display: block; + content: " "; + float: left; + margin-right: 5px; +} +.modactions.banuser { + background-image: url(../modactions_banuser.png); /* SPRITE */ +} +.modactions.unbanuser { + background-image: url(../modactions_unbanuser.png); /* SPRITE */ +} +.modactions.removelink { + background-image: url(../modactions_removelink.png); /* SPRITE */ +} +.modactions.approvelink { + background-image: url(../modactions_approvelink.png); /* SPRITE */ +} +.modactions.removecomment { + background-image: url(../modactions_removecomment.png); /* SPRITE */ +} +.modactions.approvecomment { + background-image: url(../modactions_approvecomment.png); /* SPRITE */ +} +.modactions.addmoderator { + background-image: url(../modactions_addmoderator.png); /* SPRITE */ +} +.modactions.removemoderator { + background-image: url(../modactions_removemoderator.png); /* SPRITE */ +} +.modactions.addcontributor { + background-image: url(../modactions_addcontributor.png); /* SPRITE */ +} +.modactions.removecontributor { + background-image: url(../modactions_removecontributor.png); /* SPRITE */ +} +.modactions.editsettings { + background-image: url(../modactions_editsettings.png); /* SPRITE */ +} +.modactions.editflair { + background-image: url(../modactions_editflair.png); /* SPRITE */ +} diff --git a/r2/r2/public/static/modactions_addcontributor.png b/r2/r2/public/static/modactions_addcontributor.png new file mode 100755 index 000000000..902bbe61b Binary files /dev/null and b/r2/r2/public/static/modactions_addcontributor.png differ diff --git a/r2/r2/public/static/modactions_addmoderator.png b/r2/r2/public/static/modactions_addmoderator.png new file mode 100755 index 000000000..e20a1b4ab Binary files /dev/null and b/r2/r2/public/static/modactions_addmoderator.png differ diff --git a/r2/r2/public/static/modactions_approvecomment.png b/r2/r2/public/static/modactions_approvecomment.png new file mode 100755 index 000000000..d1ab611f5 Binary files /dev/null and b/r2/r2/public/static/modactions_approvecomment.png differ diff --git a/r2/r2/public/static/modactions_approvelink.png b/r2/r2/public/static/modactions_approvelink.png new file mode 100755 index 000000000..89c8129a4 Binary files /dev/null and b/r2/r2/public/static/modactions_approvelink.png differ diff --git a/r2/r2/public/static/modactions_banuser.png b/r2/r2/public/static/modactions_banuser.png new file mode 120000 index 000000000..61e529bf9 --- /dev/null +++ b/r2/r2/public/static/modactions_banuser.png @@ -0,0 +1 @@ +reddit_ban.png \ No newline at end of file diff --git a/r2/r2/public/static/modactions_editflair.png b/r2/r2/public/static/modactions_editflair.png new file mode 120000 index 000000000..3c35c25eb --- /dev/null +++ b/r2/r2/public/static/modactions_editflair.png @@ -0,0 +1 @@ +reddit_flair.png \ No newline at end of file diff --git a/r2/r2/public/static/modactions_editsettings.png b/r2/r2/public/static/modactions_editsettings.png new file mode 120000 index 000000000..be4f605c4 --- /dev/null +++ b/r2/r2/public/static/modactions_editsettings.png @@ -0,0 +1 @@ +reddit_edit.png \ No newline at end of file diff --git a/r2/r2/public/static/modactions_removecomment.png b/r2/r2/public/static/modactions_removecomment.png new file mode 100755 index 000000000..6df7376d0 Binary files /dev/null and b/r2/r2/public/static/modactions_removecomment.png differ diff --git a/r2/r2/public/static/modactions_removecontributor.png b/r2/r2/public/static/modactions_removecontributor.png new file mode 100755 index 000000000..d8944e6ea Binary files /dev/null and b/r2/r2/public/static/modactions_removecontributor.png differ diff --git a/r2/r2/public/static/modactions_removelink.png b/r2/r2/public/static/modactions_removelink.png new file mode 100755 index 000000000..08f249365 Binary files /dev/null and b/r2/r2/public/static/modactions_removelink.png differ diff --git a/r2/r2/public/static/modactions_removemoderator.png b/r2/r2/public/static/modactions_removemoderator.png new file mode 100755 index 000000000..22823a70d Binary files /dev/null and b/r2/r2/public/static/modactions_removemoderator.png differ diff --git a/r2/r2/public/static/modactions_unbanuser.png b/r2/r2/public/static/modactions_unbanuser.png new file mode 100755 index 000000000..79f35ccbd Binary files /dev/null and b/r2/r2/public/static/modactions_unbanuser.png differ diff --git a/r2/r2/public/static/reddit_moderationlog.png b/r2/r2/public/static/reddit_moderationlog.png new file mode 100755 index 000000000..779ad58ef Binary files /dev/null and b/r2/r2/public/static/reddit_moderationlog.png differ diff --git a/r2/r2/templates/modaction.html b/r2/r2/templates/modaction.html new file mode 100755 index 000000000..6a6b809e8 --- /dev/null +++ b/r2/r2/templates/modaction.html @@ -0,0 +1,39 @@ +## 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 CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2010 +## CondeNet, Inc. All Rights Reserved. +################################################################################ + +<%namespace file="utils.html" import="timestamp, plain_link"/> + + + ${timestamp(thing.date)} ago + ${thing.mod} + ${thing.button} + ${thing.text} + %if hasattr(thing, 'target_text'): + ${plain_link(thing.target_text, thing.target_path, title=thing.target_title, sr_path=False, cname=False, _class="subreddit hover")} + %elif hasattr(thing, 'target_wrapped_user'): + ${thing.target_wrapped_user} + %endif + %if hasattr(thing, 'details') and thing.details: + (${thing.details}) + %endif + + diff --git a/r2/r2/templates/tablelisting.html b/r2/r2/templates/tablelisting.html new file mode 100755 index 000000000..89916f397 --- /dev/null +++ b/r2/r2/templates/tablelisting.html @@ -0,0 +1,64 @@ +## 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 CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2010 +## CondeNet, Inc. All Rights Reserved. +################################################################################ +<%! + from r2.models import Sub + %> +<%namespace file="utils.html" import="plain_link" /> + +<% + _id = ("_%s" % thing.parent_name) if hasattr(thing, 'parent_name') else '' + cls = thing.lookups[0].__class__.__name__.lower() + %> + + + +
+ + %for a in thing.things: + ${a} + %endfor +
+
+ +%if thing.nextprev and (thing.prev or thing.next): +

${_("view more:")} + %if thing.prev: + ${plain_link(_("first"), thing.first, _sr_path = (c.site != Sub), rel="nofollow first")} + | + ${plain_link(_("prev"), thing.prev, _sr_path = (c.site != Sub), rel="nofollow prev")} + %endif + %if thing.prev and thing.next: + | + %endif + %if thing.next: + ${plain_link(_("next"), thing.next, _sr_path = (c.site != Sub), rel="nofollow next")} + %endif +

+%endif +%if not thing.things: +

${_("there doesn't seem to be anything here")}

+%endif