diff --git a/r2/example.ini b/r2/example.ini index b9915e6b3..c1e0d6aed 100755 --- a/r2/example.ini +++ b/r2/example.ini @@ -206,6 +206,8 @@ db_table_jury_account_link = relation, account, link, main db_table_ad = thing, main db_table_adsr = relation, ad, subreddit, main +db_table_flair = relation, subreddit, account, main + disallow_db_writes = False diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 567178b83..414f8d812 100644 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -32,7 +32,7 @@ from r2.lib.utils import get_title, sanitize_url, timeuntil, set_last_modified from r2.lib.utils import query_string, timefromnow, randstr from r2.lib.utils import timeago, tup, filter_links, levenshtein from r2.lib.pages import EnemyList, FriendList, ContributorList, ModList, \ - BannedList, BoringPage, FormPage, CssError, UploadedImage, \ + FlairList, BannedList, BoringPage, FormPage, CssError, UploadedImage, \ ClickGadget, UrlParser from r2.lib.utils.trial_utils import indict, end_trial, trial_info from r2.lib.pages.things import wrap_links, default_thing_wrapper @@ -1281,7 +1281,7 @@ class ApiController(RedditController): css_on_cname = VBoolean("css_on_cname"), ) def POST_site_admin(self, form, jquery, name, ip, sr, - sponsor_text, sponsor_url, sponsor_name, **kw): + sponsor_text, sponsor_url, sponsor_name, **kw): # the status button is outside the form -- have to reset by hand form.parent().set_html('.status', "") @@ -1926,6 +1926,121 @@ class ApiController(RedditController): award._commit() form.set_html(".status", _('saved')) + @validatedForm(VFlairManager(), + VModhash(), + user = VExistingUname("name"), + text = VLength("text", max_length=64), + css_class = VCssName("css_class")) + def POST_flair(self, form, jquery, user, text, css_class): + # Check validation. + if form.has_errors('name', errors.USER_DOESNT_EXIST, errors.NO_USER): + return + if form.has_errors('css_class', errors.BAD_CSS_NAME): + form.set_html(".status:first", _('invalid css class')) + return + + # Make sure the flair relation is up-to-date, for listings. + if not c.site.is_flair(user): + c.site.add_flair(user) + new = True + else: + new = False + + # Save the flair details in the account data. + setattr(user, 'flair_%s_text' % c.site._id, text) + setattr(user, 'flair_%s_css_class' % c.site._id, css_class) + user._commit() + + if new: + user_row = FlairList().user_row(user) + jquery("#flair-table").show( + ).find("table").insert_table_rows(user_row) + else: + form.set_html('.status', _('saved')) + form.set_html( + '.user', + WrappedUser(user, force_show_flair=True).render(style='html')) + + @validate(VFlairManager(), + VModhash(), + flair_csv = nop('flair_csv')) + def POST_flaircsv(self, flair_csv): + limit = 100 # max of 100 flair settings per call + flair_text_limit = 64 # max of 64 chars in flair text + results = FlairCsv() + infile = csv.reader(flair_csv.strip().split('\n')) + for i, row in enumerate(infile): + line_result = results.add_line() + line_no = i + 1 + if line_no > limit: + line_result.error('row', + 'limit of %d rows per call reached' % limit) + break + + try: + name, text, css_class = row + except ValueError: + line_result.error('row', 'improperly formatted row, ignoring') + continue + + user = VExistingUname('name').run(name) + if not user: + line_result.error('user', + "unable to resolve user `%s', ignoring" + % name) + continue + + if not text and not css_class: + # this is equivalent to unflairing + text = None + css_class = None + + if text and len(text) > flair_text_limit: + line_result.warn('text', + 'truncating flair text to %d chars' + % flair_text_limit) + text = text[:flair_text_limit] + + if css_class and not VCssName('css_class').run(css_class): + line_result.error('css', + "invalid css class `%s', ignoring" + % css_class) + continue + + # all validation passed, enflair the user + if text or css_class: + mode = 'added' + c.site.add_flair(user) + else: + mode = 'removed' + c.site.remove_flair(user) + setattr(user, 'flair_%s_text' % c.site._id, text) + setattr(user, 'flair_%s_css_class' % c.site._id, css_class) + user._commit() + line_result.status = '%s flair for user %s' % (mode, user.name) + line_result.ok = True + + return BoringPage(_("API"), content = results).render() + + @validatedForm(VUser(), + VModhash(), + flair_enabled = VBoolean("flair_enabled")) + def POST_setflairenabled(self, form, jquery, flair_enabled): + setattr(c.user, 'flair_%s_enabled' % c.site._id, flair_enabled) + c.user._commit() + jquery.refresh() + + @noresponse(VFlairManager(), + VModhash(), + nuser = VExistingUname("name"), + iuser = VByName("id")) + def POST_unflair(self, nuser, iuser): + user = iuser or nuser + c.site.remove_flair(user) + setattr(user, 'flair_%s_text' % c.site._id, None) + setattr(user, 'flair_%s_css_class' % c.site._id, None) + user._commit() + @validatedForm(VAdmin(), award = VByName("fullname"), description = VLength("description", max_length=1000), diff --git a/r2/r2/controllers/errors.py b/r2/r2/controllers/errors.py index b718e25a2..1a32d3af1 100644 --- a/r2/r2/controllers/errors.py +++ b/r2/r2/controllers/errors.py @@ -83,6 +83,7 @@ error_list = dict(( ('NO_SELFS', _("that reddit doesn't allow text posts")), ('NO_LINKS', _("that reddit only allows text posts")), ('TOO_OLD', _("that's a piece of history now; it's too late to reply to it")), + ('BAD_CSS_NAME', _('invalid css name')), )) errors = Storage([(e, e) for e in error_list.keys()]) diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index f3aabb564..032edc85c 100644 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -483,6 +483,8 @@ class FrontController(RedditController): extension_handling = "private" elif is_moderator and location == 'traffic': pane = RedditTraffic() + elif is_moderator and location == 'flair': + pane = FlairList() elif c.user_is_sponsor and location == 'ads': pane = RedditAds() elif (not location or location == "about") and is_api(): diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index f079a85ea..8c3d56c5b 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -642,6 +642,14 @@ class VSrModerator(Validator): or c.user_is_admin): abort(403, "forbidden") +class VFlairManager(VSrModerator): + """Validates that a user is permitted to manage flair for a subreddit. + + Currently this is the same as VSrModerator. It's a separate class to act as + a placeholder if we ever need to give mods a way to delegate this aspect of + subreddit administration.""" + pass + class VSrCanDistinguish(VByName): def run(self, thing_name): if c.user_is_admin: @@ -1002,16 +1010,22 @@ class VBid(VNumber): return float(bid) - class VCssName(Validator): """ returns a name iff it consists of alphanumeric characters and possibly "-", and is below the length limit. """ + r_css_name = re.compile(r"\A[a-zA-Z0-9\-]{1,100}\Z") + def run(self, name): - if name and self.r_css_name.match(name): - return name + if name: + if self.r_css_name.match(name): + return name + else: + self.set_error(errors.BAD_CSS_NAME) + return '' + class VMenu(Validator): diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py index 9e9fd5a92..15648a6af 100644 --- a/r2/r2/lib/menus.py +++ b/r2/r2/lib/menus.py @@ -133,6 +133,7 @@ menu = MenuHandler(hot = _('hot'), contributors = _("edit approved submitters"), banned = _("ban users"), banusers = _("ban users"), + flair = _("edit user flair"), popular = _("popular"), create = _("create"), diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 0649173b0..5a3882f50 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -23,7 +23,7 @@ from r2.lib.wrapped import Wrapped, Templated, CachedTemplate from r2.models import Account, FakeAccount, DefaultSR, make_feedurl from r2.models import FakeSubreddit, Subreddit, Ad, AdSR from r2.models import Friends, All, Sub, NotFound, DomainSR, Random, Mod, RandomNSFW, MultiReddit -from r2.models import Link, Printable, Trophy, bidding, PromotionWeights +from r2.models import Link, Printable, Trophy, bidding, PromotionWeights, Comment, Flair from r2.config import cache from r2.lib.tracking import AdframeInfo from r2.lib.jsonresponse import json_respond @@ -185,6 +185,7 @@ class Reddit(Templated): NamedButton('reports', css_class = 'reddit-reported'), NamedButton('spam', css_class = 'reddit-spam'), NamedButton('banned', css_class = 'reddit-ban'), + NamedButton('flair', css_class = 'reddit-flair'), ]) return [NavMenu(buttons, type = "flat_vert", base_path = "/about/", css_class = "icon-menu", separator = '')] @@ -506,6 +507,7 @@ class SubredditInfoBar(CachedTemplate): NavButton(menu.banusers, 'banned'), NamedButton('traffic'), NavButton(menu.community_settings, 'edit'), + NavButton(menu.flair, 'flair'), ]) return [NavMenu(buttons, type = "flat_vert", base_path = "/about/", separator = '')] @@ -2241,12 +2243,24 @@ class Page_down(Templated): message = kw.get('message', _("This feature is currently unavailable. Sorry")) Templated.__init__(self, message = message) +def wrapped_flair(user, subreddit): + if not hasattr(subreddit, '_id'): + return False, '', '' + + get_flair_attr = lambda a, default=None: getattr( + user, 'flair_%s_%s' % (subreddit._id, a), default) + + return (get_flair_attr('enabled', default=True), get_flair_attr('text'), + get_flair_attr('css_class')) + class WrappedUser(CachedTemplate): + FLAIR_CSS_PREFIX = 'flair-' + def __init__(self, user, attribs = [], context_thing = None, gray = False): attribs.sort() author_cls = 'author' - author_title = None + author_title = '' if gray: author_cls += ' gray' for tup in attribs: @@ -2255,6 +2269,11 @@ class WrappedUser(CachedTemplate): if tup[1] == 'F' and '(' in tup[3]: author_title = tup[3] + flair_text, flair_css_class = wrapped_flair(user, context_thing) + has_flair = bool(flair_text) + if flair_css_class: + flair_css_class = self.FLAIR_CSS_PREFIX + flair_css_class + target = None ip_span = None context_deleted = None @@ -2269,6 +2288,9 @@ class WrappedUser(CachedTemplate): CachedTemplate.__init__(self, name = user.name, + has_flair = has_flair, + flair_text = flair_text, + flair_css_class = flair_css_class, author_cls = author_cls, author_title = author_title, attribs = attribs, @@ -2351,6 +2373,36 @@ class UserList(Templated): def container_name(self): return c.site._fullname +class FlairList(UserList): + """List of users who are tagged with flair within a subreddit.""" + type = 'flair' + destination = 'flair' + remove_action = 'unflair' + + def __init__(self): + self.cells = ('user', 'flair', 'remove') + self.table_headers = (_('user'), _('flair text, css'), '') + UserList.__init__(self) + + @property + def form_title(self): + return _('manage subreddit flair') + + @property + def table_title(self): + return _('users with flair on %(reddit)s' % dict(reddit = c.site.name)) + + def user_ids(self): + return c.site.flair + + def user_row(self, user): + get_flair_attr = lambda a: getattr(user, + 'flair_%s_%s' % (c.site._id, a), '') + user.flair_text = get_flair_attr('text') + user.flair_css_class = get_flair_attr('css_class') + return UserTableItem(user, self.type, self.cells, self.container_name, + True, self.remove_action, user) + class FriendList(UserList): """Friend list on /pref/friends""" type = 'friend' diff --git a/r2/r2/models/subreddit.py b/r2/r2/models/subreddit.py index be93f6413..0840bbdcc 100644 --- a/r2/r2/models/subreddit.py +++ b/r2/r2/models/subreddit.py @@ -188,6 +188,10 @@ class Subreddit(Thing, Printable): def subscribers(self): return self.subscriber_ids() + @property + def flair(self): + return self.flair_ids() + def spammy(self): return self._spam @@ -966,6 +970,30 @@ Subreddit.__bases__ += (UserRel('moderator', SRMember), UserRel('subscriber', SRMember, disable_ids_fn = True), UserRel('banned', SRMember)) +class Flair(Relation(Subreddit, Account)): + @classmethod + def store(cls, sr, account, text = None, css_class = None): + flair = Flair(sr, account, 'flair', text = text, css_class = css_class) + flair._commit() + + setattr(account, 'flair_%s_text' % sr._id, text) + setattr(account, 'flair_%s_css_class' % sr._id, css_class) + account._commit() + + @classmethod + @memoize('flair.all_flair_by_sr') + def all_flair_by_sr_cache(cls, sr_id): + q = cls._query(cls.c._thing1_id == sr_id) + return [t._id for t in q] + + @classmethod + def all_flair_by_sr(cls, sr_id, _update=False): + relids = cls.all_flair_by_sr_cache(sr_id, _update=_update) + return cls._byID(relids).itervalues() + +Subreddit.__bases__ += (UserRel('flair', Flair, + disable_ids_fn = True, + disable_reverse_ids_fn = True),) class SubredditPopularityByLanguage(tdb_cassandra.View): _use_db = True diff --git a/r2/r2/templates/createsubreddit.html b/r2/r2/templates/createsubreddit.html index ef6fee7b5..f66335cdf 100644 --- a/r2/r2/templates/createsubreddit.html +++ b/r2/r2/templates/createsubreddit.html @@ -216,7 +216,7 @@ (${_("leaves this page")})