From 0dcddae22fd5dbe765625ee55f7e83188e96c91d Mon Sep 17 00:00:00 2001 From: Logan Hanks Date: Tue, 21 Jun 2011 12:04:49 -0700 Subject: [PATCH] Add user "flair" for subreddits. Flair is a new relation between subreddits and accounts (stored in a manner similar to but distinct from subreddit membership). This relation can have a text field and a CSS class name associated with it (this data is actually stored under the account). The flair data is then incorporated into any mention of the account within the context of the subreddit (namely, on links and comments submitted by the user). --- r2/example.ini | 2 + r2/r2/controllers/api.py | 119 ++++++++++++++++++++++- r2/r2/controllers/errors.py | 1 + r2/r2/controllers/front.py | 2 + r2/r2/controllers/validator/validator.py | 20 +++- r2/r2/lib/menus.py | 1 + r2/r2/lib/pages/pages.py | 56 ++++++++++- r2/r2/models/subreddit.py | 28 ++++++ r2/r2/templates/createsubreddit.html | 2 +- r2/r2/templates/usertableitem.html | 16 +++ r2/r2/templates/wrappeduser.html | 11 ++- 11 files changed, 248 insertions(+), 10 deletions(-) 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")})
  • - header mouseover text: + diff --git a/r2/r2/templates/usertableitem.html b/r2/r2/templates/usertableitem.html index 9831fe05b..c19f931fc 100644 --- a/r2/r2/templates/usertableitem.html +++ b/r2/r2/templates/usertableitem.html @@ -75,5 +75,21 @@ ${timesince(thing.rel._date)} + %elif thing.name == "flair": +
    + + + + + +
    %endif diff --git a/r2/r2/templates/wrappeduser.html b/r2/r2/templates/wrappeduser.html index 6b797593e..ab8e55940 100644 --- a/r2/r2/templates/wrappeduser.html +++ b/r2/r2/templates/wrappeduser.html @@ -22,6 +22,12 @@ <%namespace file="utils.html" import="plain_link" /> +<%def name="_flair(user)"> + %if user.has_flair: + ${user.flair_text} + %endif + + %if context_deleted and not c.user_is_admin: [deleted] %else: @@ -31,8 +37,9 @@ ${_(thing.thing.original_author.name)} %else: ${plain_link(thing.name + thing.karma, "/user/%s" % thing.name, - _class = thing.author_cls + (" id-%s" % thing.fullname), - _sr_path = False, target=target, title=thing.author_title)} + _class = thing.author_cls + (" id-%s" % thing.fullname), + _sr_path = False, target=target, title=thing.author_title)} + ${_flair(thing)} %if thing.attribs: [