diff --git a/flask_website/__init__.py b/flask_website/__init__.py
index 8f84970e..5246a6ee 100644
--- a/flask_website/__init__.py
+++ b/flask_website/__init__.py
@@ -34,3 +34,7 @@ app.register_module(snippets)
app.register_module(extensions)
from flask_website.database import User, db_session
+from flask_website import utils
+
+app.jinja_env.filters['datetimeformat'] = utils.format_datetime
+app.jinja_env.filters['timedeltaformat'] = utils.format_timedelta
diff --git a/flask_website/static/style.css b/flask_website/static/style.css
index 7c346404..6a492d8d 100644
--- a/flask_website/static/style.css
+++ b/flask_website/static/style.css
@@ -1,38 +1,44 @@
-body { font-family: 'Georgia', serif; font-size: 17px; color: #000; }
-a { color: #004B6B; }
-a:hover { color: #6D4100; }
-.box { width: 540px; margin: 40px auto; }
-h2, h3 { font-family: 'Garamond', 'Georgia', serif; font-weight: normal; }
-h1 { margin: 0 0 30px 0; background: url(/static/flask.png) no-repeat center;
- height: 165px; }
-h1 span { display: none; }
-h2 { font-size: 28px; margin: 15px 0 5px 0; }
-h3 { font-size: 22px; margin: 15px 0 5px 0; }
+body { font-family: 'Georgia', serif; font-size: 17px; color: #000; }
+a { color: #004B6B; }
+a:hover { color: #6D4100; }
+.box { width: 540px; margin: 40px auto; }
+h2, h3 { font-family: 'Garamond', 'Georgia', serif; font-weight: normal; }
+h1 { margin: 0 0 30px 0; background: url(/static/flask.png) no-repeat center;
+ height: 165px; }
+h1 span { display: none; }
+h2 { font-size: 28px; margin: 15px 0 5px 0; }
+h3 { font-size: 22px; margin: 15px 0 5px 0; }
textarea, code,
-pre { font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono',
- monospace!important; font-size: 15px; background: #eee; }
-pre { padding: 7px 30px; margin: 15px -30px; line-height: 1.3; }
-.ig { color: #888; }
-p { line-height: 1.4; }
-ul { margin: 15px 0 15px 0; padding: 0; list-style: none; line-height: 1.4; }
+pre { font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono',
+ monospace!important; font-size: 15px; background: #eee; }
+pre { padding: 7px 30px; margin: 15px -30px; line-height: 1.3; }
+.ig { color: #888; }
+p { line-height: 1.4; }
+ul { margin: 15px 0 15px 0; padding: 0; list-style: none; line-height: 1.4; }
ul li:before { content: "\00BB \0020"; color: #888; position: absolute; margin-left: -19px; }
-ol { line-height: 1.4; margin: 15px 0 15px 30px; padding: 0; }
-blockquote { margin: 0; font-style: italic; color: #444; }
-.footer { font-size: 13px; color: #888; text-align: right; margin-top: 25px; }
-.nav { text-align: center; }
-.nav a { font-style: italic; }
-.backnav { float: right; color: #444; font-style: italic;
- margin: 5px 0 0 0; font-size: 0.9em; }
-.message { background: #DEEBF3; color: #004B6B; padding: 5px 30px;
- margin: 10px -30px; }
-.actions { margin-top: 0; }
-table { border: 1px solid black; border-collapse: collapse;
- margin: 15px 0; }
-td, th { border: 1px solid black; padding: 4px 10px;
- text-align: left; }
-th { background: #eee; font-weight: normal; }
+ol { line-height: 1.4; margin: 15px 0 15px 30px; padding: 0; }
+blockquote { margin: 0; font-style: italic; color: #444; }
+.footer { font-size: 13px; color: #888; text-align: right; margin-top: 25px; }
+.nav { text-align: center; }
+.nav a { font-style: italic; }
+.backnav { float: right; color: #444; font-style: italic;
+ margin: 5px 0 0 0; font-size: 0.9em; }
+.message { background: #DEEBF3; color: #004B6B; padding: 5px 30px;
+ margin: 10px -30px; }
+.actions { margin-top: 0; }
+.twitter:before { background: url(twitter.png) no-repeat; content: " ";
+ float: right; width: 64px; height: 64px;
+ margin: -25px 0 0 0; padding: 0 0 10px 10px; }
+.twitter li { margin: 15px 0; line-height: 1.2; font-size: 15px; }
+.twitter li:before { content: "♯"; padding-left: 4px; }
+.twitter .meta, .twitter .meta a { color: #888; font-size: 13px; }
+table { border: 1px solid black; border-collapse: collapse;
+ margin: 15px 0; }
+td, th { border: 1px solid black; padding: 4px 10px;
+ text-align: left; }
+th { background: #eee; font-weight: normal; }
-td input { border: none; padding: 0; }
+td input { border: none; padding: 0; }
/* forms */
input, textarea, select { border: 1px solid black; padding: 2px; background: white;
diff --git a/flask_website/static/twitter.png b/flask_website/static/twitter.png
new file mode 100755
index 00000000..bdffd361
Binary files /dev/null and b/flask_website/static/twitter.png differ
diff --git a/flask_website/templates/general/index.html b/flask_website/templates/general/index.html
index d9a9a27f..ebeb1796 100644
--- a/flask_website/templates/general/index.html
+++ b/flask_website/templates/general/index.html
@@ -5,6 +5,7 @@
h1 { margin: 0 0 30px 0; background: url(/static/logo.png) no-repeat center; height: 165px; }
h1 span, p.tagline { display: none; }
+
{% endblock %}
{% block body_title %}
{{ super() }}
@@ -67,6 +68,22 @@ def hello():
create a new ticket or fork. If you just want to chat with fellow
developers, go to #pocoo on irc.freenode.net.
+ {% if tweets %}
+
+ What people say about Flask on Twitter: +
{% endblock %}
diff --git a/flask_website/templates/mailinglist/show_thread.html b/flask_website/templates/mailinglist/show_thread.html
index 8e4b3117..67274ac1 100644
--- a/flask_website/templates/mailinglist/show_thread.html
+++ b/flask_website/templates/mailinglist/show_thread.html
@@ -25,7 +25,7 @@
{{ mail.rendered_text() }}
{% if mail.children %}
diff --git a/flask_website/twitter.py b/flask_website/twitter.py
new file mode 100644
index 00000000..5ebb9885
--- /dev/null
+++ b/flask_website/twitter.py
@@ -0,0 +1,91 @@
+from __future__ import with_statement
+import urllib2
+import time
+import threading
+from flask import json, Markup
+from werkzeug import url_encode, parse_date
+
+
+class SearchResult(object):
+
+ def __init__(self, result):
+ self.text = Markup(result['text']).unescape()
+ self.user = result['from_user']
+ self.via = Markup(Markup(result['source']).unescape())
+ self.pub_date = parse_date(result['created_at'])
+ self.profile_image = result['profile_image_url']
+ self.type = result['metadata']['result_type']
+ self.retweets = result['metadata'].get('recent_retweets') or 0
+
+
+class SearchQuery(object):
+ fetch_timeout = 10
+
+ def __init__(self, required=None, optional=None, limit=5, timeout=60, lang=None):
+ self.required = set(x.lower() for x in (required or ()))
+ self.optional = set(x.lower() for x in (optional or ()))
+ self.limit = limit
+ self.lang = lang
+ self.timeout = timeout
+ self._last_fetch = 0
+ self._last_scheduled_fetch = 0
+ self._cached = None
+
+ @property
+ def query(self):
+ def _quote_if(x):
+ if len(x.split()) != 1:
+ return u'"%s"' % x
+ return x
+ q = u' '.join(map(_quote_if, self.required))
+ q += u' ' + u' OR '.join(map(_quote_if, self.optional))
+ return q
+
+ @property
+ def feed_url(self):
+ return self.get_url(kind='atom')
+
+ def get_url(self, kind='json'):
+ return 'http://search.twitter.com/search.%s?%s' % (kind, url_encode({
+ 'q': self.query,
+ 'result_type': 'mixed',
+ 'lang': self.lang
+ }))
+
+ def fetch(self):
+ def _accept(text):
+ text = text.lower()
+ for word in self.required:
+ if word not in text:
+ return False
+ for word in self.optional:
+ if word in text:
+ return True
+ return False
+ rv = json.load(urllib2.urlopen(self.get_url()))
+ return [SearchResult(x) for x in rv['results'] if
+ _accept(x['text'])]
+
+ @property
+ def up_to_date(self):
+ return time.time() < self._last_fetch + self.timeout
+
+ def _try_refresh(self):
+ if self.up_to_date:
+ return
+ if time.time() > self._last_scheduled_fetch + self.fetch_timeout:
+ self._last_scheduled_fetch = time.time()
+ threading.Thread(target=self._fetch_and_store).start()
+
+ def _fetch_and_store(self):
+ self._cached = self.fetch()
+ self._last_fetch = time.time()
+
+ def __len__(self):
+ self._try_refresh()
+ return len(self._cached or ())
+
+ def __iter__(self):
+ self._try_refresh()
+ for item in (self._cached or ())[:self.limit]:
+ yield item
diff --git a/flask_website/utils.py b/flask_website/utils.py
index eacdb77f..f63a2a41 100644
--- a/flask_website/utils.py
+++ b/flask_website/utils.py
@@ -1,5 +1,6 @@
import re
import creoleparser
+from datetime import datetime, timedelta
from genshi import builder
from functools import wraps
from creoleparser.elements import PreBlock
@@ -17,6 +18,17 @@ pygments_formatter = HtmlFormatter(style=FlaskyStyle)
_ws_split_re = re.compile(r'(\s+)')
+TIMEDELTA_UNITS = (
+ ('year', 3600 * 24 * 365),
+ ('month', 3600 * 24 * 30),
+ ('week', 3600 * 24 * 7),
+ ('day', 3600 * 24),
+ ('hour', 3600),
+ ('minute', 60),
+ ('second', 1)
+)
+
+
class CodeBlock(PreBlock):
def __init__(self):
@@ -94,3 +106,29 @@ def requires_admin(f):
abort(401)
return f(*args, **kwargs)
return requires_login(decorated_function)
+
+
+def format_datetime(dt):
+ return dt.strftime('%Y-%m-%d @ %H:%M')
+
+
+def format_timedelta(delta, granularity='second', threshold=.85):
+ if isinstance(delta, datetime):
+ delta = datetime.utcnow() - delta
+ if isinstance(delta, timedelta):
+ seconds = int((delta.days * 86400) + delta.seconds)
+ else:
+ seconds = delta
+
+ for unit, secs_per_unit in TIMEDELTA_UNITS:
+ value = abs(seconds) / secs_per_unit
+ if value >= threshold or unit == granularity:
+ if unit == granularity and value > 0:
+ value = max(1, value)
+ value = str(int(round(value)))
+ value += u' ' + unit
+ if value != 1:
+ value += u's'
+ return value
+
+ return u''
diff --git a/flask_website/views/general.py b/flask_website/views/general.py
index 7854d8d2..188a5efa 100644
--- a/flask_website/views/general.py
+++ b/flask_website/views/general.py
@@ -1,14 +1,20 @@
from flask import Module, render_template, session, redirect, url_for, \
request, flash, g, Response
-from flask_website import oid
+from flask_website import oid, twitter
from flask_website.database import db_session, User
general = Module(__name__)
+tweets = twitter.SearchQuery(required=['flask'],
+ optional=['code', 'web', 'python', 'py',
+ 'pocoo', 'micro', 'mitsuhiko',
+ 'framework', 'django', 'jinja',
+ 'werkzeug', 'pylons'],
+ lang='en')
@general.route('/')
def index():
- return render_template('general/index.html')
+ return render_template('general/index.html', tweets=tweets)
@general.route('/logout/')