diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 5422d975b..4262848ef 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -100,6 +100,10 @@ def make_map(): mc('/bookmarklets', controller='buttons', action='bookmarklets') mc('/awards', controller='front', action='awards') + mc('/awards/confirm/:code', controller='front', + action='confirm_award_claim') + mc('/awards/claim/:code', controller='front', action='claim_award') + mc('/awards/received', controller='front', action='received_award') mc('/i18n', controller='redirect', action='redirect', dest='http://www.reddit.com/r/i18n') diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 0089dc4c9..9e78061e7 100755 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -1106,6 +1106,39 @@ class FrontController(RedditController, OAuth2ResourceController): vendor_url=vendor_url, lounge_md=lounge_md)).render() + @validate(VUser(), + token=VOneTimeToken(AwardClaimToken, "code")) + def GET_confirm_award_claim(self, token): + if not token: + abort(403) + + award = Award._by_fullname(token.awardfullname) + trophy = FakeTrophy(c.user, award, token.description, token.url) + content = ConfirmAwardClaim(trophy=trophy, user=c.user.name, + token=token) + return BoringPage(_("claim this award?"), content=content).render() + + @validate(VUser(), + token=VOneTimeToken(AwardClaimToken, "code")) + def POST_claim_award(self, token): + if not token: + abort(403) + + token.consume() + + award = Award._by_fullname(token.awardfullname) + trophy, preexisting = Trophy.claim(c.user, token.uid, award, + token.description, token.url) + redirect = '/awards/received?trophy=' + trophy._id36 + if preexisting: + redirect += '&duplicate=true' + self.redirect(redirect) + + @validate(trophy=VTrophy('trophy'), + preexisting=VBoolean('duplicate')) + def GET_received_award(self, trophy, preexisting): + content = AwardReceived(trophy=trophy, preexisting=preexisting) + return BoringPage(_("award claim"), content=content).render() def GET_gold_info(self): return GoldInfoPage(_("gold"), show_sidebar=False).render() diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 86daa75c4..fd0fadca6 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -3895,6 +3895,12 @@ class ApiHelp(Templated): class RulesPage(Templated): pass +class AwardReceived(Templated): + pass + +class ConfirmAwardClaim(Templated): + pass + class TimeSeriesChart(Templated): def __init__(self, id, title, interval, columns, rows, latest_available_data=None, classes=[]): diff --git a/r2/r2/models/account.py b/r2/r2/models/account.py index 4774d4341..464c2f61a 100644 --- a/r2/r2/models/account.py +++ b/r2/r2/models/account.py @@ -620,6 +620,23 @@ class Account(Thing): if not self._spam: AccountsActiveBySR.touch(self, sr) + def get_trophy_id(self, uid): + '''Return the ID of the Trophy associated with the given "uid" + + `uid` - The unique identifier for the Trophy to look up + + ''' + return getattr(self, 'received_trophy_%s' % uid, None) + + def set_trophy_id(self, uid, trophy_id): + '''Recored that a user has received a Trophy with "uid" + + `uid` - The trophy "type" that the user should only have one of + `trophy_id` - The ID of the corresponding Trophy object + + ''' + return setattr(self, 'received_trophy_%s' % uid, trophy_id) + class FakeAccount(Account): _nodb = True pref_no_profanity = True diff --git a/r2/r2/models/admintools.py b/r2/r2/models/admintools.py index eb54aa29c..3abaafff1 100644 --- a/r2/r2/models/admintools.py +++ b/r2/r2/models/admintools.py @@ -25,6 +25,7 @@ from r2.lib.utils import tup, fetch_things2 from r2.lib.filters import websafe from r2.lib.log import log_text from r2.models import Report, Account, Subreddit +from r2.models.token import AwardClaimToken from _pylibmc import MemcachedError from pylons import g @@ -193,6 +194,22 @@ class AdminTools(object): def admin_list(self): return list(g.admins) + def create_award_claim_code(self, unique_award_id, award_codename, + description, url): + '''Create a one-time-use claim URL for a user to claim a trophy. + + `unique_award_id` - A string that uniquely identifies the kind of + Trophy the user would be claiming. + See: token.py:AwardClaimToken.uid + `award_codename` - The codename of the Award the user will claim + `description` - The description the Trophy will receive + `url` - The URL the Trophy will receive + + ''' + award = Award._by_codename(award_codename) + token = AwardClaimToken._new(unique_award_id, award, description, url) + return token.confirm_url() + admintools = AdminTools() def cancel_subscription(subscr_id): diff --git a/r2/r2/models/award.py b/r2/r2/models/award.py index 892602a46..974aaec53 100644 --- a/r2/r2/models/award.py +++ b/r2/r2/models/award.py @@ -117,6 +117,16 @@ class Award (Thing): else: g.log.debug("%s didn't have %s" % (user, codename)) +class FakeTrophy(object): + def __init__(self, recipient, award, description=None, url=None, + cup_info=None): + self._thing2 = award + self._thing1 = recipient + self.description = description + self.url = url + self.cup_info = cup_info + self._id = self._id36 = None + class Trophy(Relation(Account, Award)): @classmethod def _new(cls, recipient, award, description = None, @@ -178,3 +188,18 @@ class Trophy(Relation(Account, Award)): trophies = Trophy._byID_rel(rel_ids, data=True, eager_load=True, thing_data=True, return_dict = False) return trophies + + @classmethod + def claim(cls, user, uid, award, description, url): + with g.make_lock("claim_award", str("%s_%s" % (user.name, uid))): + existing_trophy_id = user.get_trophy_id(uid) + if existing_trophy_id: + trophy = cls._byID(existing_trophy_id) + preexisting = True + else: + preexisting = False + trophy = cls._new(user, award, description=description, + url=url) + user.set_trophy_id(uid, trophy._id) + user._commit() + return trophy, preexisting diff --git a/r2/r2/models/token.py b/r2/r2/models/token.py index 7631b756d..3014eec83 100644 --- a/r2/r2/models/token.py +++ b/r2/r2/models/token.py @@ -26,6 +26,7 @@ from base64 import urlsafe_b64encode from pycassa.system_manager import ASCII_TYPE, DATE_TYPE, UTF8_TYPE +from pylons import g from pylons.i18n import _ from r2.lib.db import tdb_cassandra @@ -532,3 +533,46 @@ class PasswordResetToken(ConsumableToken): def valid_for_user(self, user): return (self.email_address == user.email and self.password == user.password) + + +class AwardClaimToken(ConsumableToken): + token_size = 20 + _ttl = datetime.timedelta(days=30) + _defaults = dict(ConsumableToken._defaults.items() + [ + ("awardfullname", ""), + ("description", ""), + ("url", ""), + ("uid", "")]) + _use_db = True + _connection_pool = "main" + + @classmethod + def _new(cls, uid, award, description, url): + '''Create an AwardClaimToken with the given parameters + + `uid` - A string that uniquely identifies the kind of + Trophy the user would be claiming.* + `award_codename` - The codename of the Award the user will claim + `description` - The description the Trophy will receive + `url` - The URL the Trophy will receive + + *Note that this differs from Award codenames, because it may be + desirable to allow users to have multiple copies of the same Award, + but restrict another aspect of the Trophy. For example, users + are allowed to have multiple Translator awards, but should only get + one for each language, so the `unique_award_id`s for those would be + of the form "i18n_%(language)s" + + ''' + return super(AwardClaimToken, cls)._new( + awardfullname=award._fullname, + description=description or "", + url=url or "", + uid=uid, + ) + + def claim_url(self): + return "http://%s/awards/claim/%s" % (g.domain, self._id) + + def confirm_url(self): + return "http://%s/awards/confirm/%s" % (g.domain, self._id) diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 669b6cfc3..eaed195e1 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/r2/r2/public/static/css/reddit.less @@ -4297,6 +4297,11 @@ table.lined-table { margin-top: 4px; } +.confirm-award-claim .md { + max-width: none; + font-size: 18px; +} + .trophy-table { width: 100%; } diff --git a/r2/r2/templates/awardreceived.html b/r2/r2/templates/awardreceived.html new file mode 100644 index 000000000..8d66d5c79 --- /dev/null +++ b/r2/r2/templates/awardreceived.html @@ -0,0 +1,34 @@ +## 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 reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### +<%namespace file="trophycase.html" import="trophy_info" /> + + +