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).
This commit is contained in:
Logan Hanks
2011-06-21 12:04:49 -07:00
parent c422a6171b
commit 0dcddae22f
11 changed files with 248 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -216,7 +216,7 @@
<span class="gray">(${_("leaves this page")})</span>
</li>
<li>
<lable for="header-title">header mouseover text:</lable>
<label for="header-title">header mouseover text:</label>
<input type="text" name="header-title" id="header-title"
value="${thing.site.header_title}"
/>

View File

@@ -75,5 +75,21 @@
<span title="${thing.rel._date.strftime('%Y-%m-%d %H:%M:%S')}">
${timesince(thing.rel._date)}
</span>
%elif thing.name == "flair":
<form action="/post/flair" id="flair-${thing.rel._fullname}"
method="post" class="pretty-form medium-text flair-entry"
onsubmit="return post_form(this, 'flair');">
<input type="hidden" name="name" value="${thing.user.name}" />
<input type="text" maxlength="32" name="text" class="tiny"
onfocus="$(this).parent().addClass('edited')"
value="${getattr(thing.user, 'flair_text', '')}" />
<input type="text" maxlength="32" name="css_class" class="tiny"
onfocus="$(this).parent().addClass('edited')"
value="${getattr(thing.user, 'flair_css_class', '')}" />
<button onclick="$(this).parent().removeClass('edited')" type="submit">
submit
</button>
<span class="status"></span>
</form>
%endif
</%def>

View File

@@ -22,6 +22,12 @@
<%namespace file="utils.html" import="plain_link" />
<%def name="_flair(user)">
%if user.has_flair:
<span class="flair ${user.flair_css_class}">${user.flair_text}</span>
%endif
</%def>
%if context_deleted and not c.user_is_admin:
[deleted]
%else:
@@ -31,8 +37,9 @@
<span>${_(thing.thing.original_author.name)}</span>
%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)}
<span class="userattrs">
%if thing.attribs:
&#32;[