From b9396594df80e609d57109b6747f50804f4bde12 Mon Sep 17 00:00:00 2001
From: ketralnis
Date: Thu, 18 Sep 2008 12:20:16 -0700
Subject: [PATCH] Allow a link to be forced into the organic link box for
site-wide announcements
---
r2/example.ini | 2 +
r2/r2/config/routing.py | 3 +
r2/r2/controllers/__init__.py | 7 ++
r2/r2/controllers/admin.py | 27 ++++-
r2/r2/controllers/api.py | 22 ++--
r2/r2/controllers/listingcontroller.py | 18 ++-
r2/r2/controllers/post.py | 6 +-
r2/r2/controllers/reddit_base.py | 150 ++++++++++++++++++++-----
r2/r2/lib/base.py | 2 +-
r2/r2/lib/menus.py | 1 +
r2/r2/lib/organic.py | 137 +++++++++++++++++++---
r2/r2/lib/pages/pages.py | 7 +-
r2/r2/lib/tracking.py | 67 ++++++-----
r2/r2/lib/utils/utils.py | 23 +++-
r2/r2/models/link.py | 2 +
r2/r2/public/static/organic.js | 34 +++++-
r2/r2/public/static/reddit.css | 2 +-
r2/r2/public/static/vote_piece.js | 22 +++-
r2/r2/templates/help.html | 14 ++-
r2/r2/templates/link.html | 19 ++++
r2/r2/templates/promote.html | 42 +++++++
r2/r2/templates/redditfooter.html | 2 +-
22 files changed, 497 insertions(+), 112 deletions(-)
create mode 100644 r2/r2/templates/promote.html
diff --git a/r2/example.ini b/r2/example.ini
index f41c4b9c4..c9182f76e 100644
--- a/r2/example.ini
+++ b/r2/example.ini
@@ -15,6 +15,7 @@ log_path =
memcaches = 127.0.0.1:11211
rec_cache = 127.0.0.1:11311
tracker_url =
+adtracker_url =
main_db_name = reddit
main_db_host = 127.0.0.1
@@ -81,6 +82,7 @@ solr_url =
SECRET = abcdefghijklmnopqrstuvwxyz0123456789
MODSECRET = abcdefghijklmnopqrstuvwxyz0123456789
+tracking_secret = abcdefghijklmnopqrstuvwxyz0123456789
ip_hash =
S3KEY_ID = ABCDEFGHIJKLMNOP1234
S3SECRET_KEY = aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCd
diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py
index b88db20f4..436970102 100644
--- a/r2/r2/config/routing.py
+++ b/r2/r2/config/routing.py
@@ -70,6 +70,9 @@ def make_map(global_conf={}, app_conf={}):
mc('/feedback', controller='feedback', action='feedback')
mc('/ad_inq', controller='feedback', action='ad_inq')
+ mc('/admin/promote', controller='admin', action='promote')
+ mc('/admin/unpromote', controller='admin', action='unpromote')
+
mc('/admin/i18n', controller='i18n', action='list')
mc('/admin/i18n/:action', controller='i18n')
mc('/admin/i18n/:action/:lang', controller='i18n')
diff --git a/r2/r2/controllers/__init__.py b/r2/r2/controllers/__init__.py
index db62033d8..df1805a9a 100644
--- a/r2/r2/controllers/__init__.py
+++ b/r2/r2/controllers/__init__.py
@@ -53,3 +53,10 @@ except ImportError:
from admin import AdminController
from redirect import RedirectController
+
+try:
+ from r2admin.controllers.admin import *
+except ImportError:
+ pass
+
+
diff --git a/r2/r2/controllers/admin.py b/r2/r2/controllers/admin.py
index 46d4ea78c..23b2ae51a 100644
--- a/r2/r2/controllers/admin.py
+++ b/r2/r2/controllers/admin.py
@@ -20,14 +20,31 @@
# CondeNet, Inc. All Rights Reserved.
################################################################################
from r2.controllers.reddit_base import RedditController
+from r2.controllers.reddit_base import base_listing
+
+from r2.controllers.validator import *
+from r2.lib.pages import *
+from r2.models import *
+
+from r2.lib import organic
+
+from pylons.i18n import _
def admin_profile_query(vuser, location, db_sort):
return None
class AdminController(RedditController):
- pass
+ @validate(VAdmin(),
+ thing = VByName('fullname'))
+ @validate(VAdmin())
+ def GET_promote(self):
+ current_list = organic.get_promoted()
+
+ b = IDBuilder([ x._fullname for x in current_list])
+
+ render_list = b.get_items()[0]
+
+ return AdminPage(content = Promote(render_list),
+ title = _('promote'),
+ nav_menus = []).render()
-try:
- from r2admin.controllers.admin import *
-except ImportError:
- pass
diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py
index 17c79c375..9d78896d6 100644
--- a/r2/r2/controllers/api.py
+++ b/r2/r2/controllers/api.py
@@ -53,7 +53,7 @@ from simplejson import dumps
from datetime import datetime, timedelta
from md5 import md5
-from r2.lib.organic import update_pos
+from r2.lib.organic import promote, unpromote
def link_listing_by_url(url, count = None):
try:
@@ -1303,13 +1303,6 @@ class ApiController(RedditController):
l.mid_margin = mid_margin
res.object = res._thing(l.listing(), action = 'populate')
- @Json
- @validate(pos = VInt('pos', min = 0, max = 100))
- def POST_update_pos(self, res, pos):
- if pos is not None:
- update_pos(c.user, pos)
-
-
@Json
@validate(VUser(),
ui_elem = VOneOf('id', ('organic',)))
@@ -1320,4 +1313,15 @@ class ApiController(RedditController):
setattr(c.user, "pref_" + ui_elem, False)
c.user._commit()
-
+ @Json
+ @validate(VAdmin(),
+ thing = VByName('id'))
+ def POST_promote(self, res, thing):
+ promote(thing)
+
+ @Json
+ @validate(VAdmin(),
+ thing = VByName('id'))
+ def POST_unpromote(self, res, thing):
+ unpromote(thing)
+
diff --git a/r2/r2/controllers/listingcontroller.py b/r2/r2/controllers/listingcontroller.py
index 065944061..6d3299ebc 100644
--- a/r2/r2/controllers/listingcontroller.py
+++ b/r2/r2/controllers/listingcontroller.py
@@ -180,7 +180,7 @@ class HotController(FixListing, ListingController):
where = 'hot'
def organic(self):
- o_links, pos = organic.organic_links(c.user)
+ o_links, pos, calculation_key = organic.organic_links(c.user)
if o_links:
# get links in proximity to pos
l = min(len(o_links) - 3, 8)
@@ -192,9 +192,16 @@ class HotController(FixListing, ListingController):
org_links = o_links,
visible_link = o_links[pos],
max_num = self.listing_obj.max_num,
- max_score = self.listing_obj.max_score)
- organic.update_pos(c.user, (pos + 1) % len(o_links))
- return o.listing()
+ max_score = self.listing_obj.max_score).listing()
+
+ if len(o.things) > 0:
+ # only pass through a listing if the link made it
+ # through the builder in organic_links *and* ours
+ organic.update_pos(pos+1, calculation_key)
+
+ return o
+
+ return None
def query(self):
@@ -218,7 +225,8 @@ class HotController(FixListing, ListingController):
def content(self):
# only send an organic listing for HTML rendering
if (c.site == Default and c.render_style == "html"
- and c.user_is_loggedin and c.user.pref_organic):
+ and (not c.user_is_loggedin
+ or (c.user_is_loggedin and c.user.pref_organic))):
org = self.organic()
if org:
return PaneStack([org, self.listing_obj], css_class='spacer')
diff --git a/r2/r2/controllers/post.py b/r2/r2/controllers/post.py
index 8f75895fc..c244241f8 100644
--- a/r2/r2/controllers/post.py
+++ b/r2/r2/controllers/post.py
@@ -125,9 +125,9 @@ class PostController(ApiController):
c.user._commit()
else:
ip_hash = sha.new(request.ip).hexdigest()
- c.response.set_cookie('over18',
- value = ip_hash,
- domain = g.domain if not c.frameless_cname else None)
+ domain = g.domain if not c.frameless_cname else None
+ c.cookies.add('over18', value = ip_hash,
+ domain = domain)
return self.redirect(dest)
else:
return self.redirect('/')
diff --git a/r2/r2/controllers/reddit_base.py b/r2/r2/controllers/reddit_base.py
index 563e69492..41d55da97 100644
--- a/r2/r2/controllers/reddit_base.py
+++ b/r2/r2/controllers/reddit_base.py
@@ -26,6 +26,7 @@ from pylons.i18n import _
from pylons.i18n.translation import LanguageError
from r2.lib.base import BaseController, proxyurl
from r2.lib import pages, utils, filters
+from r2.lib.utils import http_utils
from r2.lib.cache import LocalCache
import random as rand
from r2.models.account import valid_cookie, FakeAccount
@@ -38,12 +39,37 @@ from r2.lib.template_helpers import add_sr
from r2.lib.jsontemplates import api_type
from copy import copy
+from datetime import datetime
import sha, inspect, simplejson
+from urllib import quote, unquote
from r2.lib.tracking import encrypt, decrypt
NEVER = 'Thu, 31 Dec 2037 23:59:59 GMT'
+cache_affecting_cookies = ('reddit_first',)
+
+class Cookies(dict):
+ def add(name, *k, **kw):
+ self[name] = Cookie(*k, **kw)
+
+class Cookie(object):
+ def __init__(self, value, expires = None, domain = None, dirty = True):
+ self.value = value
+ self.expires = expires
+ self.dirty = dirty
+ if domain:
+ self.domain = domain
+ else:
+ self.domain = g.domain
+
+ def __repr__(self):
+ return ("Cookie(value=%s, expires=%s, domain=%s, dirty=%s)"
+ % (repr(self.value),
+ repr(self.expires),
+ repr(self.domain),
+ repr(self.dirty)))
+
class UnloggedUser(FakeAccount):
_cookie = 'options'
allowed_prefs = ('pref_content_langs', 'pref_lang')
@@ -99,13 +125,17 @@ class UnloggedUser(FakeAccount):
def read_user_cookie(name):
uname = c.user.name if c.user_is_loggedin else ""
- return request.cookies.get(uname + '_' + name) or ''
+ cookie_name = uname + '_' + name
+ if cookie_name in c.cookies:
+ return c.cookies[cookie_name].value
+ else:
+ return ''
def set_user_cookie(name, val):
uname = c.user.name if c.user_is_loggedin else ""
- c.response.set_cookie(uname + '_' + name,
- value = val,
- domain = g.domain if not c.frameless_cname else None)
+ domain = g.domain if not c.frameless_cname else None
+ c.cookies[uname + '_' + name] = Cookie(value = val,
+ domain = domain)
def read_click_cookie():
if c.user_is_loggedin:
@@ -125,22 +155,59 @@ def read_mod_cookie():
set_user_cookie('mod', '')
def firsttime():
- if not c.user_is_loggedin:
- if not request.cookies.get("reddit_first"):
- c.response.set_cookie("reddit_first", "first",
- expires = NEVER,
- domain = g.domain if not c.frameless_cname else None)
- return True
- return False
+ if get_redditfirst('firsttime'):
+ return False
+ else:
+ set_redditfirst('firsttime','first')
+ return True
+
+def get_redditfirst(key,default=None):
+ try:
+ cookie = simplejson.loads(c.cookies['reddit_first'].value)
+ return cookie[key]
+ except (ValueError,TypeError,KeyError),e:
+ # it's not a proper json dict, or the cookie isn't present, or
+ # the key isn't part of the cookie; we don't really want a
+ # broken cookie to propogate an exception up
+ return default
+
+def set_redditfirst(key,val):
+ try:
+ cookie = simplejson.loads(c.cookies['reddit_first'].value)
+ cookie[key] = val
+ except (ValueError,TypeError,KeyError),e:
+ # invalid JSON data; we'll just construct a new cookie
+ cookie = {key: val}
+
+ c.cookies['reddit_first'] = Cookie(simplejson.dumps(cookie),
+ expires = NEVER,
+ domain = g.domain)
+
+# this cookie is also accessed by organic.js, so changes to the format
+# will have to be made there as well
+organic_pos_key = 'organic_pos'
+def organic_pos():
+ "organic_pos() -> (calc_date = str(), pos = int())"
+ try:
+ d,p = get_redditfirst(organic_pos_key, (None,0))
+ except ValueError:
+ d,p = (None,0)
+ return d,p
+
+def set_organic_pos(key,pos):
+ "set_organic_pos(str(), int()) -> None"
+ set_redditfirst(organic_pos_key,[key,pos])
+
def over18():
if c.user.pref_over_18 or c.user_is_admin:
return True
else:
- cookie = request.cookies.get('over18')
- if cookie == sha.new(request.ip).hexdigest():
- return True
+ if 'over18' in c.cookies:
+ cookie = c.cookies['over18'].value
+ if cookie == sha.new(request.ip).hexdigest():
+ return True
def set_subreddit():
sr_name=request.environ.get("subreddit", request.params.get('r'))
@@ -327,13 +394,19 @@ class RedditController(BaseController):
return kw
def request_key(self):
+ # note that this references the cookie at request time, not
+ # the current value of it
+ cookie_keys = []
+ for x in cache_affecting_cookies:
+ cookie_keys.append(request.cookies.get(x,''))
+
key = ''.join((str(c.lang),
str(c.content_langs),
request.host,
str(c.cname),
str(request.fullpath),
- str(c.firsttime),
- str(c.over18)))
+ str(c.over18),
+ ''.join(cookie_keys)))
return key
def cached_response(self):
@@ -341,15 +414,12 @@ class RedditController(BaseController):
@staticmethod
def login(user, admin = False, rem = False):
- c.response.set_cookie(g.login_cookie,
- value = user.make_cookie(admin = admin),
- domain = g.domain,
- expires = NEVER if rem else None)
+ c.cookies[g.login_cookie] = Cookie(value = user.make_cookie(admin = admin),
+ expires = NEVER if rem else None)
@staticmethod
def logout(admin = False):
- c.response.set_cookie(g.login_cookie, value = '',
- domain = g.domain)
+ c.cookies[g.login_cookie] = Cookie(value='')
def pre(self):
g.cache.caches = (LocalCache(),) + g.cache.caches[1:]
@@ -357,10 +427,18 @@ class RedditController(BaseController):
#check if user-agent needs a dose of rate-limiting
ratelimit_agents()
+ # populate c.cookies
+ c.cookies = Cookies()
+ for k,v in request.cookies.iteritems():
+ c.cookies[k] = Cookie(value=unquote(v), dirty=False)
+
c.response_wrappers = []
c.errors = ErrorSet()
+ c.firsttime = firsttime()
(c.user, maybe_admin) = \
- valid_cookie(request.cookies.get(g.login_cookie))
+ valid_cookie(c.cookies[g.login_cookie].value
+ if g.login_cookie in c.cookies
+ else '')
if c.user:
c.user_is_loggedin = True
@@ -388,7 +466,6 @@ class RedditController(BaseController):
set_iface_lang()
set_content_lang()
set_cnameframe()
- c.firsttime = firsttime()
# set some environmental variables in case we hit an abort
if not isinstance(c.site, FakeSubreddit):
@@ -411,6 +488,16 @@ class RedditController(BaseController):
response = c.response
response.headers = r.headers
response.content = r.content
+
+ for x in r.cookies.keys():
+ if x in cache_affecting_cookies:
+ cookie = r.cookies[x]
+ response.set_cookie(key = x,
+ value = cookie.value,
+ domain = cookie.get('domain',None),
+ expires = cookie.get('expires',None),
+ path = cookie.get('path',None))
+
response.status_code = r.status_code
request.environ['pylons.routes_dict']['action'] = 'cached_response'
# make sure to carry over the content type
@@ -438,6 +525,17 @@ class RedditController(BaseController):
response.headers['Cache-Control'] = 'no-cache'
response.headers['Pragma'] = 'no-cache'
+ # send cookies
+ if not c.used_cache:
+ # if we used the cache, these cookies should be set by the
+ # cached response object instead
+ for k,v in c.cookies.iteritems():
+ if v.dirty:
+ response.set_cookie(key = k,
+ value = quote(v.value),
+ domain = v.domain,
+ expires = v.expires)
+
#return
#set content cache
if (g.page_cache_time
@@ -448,7 +546,7 @@ class RedditController(BaseController):
config.cache.set(self.request_key(),
response,
g.page_cache_time)
-
+
def check_modified(self, thing, action):
if c.user_is_loggedin:
return
@@ -457,7 +555,7 @@ class RedditController(BaseController):
if date is True:
abort(304, 'not modified')
else:
- c.response.headers['Last-Modified'] = utils.http_date_str(date)
+ c.response.headers['Last-Modified'] = http_utils.http_date_str(date)
def abort404(self):
abort(404, "not found")
diff --git a/r2/r2/lib/base.py b/r2/r2/lib/base.py
index 6eda91c0d..48a1eba84 100644
--- a/r2/r2/lib/base.py
+++ b/r2/r2/lib/base.py
@@ -178,7 +178,7 @@ embedopen = urllib2.OpenerDirector()
embedopen.add_handler(EmbedHandler())
def proxyurl(url):
- cstrs = ['%s="%s"' % (k, v) for k, v in request.cookies.iteritems()]
+ cstrs = ['%s="%s"' % (k, v.value) for k, v in c.cookies.iteritems()]
cookiestr = "; ".join(cstrs)
headers = {"Cookie":cookiestr}
diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py
index 5b69286ed..7d8ac1a12 100644
--- a/r2/r2/lib/menus.py
+++ b/r2/r2/lib/menus.py
@@ -124,6 +124,7 @@ menu = MenuHandler(hot = _('hot'),
mine = _("my reddits"),
i18n = _("translate site"),
+ promote = _("promote"),
reporters = _("reporters"),
reports = _("reports"),
reportedauth = _("reported authors"),
diff --git a/r2/r2/lib/organic.py b/r2/r2/lib/organic.py
index c5e3ecb56..c05948359 100644
--- a/r2/r2/lib/organic.py
+++ b/r2/r2/lib/organic.py
@@ -20,28 +20,114 @@
# CondeNet, Inc. All Rights Reserved.
################################################################################
from r2.models import *
-from r2.lib.memoize import memoize
+from r2.lib.memoize import memoize, clear_memo
from r2.lib.normalized_hot import get_hot, only_recent
from r2.lib import count
+from r2.lib.utils import UniqueIterator, timeago, timefromnow
+from r2.lib.db.operators import desc
import random
+from datetime import datetime
from pylons import g
-cache = g.cache
-def pos_key(user):
- return 'organic_pos_' + user.name
-
+# lifetime in seconds of organic listing in memcached
+organic_lifetime = 5*60
+promoted_memo_key = 'cached_promoted_links'
+
def keep_link(link):
return not any((link.likes != None,
link.saved,
link.clicked,
link.hidden))
-
-@memoize('cached_organic_links', time = 300)
+def promote(thing, subscribers_only = False):
+ thing.promoted = True
+ thing.promoted_on = datetime.now(g.tz)
+ thing.promote_until = timefromnow("1 day")
+ if subscribers_only:
+ thing.promoted_subscribersonly = True
+ thing._commit()
+ clear_memo(promoted_memo_key)
+
+def unpromote(thing):
+ thing.promoted = False
+ thing.unpromoted_on = datetime.now(g.tz)
+ thing._commit()
+ clear_memo(promoted_memo_key)
+
+def clean_promoted():
+ """
+ Remove any stale promoted entries (should be run periodically to
+ keep the list small)
+ """
+ p = get_promoted()
+ for x in p:
+ if datetime.now(g.tz) > x.promote_until:
+ unpromote(x)
+ clear_memo(promoted_memo_key)
+
+@memoize(promoted_memo_key, time = organic_lifetime)
+def get_promoted():
+ return [ x for x in Link._query(Link.c.promoted == True,
+ sort = desc('_date'),
+ data = True)
+ if x.promote_until > datetime.now(g.tz) ]
+
+def insert_promoted(link_names, subscribed_reddits):
+ """
+ The oldest promoted link that c.user hasn't seen yet, and sets the
+ timestamp for their seen promotions in their cookie
+ """
+ promoted_items = get_promoted()
+
+ if not promoted_items:
+ return
+
+ def my_keepfn(l):
+ if l.promoted_subscribersonly and l.sr_id not in subscribed_reddits:
+ return False
+ else:
+ return keep_link(l)
+
+ # remove any that the user has acted on
+ builder = IDBuilder([ x._fullname for x in promoted_items ],
+ skip = True, keep_fn = my_keepfn)
+ promoted_items = builder.get_items()[0]
+
+ # in the future, we may want to weight this sorting somehow
+ random.shuffle(promoted_items)
+
+ if not promoted_items:
+ return
+
+ every_n = 5
+
+ # 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
+ if c.user_is_loggedin or len(promoted_items) < 3:
+ skip_first = random.choice((True,False))
+ else:
+ skip_first = False
+
+ # insert one promoted item for every N items
+ for i, item in enumerate(promoted_items):
+ pos = i * every_n
+ if pos > len(link_names):
+ break
+ elif pos == 0 and skip_first:
+ # don't always show one for logged-in users
+ continue
+ else:
+ link_names.insert(pos, promoted_items[i]._fullname)
+
+@memoize('cached_organic_links', time = organic_lifetime)
def cached_organic_links(username):
- user = Account._by_name(username)
+ if username:
+ user = Account._by_name(username)
+ else:
+ user = FakeAccount()
sr_count = count.get_link_counts()
srs = Subreddit.user_subreddits(user)
@@ -61,16 +147,35 @@ def cached_organic_links(username):
new_item = random.choice(items[1:4])
link_names.insert(0, new_item._fullname)
+ insert_promoted(link_names, srs)
+
builder = IDBuilder(link_names, num = 30, skip = True, keep_fn = keep_link)
links = builder.get_items()[0]
- cache.set(pos_key(user), 0)
- return [l._fullname for l in links]
+
+ calculation_key = str(datetime.now(g.tz))
+
+ update_pos(0, calculation_key)
+
+ ret = [l._fullname for l in UniqueIterator(links)]
+
+ return (calculation_key, ret)
def organic_links(user):
- links = cached_organic_links(user.name)
- pos = cache.get(pos_key(user)) or 0
- return (links, pos)
+ from r2.controllers.reddit_base import organic_pos
-def update_pos(user, pos):
- cache.set(pos_key(user), pos)
-
+ username = user.name if c.user_is_loggedin else None
+ cached_key, links = cached_organic_links(username)
+
+ cookie_key, pos = organic_pos()
+ # pos will be 0 if it wasn't specified
+ if links and (cookie_key == cached_key):
+ # make sure that we're not running off the end of the list
+ pos = pos % len(links)
+
+ return links, pos, cached_key
+
+def update_pos(pos, key):
+ "Update the user's current position within the cached organic list."
+ from r2.controllers import reddit_base
+
+ reddit_base.set_organic_pos(key, pos)
diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py
index 29c336476..84acc13d5 100644
--- a/r2/r2/lib/pages/pages.py
+++ b/r2/r2/lib/pages/pages.py
@@ -1074,7 +1074,7 @@ class DetailsPage(LinkInfoPage):
# TODO: a better way?
from admin_pages import Details
return self.content_stack(self.link_listing, Details(link = self.link))
-
+
class Cnameframe(Wrapped):
"""The frame page."""
def __init__(self, original_path, subreddit, sub_domain):
@@ -1089,3 +1089,8 @@ class Cnameframe(Wrapped):
else:
self.title = ""
self.frame_target = None
+
+class Promote(Wrapped):
+ def __init__(self, current_list, *k, **kw):
+ self.things = current_list
+ Wrapped.__init__(self, *k, **kw)
diff --git a/r2/r2/lib/tracking.py b/r2/r2/lib/tracking.py
index 43259e999..e88a4a901 100644
--- a/r2/r2/lib/tracking.py
+++ b/r2/r2/lib/tracking.py
@@ -47,7 +47,7 @@ def pkcs5unpad(text, padlen = 8):
def cipher(lv):
'''returns a pycrypto object used by encrypt and decrypt, with the key based on g.SECRET'''
- key = g.SECRET
+ key = g.tracking_secret
return AES.new(key[:key_len], AES.MODE_CBC, lv[:key_len])
def encrypt(text):
@@ -85,11 +85,12 @@ def safe_str(text):
return ''
return text
-class UserInfo():
+class Info(object):
'''Class for generating and reading user tracker information.'''
- __slots__ = ['name', 'site', 'lang']
-
- def __init__(self, text = ''):
+ __slots__ = []
+ tracker_url = ""
+
+ def __init__(self, text = '', **kw):
for s in self.__slots__:
setattr(self, s, '')
@@ -103,33 +104,49 @@ class UserInfo():
if i < len(self.__slots__):
setattr(self, self.__slots__[i], d)
else:
- self.name = safe_str(c.user.name if c.user_is_loggedin else '')
- self.site = safe_str(c.site.name if c.site else '')
- self.lang = safe_str(c.lang if c.lang else '')
+ self.init_defaults(**kw)
+ def init_defaults(self, **kw):
+ raise NotImplementedError
+
def tracking_url(self):
data = '|'.join(getattr(self, s) for s in self.__slots__)
data = encrypt(data)
- return "%s?v=%s" % (g.tracker_url, data)
-
+ return "%s?v=%s" % (self.tracker_url, data)
-def gen_url():
- """function for safely creating a tracking url, trapping exceptions so as not to interfere with
- the app"""
- try:
- return UserInfo().tracking_url()
- except Exception, e:
- print "error in gen_url!!!!!"
- print e
+ @classmethod
+ def gen_url(cls, **kw):
try:
- randstr = ''.join(choice('1234567890abcdefghijklmnopqrstuvwxyz' +
- 'ABCDEFGHIJKLMNOPQRSTUVWXYZ+')
- for x in xrange(pad_len))
- return "%s?v=%s" % (g.tracker_url, randstr)
- except:
- print "fallback rendering failed as well"
- return ""
+ return cls(**kw).tracking_url()
+ except Exception,e:
+ print e
+ try:
+ randstr = ''.join(choice('1234567890abcdefghijklmnopqrstuvwxyz' +
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ+')
+ for x in xrange(pad_len))
+ return "%s?v=%s" % (cls.tracker_url, randstr)
+ except:
+ print "fallback rendering failed as well"
+ return ""
+
+class UserInfo(Info):
+ '''Class for generating and reading user tracker information.'''
+ __slots__ = ['name', 'site', 'lang']
+ tracker_url = g.tracker_url
+
+ def init_defaults(self):
+ self.name = safe_str(c.user.name if c.user_is_loggedin else '')
+ self.site = safe_str(c.site.name if c.site else '')
+ self.lang = safe_str(c.lang if c.lang else '')
+
+class PromotedLinkInfo(Info):
+ __slots__ = ['fullname']
+ tracker_url = g.adtracker_url
+
+ def init_defaults(self, fullname = None):
+ self.fullname = fullname
+
def benchmark(n = 10000):
"""on my humble desktop machine, this gives ~150 microseconds per gen_url"""
diff --git a/r2/r2/lib/utils/utils.py b/r2/r2/lib/utils/utils.py
index b37b7108e..9e2805b3c 100644
--- a/r2/r2/lib/utils/utils.py
+++ b/r2/r2/lib/utils/utils.py
@@ -330,7 +330,6 @@ def sanitize_url(url, require_scheme = False):
and '%' not in u.netloc):
return url
-
def timeago(interval):
"""Returns a datetime object corresponding to time 'interval' in
the past. Interval is of the same form as is returned by
@@ -338,9 +337,17 @@ def timeago(interval):
English (i.e., untranslated) and the format is
[num] second|minute|hour|day|week|month|year(s)
-
"""
from pylons import g
+ return datetime.now(g.tz) - timeinterval_fromstr(interval)
+
+def timefromnow(interval):
+ "The opposite of timeago"
+ from pylons import g
+ return datetime.now(g.tz) + timeinterval_fromstr(interval)
+
+def timeinterval_fromstr(interval):
+ "Used by timeago and timefromnow to generate timedeltas from friendly text"
parts = interval.strip().split(' ')
if len(parts) == 1:
num = 1
@@ -360,7 +367,7 @@ def timeago(interval):
month = 60 * 60 * 24 * 30,
year = 60 * 60 * 24 * 365)[period]
delta = num * d
- return datetime.now(g.tz) - timedelta(0, delta)
+ return timedelta(0, delta)
def timetext(delta, resultion = 1, bare=True):
"""
@@ -933,12 +940,16 @@ class IteratorChunker(object):
self.done=True
return chunk
-def IteratorFilter(iterator, filter):
+def IteratorFilter(iterator, fn):
for x in iterator:
- if filter(x):
+ if fn(x):
yield x
-def NoDupsIterator(iterator):
+def UniqueIterator(iterator):
+ """
+ Takes an iterator and returns an iterator that returns only the
+ first occurence of each entry
+ """
so_far = set()
def no_dups(x):
if x in so_far:
diff --git a/r2/r2/models/link.py b/r2/r2/models/link.py
index effec3711..54d4d94b9 100644
--- a/r2/r2/models/link.py
+++ b/r2/r2/models/link.py
@@ -48,6 +48,8 @@ class Link(Thing, Printable):
banned_before_moderator = False,
media_object = None,
has_thumbnail = False,
+ promoted = False,
+ promoted_subscribersonly = False,
ip = '0.0.0.0')
def __init__(self, *a, **kw):
diff --git a/r2/r2/public/static/organic.js b/r2/r2/public/static/organic.js
index e9c83f01f..1e2974639 100644
--- a/r2/r2/public/static/organic.js
+++ b/r2/r2/public/static/organic.js
@@ -62,6 +62,29 @@ function _fire_and_shift(type) {
};
}
+function update_organic_pos(new_pos) {
+ var c = readLCookie('reddit_first');
+ if(c != '') {
+ try {
+ c = c.parseJSON();
+ } catch(e) {
+ c = '';
+ }
+ }
+
+ if(c == '') {
+ c = {};
+ }
+
+ if(c.organic_pos && c.organic_pos.length >= 2) {
+ c.organic_pos[1] = new_pos;
+ } else {
+ c.organic_pos = ['none', new_pos];
+ }
+
+ createLCookie('reddit_first', c.toJSONString());
+}
+
OrganicListing.unhide = _fire_and_shift('unhide');
OrganicListing.hide = _fire_and_shift('hide');
OrganicListing.report = _fire_and_shift('report');
@@ -95,7 +118,7 @@ OrganicListing.prototype.change = function(dir) {
if(this.listing.max_pos == pos)
this.listing.update_pos = true;
else if (this.listing.update_pos) {
- redditRequest('update_pos', {pos: (pos+1) % len});
+ update_organic_pos((pos+1)%len);
this.listing.max_pos = pos;
}
}
@@ -109,6 +132,14 @@ OrganicListing.prototype.change = function(dir) {
c.fade("veryfast");
add_to_aniframes(function() {
c.hide();
+ if(n.$('promoted')) {
+ var i = new Image();
+ i.src = n.$('promoted').tracking_url.value;
+ // just setting i.src is enough to most browsers to
+ // download, but in Opera it's not, we'd need to
+ // uncomment this line to get the image in the DOM
+ /* n.$('promoted').appendChild(i); */
+ }
n.show();
n.set_opacity(0);
}, 1);
@@ -129,3 +160,4 @@ function get_organic(next) {
l.change(-1);
return false;
}
+
diff --git a/r2/r2/public/static/reddit.css b/r2/r2/public/static/reddit.css
index d44614f6c..cc4b033f5 100644
--- a/r2/r2/public/static/reddit.css
+++ b/r2/r2/public/static/reddit.css
@@ -586,7 +586,7 @@ before enabling */
.infobar {
background-color: #f6e69f;
padding: 5px 10px;
- margin: 5px 310px 5px 5px;
+ margin: 5px 310px 5px 0px;
border: 1px solid orange;
}
diff --git a/r2/r2/public/static/vote_piece.js b/r2/r2/public/static/vote_piece.js
index 17b54e38c..812886e2a 100644
--- a/r2/r2/public/static/vote_piece.js
+++ b/r2/r2/public/static/vote_piece.js
@@ -13,7 +13,7 @@ function cookieName(name) {
return (logged || '') + "_" + name;
}
-function createCookie(name,value,days) {
+function createLCookie(name,value,days) {
var domain = "; domain=" + cur_domain;
if (days) {
var date = new Date();
@@ -21,20 +21,30 @@ function createCookie(name,value,days) {
var expires="; expires="+date.toGMTString();
}
else expires="";
- document.cookie=cookieName(name)+"="+value+expires+domain+"; path=/";
+ document.cookie=name+"="+escape(value)+expires+domain+"; path=/";
}
-function readCookie(name) {
- var nameEQ=cookieName(name) + "=";
+function createCookie(name, value, days) {
+ return createLCookie(cookieName(name));
+}
+
+function readLCookie(nameEQ) {
+ nameEQ=nameEQ+'=';
var ca=document.cookie.split(';');
for(var i=0;i< ca.length;i++) {
var c =ca[i];
while(c.charAt(0)==' ') c=c.substring(1,c.length);
- if(c.indexOf(nameEQ)==0) return c.substring(nameEQ.length,c.length);
+ if(c.indexOf(nameEQ)==0) {
+ return unescape(c.substring(nameEQ.length,c.length));
+ }
}
- return '';
+ return '';
}
+function readCookie(name) {
+ var nameEQ=cookieName(name) + "=";
+ return readLCookie(nameEQ);
+}
/*function setModCookie(id, c) {
createCookie("mod", readCookie("mod") + id + "=" + c + ":");
diff --git a/r2/r2/templates/help.html b/r2/r2/templates/help.html
index f72f613e6..1662808b8 100644
--- a/r2/r2/templates/help.html
+++ b/r2/r2/templates/help.html
@@ -32,12 +32,14 @@
${help}
- ${state_button("leave" + name, name, _("here"),
- "return deletetoggle(this, disable_ui);", "",
- fmt = _("Click %(here)s to disable this feature."),
- fmt_param = "here",
- question = _("are you sure?"),
- yes = _("yes"), no = _("no"))}
+ %if c.user_is_loggedin:
+ ${state_button("leave" + name, name, _("here"),
+ "return deletetoggle(this, disable_ui);", "",
+ fmt = _("Click %(here)s to disable this feature."),
+ fmt_param = "here",
+ question = _("are you sure?"),
+ yes = _("yes"), no = _("no"))}
+ %endif
${state_button("nvm" + name, name, _("here"),
"hide('%s-help'); return false;" % name, "",
fmt = _("Click %(here)s to close help."),
diff --git a/r2/r2/templates/link.html b/r2/r2/templates/link.html
index 96431a1a1..220cc44c9 100644
--- a/r2/r2/templates/link.html
+++ b/r2/r2/templates/link.html
@@ -23,6 +23,7 @@
<%!
from r2.models.subreddit import Default
from r2.lib.template_helpers import get_domain
+ from r2.lib import tracking
%>
<%inherit file="printable.html"/>
@@ -81,6 +82,24 @@
${self.admintagline()}
${self.mediadiv()}
+ %if thing.promoted:
+ <%
+ tracking_url = tracking.PromotedLinkInfo.gen_url(fullname=thing._fullname)
+ %>
+
+
+ %endif
%def>
<%def name="subreddit()" buffered="True">
diff --git a/r2/r2/templates/promote.html b/r2/r2/templates/promote.html
new file mode 100644
index 000000000..bc4ef6fac
--- /dev/null
+++ b/r2/r2/templates/promote.html
@@ -0,0 +1,42 @@
+## 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-2008
+## CondeNet, Inc. All Rights Reserved.
+################################################################################
+<%namespace file="utils.html" import="plain_link"/>
+<%namespace file="printable.html" import="state_button"/>
+
+
+
+
${_('current promotions')}
+
+
+ %for t in thing.things:
+ -
+ ${plain_link(t.title,t.permalink)}
+
+
+ %endfor
+
+
diff --git a/r2/r2/templates/redditfooter.html b/r2/r2/templates/redditfooter.html
index eeef440ce..1894805ff 100644
--- a/r2/r2/templates/redditfooter.html
+++ b/r2/r2/templates/redditfooter.html
@@ -44,7 +44,7 @@
dict(year=datetime.datetime.now().timetuple()[0])}
%if g.tracker_url:
-
+
%endif
%if c.frameless_cname:
<%