diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 3a8166588..86d81a595 100644 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -432,6 +432,8 @@ class ApiController(RedditController): if container and container.is_moderator(c.user): container.remove_moderator(c.user) Subreddit.special_reddits(c.user, "moderator", _update=True) + ModAction.create(container, c.user, 'removemoderator', target=c.user, + details='remove_self') @noresponse(VUser(), VModhash(), @@ -477,7 +479,13 @@ class ApiController(RedditController): if type in ("friend", "enemy") and container != c.user: abort(403, 'forbidden') fn = getattr(container, 'remove_' + type) - fn(victim) + new = fn(victim) + + # Log this action + if new and type in ('moderator','contributor','banned'): + action = dict(banned='unbanuser', moderator='removemoderator', + contributor='removecontributor').get(type, None) + ModAction.create(c.site, c.user, action, target=victim) if type == "friend" and c.user.gold: c.user.friend_rels_cache(_update=True) @@ -518,6 +526,12 @@ class ApiController(RedditController): new = fn(friend) + # Log this action + if new and type in ('moderator','contributor','banned'): + action = dict(banned='banuser', moderator='addmoderator', + contributor='addcontributor').get(type, None) + ModAction.create(c.site, c.user, action, target=friend) + if type == "friend" and c.user.gold: # Yes, the order of the next two lines is correct. # First you recalculate the rel_ids, then you find @@ -1069,6 +1083,9 @@ class ApiController(RedditController): c.site._commit() + ModAction.create(c.site, c.user, action='editsettings', + details='stylesheet') + form.set_html(".status", _('saved')) form.set_html(".errors ul", "") @@ -1114,7 +1131,8 @@ class ApiController(RedditController): return self.abort(403,'forbidden') c.site.del_image(name) c.site._commit() - + ModAction.create(c.site, c.user, action='editsettings', + details='del_image', description=name) @validatedForm(VSrModerator(), VModhash(), @@ -1136,6 +1154,9 @@ class ApiController(RedditController): c.site.header = None c.site.header_size = None c.site._commit() + ModAction.create(c.site, c.user, action='editsettings', + details='del_header') + # hide the button which started this form.find('.delete-img').hide() # hide the preview box @@ -1215,6 +1236,14 @@ class ApiController(RedditController): if add_image_to_sr: c.site.add_image(name, url = new_url) c.site._commit() + + if header: + ModAction.create(c.site, c.user, action='editsettings', + details='upload_image_header') + else: + ModAction.create(c.site, c.user, action='editsettings', + details='upload_image', description=name) + return UploadedImage(_('saved'), new_url, name, errors=errors, form_id=form_id).render() @@ -1313,6 +1342,9 @@ class ApiController(RedditController): if not sr.domain: del kw['css_on_cname'] for k, v in kw.iteritems(): + if getattr(sr, k, None) != v: + ModAction.create(sr, c.user, action='editsettings', + details=k) setattr(sr, k, v) sr._commit() @@ -1358,6 +1390,16 @@ class ApiController(RedditController): end_trial(thing, why + "-removed") admintools.spam(thing, False, not c.user_is_admin, c.user.name) + if isinstance(c.site, FakeSubreddit): + sr = Subreddit._byID(thing.sr_id) + else: + sr = c.site + + if isinstance(thing, Link): + ModAction.create(sr, c.user, 'removelink', target=thing) + elif isinstance(thing, Comment): + ModAction.create(sr, c.user, 'removecomment', target=thing) + @noresponse(VUser(), VModhash(), why = VSrCanBan('id'), thing = VByName('id')) @@ -1368,6 +1410,16 @@ class ApiController(RedditController): end_trial(thing, why + "-approved") admintools.unspam(thing, c.user.name) + if isinstance(c.site, FakeSubreddit): + sr = Subreddit._byID(thing.sr_id) + else: + sr = c.site + + if isinstance(thing, Link): + ModAction.create(sr, c.user, 'approvelink', target=thing) + elif isinstance(thing, Comment): + ModAction.create(sr, c.user, 'approvecomment', target=thing) + @validatedForm(VUser(), VModhash(), VCanDistinguish(('id', 'how')), thing = VByName('id'), @@ -1904,6 +1956,10 @@ class ApiController(RedditController): setattr(user, 'flair_%s_css_class' % c.site._id, css_class) user._commit() + if c.user != user: + ModAction.create(c.site, c.user, action='editflair', target=user, + details='flair_edit') + if new: jquery.redirect('?name=%s' % user.name) else: @@ -1929,6 +1985,9 @@ class ApiController(RedditController): setattr(user, 'flair_%s_css_class' % c.site._id, None) user._commit() + ModAction.create(c.site, c.user, action='editflair', target=user, + details='flair_delete') + jquery('#flairrow_%s' % user._id36).remove() unflair = WrappedUser( user, include_flair_selector=True).render(style='html') @@ -1992,6 +2051,10 @@ class ApiController(RedditController): setattr(user, 'flair_%s_text' % c.site._id, text) setattr(user, 'flair_%s_css_class' % c.site._id, css_class) user._commit() + + ModAction.create(c.site, c.user, action='editflair', target=user, + details='flair_csv') + line_result.status = '%s flair for user %s' % (mode, user.name) line_result.ok = True @@ -2013,9 +2076,18 @@ class ApiController(RedditController): flair_self_assign_enabled = VBoolean("flair_self_assign_enabled")) def POST_flairconfig(self, form, jquery, flair_enabled, flair_position, flair_self_assign_enabled): - c.site.flair_enabled = flair_enabled - c.site.flair_position = flair_position - c.site.flair_self_assign_enabled = flair_self_assign_enabled + if c.site.flair_enabled != flair_enabled: + c.site.flair_enabled = flair_enabled + ModAction.create(c.site, c.user, action='editflair', + details='flair_enabled') + if c.site.flair_position != flair_position: + c.site.flair_position = flair_position + ModAction.create(c.site, c.user, action='editflair', + details='flair_position') + if c.site.flair_self_assign_enabled != flair_self_assign_enabled: + c.site.flair_self_assign_enabled = flair_self_assign_enabled + ModAction.create(c.site, c.user, action='editflair', + details='flair_self_enabled') c.site._commit() jquery.refresh() @@ -2081,6 +2153,8 @@ class ApiController(RedditController): form.set_html('.status', _('saved')) jquery('input[name="text"]').data('saved', text) jquery('input[name="css_class"]').data('saved', css_class) + ModAction.create(c.site, c.user, action='editflair', + details='flair_template') @validatedForm(VFlairManager(), VModhash(), @@ -2089,11 +2163,15 @@ class ApiController(RedditController): idx = FlairTemplateBySubredditIndex.by_sr(c.site._id) if idx.delete_by_id(flair_template._id): jquery('#%s' % flair_template._id).parent().remove() + ModAction.create(c.site, c.user, action='editflair', + details='flair_delete_template') @validatedForm(VFlairManager(), VModhash()) def POST_clearflairtemplates(self, form, jquery): FlairTemplateBySubredditIndex.clear(c.site._id) jquery.refresh() + ModAction.create(c.site, c.user, action='editflair', + details='flair_clear_template') @validate(VUser(), user = VOptionalExistingUname('name')) @@ -2137,6 +2215,10 @@ class ApiController(RedditController): setattr(user, 'flair_%s_css_class' % c.site._id, css_class) user._commit() + if (c.site.is_moderator(c.user) or c.user_is_admin) and c.user != user: + ModAction.create(c.site, c.user, action='editflair', target=user, + details='flair_edit') + # Push some client-side updates back to the browser. u = WrappedUser(user, force_show_flair=True, flair_text_editable=flair_template.text_editable, diff --git a/r2/r2/models/__init__.py b/r2/r2/models/__init__.py index ff1339fed..669b2f899 100644 --- a/r2/r2/models/__init__.py +++ b/r2/r2/models/__init__.py @@ -36,3 +36,4 @@ from mail_queue import Email, has_opted_out, opt_count from gold import * from admintools import * from oauth2 import * +from modaction import * diff --git a/r2/r2/models/modaction.py b/r2/r2/models/modaction.py new file mode 100644 index 000000000..09637621d --- /dev/null +++ b/r2/r2/models/modaction.py @@ -0,0 +1,250 @@ +from r2.lib.db import tdb_cassandra +from r2.lib.utils import tup +from r2.models import Account, Subreddit, Link, Printable +from pycassa.system_manager import TIME_UUID_TYPE +from uuid import UUID +from pylons.i18n import _ +from pylons import request + +class ModAction(tdb_cassandra.UuidThing, Printable): + """ + Columns: + sr_id - Subreddit id36 + mod_id - Account id36 of moderator + action - specific name of action, must be in ModAction.actions + target_fullname - optional fullname of the target of the action + details - subcategory available for some actions, must show up in + description - optional user + """ + + _use_db = True + _connection_pool = 'main' + _str_props = ('sr_id36', 'mod_id36', 'target_fullname', 'action', 'details', + 'description') + _defaults = {} + + actions = ('banuser', 'unbanuser', 'removelink', 'approvelink', + 'removecomment', 'approvecomment', 'addmoderator', + 'removemoderator', 'addcontributor', 'removecontributor', + 'editsettings', 'editflair') + + _menu = {'banuser': _('ban user'), + 'unbanuser': _('unban user'), + 'removelink': _('remove post'), + 'approvelink': _('approve post'), + 'removecomment': _('remove comment'), + 'approvecomment': _('approve comment'), + 'addmoderator': _('add moderator'), + 'removemoderator': _('remove moderator'), + 'addcontributor': _('add contributor'), + 'removecontributor': _('remove contributor'), + 'editsettings': _('edit settings'), + 'editflair': _('edit user flair')} + + _text = {'banuser': _('banned'), + 'unbanuser': _('unbanned'), + 'removelink': _('removed post'), + 'approvelink': _('approved post'), + 'removecomment': _('removed comment'), + 'approvecomment': _('approved comment'), + 'addmoderator': _('added moderator'), + 'removemoderator': _('removed moderator'), + 'addcontributor': _('added approved contributor'), + 'removecontributor': _('removed approved contributor'), + 'editsettings': _('edited settings'), + 'editflair': _('edited user flair')} + + _details_text = {# removemoderator + 'remove_self': _('removed self'), + # editsettings + 'title': _('title'), + 'description': _('description'), + 'lang': _('language'), + 'type': _('type'), + 'link_type': _('link type'), + 'over_18': _('toggle viewers must be over 18'), + 'allow_top': _('toggle allow in default set'), + 'show_media': _('toggle show thumbnail images of content'), + 'domain': _('domain'), + 'show_cname_sidebar': _('toggle show sidebar from cname'), + 'css_on_cname': _('toggle custom CSS from cname'), + 'header_title': _('header title'), + 'stylesheet': _('stylesheet'), + 'del_header': _('delete header image'), + 'del_image': _('delete image'), + 'upload_image_header': _('upload header image'), + 'upload_image': _('upload image'), + # editflair + 'flair_edit': _('add/edit flair'), + 'flair_delete': _('delete flair'), + 'flair_csv': _('edit by csv'), + 'flair_enabled': _('toggle flair enabled'), + 'flair_position': _('toggle flair position'), + 'flair_self_enabled': _('toggle user assigned flair enabled'), + 'flair_template': _('add/edit flair templates'), + 'flair_delete_template': _('delete flair template'), + 'flair_clear_template': _('clear flair templates')} + + # This stuff won't change + cache_ignore = set(['subreddit']).union(Printable.cache_ignore) + + # Thing properties for Printable + @property + def author_id(self): + return int(self.mod_id36, 36) + + @property + def sr_id(self): + return int(self.sr_id36, 36) + + @property + def _ups(self): + return 0 + + @property + def _downs(self): + return 0 + + @property + def _deleted(self): + return False + + @property + def _spam(self): + return False + + @property + def reported(self): + return False + + @classmethod + def create(cls, sr, mod, action, details=None, target=None, description=None): + # Split this off into separate function to check for valid actions? + if not action in cls.actions: + raise ValueError("Invalid ModAction: %s" % action) + + kw = dict(sr_id36=sr._id36, mod_id36=mod._id36, action=action) + + if target: + kw['target_fullname'] = target._fullname + if details: + kw['details'] = details + if description: + kw['description'] = description + + ma = cls(**kw) + ma._commit() + return ma + + def _on_create(self): + """ + Update all Views. + """ + + views = (ModActionBySR, ModActionBySRMod, ModActionBySRAction) + + for v in views: + v.add_object(self) + + @classmethod + def get_actions(cls, sr, mod=None, action=None, after=None, reverse=False, count=1000): + """ + Get a ColumnQuery that yields ModAction objects according to + specified criteria. + """ + if after and isinstance(after, basestring): + after = cls._byID(UUID(after)) + elif after and isinstance(after, UUID): + after = cls._byID(after) + + if not isinstance(after, cls): + after = None + + if not mod and not action: + rowkey = sr._id36 + q = ModActionBySR.query(rowkey, after=after, reverse=reverse, count=count) + elif mod and not action: + rowkey = '%s_%s' % (sr._id36, mod._id36) + q = ModActionBySRMod.query(rowkey, after=after, reverse=reverse, count=count) + elif not mod and action: + rowkey = '%s_%s' % (sr._id36, action) + q = ModActionBySRAction.query(rowkey, after=after, reverse=reverse, count=count) + else: + raise NotImplementedError("Can't query by both mod and action") + + return q + + def get_extra_text(self): + text = '' + if hasattr(self, 'details') and not self.details == None: + text += self._details_text.get(self.details, self.details) + if hasattr(self, 'description') and not self.description == None: + text += ' %s' % self.description + return text + + @classmethod + def add_props(cls, user, wrapped): + + from r2.lib.menus import NavButton + from r2.lib.db.thing import Thing + from r2.lib.pages import WrappedUser, SimpleLinkDisplay + + request_path = request.path + + target_fullnames = [item.target_fullname for item in wrapped if hasattr(item, 'target_fullname')] + targets = Thing._by_fullname(target_fullnames) + + for item in wrapped: + # Can I move these buttons somewhere else? Does it make sense to do so? + css_class = 'modactions %s' % item.action + item.button = NavButton('', item.action, opt='type', css_class=css_class) + item.button.build(base_path=request_path) + + mod_name = item.author.name + item.mod = NavButton(mod_name, mod_name, opt='mod') + item.mod.build(base_path=request_path) + 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: + 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) + + Printable.add_props(user, wrapped) + +class ModActionBySR(tdb_cassandra.View): + _use_db = True + _connection_pool = 'main' + _compare_with = TIME_UUID_TYPE + _view_of = ModAction + _ttl = 60*60*24*30*3 # 3 month ttl + + @classmethod + def _rowkey(cls, ma): + return ma.sr_id36 + +class ModActionBySRMod(tdb_cassandra.View): + _use_db = True + _connection_pool = 'main' + _compare_with = TIME_UUID_TYPE + _view_of = ModAction + _ttl = 60*60*24*30*3 # 3 month ttl + + @classmethod + def _rowkey(cls, ma): + return '%s_%s' % (ma.sr_id36, ma.mod_id36) + +class ModActionBySRAction(tdb_cassandra.View): + _use_db = True + _connection_pool = 'main' + _compare_with = TIME_UUID_TYPE + _view_of = ModAction + _ttl = 60*60*24*30*3 # 3 month ttl + + @classmethod + def _rowkey(cls, ma): + return '%s_%s' % (ma.sr_id36, ma.action)