diff --git a/r2/Makefile b/r2/Makefile index 885431292..90e849960 100644 --- a/r2/Makefile +++ b/r2/Makefile @@ -21,11 +21,14 @@ ################################################################################ # Jacascript files to be compressified -js_targets = jquery.js jquery.json.js jquery.reddit.js reddit.js +js_targets = jquery.js jquery.json.js jquery.reddit.js reddit.js ui.core.js ui.datepicker.js sponsored.js # CSS targets -css_targets = reddit.css reddit-ie6-hax.css reddit-ie7-hax.css mobile.css spreadshirt.css +main_css = reddit.css +css_targets = reddit-ie6-hax.css reddit-ie7-hax.css mobile.css spreadshirt.css SED=sed +CAT=cat +CSS_COMPRESS = $(SED) -e 's/ \+/ /' -e 's/\/\*.*\*\///g' -e 's/: /:/' | grep -v "^ *$$" package = r2 static_dir = $(package)/public/static @@ -41,7 +44,8 @@ PRIVATEREPOS = $(shell python -c 'exec "try: import r2admin; print r2admin.__pat JSTARGETS := $(foreach js, $(js_targets), $(static_dir)/$(js)) CSSTARGETS := $(foreach css, $(css_targets), $(static_dir)/$(css)) -RTLCSS = $(CSSTARGETS:.css=-rtl.css) +MAINCSS := $(foreach css, $(main_css), $(static_dir)/$(css)) +RTLCSS = $(CSSTARGETS:.css=-rtl.css) $(MAINCSS:.css=-rtl.css) MD5S = $(JSTARGETS:=.md5) $(CSSTARGETS:=.md5) @@ -67,10 +71,10 @@ $(JSTARGETS): $(static_dir)/%.js : $(static_dir)/js/%.js $(JSCOMPRESS) < $< > $@ $(CSSTARGETS): $(static_dir)/%.css : $(static_dir)/css/%.css - $(SED) -e 's/ \+/ /' \ - -e 's/\/\*.*\*\///g' \ - -e 's/: /:/' \ - $< | grep -v "^ *$$" > $@ + $(CAT) $< | $(CSS_COMPRESS) > $@ + +$(MAINCSS): $(static_dir)/%.css : $(static_dir)/css/%.css + python r2/lib/contrib/nymph.py $< | $(CSS_COMPRESS) > $@ $(RTLCSS): %-rtl.css : %.css $(SED) -e "s/left/>#### 2)): + item.editted = True + item._commit() tc.changed(item) @@ -591,7 +606,8 @@ class ApiController(RedditController): link = Link._byID(parent.link_id, data = True) parent_comment = parent sr = parent.subreddit_slow - if not sr.should_ratelimit(c.user, 'comment'): + if ((link.is_self and link.author_id == c.user._id) + or not sr.should_ratelimit(c.user, 'comment')): should_ratelimit = False #remove the ratelimit error if the user's karma is high @@ -616,12 +632,13 @@ class ApiController(RedditController): comment, ip) item.parent_id = parent._id else: - item, inbox_rel = Comment._new(c.user, link, parent_comment, - comment, ip) + item, inbox_rel = Comment._new(c.user, link, parent_comment, + comment, ip) Vote.vote(c.user, item, True, ip) - # flag search indexer that something has changed - tc.changed(item) - + + # will also update searchchanges as appropriate + worker.do(lambda: amqp.add_item('new_comment', item._fullname)) + #update last modified set_last_modified(c.user, 'overview') set_last_modified(c.user, 'commented') @@ -724,7 +741,7 @@ class ApiController(RedditController): def POST_vote(self, dir, thing, ip, vote_type): ip = request.ip user = c.user - if not thing: + if not thing or thing._deleted: return # TODO: temporary hack until we migrate the rest of the vote data @@ -732,6 +749,8 @@ class ApiController(RedditController): g.log.debug("POST_vote: ignoring old vote on %s" % thing._fullname) return + # in a lock to prevent duplicate votes from people + # double-clicking the arrows with g.make_lock('vote_lock(%s,%s)' % (c.user._id36, thing._id36)): dir = (True if dir > 0 else False if dir < 0 @@ -846,26 +865,30 @@ class ApiController(RedditController): return self.abort(403,'forbidden') c.site.del_image(name) c.site._commit() - + @validatedForm(VSrModerator(), - VModhash()) - def POST_delete_sr_header(self, form, jquery): + VModhash(), + sponsor = VInt("sponsor", min = 0, max = 1)) + def POST_delete_sr_header(self, form, jquery, sponsor): """ Called when the user request that the header on a sr be reset. """ # just in case we need to kill this feature from XSS if g.css_killswitch: return self.abort(403,'forbidden') - if c.site.header: + if sponsor and c.user_is_admin: + c.site.sponsorship_img = None + c.site._commit() + elif c.site.header: + # reset the header image on the page + jquery('#header-img').attr("src", DefaultSR.header) c.site.header = None c.site._commit() - # reset the header image on the page - form.find('#header-img').attr("src", DefaultSR.header) # hide the button which started this - form.find('#delete-img').hide() + form.find('.delete-img').hide() # hide the preview box - form.find('#img-preview-container').hide() + form.find('.img-preview-container').hide() # reset the status boxes form.set_html('.img-status', _("deleted")) @@ -885,8 +908,10 @@ class ApiController(RedditController): VModhash(), file = VLength('file', max_length=1024*500), name = VCssName("name"), - header = nop('header')) - def POST_upload_sr_img(self, file, header, name): + form_id = VLength('formid', max_length = 100), + header = VInt('header', max=1, min=0), + sponsor = VInt('sponsor', max=1, min=0)) + def POST_upload_sr_img(self, file, header, sponsor, name, form_id): """ Called on /about/stylesheet when an image needs to be replaced or uploaded, as well as on /about/edit for updating the @@ -909,13 +934,16 @@ class ApiController(RedditController): try: cleaned = cssfilter.clean_image(file,'PNG') if header: - num = None # there is one and only header, and it is unnumbered + # there is one and only header, and it is unnumbered + resource = None + elif sponsor and c.user_is_admin: + resource = "sponsor" elif not name: # error if the name wasn't specified or didn't satisfy # the validator errors['BAD_CSS_NAME'] = _("bad image name") else: - num = c.site.add_image(name, max_num = g.max_sr_images) + resource = c.site.add_image(name, max_num = g.max_sr_images) c.site._commit() except cssfilter.BadImage: @@ -931,15 +959,18 @@ class ApiController(RedditController): else: # with the image num, save the image an upload to s3. the # header image will be of the form "${c.site._fullname}.png" - # while any other image will be ${c.site._fullname}_${num}.png - new_url = cssfilter.save_sr_image(c.site, cleaned, num = num) + # while any other image will be ${c.site._fullname}_${resource}.png + new_url = cssfilter.save_sr_image(c.site, cleaned, + resource = resource) if header: c.site.header = new_url + elif sponsor and c.user_is_admin: + c.site.sponsorship_img = new_url c.site._commit() - + return UploadedImage(_('saved'), new_url, name, - errors = errors).render() - + errors = errors, form_id = form_id).render() + @validatedForm(VUser(), VModhash(), @@ -950,21 +981,27 @@ class ApiController(RedditController): name = VSubredditName("name"), title = VLength("title", max_length = 100), domain = VCnameDomain("domain"), - description = VLength("description", max_length = 500), + description = VLength("description", max_length = 1000), lang = VLang("lang"), over_18 = VBoolean('over_18'), show_media = VBoolean('show_media'), type = VOneOf('type', ('public', 'private', 'restricted')), ip = ValidIP(), + ad_type = VOneOf('ad', ('default', 'basic', 'custom')), + ad_file = VLength('ad-location', max_length = 500), + sponsor_name =VLength('sponsorship-name', max_length = 500), + sponsor_url = VLength('sponsorship-url', max_length = 500), + css_on_cname = VBoolean("css_on_cname"), ) - def POST_site_admin(self, form, jquery, name ='', ip = None, sr = None, **kw): + def POST_site_admin(self, form, jquery, name, ip, sr, ad_type, ad_file, + sponsor_url, sponsor_name, **kw): # the status button is outside the form -- have to reset by hand form.parent().set_html('.status', "") redir = False kw = dict((k, v) for k, v in kw.iteritems() if k in ('name', 'title', 'domain', 'description', 'over_18', - 'show_media', 'type', 'lang',)) + 'show_media', 'type', 'lang', "css_on_cname")) #if a user is banned, return rate-limit errors if c.user._spam: @@ -976,11 +1013,8 @@ class ApiController(RedditController): if cname_sr and (not sr or sr != cname_sr): c.errors.add(errors.USED_CNAME) - if not sr and form.has_errors(None, errors.RATELIMIT): - # this form is a little odd in that the error field - # doesn't occur within the form, so we need to manually - # set this text - form.parent().find('.RATELIMIT').html(c.errors[errors.RATELIMIT].message).show() + if not sr and form.has_errors("ratelimit", errors.RATELIMIT): + pass elif not sr and form.has_errors("name", errors.SUBREDDIT_EXISTS, errors.BAD_SR_NAME): form.find('#example_name').hide() @@ -996,13 +1030,17 @@ class ApiController(RedditController): #sending kw is ok because it was sanitized above sr = Subreddit._new(name = name, author_id = c.user._id, ip = ip, **kw) + + # will also update search + worker.do(lambda: amqp.add_item('new_subreddit', sr._fullname)) + Subreddit.subscribe_defaults(c.user) # make sure this user is on the admin list of that site! if sr.add_subscriber(c.user): sr._incr('_ups', 1) sr.add_moderator(c.user) sr.add_contributor(c.user) - redir = sr.path + "about/edit/?created=true" + redir = sr.path + "about/edit/?created=true" if not c.user_is_admin: VRatelimit.ratelimit(rate_user=True, rate_ip = True, @@ -1010,9 +1048,20 @@ class ApiController(RedditController): #editting an existing reddit elif sr.is_moderator(c.user) or c.user_is_admin: + + if c.user_is_admin: + sr.ad_type = ad_type + if ad_type != "custom": + ad_file = Subreddit._defaults['ad_file'] + sr.ad_file = ad_file + sr.sponsorship_url = sponsor_url or None + sr.sponsorship_name = sponsor_name or None + #assume sr existed, or was just built old_domain = sr.domain + if not sr.domain: + del kw['css_on_cname'] for k, v in kw.iteritems(): setattr(sr, k, v) sr._commit() @@ -1028,6 +1077,8 @@ class ApiController(RedditController): if redir: form.redirect(redir) + else: + jquery.refresh() @noresponse(VUser(), VModhash(), VSrCanBan('id'), @@ -1108,7 +1159,7 @@ class ApiController(RedditController): user = c.user if c.user_is_loggedin else None if not link or not link.subreddit_slow.can_view(user): return self.abort(403,'forbidden') - + if children: builder = CommentBuilder(link, CommentSortMenu.operator(sort), children) @@ -1124,7 +1175,7 @@ class ApiController(RedditController): cm.child = None else: items.append(cm.child) - + return items # assumes there is at least one child # a = _children(items[0].child.things) @@ -1191,10 +1242,11 @@ class ApiController(RedditController): return else: emailer.password_email(user) - form.set_html(".status", _("an email will be sent to that account's address shortly")) + form.set_html(".status", + _("an email will be sent to that account's address shortly")) - @validatedForm(cache_evt = VCacheKey('reset', ('key', 'name')), + @validatedForm(cache_evt = VCacheKey('reset', ('key',)), password = VPassword(['passwd', 'passwd2'])) def POST_resetpassword(self, form, jquery, cache_evt, password): if form.has_errors('name', errors.EXPIRED): @@ -1280,6 +1332,91 @@ class ApiController(RedditController): tr._is_enabled = True + @validatedForm(VAdmin(), + award = VByName("fullname"), + colliding_award=VAwardByCodename(("codename", "fullname")), + codename = VLength("codename", max_length = 100), + title = VLength("title", max_length = 100), + imgurl = VLength("imgurl", max_length = 1000)) + def POST_editaward(self, form, jquery, award, colliding_award, codename, + title, imgurl): + if form.has_errors(("codename", "title", "imgurl"), errors.NO_TEXT): + pass + + if form.has_errors(("codename"), errors.INVALID_OPTION): + form.set_html(".status", "some other award has that codename") + pass + + if form.has_error(): + return + + if award is None: + Award._new(codename, title, imgurl) + form.set_html(".status", "saved. reload to see it.") + return + + award.codename = codename + award.title = title + award.imgurl = imgurl + award._commit() + form.set_html(".status", _('saved')) + + @validatedForm(VAdmin(), + award = VByName("fullname"), + description = VLength("description", max_length=1000), + url = VLength("url", max_length=1000), + cup_hours = VFloat("cup_hours", + coerce=False, min=0, max=24 * 365), + recipient = VExistingUname("recipient")) + def POST_givetrophy(self, form, jquery, award, description, + url, cup_hours, recipient): + if form.has_errors("award", errors.NO_TEXT): + pass + + if form.has_errors("recipient", errors.USER_DOESNT_EXIST): + pass + + if form.has_errors("recipient", errors.NO_USER): + pass + + if form.has_errors("fullname", errors.NO_TEXT): + pass + + if form.has_errors("cup_hours", errors.BAD_NUMBER): + pass + + if form.has_error(): + return + + if cup_hours: + cup_seconds = int(cup_hours * 3600) + cup_expiration = timefromnow("%s seconds" % cup_seconds) + else: + cup_expiration = None + + t = Trophy._new(recipient, award, description=description, + url=url, cup_expiration=cup_expiration) + + form.set_html(".status", _('saved')) + + @validatedForm(VAdmin(), + account = VExistingUname("account")) + def POST_removecup(self, form, jquery, account): + if not account: + return self.abort404() + account.remove_cup() + + @validatedForm(VAdmin(), + trophy = VTrophy("trophy_fn")) + def POST_removetrophy(self, form, jquery, trophy): + if not trophy: + return self.abort404() + recipient = trophy._thing1 + award = trophy._thing2 + trophy._delete() + Trophy.by_account(recipient, _update=True) + Trophy.by_award(award, _update=True) + @validatedForm(links = VByName('links', thing_cls = Link, multiple = True), show = VByName('show', thing_cls = Link, multiple = False)) def POST_fetch_links(self, form, jquery, links, show): @@ -1300,113 +1437,6 @@ class ApiController(RedditController): setattr(c.user, "pref_" + ui_elem, False) c.user._commit() - @noresponse(VSponsor(), - thing = VByName('id')) - def POST_unpromote(self, thing): - if not thing: return - unpromote(thing) - - @validatedForm(VSponsor(), - ValidDomain('url'), - ip = ValidIP(), - l = VLink('link_id'), - title = VTitle('title'), - url = VUrl(['url', 'sr'], allow_self = False), - sr = VSubmitSR('sr'), - subscribers_only = VBoolean('subscribers_only'), - disable_comments = VBoolean('disable_comments'), - expire = VOneOf('expire', ['nomodify', - 'expirein', 'cancel']), - timelimitlength = VInt('timelimitlength',1,1000), - timelimittype = VOneOf('timelimittype', - ['hours','days','weeks'])) - def POST_edit_promo(self, form, jquery, ip, - title, url, sr, subscribers_only, - disable_comments, - expire = None, - timelimitlength = None, timelimittype = None, - l = None): - if isinstance(url, str): - # VUrl may have modified the URL to make it valid, like - # adding http:// - form.set_input('url', url) - elif isinstance(url, tuple) and isinstance(url[0], Link): - # there's already one or more links with this URL, but - # we're allowing mutliple submissions, so we really just - # want the URL - url = url[0].url - if form.has_errors('title', errors.NO_TEXT, errors.TOO_LONG): - pass - elif form.has_errors('url', errors.NO_URL, errors.BAD_URL): - pass - elif ( (not l or url != l.url) and - form.has_errors('url', errors.NO_URL, errors.ALREADY_SUB) ): - #if url == l.url, we're just editting something else - pass - elif form.has_errors('sr', errors.SUBREDDIT_NOEXIST, - errors.SUBREDDIT_NOTALLOWED): - pass - elif (expire == 'expirein' and - form.has_errors('timelimitlength', errors.BAD_NUMBER)): - pass - elif l: - l.title = title - old_url = l.url - l.url = url - l.is_self = False - - l.promoted_subscribersonly = subscribers_only - l.disable_comments = disable_comments - - if expire == 'cancel': - l.promote_until = None - elif expire == 'expirein' and timelimitlength and timelimittype: - l.promote_until = timefromnow("%d %s" % (timelimitlength, - timelimittype)) - l._commit() - l.update_url_cache(old_url) - - form.redirect('/promote/edit_promo/%s' % to36(l._id)) - else: - l = Link._submit(title, url, c.user, sr, ip) - - if expire == 'expirein' and timelimitlength and timelimittype: - promote_until = timefromnow("%d %s" % (timelimitlength, - timelimittype)) - else: - promote_until = None - - l._commit() - - promote(l, subscribers_only = subscribers_only, - promote_until = promote_until, - disable_comments = disable_comments) - - form.redirect('/promote/edit_promo/%s' % to36(l._id)) - - def GET_link_thumb(self, *a, **kw): - """ - See GET_upload_sr_image for rationale - """ - return "nothing to see here." - - @validate(VSponsor(), - link = VByName('link_id'), - file = VLength('file', 500*1024)) - def POST_link_thumb(self, link=None, file=None): - errors = dict(BAD_CSS_NAME = "", IMAGE_ERROR = "") - try: - force_thumbnail(link, file) - except cssfilter.BadImage: - # if the image doesn't clean up nicely, abort - errors["IMAGE_ERROR"] = _("bad image") - - if any(errors.values()): - return UploadedImage("", "", "upload", errors = errors).render() - else: - return UploadedImage(_('saved'), thumbnail_url(link), "", - errors = errors).render() - @validatedForm(type = VOneOf('type', ('click'), default = 'click'), links = VByName('ids', thing_cls = Link, multiple = True)) def GET_gadget(self, form, jquery, type, links): @@ -1434,26 +1464,33 @@ class ApiController(RedditController): c.user.pref_frame_commentspanel = False c.user._commit() - @validatedForm(promoted = VByName('ids', thing_cls = Link, multiple = True)) - def POST_onload(self, form, jquery, promoted, *a, **kw): - if not promoted: - return - - # make sure that they are really promoted - promoted = [ l for l in promoted if l.promoted ] - - for l in promoted: - dest = l.url - + @validatedForm(promoted = VByName('ids', thing_cls = Link, + multiple = True), + sponsorships = VByName('ids', thing_cls = Subreddit, + multiple = True)) + def POST_onload(self, form, jquery, promoted, sponsorships, *a, **kw): + def add_tracker(dest, where, what): jquery.set_tracker( - l._fullname, - tracking.PromotedLinkInfo.gen_url(fullname=l._fullname, + where, + tracking.PromotedLinkInfo.gen_url(fullname=what, ip = request.ip), - tracking.PromotedLinkClickInfo.gen_url(fullname = l._fullname, + tracking.PromotedLinkClickInfo.gen_url(fullname = what, dest = dest, ip = request.ip) ) + if promoted: + # make sure that they are really promoted + promoted = [ l for l in promoted if l.promoted ] + for l in promoted: + add_tracker(l.url, l._fullname, l._fullname) + + if sponsorships: + for s in sponsorships: + add_tracker(s.sponsorship_url, s._fullname, + "%s_%s" % (s._fullname, s.sponsorship_name)) + + @json_validate(query = nop('query')) def POST_search_reddit_names(self, query): names = [] @@ -1469,7 +1506,7 @@ class ApiController(RedditController): wrapped = wrap_links(link) wrapped = list(wrapped)[0] - return spaceCompress(websafe(wrapped.link_child.content())) + return websafe(spaceCompress(wrapped.link_child.content())) @validatedForm(link = VByName('name', thing_cls = Link, multiple = False), color = VOneOf('color', spreadshirt.ShirtPane.colors), diff --git a/r2/r2/controllers/awards.py b/r2/r2/controllers/awards.py new file mode 100644 index 000000000..b4f68aafa --- /dev/null +++ b/r2/r2/controllers/awards.py @@ -0,0 +1,54 @@ +# 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 CondeNet, Inc. +# +# All portions of the code written by CondeNet are Copyright (c) 2006-2009 +# CondeNet, Inc. All Rights Reserved. +################################################################################ +from pylons import request, g +from reddit_base import RedditController +from r2.lib.pages import AdminPage, AdminAwards +from r2.lib.pages import AdminAwardGive, AdminAwardWinners +from validator import * + +class AwardsController(RedditController): + + @validate(VAdmin()) + def GET_index(self): + res = AdminPage(content = AdminAwards(), + title = 'awards').render() + return res + + @validate(VAdmin(), + award = VAwardByCodename('awardcn')) + def GET_give(self, award): + if award is None: + abort(404, 'page not found') + + res = AdminPage(content = AdminAwardGive(award), + title='give an award').render() + return res + + @validate(VAdmin(), + award = VAwardByCodename('awardcn')) + def GET_winners(self, award): + if award is None: + abort(404, 'page not found') + + res = AdminPage(content = AdminAwardWinners(award), + title='award winners').render() + return res diff --git a/r2/r2/controllers/embed.py b/r2/r2/controllers/embed.py index 335b908f3..1fc4ec83e 100644 --- a/r2/r2/controllers/embed.py +++ b/r2/r2/controllers/embed.py @@ -44,16 +44,18 @@ class EmbedController(RedditController): # Add "edit this page" link if the user is allowed to edit the wiki if c.user_is_loggedin and c.user.can_wiki(): edit_text = _('edit this page') - read_first = _('read this first') + yes_you_can = _("yes, it's okay!") + read_first = _('just read this first.') url = "http://code.reddit.com/wiki" + websafe(fp) + "?action=edit" edittag = """
- """ % (url, edit_text, read_first) + """ % (url, edit_text, yes_you_can, read_first) output.append(edittag) @@ -69,6 +71,8 @@ class EmbedController(RedditController): fp = request.path.rstrip("/") u = "http://code.reddit.com/wiki" + fp + '?stripped=1' + g.log.debug("Pulling %s for help" % u) + try: content = proxyurl(u) return self.rendercontent(content, fp) diff --git a/r2/r2/controllers/error.py b/r2/r2/controllers/error.py index 5d591981b..6a818330d 100644 --- a/r2/r2/controllers/error.py +++ b/r2/r2/controllers/error.py @@ -33,6 +33,7 @@ try: # the stack trace won't be presented to the user in production from reddit_base import RedditController from r2.models.subreddit import Default, Subreddit + from r2.models.link import Link from r2.lib import pages from r2.lib.strings import strings, rand_strings except Exception, e: @@ -45,7 +46,7 @@ except Exception, e: # kill this app import os os._exit(1) - + redditbroke = \ ''' @@ -131,10 +132,14 @@ class ErrorController(RedditController): code = request.GET.get('code', '') srname = request.GET.get('srname', '') + takedown = request.GET.get('takedown', "") if srname: c.site = Subreddit._by_name(srname) if c.render_style not in self.allowed_render_styles: return str(code) + elif takedown and code == '404': + link = Link._by_fullname(takedown) + return pages.TakedownPage(link).render() elif code == '403': return self.send403() elif code == '500': diff --git a/r2/r2/controllers/errors.py b/r2/r2/controllers/errors.py index aa730268b..8791ae9bb 100644 --- a/r2/r2/controllers/errors.py +++ b/r2/r2/controllers/errors.py @@ -25,6 +25,7 @@ from copy import copy error_list = dict(( ('USER_REQUIRED', _("please login to do that")), + ('VERIFIED_USER_REQUIRED', _("you need to set a valid email address to do that.")), ('NO_URL', _('a url is required')), ('BAD_URL', _('you should check that url')), ('BAD_CAPTCHA', _('care to try these again?')), @@ -45,7 +46,8 @@ error_list = dict(( ('USER_DOESNT_EXIST', _("that user doesn't exist")), ('NO_USER', _('please enter a username')), ('INVALID_PREF', "that preference isn't valid"), - ('BAD_NUMBER', _("that number isn't in the right range")), + ('BAD_NUMBER', _("that number isn't in the right range (%(min)d to %(max)d)")), + ('BAD_BID', _("your bid must be at least $%(min)d per day and no more than to $%(max)d in total.")), ('ALREADY_SUB', _("that link has already been submitted")), ('SUBREDDIT_EXISTS', _('that reddit already exists')), ('SUBREDDIT_NOEXIST', _('that reddit doesn\'t exist')), @@ -64,7 +66,12 @@ error_list = dict(( ('BAD_EMAILS', _('the following emails are invalid: %(emails)s')), ('NO_EMAILS', _('please enter at least one email address')), ('TOO_MANY_EMAILS', _('please only share to %(num)s emails at a time.')), - + ('BAD_DATE', _('please provide a date of the form mm/dd/yyyy')), + ('BAD_DATE_RANGE', _('the dates need to be in order and not identical')), + ('BAD_FUTURE_DATE', _('please enter a date at least %(day)s days in the future')), + ('BAD_PAST_DATE', _('please enter a date at least %(day)s days in the past')), + ('BAD_ADDRESS', _('address problem: %(message)s')), + ('BAD_CARD', _('card problem: %(message)s')), ('TOO_LONG', _("this is too long (max: %(max_length)s)")), ('NO_TEXT', _('we need something here')), )) @@ -123,3 +130,4 @@ class ErrorSet(object): del self.errors[pair] class UserRequiredException(Exception): pass +class VerifiedUserRequiredException(Exception): pass diff --git a/r2/r2/controllers/feedback.py b/r2/r2/controllers/feedback.py index fb123289a..0f27e1b06 100644 --- a/r2/r2/controllers/feedback.py +++ b/r2/r2/controllers/feedback.py @@ -22,20 +22,26 @@ from reddit_base import RedditController from pylons import c, request from pylons.i18n import _ -from r2.lib.pages import FormPage, Feedback, Captcha +from r2.lib.pages import FormPage, Feedback, Captcha, PaneStack, SelfServeBlurb class FeedbackController(RedditController): def GET_ad_inq(self): title = _("inquire about advertising on reddit") return FormPage('advertise', - content = Feedback(title=title, - action='ad_inq'), + content = PaneStack([SelfServeBlurb(), + Feedback(title=title, + action='ad_inq')]), loginbox = False).render() def GET_feedback(self): title = _("send reddit feedback") return FormPage('feedback', - content = Feedback(title=title, - action='feedback'), + content = Feedback(title=title, action='feedback'), + loginbox = False).render() + + def GET_i18n(self): + title = _("help translate reddit into your language") + return FormPage('help translate', + content = Feedback(title=title, action='i18n'), loginbox = False).render() diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index a0aa8a5a2..1d0f2eb69 100644 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -102,7 +102,46 @@ class FrontController(RedditController): """The 'what is my password' page""" return BoringPage(_("password"), content=Password()).render() - @validate(cache_evt = VCacheKey('reset', ('key', 'name')), + @validate(VUser(), + dest = VDestination()) + def GET_verify(self, dest): + if c.user.email_verified: + content = InfoBar(message = strings.email_verified) + if dest: + return self.redirect(dest) + else: + content = PaneStack( + [InfoBar(message = strings.verify_email), + PrefUpdate(email = True, verify = True, + password = False)]) + return BoringPage(_("verify email"), content = content).render() + + @validate(VUser(), + cache_evt = VCacheKey('email_verify', ('key',)), + key = nop('key'), + dest = VDestination(default = "/prefs/update")) + def GET_verify_email(self, cache_evt, key, dest): + if c.user_is_loggedin and c.user.email_verified: + cache_evt.clear() + return self.redirect(dest) + elif not (cache_evt.user and + key == passhash(cache_evt.user.name, cache_evt.user.email)): + content = PaneStack( + [InfoBar(message = strings.email_verify_failed), + PrefUpdate(email = True, verify = True, + password = False)]) + return BoringPage(_("verify email"), content = content).render() + elif c.user != cache_evt.user: + # wrong user. Log them out and try again. + self.logout() + return self.redirect(request.fullpath) + else: + cache_evt.clear() + c.user.email_verified = True + c.user._commit() + return self.redirect(dest) + + @validate(cache_evt = VCacheKey('reset', ('key',)), key = nop('key')) def GET_resetpassword(self, cache_evt, key): """page hit once a user has been sent a password reset email @@ -129,11 +168,13 @@ class FrontController(RedditController): """The (now depricated) details page. Content on this page has been subsubmed by the presence of the LinkInfoBar on the rightbox, so it is only useful for Admin-only wizardry.""" - return DetailsPage(link = article).render() - + return DetailsPage(link = article, expand_children=False).render() + @validate(article = VLink('article')) def GET_shirt(self, article): + if not can_view_link_comments(article): + abort(403, 'forbidden') if g.spreadshirt_url: from r2.lib.spreadshirt import ShirtPage return ShirtPage(link = article).render() @@ -147,12 +188,18 @@ class FrontController(RedditController): def GET_comments(self, article, comment, context, sort, num_comments): """Comment page for a given 'article'.""" if comment and comment.link_id != article._id: - return self.abort404() - - if not c.default_sr and c.site._id != article.sr_id: return self.abort404() - - if not article.subreddit_slow.can_view(c.user): + + sr = Subreddit._byID(article.sr_id, True) + + if sr.name == g.takedown_sr: + request.environ['REDDIT_TAKEDOWN'] = article._fullname + return self.abort404() + + if not c.default_sr and c.site._id != sr._id: + return self.abort404() + + if not can_view_link_comments(article): abort(403, 'forbidden') #check for 304 @@ -188,8 +235,7 @@ class FrontController(RedditController): displayPane.append(PermalinkMessage(article.make_permalink_slow())) # insert reply box only for logged in user - if c.user_is_loggedin and article.subreddit_slow.can_comment(c.user)\ - and not is_api(): + if c.user_is_loggedin and can_comment_link(article) and not is_api(): #no comment box for permalinks displayPane.append(UserText(item = article, creating = True, post_form = 'comment', @@ -200,7 +246,7 @@ class FrontController(RedditController): displayPane.append(listing.listing()) loc = None if c.focal_comment or context is not None else 'comments' - + res = LinkInfoPage(link = article, comment = comment, content = displayPane, subtitle = _("comments"), @@ -300,10 +346,10 @@ class FrontController(RedditController): return self.abort404() return EditReddit(content = pane).render() - - def GET_stats(self): - """The stats page.""" - return BoringPage(_("stats"), content = UserStats()).render() + + def GET_awards(self): + """The awards page.""" + return BoringPage(_("awards"), content = UserAwards()).render() # filter for removing punctuation which could be interpreted as lucene syntax related_replace_regex = re.compile('[?\\&|!{}+~^()":*-]+') @@ -315,6 +361,9 @@ class FrontController(RedditController): """Related page: performs a search using title of article as the search query.""" + if not can_view_link_comments(article): + abort(403, 'forbidden') + title = c.site.name + ((': ' + article.title) if hasattr(article, 'title') else '') query = self.related_replace_regex.sub(self.related_replace_with, @@ -335,8 +384,10 @@ class FrontController(RedditController): @base_listing @validate(article = VLink('article')) def GET_duplicates(self, article, num, after, reverse, count): - links = link_duplicates(article) + if not can_view_link_comments(article): + abort(403, 'forbidden') + links = link_duplicates(article) builder = IDBuilder([ link._fullname for link in links ], num = num, after = after, reverse = reverse, count = count, skip = False) @@ -492,6 +543,12 @@ class FrontController(RedditController): c.response.content = '' return c.response + @validate(VAdmin(), + comment = VCommentByID('comment_id')) + def GET_comment_by_id(self, comment): + href = comment.make_permalink_slow(context=5, anchor=True) + return self.redirect(href) + @validate(VUser(), VSRSubmitPage(), url = VRequired('url', None), @@ -609,15 +666,23 @@ class FrontController(RedditController): return self.abort404() - @validate(VSponsor(), + @validate(VTrafficViewer('article'), article = VLink('article')) def GET_traffic(self, article): - res = LinkInfoPage(link = article, + content = PromotedTraffic(article) + if c.render_style == 'csv': + c.response.content = content.as_csv() + return c.response + + return LinkInfoPage(link = article, comment = None, - content = PromotedTraffic(article)).render() - return res - + content = content).render() + @validate(VAdmin()) def GET_site_traffic(self): return BoringPage("traffic", content = RedditTraffic()).render() + + + def GET_ad(self, reddit = None): + return Dart_Ad(reddit).render(style="html") diff --git a/r2/r2/controllers/health.py b/r2/r2/controllers/health.py new file mode 100644 index 000000000..7c8a041df --- /dev/null +++ b/r2/r2/controllers/health.py @@ -0,0 +1,52 @@ +from threading import Thread +import os +import time + +from pylons.controllers.util import abort +from pylons import c, g + +from reddit_base import RedditController +from r2.lib.utils import worker + +class HealthController(RedditController): + def shutdown(self): + thread_pool = c.thread_pool + def _shutdown(): + #give busy threads 30 seconds to finish up + for s in xrange(30): + busy = thread_pool.track_threads()['busy'] + if not busy: + break + time.sleep(1) + + thread_pool.shutdown() + worker.join() + os._exit(3) + + t = Thread(target = _shutdown) + t.setDaemon(True) + t.start() + + def GET_health(self): + c.dontcache = True + + if g.shutdown: + if g.shutdown == 'init': + self.shutdown() + g.shutdown = 'shutdown' + abort(503, 'service temporarily unavailable') + else: + c.response_content_type = 'text/plain' + c.response.content = "i'm still alive!" + return c.response + + def GET_shutdown(self): + if not g.allow_shutdown: + self.abort404() + + c.dontcache = True + #the will make the next health-check initiate the shutdown + g.shutdown = 'init' + c.response_content_type = 'text/plain' + c.response.content = 'shutting down...' + return c.response diff --git a/r2/r2/controllers/listingcontroller.py b/r2/r2/controllers/listingcontroller.py index c16c4f8e7..a41061b7b 100644 --- a/r2/r2/controllers/listingcontroller.py +++ b/r2/r2/controllers/listingcontroller.py @@ -38,6 +38,7 @@ from r2.lib.jsontemplates import is_api from r2.lib.solrsearch import SearchQuery from r2.lib.utils import iters, check_cheating, timeago from r2.lib import sup +from r2.lib.promote import PromoteSR from admin import admin_profile_query @@ -97,7 +98,6 @@ class ListingController(RedditController): show_sidebar = self.show_sidebar, nav_menus = self.menus, title = self.title(), - infotext = self.infotext, **self.render_params).render() return res @@ -135,10 +135,17 @@ class ListingController(RedditController): return b def keep_fn(self): - return None + def keep(item): + wouldkeep = item.keep_item(item) + if getattr(item, "promoted", None) is not None: + return False + return wouldkeep + return keep def listing(self): """Listing to generate from the builder""" + if c.site.path == PromoteSR.path and not c.user_is_sponsor: + abort(403, 'forbidden') listing = LinkListing(self.builder_obj, show_nums = self.show_nums) return listing.listing() @@ -189,9 +196,12 @@ class HotController(FixListing, ListingController): if o_links: # get links in proximity to pos l = min(len(o_links) - 3, 8) - disp_links = [o_links[(i + pos) % len(o_links)] for i in xrange(-2, l)] - - b = IDBuilder(disp_links, wrap = self.builder_wrapper) + disp_links = [o_links[(i + pos) % len(o_links)] + for i in xrange(-2, l)] + def keep_fn(item): + return item.likes is None and item.keep_item(item) + b = IDBuilder(disp_links, wrap = self.builder_wrapper, + skip = True, keep_fn = keep_fn) o = OrganicListing(b, org_links = o_links, visible_link = o_links[pos], @@ -276,7 +286,10 @@ class NewController(ListingController): for things like the spam filter and thumbnail fetcher to act on them before releasing them into the wild""" wouldkeep = item.keep_item(item) - if c.user_is_loggedin and (c.user_is_admin or item.subreddit.is_moderator(c.user)): + if item.promoted is not None: + return False + elif c.user_is_loggedin and (c.user_is_admin or + item.subreddit.is_moderator(c.user)): # let admins and moderators see them regardless return wouldkeep elif wouldkeep and c.user_is_loggedin and c.user._id == item.author_id: @@ -379,7 +392,6 @@ class RecommendedController(ListingController): class UserController(ListingController): render_cls = ProfilePage - skip = False show_nums = False def title(self): @@ -393,6 +405,14 @@ class UserController(ListingController): % dict(user = self.vuser.name, site = c.site.name) return title + # TODO: this might not be the place to do this + skip = True + def keep_fn(self): + # keep promotions off of profile pages. + def keep(item): + return getattr(item, "promoted", None) is None + return keep + def query(self): q = None if self.where == 'overview': @@ -475,7 +495,6 @@ class MessageController(ListingController): w = Wrapped(thing) w.render_class = Message w.to_id = c.user._id - w.subject = _('comment reply') w.was_comment = True w.permalink, w._fullname = p, f return w diff --git a/r2/r2/controllers/post.py b/r2/r2/controllers/post.py index 83799e2d6..c9eee9d28 100644 --- a/r2/r2/controllers/post.py +++ b/r2/r2/controllers/post.py @@ -26,6 +26,7 @@ from r2.lib.emailer import opt_in, opt_out from pylons import request, c, g from validator import * from pylons.i18n import _ +from r2.models import * import sha def to_referer(func, **params): @@ -37,7 +38,7 @@ def to_referer(func, **params): class PostController(ApiController): - def response_func(self, kw): + def api_wrapper(self, kw): return Storage(**kw) #TODO: feature disabled for now @@ -103,11 +104,16 @@ class PostController(ApiController): pref_num_comments = VInt('num_comments', 1, g.max_comments, default = g.num_comments), pref_show_stylesheets = VBoolean('show_stylesheets'), + pref_show_promote = VBoolean('show_promote'), all_langs = nop('all-langs', default = 'all')) def POST_options(self, all_langs, pref_lang, **kw): #temporary. eventually we'll change pref_clickgadget to an #integer preference kw['pref_clickgadget'] = kw['pref_clickgadget'] and 5 or 0 + if c.user.pref_show_promote is None: + kw['pref_show_promote'] = None + elif not kw.get('pref_show_promote'): + kw['pref_show_promote'] = False self.set_options(all_langs, pref_lang, **kw) u = UrlParser(c.site.path + "prefs") @@ -115,14 +121,14 @@ class PostController(ApiController): if c.cname: u.put_in_frame() return self.redirect(u.unparse()) - + def GET_over18(self): return BoringPage(_("over 18?"), content = Over18()).render() @validate(over18 = nop('over18'), uh = nop('uh'), - dest = nop('dest')) + dest = VDestination(default = '/')) def POST_over18(self, over18, uh, dest): if over18 == 'yes': if c.user_is_loggedin and c.user.valid_hash(uh): @@ -199,3 +205,4 @@ class PostController(ApiController): def GET_login(self, *a, **kw): return self.redirect('/login' + query_string(dict(dest="/"))) + diff --git a/r2/r2/controllers/promotecontroller.py b/r2/r2/controllers/promotecontroller.py index 21653d9a2..e2c320052 100644 --- a/r2/r2/controllers/promotecontroller.py +++ b/r2/r2/controllers/promotecontroller.py @@ -22,6 +22,7 @@ from validator import * from pylons.i18n import _ from r2.models import * +from r2.lib.authorize import get_account_info, edit_profile from r2.lib.pages import * from r2.lib.pages.things import wrap_links from r2.lib.menus import * @@ -29,48 +30,413 @@ from r2.controllers import ListingController from r2.controllers.reddit_base import RedditController -from r2.lib.promote import get_promoted +from r2.lib.promote import get_promoted, STATUS, PromoteSR from r2.lib.utils import timetext - +from r2.lib.media import force_thumbnail, thumbnail_url +from r2.lib import cssfilter from datetime import datetime -class PromoteController(RedditController): - @validate(VSponsor()) - def GET_index(self): - return self.GET_current_promos() +class PromoteController(ListingController): + skip = False + where = 'promoted' + render_cls = PromotePage - @validate(VSponsor()) - def GET_current_promos(self): - render_list = list(wrap_links(get_promoted())) - for x in render_list: - if x.promote_until: - x.promote_expires = timetext(datetime.now(g.tz) - x.promote_until) - page = PromotePage('current_promos', - content = PromotedLinks(render_list)) + @property + def title_text(self): + return _('promoted by you') + + def query(self): + q = Link._query(Link.c.sr_id == PromoteSR._id) + if not c.user_is_sponsor: + # get user's own promotions + q._filter(Link.c.author_id == c.user._id) + q._filter(Link.c._spam == (True, False), + Link.c.promoted == (True, False)) + q._sort = desc('_date') + + if self.sort == "future_promos": + q._filter(Link.c.promote_status == STATUS.unseen) + elif self.sort == "pending_promos": + if c.user_is_admin: + q._filter(Link.c.promote_status == STATUS.pending) + else: + q._filter(Link.c.promote_status == (STATUS.unpaid, + STATUS.unseen, + STATUS.accepted, + STATUS.rejected)) + elif self.sort == "unpaid_promos": + q._filter(Link.c.promote_status == STATUS.unpaid) + elif self.sort == "live_promos": + q._filter(Link.c.promote_status == STATUS.promoted) + + return q + + @validate(VPaidSponsor(), + VVerifiedUser()) + def GET_listing(self, sort = "", **env): + self.sort = sort + return ListingController.GET_listing(self, **env) + + GET_index = GET_listing - return page.render() - - @validate(VSponsor()) + # To open up: VSponsor -> VVerifiedUser + @validate(VPaidSponsor(), + VVerifiedUser()) def GET_new_promo(self): - page = PromotePage('new_promo', - content = PromoteLinkForm()) - return page.render() + return PromotePage('content', content = PromoteLinkForm()).render() - @validate(VSponsor(), + @validate(VSponsor('link'), link = VLink('link')) def GET_edit_promo(self, link): - sr = Subreddit._byID(link.sr_id) - listing = wrap_links(link) - + if link.promoted is None: + return self.abort404() + rendered = wrap_links(link) timedeltatext = '' if link.promote_until: timedeltatext = timetext(link.promote_until - datetime.now(g.tz), resultion=2) - form = PromoteLinkForm(sr = sr, link = link, - listing = listing, + form = PromoteLinkForm(link = link, + listing = rendered, timedeltatext = timedeltatext) page = PromotePage('new_promo', content = form) return page.render() + @validate(VPaidSponsor(), + VVerifiedUser()) + def GET_graph(self): + content = Promote_Graph() + if c.user_is_sponsor and c.render_style == 'csv': + c.response.content = content.as_csv() + return c.response + return PromotePage("grpaph", content = content).render() + + + ### POST controllers below + @validatedForm(VSponsor(), + link = VByName("link"), + bid = VBid('bid', "link")) + def POST_freebie(self, form, jquery, link, bid): + if link and link.promoted is not None and bid: + promote.auth_paid_promo(link, c.user, -1, bid) + jquery.refresh() + + @validatedForm(VSponsor(), + link = VByName("link"), + note = nop("note")) + def POST_promote_note(self, form, jquery, link, note): + if link and link.promoted is not None: + form.find(".notes").children(":last").after( + "" + promote.promotion_log(link, note, True) + "
") + + + @validatedForm(VSponsor(), + link = VByName("link"), + refund = VFloat("refund")) + def POST_refund(self, form, jquery, link, refund): + if link: + # make sure we don't refund more than we should + author = Account._byID(link.author_id) + promote.refund_promo(link, author, refund) + jquery.refresh() + + @noresponse(VSponsor(), + thing = VByName('id')) + def POST_promote(self, thing): + if thing: + now = datetime.now(g.tz) + # make accepted if unseen or already rejected + if thing.promote_status in (promote.STATUS.unseen, + promote.STATUS.rejected): + promote.accept_promo(thing) + # if not finished and the dates are current + elif (thing.promote_status < promote.STATUS.finished and + thing._date <= now and thing.promote_until > now): + # if already pending, cron job must have failed. Promote. + if thing.promote_status == promote.STATUS.accepted: + promote.pending_promo(thing) + promote.promote(thing) + + @noresponse(VSponsor(), + thing = VByName('id'), + reason = nop("reason")) + def POST_unpromote(self, thing, reason): + if thing: + if (c.user_is_sponsor and + (thing.promote_status in (promote.STATUS.unpaid, + promote.STATUS.unseen, + promote.STATUS.accepted, + promote.STATUS.promoted)) ): + promote.reject_promo(thing, reason = reason) + else: + promote.unpromote(thing) + + # TODO: when opening up, may have to refactor + @validatedForm(VPaidSponsor('link_id'), + VModhash(), + VRatelimit(rate_user = True, + rate_ip = True, + prefix = 'create_promo_'), + ip = ValidIP(), + l = VLink('link_id'), + title = VTitle('title'), + url = VUrl('url', allow_self = False), + dates = VDateRange(['startdate', 'enddate'], + future = g.min_promote_future, + reference_date = promote.promo_datetime_now, + business_days = True, + admin_override = True), + disable_comments = VBoolean("disable_comments"), + set_clicks = VBoolean("set_maximum_clicks"), + max_clicks = VInt("maximum_clicks", min = 0), + set_views = VBoolean("set_maximum_views"), + max_views = VInt("maximum_views", min = 0), + bid = VBid('bid', 'link_id')) + def POST_new_promo(self, form, jquery, l, ip, title, url, dates, + disable_comments, + set_clicks, max_clicks, set_views, max_views, bid): + should_ratelimit = False + if not c.user_is_sponsor: + set_clicks = False + set_views = False + should_ratelimit = True + if not set_clicks: + max_clicks = None + if not set_views: + max_views = None + + if not should_ratelimit: + c.errors.remove((errors.RATELIMIT, 'ratelimit')) + + # demangle URL in canonical way + if url: + if isinstance(url, (unicode, str)): + form.set_inputs(url = url) + elif isinstance(url, tuple) or isinstance(url[0], Link): + # there's already one or more links with this URL, but + # we're allowing mutliple submissions, so we really just + # want the URL + url = url[0].url + + # check dates and date range + start, end = [x.date() for x in dates] if dates else (None, None) + if not l or (l._date.date(), l.promote_until.date()) == (start,end): + if (form.has_errors('startdate', errors.BAD_DATE, + errors.BAD_FUTURE_DATE) or + form.has_errors('enddate', errors.BAD_DATE, + errors.BAD_FUTURE_DATE, errors.BAD_DATE_RANGE)): + return + + # dates have been validated at this point. Next validate title, etc. + if (form.has_errors('title', errors.NO_TEXT, + errors.TOO_LONG) or + form.has_errors('url', errors.NO_URL, errors.BAD_URL) or + form.has_errors('bid', errors.BAD_BID) or + (not l and jquery.has_errors('ratelimit', errors.RATELIMIT))): + return + elif l: + if l.promote_status == promote.STATUS.finished: + form.parent().set_html(".status", + _("that promoted link is already finished.")) + else: + # we won't penalize for changes of dates provided + # the submission isn't pending (or promoted, or + # finished) + changed = False + if dates and not promote.update_promo_dates(l, *dates): + form.parent().set_html(".status", + _("too late to change the date.")) + else: + changed = True + + # check for changes in the url and title + if promote.update_promo_data(l, title, url): + changed = True + # sponsors can change the bid value (at the expense of making + # the promotion a freebie) + if c.user_is_sponsor and bid != l.promote_bid: + promote.auth_paid_promo(l, c.user, -1, bid) + promote.accept_promo(l) + changed = True + + if c.user_is_sponsor: + l.maximum_clicks = max_clicks + l.maximum_views = max_views + changed = True + + l.disable_comments = disable_comments + l._commit() + + if changed: + jquery.refresh() + + # no link so we are creating a new promotion + elif dates: + promote_start, promote_end = dates + # check that the bid satisfies the minimum + duration = max((promote_end - promote_start).days, 1) + if bid / duration >= g.min_promote_bid: + l = promote.new_promotion(title, url, c.user, ip, + promote_start, promote_end, bid, + disable_comments = disable_comments, + max_clicks = max_clicks, + max_views = max_views) + # if the submitter is a sponsor (or implicitly an admin) we can + # fast-track the approval and auto-accept the bid + if c.user_is_sponsor: + promote.auth_paid_promo(l, c.user, -1, bid) + promote.accept_promo(l) + + # register a vote + v = Vote.vote(c.user, l, True, ip) + + # set the rate limiter + if should_ratelimit: + VRatelimit.ratelimit(rate_user=True, rate_ip = True, + prefix = "create_promo_", + seconds = 60) + + form.redirect(promote.promo_edit_url(l)) + else: + c.errors.add(errors.BAD_BID, + msg_params = dict(min=g.min_promote_bid, + max=g.max_promote_bid), + field = 'bid') + form.set_error(errors.BAD_BID, "bid") + + @validatedForm(VSponsor('container'), + VModhash(), + user = VExistingUname('name'), + thing = VByName('container')) + def POST_traffic_viewer(self, form, jquery, user, thing): + """ + Adds a user to the list of users allowed to view a promoted + link's traffic page. + """ + if not form.has_errors("name", + errors.USER_DOESNT_EXIST, errors.NO_USER): + form.set_inputs(name = "") + form.set_html(".status:first", _("added")) + if promote.add_traffic_viewer(thing, user): + user_row = TrafficViewerList(thing).user_row(user) + jquery("#traffic-table").show( + ).find("table").insert_table_rows(user_row) + + # send the user a message + msg = strings.msg_add_friend.get("traffic") + subj = strings.subj_add_friend.get("traffic") + if msg and subj: + d = dict(url = thing.make_permalink_slow(), + traffic_url = promote.promo_traffic_url(thing), + title = thing.title) + msg = msg % d + subk =msg % d + item, inbox_rel = Message._new(c.user, user, + subj, msg, request.ip) + if g.write_query_queue: + queries.new_message(item, inbox_rel) + + + @validatedForm(VSponsor('container'), + VModhash(), + iuser = VByName('id'), + thing = VByName('container')) + def POST_rm_traffic_viewer(self, form, jquery, iuser, thing): + if thing and iuser: + promote.rm_traffic_viewer(thing, iuser) + + + @validatedForm(VSponsor('link'), + link = VByName("link"), + customer_id = VInt("customer_id", min = 0), + bid = VBid("bid", "link"), + pay_id = VInt("account", min = 0), + edit = VBoolean("edit"), + address = ValidAddress(["firstName", "lastName", + "company", "address", + "city", "state", "zip", + "country", "phoneNumber"], + usa_only = True), + creditcard = ValidCard(["cardNumber", "expirationDate", + "cardCode"])) + def POST_update_pay(self, form, jquery, bid, link, customer_id, pay_id, + edit, address, creditcard): + address_modified = not pay_id or edit + if address_modified: + if (form.has_errors(["firstName", "lastName", "company", "address", + "city", "state", "zip", + "country", "phoneNumber"], + errors.BAD_ADDRESS) or + form.has_errors(["cardNumber", "expirationDate", "cardCode"], + errors.BAD_CARD)): + pass + else: + pay_id = edit_profile(c.user, address, creditcard, pay_id) + if form.has_errors('bid', errors.BAD_BID) or not bid: + pass + # if link is in use or finished, don't make a change + elif link.promote_status == promote.STATUS.promoted: + form.set_html(".status", + _("that link is currently promoted. " + "you can't update your bid now.")) + elif link.promote_status == promote.STATUS.finished: + form.set_html(".status", + _("that promotion is already over, so updating " + "your bid is kind of pointless, don't you think?")) + # don't create or modify a transaction if no changes have been made. + elif (link.promote_status > promote.STATUS.unpaid and + not address_modified and + getattr(link, "promote_bid", "") == bid): + form.set_html(".status", + _("no changes needed to be made")) + elif pay_id: + # valid bid and created or existing bid id. + # check if already a transaction + if promote.auth_paid_promo(link, c.user, pay_id, bid): + form.redirect(promote.promo_edit_url(link)) + else: + form.set_html(".status", + _("failed to authenticate card. sorry.")) + + @validate(VSponsor("link"), + article = VLink("link")) + def GET_pay(self, article): + data = get_account_info(c.user) + # no need for admins to play in the credit card area + if c.user_is_loggedin and c.user._id != article.author_id: + return self.abort404() + + content = PaymentForm(link = article, + customer_id = data.customerProfileId, + profiles = data.paymentProfiles) + res = LinkInfoPage(link = article, + content = content) + return res.render() + + def GET_link_thumb(self, *a, **kw): + """ + See GET_upload_sr_image for rationale + """ + return "nothing to see here." + + @validate(VSponsor("link_id"), + link = VByName('link_id'), + file = VLength('file', 500*1024)) + def POST_link_thumb(self, link=None, file=None): + errors = dict(BAD_CSS_NAME = "", IMAGE_ERROR = "") + try: + force_thumbnail(link, file) + except cssfilter.BadImage: + # if the image doesn't clean up nicely, abort + errors["IMAGE_ERROR"] = _("bad image") + + if any(errors.values()): + return UploadedImage("", "", "upload", errors = errors).render() + else: + if not c.user_is_sponsor: + promote.unapproved_promo(link) + return UploadedImage(_('saved'), thumbnail_url(link), "", + errors = errors).render() + + diff --git a/r2/r2/controllers/reddit_base.py b/r2/r2/controllers/reddit_base.py index 065832aed..8a961b7e8 100644 --- a/r2/r2/controllers/reddit_base.py +++ b/r2/r2/controllers/reddit_base.py @@ -42,6 +42,7 @@ from Cookie import CookieError from datetime import datetime import sha, simplejson, locale from urllib import quote, unquote +from simplejson import dumps from r2.lib.tracking import encrypt, decrypt @@ -409,10 +410,16 @@ def base_listing(fn): @validate(num = VLimit('limit'), after = VByName('after'), before = VByName('before'), - count = VCount('count')) + count = VCount('count'), + target = VTarget("target")) def new_fn(self, before, **env): + if c.render_style == "htmllite": + c.link_target = env.get("target") + elif "target" in env: + del env["target"] + kw = build_arg_list(fn, env) - + #turn before into after/reverse kw['reverse'] = False if before: @@ -454,8 +461,12 @@ class RedditController(BaseController): c.cookies[g.login_cookie] = Cookie(value='') def pre(self): + c.start_time = datetime.now(g.tz) + g.cache.caches = (LocalCache(),) + g.cache.caches[1:] + c.domain_prefix = request.environ.get("reddit-domain-prefix", + g.domain_prefix) #check if user-agent needs a dose of rate-limiting if not c.error_page: ratelimit_agents() @@ -506,6 +517,11 @@ class RedditController(BaseController): c.have_messages = c.user.msgtime c.user_is_admin = maybe_admin and c.user.name in g.admins c.user_is_sponsor = c.user_is_admin or c.user.name in g.sponsors + if not g.disallow_db_writes: + c.user.update_last_visit(c.start_time) + + #TODO: temporary + c.user_is_paid_sponsor = c.user.name.lower() in g.paid_sponsors c.over18 = over18() @@ -544,7 +560,7 @@ class RedditController(BaseController): elif not c.user.pref_show_stylesheets and not c.cname: c.allow_styles = False #if the site has a cname, but we're not using it - elif c.site.domain and not c.cname: + elif c.site.domain and c.site.css_on_cname and not c.cname: c.allow_styles = False #check content cache @@ -608,6 +624,7 @@ class RedditController(BaseController): and request.method == 'GET' and not c.user_is_loggedin and not c.used_cache + and not c.dontcache and response.status_code != 503 and response.content and response.content[0]): g.rendercache.set(self.request_key(), @@ -645,3 +662,11 @@ class RedditController(BaseController): merged = copy(request.get) merged.update(dict) return request.path + utils.query_string(merged) + + def api_wrapper(self, kw): + data = dumps(kw) + if request.method == "GET" and request.GET.get("callback"): + return "%s(%s)" % (websafe_json(request.GET.get("callback")), + websafe_json(data)) + return self.sendstring(data) + diff --git a/r2/r2/controllers/toolbar.py b/r2/r2/controllers/toolbar.py index 4c26f85d8..97ab9a13a 100644 --- a/r2/r2/controllers/toolbar.py +++ b/r2/r2/controllers/toolbar.py @@ -89,6 +89,8 @@ class ToolbarController(RedditController): "/tb/$id36, show a given link with the toolbar" if not link: return self.abort404() + elif link.is_self: + return self.redirect(link.url) res = Frame(title = link.title, url = link.url, @@ -160,7 +162,7 @@ class ToolbarController(RedditController): wrapper = make_wrapper(render_class = StarkComment, target = "_top") - b = TopCommentBuilder(link, CommentSortMenu.operator('top'), + b = TopCommentBuilder(link, CommentSortMenu.operator('confidence'), wrap = wrapper) listing = NestedListing(b, num = 10, # TODO: add config var diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index 93c9ee9ba..0d1e515ac 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -22,7 +22,7 @@ from pylons import c, request, g from pylons.i18n import _ from pylons.controllers.util import abort -from r2.lib import utils, captcha +from r2.lib import utils, captcha, promote from r2.lib.filters import unkeep_space, websafe, _force_unicode from r2.lib.db.operators import asc, desc from r2.lib.template_helpers import add_sr @@ -30,12 +30,36 @@ from r2.lib.jsonresponse import json_respond, JQueryResponse, JsonResponse from r2.lib.jsontemplates import api_type from r2.models import * +from r2.lib.authorize import Address, CreditCard from r2.controllers.errors import errors, UserRequiredException +from r2.controllers.errors import VerifiedUserRequiredException from copy import copy from datetime import datetime, timedelta import re, inspect +import pycountry + +def visible_promo(article): + is_promo = getattr(article, "promoted", None) is not None + is_author = (c.user_is_loggedin and + c.user._id == article.author_id) + # promos are visible only if comments are not disabled and the + # user is either the author or the link is live/previously live. + if is_promo: + return (not article.disable_comments and + (is_author or + article.promote_status >= promote.STATUS.promoted)) + # not a promo, therefore it is visible + return True + +def can_view_link_comments(article): + return (article.subreddit_slow.can_view(c.user) and + visible_promo(article)) + +def can_comment_link(article): + return (article.subreddit_slow.can_comment(c.user) and + visible_promo(article)) class Validator(object): default_param = None @@ -110,6 +134,8 @@ def validate(*simple_vals, **param_vals): return fn(self, *a, **kw) except UserRequiredException: return self.intermediate_redirect('/login') + except VerifiedUserRequiredException: + return self.intermediate_redirect('/verify') return newfn return val @@ -138,7 +164,10 @@ def api_validate(response_function): simple_vals, param_vals, *a, **kw) except UserRequiredException: responder.send_failure(errors.USER_REQUIRED) - return self.response_func(responder.make_response()) + return self.api_wrapper(responder.make_response()) + except VerifiedUserRequiredException: + responder.send_failure(errors.VERIFIED_USER_REQUIRED) + return self.api_wrapper(responder.make_response()) return newfn return val return _api_validate @@ -147,12 +176,12 @@ def api_validate(response_function): @api_validate def noresponse(self, self_method, responder, simple_vals, param_vals, *a, **kw): self_method(self, *a, **kw) - return self.response_func({}) + return self.api_wrapper({}) @api_validate def json_validate(self, self_method, responder, simple_vals, param_vals, *a, **kw): r = self_method(self, *a, **kw) - return self.response_func(r) + return self.api_wrapper(r) @api_validate def validatedForm(self, self_method, responder, simple_vals, param_vals, @@ -175,7 +204,7 @@ def validatedForm(self, self_method, responder, simple_vals, param_vals, if val: return val else: - return self.response_func(responder.make_response()) + return self.api_wrapper(responder.make_response()) @@ -209,22 +238,58 @@ class VRequired(Validator): else: return item -class VLink(Validator): - def __init__(self, param, redirect = True, *a, **kw): +class VThing(Validator): + def __init__(self, param, thingclass, redirect = True, *a, **kw): Validator.__init__(self, param, *a, **kw) + self.thingclass = thingclass self.redirect = redirect - - def run(self, link_id): - if link_id: + + def run(self, thing_id): + if thing_id: try: - aid = int(link_id, 36) - return Link._byID(aid, True) + tid = int(thing_id, 36) + thing = self.thingclass._byID(tid, True) + if thing.__class__ != self.thingclass: + raise TypeError("Expected %s, got %s" % + (self.thingclass, thing.__class__)) + return thing except (NotFound, ValueError): if self.redirect: abort(404, 'page not found') else: return None +class VLink(VThing): + def __init__(self, param, redirect = True, *a, **kw): + VThing.__init__(self, param, Link, redirect=redirect, *a, **kw) + +class VCommentByID(VThing): + def __init__(self, param, redirect = True, *a, **kw): + VThing.__init__(self, param, Comment, redirect=redirect, *a, **kw) + +class VAward(VThing): + def __init__(self, param, redirect = True, *a, **kw): + VThing.__init__(self, param, Award, redirect=redirect, *a, **kw) + +class VAwardByCodename(Validator): + def run(self, codename, required_fullname=None): + if not codename: + return self.set_error(errors.NO_TEXT) + + try: + a = Award._by_codename(codename) + except NotFound: + a = None + + if a and required_fullname and a._fullname != required_fullname: + return self.set_error(errors.INVALID_OPTION) + else: + return a + +class VTrophy(VThing): + def __init__(self, param, redirect = True, *a, **kw): + VThing.__init__(self, param, Trophy, redirect=redirect, *a, **kw) + class VMessage(Validator): def run(self, message_id): if message_id: @@ -425,10 +490,45 @@ class VAdmin(Validator): if not c.user_is_admin: abort(404, "page not found") -class VSponsor(Validator): +class VVerifiedUser(VUser): def run(self): - if not c.user_is_sponsor: - abort(403, 'forbidden') + VUser.run(self) + if not c.user.email_verified: + raise VerifiedUserRequiredException + +class VSponsor(VVerifiedUser): + def user_test(self, thing): + return (thing.author_id == c.user._id) + + def run(self, link_id = None): + VVerifiedUser.run(self) + if c.user_is_sponsor: + return + elif link_id: + try: + if '_' in link_id: + t = Link._by_fullname(link_id, True) + else: + aid = int(link_id, 36) + t = Link._byID(aid, True) + if self.user_test(t): + return + except (NotFound, ValueError): + pass + abort(403, 'forbidden') + +class VTrafficViewer(VSponsor): + def user_test(self, thing): + return (VSponsor.user_test(self, thing) or + promote.is_traffic_viewer(thing, c.user)) + +# TODO: tempoary validator to be replaced with Vuser once we get he +# bugs worked out +class VPaidSponsor(VSponsor): + def run(self, link_id = None): + if c.user_is_paid_sponsor: + return + VSponsor.run(self, link_id) class VSrModerator(Validator): def run(self): @@ -496,8 +596,10 @@ class VSubmitParent(VByName): if isinstance(parent, Message): return parent else: - sr = parent.subreddit_slow - if c.user_is_loggedin and sr.can_comment(c.user): + link = parent + if isinstance(parent, Comment): + link = Link._byID(parent.link_id) + if c.user_is_loggedin and can_comment_link(link): return parent #else abort(403, "forbidden") @@ -614,6 +716,13 @@ class VExistingUname(VRequired): VRequired.__init__(self, item, errors.NO_USER, *a, **kw) def run(self, name): + if name and name.startswith('~') and c.user_is_admin: + try: + user_id = int(name[1:]) + return Account._byID(user_id) + except (NotFound, ValueError): + return self.error(errors.USER_DOESNT_EXIST) + # make sure the name satisfies our user name regexp before # bothering to look it up. name = chkuser(name) @@ -630,31 +739,74 @@ class VUserWithEmail(VExistingUname): if not user or not hasattr(user, 'email') or not user.email: return self.error(errors.NO_EMAIL_FOR_USER) return user - + class VBoolean(Validator): def run(self, val): return val != "off" and bool(val) -class VInt(Validator): - def __init__(self, param, min=None, max=None, *a, **kw): - self.min = min - self.max = max +class VNumber(Validator): + def __init__(self, param, min=None, max=None, coerce = True, + error = errors.BAD_NUMBER, *a, **kw): + self.min = self.cast(min) if min is not None else None + self.max = self.cast(max) if max is not None else None + self.coerce = coerce + self.error = error Validator.__init__(self, param, *a, **kw) + def cast(self, val): + raise NotImplementedError + def run(self, val): if not val: return - try: - val = int(val) + val = self.cast(val) if self.min is not None and val < self.min: - val = self.min + if self.coerce: + val = self.min + else: + raise ValueError, "" elif self.max is not None and val > self.max: - val = self.max + if self.coerce: + val = self.max + else: + raise ValueError, "" return val except ValueError: - self.set_error(errors.BAD_NUMBER) + self.set_error(self.error, msg_params = dict(min=self.min, + max=self.max)) + +class VInt(VNumber): + def cast(self, val): + return int(val) + +class VFloat(VNumber): + def cast(self, val): + return float(val) + +class VBid(VNumber): + def __init__(self, bid, link_id): + self.duration = 1 + VNumber.__init__(self, (bid, link_id), min = g.min_promote_bid, + max = g.max_promote_bid, coerce = False, + error = errors.BAD_BID) + + def cast(self, val): + return float(val)/self.duration + + def run(self, bid, link_id): + if link_id: + try: + link = Thing._by_fullname(link_id, return_dict = False, + data=True) + self.duration = max((link.promote_until - link._date).days, 1) + except NotFound: + pass + if VNumber.run(self, bid): + return float(bid) + + class VCssName(Validator): """ @@ -728,7 +880,7 @@ class VRatelimit(Validator): field = 'ratelimit') else: self.set_error(self.error) - + @classmethod def ratelimit(self, rate_user = False, rate_ip = False, prefix = "rate_", seconds = None): @@ -750,28 +902,33 @@ class VCommentIDs(Validator): comments = Comment._byID(cids, data=True, return_dict = False) return comments -class VCacheKey(Validator): - def __init__(self, cache_prefix, param, *a, **kw): + +class CachedUser(object): + def __init__(self, cache_prefix, user, key): self.cache_prefix = cache_prefix - self.user = None - self.key = None - Validator.__init__(self, param, *a, **kw) + self.user = user + self.key = key def clear(self): if self.key and self.cache_prefix: g.cache.delete(str(self.cache_prefix + "_" + self.key)) - def run(self, key, name): - self.key = key + +class VCacheKey(Validator): + def __init__(self, cache_prefix, param, *a, **kw): + self.cache_prefix = cache_prefix + Validator.__init__(self, param, *a, **kw) + + def run(self, key): + c_user = CachedUser(self.cache_prefix, None, key) if key: - uid = g.cache.get(str(self.cache_prefix + "_" + self.key)) + uid = g.cache.get(str(self.cache_prefix + "_" + key)) if uid: try: - self.user = Account._byID(uid, data = True) + c_user.user = Account._byID(uid, data = True) except NotFound: return - #found everything we need - return self + return c_user self.set_error(errors.EXPIRED) class VOneOf(Validator): @@ -894,3 +1051,154 @@ class ValidDomain(Validator): if url and is_banned_domain(url): self.set_error(errors.BANNED_DOMAIN) + + + + +class VDate(Validator): + """ + Date checker that accepts string inputs in %m/%d/%Y format. + + Optional parameters include 'past' and 'future' which specify how + far (in days) into the past or future the date must be to be + acceptable. + + NOTE: the 'future' param will have precidence during evaluation. + + Error conditions: + * BAD_DATE on mal-formed date strings (strptime parse failure) + * BAD_FUTURE_DATE and BAD_PAST_DATE on respective range errors. + + """ + def __init__(self, param, future=None, past = None, + admin_override = False, + reference_date = lambda : datetime.now(g.tz), + business_days = False): + self.future = future + self.past = past + + # are weekends to be exluded from the interval? + self.business_days = business_days + + # function for generating "now" + self.reference_date = reference_date + + # do we let admins override date range checking? + self.override = admin_override + Validator.__init__(self, param) + + def run(self, date): + now = self.reference_date() + override = c.user_is_sponsor and self.override + try: + date = datetime.strptime(date, "%m/%d/%Y") + if not override: + # can't put in __init__ since we need the date on the fly + future = utils.make_offset_date(now, self.future, + business_days = self.business_days) + past = utils.make_offset_date(now, self.past, future = False, + business_days = self.business_days) + if self.future is not None and date.date() < future.date(): + self.set_error(errors.BAD_FUTURE_DATE, + {"day": future.days}) + elif self.past is not None and date.date() > past.date(): + self.set_error(errors.BAD_PAST_DATE, + {"day": past.days}) + return date.replace(tzinfo=g.tz) + except (ValueError, TypeError): + self.set_error(errors.BAD_DATE) + +class VDateRange(VDate): + """ + Adds range validation to VDate. In addition to satisfying + future/past requirements in VDate, two date fields must be + provided and they must be in order. + + Additional Error conditions: + * BAD_DATE_RANGE if start_date is not less than end_date + """ + def run(self, *a): + try: + start_date, end_date = [VDate.run(self, x) for x in a] + if not start_date or not end_date or end_date < start_date: + self.set_error(errors.BAD_DATE_RANGE) + return (start_date, end_date) + except ValueError: + # insufficient number of arguments provided (expect 2) + self.set_error(errors.BAD_DATE_RANGE) + + +class VDestination(Validator): + def __init__(self, param = 'dest', default = "", **kw): + self.default = default + Validator.__init__(self, param, **kw) + + def run(self, dest): + return dest or request.referer or self.default + +class ValidAddress(Validator): + def __init__(self, param, usa_only = True): + self.usa_only = usa_only + Validator.__init__(self, param) + + def set_error(self, msg, field): + Validator.set_error(self, errors.BAD_ADDRESS, + dict(message=msg), field = field) + + def run(self, firstName, lastName, company, address, + city, state, zipCode, country, phoneNumber): + if not firstName: + self.set_error(_("please provide a first name"), "firstName") + elif not lastName: + self.set_error(_("please provide a last name"), "lastName") + elif not address: + self.set_error(_("please provide an address"), "address") + elif not city: + self.set_error(_("please provide your city"), "city") + elif not state: + self.set_error(_("please provide your state"), "state") + elif not zipCode: + self.set_error(_("please provide your zip or post code"), "zip") + elif (not self.usa_only and + (not country or not pycountry.countries.get(alpha2=country))): + self.set_error(_("please pick a country"), "country") + else: + if self.usa_only: + country = 'United States' + else: + country = pycountry.countries.get(alpha2=country).name + return Address(firstName = firstName, + lastName = lastName, + company = company or "", + address = address, + city = city, state = state, + zip = zipCode, country = country, + phoneNumber = phoneNumber or "") + +class ValidCard(Validator): + valid_ccn = re.compile(r"\d{13,16}") + valid_date = re.compile(r"\d\d\d\d-\d\d") + valid_ccv = re.compile(r"\d{3,4}") + def set_error(self, msg, field): + Validator.set_error(self, errors.BAD_CARD, + dict(message=msg), field = field) + + def run(self, cardNumber, expirationDate, cardCode): + if not self.valid_ccn.match(cardNumber or ""): + self.set_error(_("credit card numbers should be 13 to 16 digits"), + "cardNumber") + elif not self.valid_date.match(expirationDate or ""): + self.set_error(_("dates should be YYYY-MM"), "expirationDate") + elif not self.valid_ccv.match(cardCode or ""): + self.set_error(_("card verification codes should be 3 or 4 digits"), + "cardCode") + else: + return CreditCard(cardNumber = cardNumber, + expirationDate = expirationDate, + cardCode = cardCode) + +class VTarget(Validator): + target_re = re.compile("^[\w_-]{3,20}$") + def run(self, name): + if name and self.target_re.match(name): + return name diff --git a/r2/r2/i18n/r2.pot b/r2/r2/i18n/r2.pot index 893731c97..2b5621c7c 100644 --- a/r2/r2/i18n/r2.pot +++ b/r2/r2/i18n/r2.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: r2 0.0.0\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2009-06-09 10:14-0700\n" +"POT-Creation-Date: 2009-08-20 11:28-0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME([^<]+)')
a_re = re.compile('>([^<]+)')
fix_url = re.compile('<(http://[^\s\'\"\]\)]+)>')
-
#TODO markdown should be looked up in batch?
#@memoize('markdown')
def safemarkdown(text, nofollow=False, target=None):
diff --git a/r2/r2/lib/jsonresponse.py b/r2/r2/lib/jsonresponse.py
index 5b60db4d7..5fcf12dff 100644
--- a/r2/r2/lib/jsonresponse.py
+++ b/r2/r2/lib/jsonresponse.py
@@ -78,10 +78,12 @@ class JsonResponse(object):
def has_errors(self, field_name, *errors, **kw):
have_error = False
+ field_name = tup(field_name)
for error_name in errors:
- if (error_name, field_name) in c.errors:
- self.set_error(error_name, field_name)
- have_error = True
+ for fname in field_name:
+ if (error_name, fname) in c.errors:
+ self.set_error(error_name, fname)
+ have_error = True
return have_error
def _things(self, things, action, *a, **kw):
diff --git a/r2/r2/lib/jsontemplates.py b/r2/r2/lib/jsontemplates.py
index 142b850eb..dc9ab563b 100644
--- a/r2/r2/lib/jsontemplates.py
+++ b/r2/r2/lib/jsontemplates.py
@@ -74,6 +74,10 @@ class JsonTemplate(Template):
def render(self, thing = None, *a, **kw):
return ObjectTemplate({})
+class TakedownJsonTemplate(JsonTemplate):
+ def render(self, thing = None, *a, **kw):
+ return thing.explanation
+
class TableRowTemplate(JsonTemplate):
def cells(self, thing):
raise NotImplementedError
@@ -202,11 +206,12 @@ class LinkJsonTemplate(ThingJsonTemplate):
domain = "domain",
title = "title",
url = "url",
- author = "author",
+ author = "author",
thumbnail = "thumbnail",
media = "media_object",
media_embed = "media_embed",
selftext = "selftext",
+ selftext_html= "selftext_html",
num_comments = "num_comments",
subreddit = "subreddit",
subreddit_id = "subreddit_id")
@@ -228,6 +233,8 @@ class LinkJsonTemplate(ThingJsonTemplate):
elif attr == 'subreddit_id':
return thing.subreddit._fullname
elif attr == 'selftext':
+ return thing.selftext
+ elif attr == 'selftext_html':
return safemarkdown(thing.selftext)
return ThingJsonTemplate.thing_attr(self, thing, attr)
@@ -237,6 +244,9 @@ class LinkJsonTemplate(ThingJsonTemplate):
return d
+class PromotedLinkJsonTemplate(LinkJsonTemplate):
+ _data_attrs_ = LinkJsonTemplate.data_attrs(promoted = "promoted")
+ del _data_attrs_['author']
class CommentJsonTemplate(ThingJsonTemplate):
_data_attrs_ = ThingJsonTemplate.data_attrs(ups = "upvotes",
@@ -293,8 +303,13 @@ class MoreCommentJsonTemplate(CommentJsonTemplate):
def kind(self, wrapped):
return "more"
+ def thing_attr(self, thing, attr):
+ if attr in ('body', 'body_html'):
+ return ""
+ return CommentJsonTemplate.thing_attr(self, thing, attr)
+
def rendered_data(self, wrapped):
- return ThingJsonTemplate.rendered_data(self, wrapped)
+ return CommentJsonTemplate.rendered_data(self, wrapped)
class MessageJsonTemplate(ThingJsonTemplate):
_data_attrs_ = ThingJsonTemplate.data_attrs(new = "new",
@@ -309,9 +324,9 @@ class MessageJsonTemplate(ThingJsonTemplate):
def thing_attr(self, thing, attr):
if attr == "was_comment":
- return hasattr(thing, "was_comment")
+ return thing.was_comment
elif attr == "context":
- return ("" if not hasattr(thing, "was_comment")
+ return ("" if not thing.was_comment
else thing.permalink + "?context=3")
elif attr == "dest":
return thing.to.name
diff --git a/r2/r2/lib/media.py b/r2/r2/lib/media.py
index e88dad847..1d31f8306 100644
--- a/r2/r2/lib/media.py
+++ b/r2/r2/lib/media.py
@@ -23,17 +23,17 @@
from pylons import g, config
from r2.models.link import Link
-from r2.lib.workqueue import WorkQueue
from r2.lib import s3cp
from r2.lib.utils import timeago, fetch_things2
+from r2.lib.utils import TimeoutFunction, TimeoutFunctionException
from r2.lib.db.operators import desc
from r2.lib.scraper import make_scraper, str_to_image, image_to_str, prepare_image
+from r2.lib import amqp
import tempfile
-from Queue import Queue
+import traceback
s3_thumbnail_bucket = g.s3_thumb_bucket
-media_period = g.media_period
threads = 20
log = g.log
@@ -41,6 +41,7 @@ def thumbnail_url(link):
"""Given a link, returns the url for its thumbnail based on its fullname"""
return 'http:/%s%s.png' % (s3_thumbnail_bucket, link._fullname)
+
def upload_thumb(link, image):
"""Given a link and an image, uploads the image to s3 into an image
based on the link's fullname"""
@@ -52,26 +53,6 @@ def upload_thumb(link, image):
s3cp.send_file(f.name, resource, 'image/png', 'public-read', None, False)
log.debug('thumbnail %s: %s' % (link._fullname, thumbnail_url(link)))
-def make_link_info_job(results, link, useragent):
- """Returns a unit of work to send to a work queue that downloads a
- link's thumbnail and media object. Places the result in the results
- dict"""
- def job():
- try:
- scraper = make_scraper(link.url)
-
- thumbnail = scraper.thumbnail()
- media_object = scraper.media_object()
-
- if thumbnail:
- upload_thumb(link, thumbnail)
-
- results[link] = (thumbnail, media_object)
- except:
- log.warning('error fetching %s %s' % (link._fullname, link.url))
- raise
-
- return job
def update_link(link, thumbnail, media_object):
"""Sets the link's has_thumbnail and media_object attributes iin the
@@ -84,40 +65,48 @@ def update_link(link, thumbnail, media_object):
link._commit()
-def process_new_links(period = media_period, force = False):
- """Fetches links from the last period and sets their media
- properities. If force is True, it will fetch properities for links
- even if the properties already exist"""
- links = Link._query(Link.c._date > timeago(period), sort = desc('_date'),
- data = True)
- results = {}
- jobs = []
- for link in fetch_things2(links):
- if link.is_self or link.promoted:
- continue
- elif not force and (link.has_thumbnail or link.media_object):
- continue
- jobs.append(make_link_info_job(results, link, g.useragent))
+def set_media(link, force = False):
+ if link.is_self:
+ return
+ if not force and link.promoted:
+ return
+ elif not force and (link.has_thumbnail or link.media_object):
+ return
+
+ scraper = make_scraper(link.url)
- #send links to a queue
- wq = WorkQueue(jobs, num_workers = 20, timeout = 30)
- wq.start()
- wq.jobs.join()
+ thumbnail = scraper.thumbnail()
+ media_object = scraper.media_object()
- #when the queue is finished, do the db writes in this thread
- for link, info in results.items():
- update_link(link, info[0], info[1])
+ if thumbnail:
+ upload_thumb(link, thumbnail)
-def set_media(link):
- """Sets the media properties for a single link."""
- results = {}
- make_link_info_job(results, link, g.useragent)()
- update_link(link, *results[link])
+ update_link(link, thumbnail, media_object)
def force_thumbnail(link, image_data):
image = str_to_image(image_data)
image = prepare_image(image)
upload_thumb(link, image)
update_link(link, thumbnail = True, media_object = None)
-
+
+def run():
+ def process_msgs(msgs):
+ def _process_link(fname):
+ print "media: Processing %s" % fname
+
+ link = Link._by_fullname(fname, data=True, return_dict=False)
+ set_media(link)
+ for msg in msgs:
+ fname = msg.body
+ try:
+ TimeoutFunction(_process_link, 30)(fname)
+ except TimeoutFunctionException:
+ print "Timed out on %s" % fname
+ except KeyboardInterrupt:
+ raise
+ except:
+ print "Error fetching %s" % fname
+ print traceback.format_exc()
+
+ amqp.handle_items('scraper_q', process_msgs, limit=1)
diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py
index 06408bfbb..9d2de0052 100644
--- a/r2/r2/lib/menus.py
+++ b/r2/r2/lib/menus.py
@@ -1,4 +1,3 @@
-
# 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
@@ -62,6 +61,7 @@ menu = MenuHandler(hot = _('hot'),
more = _('more'),
relevance = _('relevance'),
controversial = _('controversial'),
+ confidence = _('best'),
saved = _('saved {toolbar}'),
recommended = _('recommended'),
rising = _('rising'),
@@ -83,7 +83,6 @@ menu = MenuHandler(hot = _('hot'),
adminon = _("turn admin on"),
adminoff = _("turn admin off"),
prefs = _("preferences"),
- stats = _("stats"),
submit = _("submit"),
help = _("help"),
blog = _("the reddit blog"),
@@ -115,27 +114,31 @@ menu = MenuHandler(hot = _('hot'),
sent = _("sent"),
# comments
+ comments = _("comments {toolbar}"),
related = _("related"),
details = _("details"),
duplicates = _("other discussions (%(num)s)"),
shirt = _("shirt"),
- traffic = _("traffic"),
+ traffic = _("traffic stats"),
# reddits
home = _("home"),
about = _("about"),
- edit = _("edit"),
- banned = _("banned"),
+ edit = _("edit this reddit"),
+ moderators = _("edit moderators"),
+ contributors = _("edit contributors"),
+ banned = _("ban users"),
banusers = _("ban users"),
popular = _("popular"),
create = _("create"),
mine = _("my reddits"),
- i18n = _("translate site"),
+ i18n = _("help translate"),
+ awards = _("awards"),
promoted = _("promoted"),
reporters = _("reporters"),
- reports = _("reports"),
+ reports = _("reported links"),
reportedauth = _("reported authors"),
info = _("info"),
share = _("share"),
@@ -148,9 +151,15 @@ menu = MenuHandler(hot = _('hot'),
deleted = _("deleted"),
reported = _("reported"),
- promote = _('promote'),
- new_promo = _('new promoted link'),
- current_promos = _('promoted links'),
+ promote = _('self-serve'),
+ new_promo = _('create promotion'),
+ my_current_promos = _('my promoted links'),
+ current_promos = _('all promoted links'),
+ future_promos = _('unapproved'),
+ graph = _('analytics'),
+ live_promos = _('live'),
+ unpaid_promos = _('unpaid'),
+ pending_promos = _('pending')
)
def menu_style(type):
@@ -179,7 +188,10 @@ class NavMenu(Styled):
base_path = '', separator = '|', **kw):
self.options = options
self.base_path = base_path
- kw['style'], kw['css_class'] = menu_style(type)
+
+ #add the menu style, but preserve existing css_class parameter
+ kw['style'], css_class = menu_style(type)
+ kw['css_class'] = css_class + ' ' + kw.get('css_class', '')
#used by flatlist to delimit menu items
self.separator = separator
@@ -223,11 +235,11 @@ class NavButton(Styled):
nocname=False, opt = '', aliases = [],
target = "", style = "plain", **kw):
# keep original dest to check against c.location when rendering
- aliases = set(a.rstrip('/') for a in aliases)
- aliases.add(dest.rstrip('/'))
+ aliases = set(_force_unicode(a.rstrip('/')) for a in aliases)
+ aliases.add(_force_unicode(dest.rstrip('/')))
self.request_params = dict(request.GET)
- self.stripped_path = request.path.rstrip('/').lower()
+ self.stripped_path = _force_unicode(request.path.rstrip('/').lower())
Styled.__init__(self, style = style, sr_path = sr_path,
nocname = nocname, target = target,
@@ -264,6 +276,8 @@ class NavButton(Styled):
else:
if self.stripped_path == self.bare_path:
return True
+ if self.bare_path and self.stripped_path.startswith(self.bare_path):
+ return True
if self.stripped_path in self.aliases:
return True
@@ -388,10 +402,13 @@ class SortMenu(SimpleGetMenu):
return operators.desc('_score')
elif sort == 'controversial':
return operators.desc('_controversy')
+ elif sort == 'confidence':
+ return operators.desc('_confidence')
class CommentSortMenu(SortMenu):
"""Sort menu for comments pages"""
- options = ('hot', 'new', 'controversial', 'top', 'old')
+ default = 'confidence'
+ options = ('hot', 'new', 'controversial', 'top', 'old', 'confidence')
class SearchSortMenu(SortMenu):
"""Sort menu for search pages."""
diff --git a/r2/r2/lib/migrate.py b/r2/r2/lib/migrate.py
index 18fec62cc..53a59b6f2 100644
--- a/r2/r2/lib/migrate.py
+++ b/r2/r2/lib/migrate.py
@@ -22,6 +22,7 @@
"""
One-time use functions to migrate from one reddit-version to another
"""
+from r2.lib.promote import *
def add_allow_top_to_srs():
"Add the allow_top property to all stored subreddits"
@@ -33,3 +34,104 @@ def add_allow_top_to_srs():
sort = desc('_date'))
for sr in fetch_things2(q):
sr.allow_top = True; sr._commit()
+
+def convert_promoted():
+ """
+ should only need to be run once to update old style promoted links
+ to the new style.
+ """
+ from r2.lib.utils import fetch_things2
+ from r2.lib import authorize
+
+ q = Link._query(Link.c.promoted == (True, False),
+ sort = desc("_date"))
+ sr_id = PromoteSR._id
+ bid = 100
+ with g.make_lock(promoted_lock_key):
+ promoted = {}
+ set_promoted({})
+ for l in fetch_things2(q):
+ print "updating:", l
+ try:
+ if not l._loaded: l._load()
+ # move the promotion into the promo subreddit
+ l.sr_id = sr_id
+ # set it to accepted (since some of the update functions
+ # check that it is not already promoted)
+ l.promote_status = STATUS.accepted
+ author = Account._byID(l.author_id)
+ l.promote_trans_id = authorize.auth_transaction(bid, author, -1, l)
+ l.promote_bid = bid
+ l.maximum_clicks = None
+ l.maximum_views = None
+ # set the dates
+ start = getattr(l, "promoted_on", l._date)
+ until = getattr(l, "promote_until", None) or \
+ (l._date + timedelta(1))
+ l.promote_until = None
+ update_promo_dates(l, start, until)
+ # mark it as promoted if it was promoted when we got there
+ if l.promoted and l.promote_until > datetime.now(g.tz):
+ l.promote_status = STATUS.pending
+ else:
+ l.promote_status = STATUS.finished
+
+ if not hasattr(l, "disable_comments"):
+ l.disable_comments = False
+ # add it to the auction list
+ if l.promote_status == STATUS.pending and l._fullname not in promoted:
+ promoted[l._fullname] = auction_weight(l)
+ l._commit()
+ except AttributeError:
+ print "BAD THING:", l
+ print promoted
+ set_promoted(promoted)
+ # run what is normally in a cron job to clear out finished promos
+ #promote_promoted()
+
+def store_market():
+
+ """
+ create index ix_promote_date_actual_end on promote_date(actual_end);
+ create index ix_promote_date_actual_start on promote_date(actual_start);
+ create index ix_promote_date_start_date on promote_date(start_date);
+ create index ix_promote_date_end_date on promote_date(end_date);
+
+ alter table promote_date add column account_id bigint;
+ create index ix_promote_date_account_id on promote_date(account_id);
+ alter table promote_date add column bid real;
+ alter table promote_date add column refund real;
+
+ """
+
+ for p in PromoteDates.query().all():
+ l = Link._by_fullname(p.thing_name, True)
+ if hasattr(l, "promote_bid") and hasattr(l, "author_id"):
+ p.account_id = l.author_id
+ p._commit()
+ PromoteDates.update(l, l._date, l.promote_until)
+ PromoteDates.update_bid(l)
+
+def subscribe_to_blog_and_annoucements(filename):
+ import re
+ from time import sleep
+ from r2.models import Account, Subreddit
+
+ r_blog = Subreddit._by_name("blog")
+ r_announcements = Subreddit._by_name("announcements")
+
+ contents = file(filename).read()
+ numbers = [ int(s) for s in re.findall("\d+", contents) ]
+
+# d = Account._byID(numbers, data=True)
+
+# for i, account in enumerate(d.values()):
+ for i, account_id in enumerate(numbers):
+ account = Account._byID(account_id, data=True)
+
+ for sr in r_blog, r_announcements:
+ if sr.add_subscriber(account):
+ sr._incr("_ups", 1)
+ print ("%d: subscribed %s to %s" % (i, account.name, sr.name))
+ else:
+ print ("%d: didn't subscribe %s to %s" % (i, account.name, sr.name))
diff --git a/r2/r2/lib/normalized_hot.py b/r2/r2/lib/normalized_hot.py
index ed86a00d7..699107bf8 100644
--- a/r2/r2/lib/normalized_hot.py
+++ b/r2/r2/lib/normalized_hot.py
@@ -101,7 +101,7 @@ def normalized_hot_cached(sr_ids):
if not items:
continue
- top_score = max(items[0]._hot, 1)
+ top_score = max(max(x._hot for x in items), 1)
if items:
results.extend((l, l._hot / top_score) for l in items)
diff --git a/r2/r2/lib/organic.py b/r2/r2/lib/organic.py
index 8ff386d85..da011bbf0 100644
--- a/r2/r2/lib/organic.py
+++ b/r2/r2/lib/organic.py
@@ -24,7 +24,7 @@ from r2.lib.memoize import memoize
from r2.lib.normalized_hot import get_hot, only_recent
from r2.lib import count
from r2.lib.utils import UniqueIterator, timeago
-from r2.lib.promote import get_promoted
+from r2.lib.promote import random_promoted
from pylons import c
@@ -45,36 +45,28 @@ def insert_promoted(link_names, sr_ids, logged_in):
Inserts promoted links into an existing organic list. Destructive
on `link_names'
"""
- promoted_items = get_promoted()
+ promoted_items = random_promoted()
if not promoted_items:
return
- def my_keepfn(l):
- if l.promoted_subscribersonly and l.sr_id not in sr_ids:
- return False
- else:
- return keep_link(l)
-
# no point in running the builder over more promoted links than
# we'll even use
max_promoted = max(1,len(link_names)/promoted_every_n)
- # in the future, we may want to weight this sorting somehow
- random.shuffle(promoted_items)
-
# remove any that the user has acted on
- builder = IDBuilder(promoted_items,
- skip = True, keep_fn = my_keepfn,
- num = max_promoted)
+ def keep(item):
+ if c.user_is_loggedin and c.user._id == item.author_id:
+ return True
+ else:
+ return item.keep_item(item)
+
+ builder = IDBuilder(promoted_items, keep_fn = keep,
+ skip = True, num = max_promoted)
promoted_items = builder.get_items()[0]
if not promoted_items:
return
-
- #make a copy before we start messing with things
- orig_promoted = list(promoted_items)
-
# don't insert one at the head of the list 50% of the time for
# logged in users, and 50% of the time for logged-off users when
# the pool of promoted links is less than 3 (to avoid showing the
@@ -82,11 +74,6 @@ def insert_promoted(link_names, sr_ids, logged_in):
if (logged_in or len(promoted_items) < 3) and random.choice((True,False)):
promoted_items.insert(0, None)
- #repeat the same promoted links for non logged in users
- if not logged_in:
- while len(promoted_items) * promoted_every_n < len(link_names):
- promoted_items.extend(orig_promoted)
-
# insert one promoted item for every N items
for i, item in enumerate(promoted_items):
pos = i * promoted_every_n + i
@@ -106,11 +93,9 @@ def cached_organic_links(user_id, langs):
sr_ids = Subreddit.user_subreddits(user)
sr_count = count.get_link_counts()
-
#only use links from reddits that you're subscribed to
link_names = filter(lambda n: sr_count[n][1] in sr_ids, sr_count.keys())
link_names.sort(key = lambda n: sr_count[n][0])
-
#potentially add a up and coming link
if random.choice((True, False)) and sr_ids:
sr = Subreddit._byID(random.choice(sr_ids))
@@ -122,6 +107,8 @@ def cached_organic_links(user_id, langs):
new_item = random.choice(items[1:4])
link_names.insert(0, new_item._fullname)
+ insert_promoted(link_names, sr_ids, user_id is not None)
+
# remove any that the user has acted on
builder = IDBuilder(link_names,
skip = True, keep_fn = keep_link,
@@ -133,8 +120,6 @@ def cached_organic_links(user_id, langs):
if user_id:
update_pos(0)
- insert_promoted(link_names, sr_ids, user_id is not None)
-
# remove any duplicates caused by insert_promoted if the user is logged in
if user_id:
link_names = list(UniqueIterator(link_names))
diff --git a/r2/r2/lib/pages/admin_pages.py b/r2/r2/lib/pages/admin_pages.py
index 6d0c6e396..8e05bf987 100644
--- a/r2/r2/lib/pages/admin_pages.py
+++ b/r2/r2/lib/pages/admin_pages.py
@@ -26,6 +26,7 @@ from r2.lib.menus import NamedButton, NavButton, menu, NavMenu
class AdminSidebar(Templated):
def __init__(self, user):
+ Templated.__init__(self)
self.user = user
@@ -46,7 +47,9 @@ class AdminPage(Reddit):
buttons = []
if g.translator:
- buttons.append(NavButton(menu.i18n, ""))
+ buttons.append(NavButton(menu.i18n, "i18n"))
+
+ buttons.append(NavButton(menu.awards, "awards"))
admin_menu = NavMenu(buttons, title='show', base_path = '/admin',
type="lightdrop")
diff --git a/r2/r2/lib/pages/graph.py b/r2/r2/lib/pages/graph.py
index a3184aa25..376002bc1 100644
--- a/r2/r2/lib/pages/graph.py
+++ b/r2/r2/lib/pages/graph.py
@@ -138,9 +138,9 @@ class LineGraph(object):
def __init__(self, xydata, colors = ("FF4500", "336699"),
width = 300, height = 175):
-
+
series = zip(*xydata)
-
+
self.xdata = DataSeries(series[0])
self.ydata = map(DataSeries, series[1:])
self.width = width
@@ -150,13 +150,13 @@ class LineGraph(object):
def google_chart(self, multiy = True, ylabels = [], title = "",
bar_fmt = True):
xdata, ydata = self.xdata, self.ydata
-
+
# Bar format makes the line chart look like it is a series of
# contiguous bars without the boundary line between each bar.
if bar_fmt:
xdata = DataSeries(range(len(self.xdata))).toBarX()
ydata = [y.toBarY() for y in self.ydata]
-
+
# TODO: currently we are only supporting time series. Make general
xaxis = make_date_axis_labels(self.xdata)
diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py
index f78088428..3ce70866a 100644
--- a/r2/r2/lib/pages/pages.py
+++ b/r2/r2/lib/pages/pages.py
@@ -23,29 +23,31 @@ from r2.lib.wrapped import Wrapped, Templated, NoTemplateFound, CachedTemplate
from r2.models import Account, Default
from r2.models import FakeSubreddit, Subreddit
from r2.models import Friends, All, Sub, NotFound, DomainSR
-from r2.models import Link, Printable
+from r2.models import Link, Printable, Trophy, bidding, PromoteDates
from r2.config import cache
+from r2.lib.tracking import AdframeInfo
from r2.lib.jsonresponse import json_respond
from r2.lib.jsontemplates import is_api
from pylons.i18n import _, ungettext
from pylons import c, request, g
from pylons.controllers.util import abort
+from r2.lib import promote
from r2.lib.traffic import load_traffic, load_summary
from r2.lib.captcha import get_iden
-from r2.lib.filters import spaceCompress, _force_unicode, _force_utf8, unsafe
+from r2.lib.filters import spaceCompress, _force_unicode, _force_utf8, unsafe, websafe
from r2.lib.menus import NavButton, NamedButton, NavMenu, PageNameNav, JsButton
from r2.lib.menus import SubredditButton, SubredditMenu
from r2.lib.menus import OffsiteButton, menu, JsNavMenu
from r2.lib.strings import plurals, rand_strings, strings, Score
from r2.lib.utils import title_to_url, query_string, UrlParser, to_js, vote_hash
-from r2.lib.utils import link_duplicates
+from r2.lib.utils import link_duplicates, make_offset_date, to_csv
from r2.lib.template_helpers import add_sr, get_domain
from r2.lib.subreddit_search import popular_searches
from r2.lib.scraper import scrapers
import sys, random, datetime, locale, calendar, simplejson, re
-import graph
+import graph, pycountry
from itertools import chain
from urllib import quote
@@ -128,6 +130,27 @@ class Reddit(Templated):
self.toolbars = self.build_toolbars()
+ def sr_admin_menu(self):
+ buttons = [NamedButton('edit', css_class = 'reddit-edit'),
+ NamedButton('moderators', css_class = 'reddit-moderators')]
+
+ if c.site.type != 'public':
+ buttons.append(NamedButton('contributors',
+ css_class = 'reddit-contributors'))
+
+ buttons.extend([
+ NamedButton('traffic', css_class = 'reddit-traffic'),
+ NamedButton('reports', css_class = 'reddit-reported'),
+ NamedButton('spam', css_class = 'reddit-spam'),
+ NamedButton('banned', css_class = 'reddit-ban'),
+ ])
+ return [NavMenu(buttons, type = "flat_vert", base_path = "/about/",
+ css_class = "icon-menu", separator = '')]
+
+ def sr_moderators(self):
+ accounts = [Account._byID(uid, True) for uid in c.site.moderators]
+ return [WrappedUser(a) for a in accounts if not a._deleted]
+
def rightbox(self):
"""generates content in
+
+ Apps
+
+
+
+
+ Apps
+
+ 




| fn | +cn | +img | +title | +buttons | +
|---|---|---|---|---|
| ${award._fullname} | +${award.codename} | +${award.title} | ++ ${awardbuttons(award.codename)} + ${awardedit(award._fullname, award.title, award.codename, award.imgurl)} + | +
| today | |
|---|---|
| $a['award'] | ${plain_link(u.name, "/user/%s" % u.name)} ($sanekarma($u.pop)) |
| ${_("today")} | |||||
|---|---|---|---|---|---|
| ${plain_link(user.name, "/user/%s" % user.name)} (${user.link_karma}) | -+${change} | ++ ${trophy._thing1.name} + | ++ ${trophy._name} + | ++ ${getattr(trophy, "description", "")} + | ++ %if hasattr(trophy, "url"): + ${trophy.url} + %endif + |
| ${_("this week")} | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ${plain_link(user.name, "/user/%s" % user.name)} (${user.link_karma}) | -+${change} | +
+ |
+
+ ${thing.award.title}+ |
+ + description + | ++ url + |
|---|
| ${_("all-time")} | |
|---|---|
| ${plain_link(user.name, "/user/%s" % user.name)} (${user.link_karma}) | -|
+ back to awards +
+ diff --git a/r2/r2/templates/admintranslations.html b/r2/r2/templates/admintranslations.html index 32e43b3e9..8ab0899d4 100644 --- a/r2/r2/templates/admintranslations.html +++ b/r2/r2/templates/admintranslations.html @@ -40,7 +40,7 @@-
| queue | +length | +
|---|---|
| + | |
| ${name} | +${length} / ${max_len} | +
| + + + | ++ ${_("Use the same ad frame as on the front page.")} + + |
| ${_("toolbar link")} | -${thing.a.tblink} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ${_("submitted on")} | -${thing.a._date.strftime(thing.datefmt)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${ungettext('point', 'points', 5)} | -${thing.a.score} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${_("up votes")} | -${thing.a.upvotes} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${_("down votes")} | -${thing.a.downvotes} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${_('promoted on')} | -- ${thing.a.promoted_on.strftime(thing.datefmt)} - | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ${_('promoted by')} | +${_('promoted on')} | - ${thing.a.promoted_by_name} + ${thing.a.promoted_on.strftime(thing.datefmt)} | -|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ${_('unpromoted on')} | ++ ${thing.a.unpromoted_on.strftime(thing.datefmt)} + | +||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - | ${(_('shown only to subscribers of %(subreddit)s') - % dict(subreddit = c.site.name))} | -||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| + | + %if field == "address": + + %elif field == "country": + ## TODO: pycountry does country name i18n + + %else: + + %endif + %if optional: + ${optional} + %endif + | ++ ${error_field(error_name, field)} + | +
|---|---|---|
| + | + + %if disabled and profile: + + %endif + | +|
| + | + + | +
| ${label}: | -${lc} | -/ | -${cc} | -
|---|
| ${label}: | +${lc} | +/ | +${cc} | +
|---|
+
+ ${plain_link(_("send message"), "/message/compose/?to=%s" % thing.user.name)}
+ %endif
+
+
+ ${_("redditor for %(time)s") % dict(time=timesince(thing.user._date))}
+
+ Below is a calendar of your scheduled and completed promotions (if you have any, of course), along with some site-wide averages to use as a guide for setting up future promotions. These values are:
+| user | +promos | +bids | +credits | +total | +per promo | +
|---|---|---|---|---|---|
| ${account.name} | +${len(promos)} | +${money(bid)} | +${money(refund)} | +${money(bid - refund)} | +${money((bid - refund)/len(promos))} | +
| Total | +${total_promos} | +${money(total_bid)} | +${money(total_refund)} | +${money(total_bid - total_refund)} | +${money((total_bid - total_refund)/total_promos)} | +
| Impresions | +Clicks | ++ | + | ||||
|---|---|---|---|---|---|---|---|
| date | +unique | +total | +unique | +total | +price | +points | +title | +
| + ${link._date.strftime("%Y-%m-%d")} + | +${num(uimp)} | +${num(nimp)} | +${num(ucli)} | +${num(ncli)} | +${money(link.promote_bid)} | +${link._ups - link._downs} | ++ ${link.title} + | +
- ${_('sponsored link')} + %if thing.is_author or c.user_is_sponsor: + %if thing.promote_status == STATUS.unpaid: + ${_('unpaid sponsored link')} + %elif thing.promote_status == STATUS.unseen: + ${_('unapproved sponsored link')} + %elif thing.promote_status == STATUS.accepted: + ${_('accepted sponsored link')} + %elif thing.promote_status == STATUS.rejected: + ${_('rejected sponsored link')} + %elif thing.promote_status == STATUS.pending: + ${_('pending sponsored link')} + %elif thing.promote_status == STATUS.promoted: + ${_('sponsored link')} + %else: + ${_('finished sponsored link')} + %endif + %if c.user_is_sponsor: + <% days = (thing.promote_until - thing._date).days %> + | $${thing.promote_bid} | ${days} ${ungettext("day", "days", days)} + %endif + %else: + ${_('sponsored link')} + %endif
-| Impresions | -Clicks | -- | - | ||
|---|---|---|---|---|---|
| unique | -total | -unique | -total | -points | -title | -
| ${uimp} | -${nimp} | -${ucli} | -${ncli} | -${link._ups - link._downs} | -- ${link.title} - | -
+ Below you will see your promotion's impression and click traffic per hour of promotion. Please note that these traffic totals will lag behind by two to three hours, and that daily totals will be preliminary till 24 hours after the link has finished its run. +
++ Also below is a form which can be used to share traffic results with another user. +
+
+ %endif
+ %utils:line_field>
%endif
+## provide a way to give refunds, but only if the link isn't already a freebie
+<%
+ cur_bid = getattr(thing.link, "promote_bid", g.min_promote_bid)
+ cur_refund = cur_bid - getattr(thing.link, "promo_refund", 0)
+%>
+%if c.user_is_sponsor and getattr(thing.link, "promote_paid", False) and \
+ getattr(thing.link, "promote_trans_id", -1) > 0 and cur_refund > 0:
+ <%utils:line_field title="${_('refund')}" id="bid-field">
+
+ %utils:line_field>
+%endif
+
+%if not thing.link or thing.link.promote_status != STATUS.finished:
+ %if thing.link:
+ <%
+ thumb = None if not thing.link.has_thumbnail else thumbnail_url(thing.link) %>
+ <%utils:line_field title="${_('look and feel')}">
+ | date | +user | +transaction id | +pay id | +amount | +status | +
|---|---|---|---|---|---|
| ${bid.date} | +${accounts[bid.account_id].name} | +${bid.transaction} | +${bid.pay_id} | +$${"%.2f" % bid.bid} | +${status} | +