importing stream into git
[stream.git] / stream.py
blob06e91c135937f02b4a23b24344fde97f4ad48ede
1 # stream: the weblog killer!
3 # the basic idea behind stream is you select interesting things
4 # out of your thoughts stream and share it with the world. They
5 # may or may not be interesting to the target reader at the
6 # moment, but you keep contributing!
8 # the feed (rss, atom) has 'per day' items. This means all things
9 # posted that day go in a single feed entry. This, IMO,
10 # encourages Flow, the mental state.
12 # ..but by all means, cease bragging.
14 # -*- mode: python -*-
16 import os, re, datetime, calendar
17 from time import strptime
18 import smtplib
20 from sqlalchemy import *
21 import logging # this masks sqlalchemy's logging variable
23 import web
24 web.webapi.internalerror = web.debugerror
26 from genshi.template import TemplateLoader
27 from genshi.core import Markup
28 from genshi.builder import tag as T
31 LOG_FILE = "/home/protected/logs/stream.log"
32 UTC_OFFSET = ("+5 hours", "+30 minutes") # adheres to sqlite
33 SQLITE_CONN = "sqlite:////home/protected/data/stream.db"
34 SECRET_FILE = "~/.stream.secret"
35 SUBTITLE = "to extend, wave or float outward, as if in the wind"
37 logging.basicConfig(level=logging.INFO,
38 format='%(asctime)s %(levelname)s %(message)s',
39 filename=LOG_FILE)
42 loader = TemplateLoader(["templates"], auto_reload=True)
44 def render(name, **namespace):
45 "Render the genshi template `name' using `namespace'"
46 tmpl = loader.load(name)
47 namespace.update(globals()) # we need not pass common methods explicitly
48 stream = tmpl.generate(**namespace)
49 web.header(
50 "Content-Type","%s; charset=utf-8" % namespace.get(
51 'content_type', 'text/html'))
52 return stream.render(namespace.get('stream_kind', 'html'))
56 metadata = BoundMetaData(SQLITE_CONN)
57 posts_table = Table("posts", metadata,
58 Column("post_id", Integer,
59 primary_key=True, autoincrement=True),
60 Column("content", String),
61 Column("time", DateTime,
62 default=func.datetime("now", "UTC",
63 *UTC_OFFSET)))
64 posts_table.create(checkfirst=True)
66 ONE_DAY = datetime.timedelta(1)
68 def sqlite_day(d):
69 # this is accepted by sqlite
70 # YYYY-MM-DD
71 # http://www.sqlite.org/cvstrac/wiki?p=DateAndTimeFunctions
72 return d.strftime("%Y-%m-%d")
75 class PostGroup:
76 """A PostGroup refers to a day's posts.
77 . """
79 class NoPosts(Exception): pass
81 def __init__(self, day, posts):
82 """
83 Properties:
85 `day' is the day corresponding to this post group.
86 `posts' is the ordered list of posts belonging to that day.
87 """
88 self.day = day
89 self.posts = posts
91 def prev(self):
92 prev_post = self.posts[-1].prev()
93 if prev_post is None:
94 return None
96 return PostGroup.for_day(prev_post.day())
98 def next(self):
99 next_post = self.posts[0].next()
100 if next_post is None:
101 return None
103 return PostGroup.for_day(next_post.day())
105 def permalink(self, month_view=False):
106 if month_view:
107 return link_to("%d/%.2d#%.2d" %
108 (self.day.year, self.day.month, self.day.day))
109 else:
110 return link_to("%d/%.2d/%.2d" %
111 (self.day.year, self.day.month, self.day.day))
113 def id(self):
114 "Uniq id for this group"
115 return self.day.strftime("%Y%m%d")
117 def title(self):
118 return self.day.strftime("%A %b %d, %Y")
120 def last_updated(self):
121 ".. is the time of most recent post in this group"
122 def rfc3339_date(date):
123 return date.strftime('%Y-%m-%dT%H:%M:%SZ')
124 return rfc3339_date(self.posts[0].time)
126 @staticmethod
127 def months(in_year):
128 "Return hash of month => count of post_group's'"
129 r = select([func.strftime("%m", posts_table.c.time, "UTC", *UTC_OFFSET),
130 func.count(posts_table.c.post_id)],
131 func.strftime("%Y",posts_table.c.time,
132 "UTC", *UTC_OFFSET)==str(in_year),
133 engine=metadata.get_engine(),
134 group_by=[func.strftime("%m", posts_table.c.time,
135 "UTC", *UTC_OFFSET)]
136 ).execute().fetchall()
137 count = {}
138 for m,c in r:
139 count[int(m)] = c
140 return count
142 @staticmethod
143 def years():
144 "Return hash of year => count of post_group's'"
145 r = select([func.strftime("%Y", posts_table.c.time, "UTC", *UTC_OFFSET),
146 func.count(posts_table.c.post_id)],
147 engine=metadata.get_engine(),
148 group_by=[func.strftime("%Y", posts_table.c.time,
149 "UTC", *UTC_OFFSET)]
150 ).execute().fetchall()
151 count = {}
152 for y,c in r:
153 count[int(y)] = c
154 return count
156 @staticmethod
157 def recent(limit_days=10, skip_today=False):
158 end_date = Post.last().day()
160 # Find the day in which limit_days'th post lies.
161 s = select([posts_table.c.time.label("time")],
162 engine=metadata.get_engine(),
163 order_by=[desc(posts_table.c.time)],
164 group_by=[func.strftime("%Y-%m-%d", posts_table.c.time,
165 "UTC", *UTC_OFFSET)],
166 use_labels=True
168 dates = select([s.c.time],
169 limit=limit_days).execute().fetchall()
171 logging.debug("dates are: %s" % dates)
172 start_date = datetime.date(dates[-1][0].year,
173 dates[-1][0].month,
174 dates[-1][0].day)
176 if skip_today and end_date == Post.today():
177 end_date -= ONE_DAY
179 return PostGroup.for_range(start_date, end_date)
181 @staticmethod
182 def for_range(start_date, end_date):
183 """Return all PostGroup's for given range in
184 descending order of post time.
186 logging.debug("for_range: %s - %s", start_date, end_date)
187 query = create_session().query(Post)
188 s = sqlite_day
190 posts = query.select(and_(posts_table.c.time < s(end_date + ONE_DAY),
191 posts_table.c.time >= s(start_date)),
192 order_by=[desc(posts_table.c.time)])
194 logging.debug("Posts returned: %s" % posts)
195 # group them...
196 grouped_posts = []
197 for post in posts:
198 if grouped_posts and post.day() == grouped_posts[-1].day:
199 grouped_posts[-1].posts.append(post)
200 else:
201 grouped_posts.append( PostGroup(post.day(), [post]) )
203 return grouped_posts
205 @staticmethod
206 def for_day(day):
207 "Return PostGroup for the given day."
208 first, = PostGroup.for_range(day, day)
209 return first
212 class Post(object):
214 def day(self):
215 return datetime.date(self.time.year,
216 self.time.month,
217 self.time.day)
219 def prev(self):
220 "Get the previous post (posted before self)"
221 query = create_session().query(Post)
222 return query.selectfirst(posts_table.c.time < self.time,
223 order_by=[desc(posts_table.c.time)])
225 def next(self):
226 "Get the next post (posted after self)"
227 query = create_session().query(Post)
228 return query.selectfirst(posts_table.c.time > self.time,
229 order_by=[asc(posts_table.c.time)])
231 @staticmethod
232 def last():
233 "Return the last/recent post"
234 query = create_session().query(Post)
235 return query.selectfirst(order_by=[desc(posts_table.c.time)])
237 @staticmethod
238 def first():
239 "Return the first/old post"
240 query = create_session().query(Post)
241 return query.selectfirst(order_by=[asc(posts_table.c.time)])
243 @staticmethod
244 def today():
245 "Return today's date"
246 today, = select([func.datetime("now", "UTC", *UTC_OFFSET)],
247 engine=metadata.get_engine()
248 ).execute().fetchone()
249 return datetime.date(*strptime(today, "%Y-%m-%d %H:%M:%S")[0:3])
252 mapper(Post, posts_table)
254 def match_secret(secret):
255 return open(os.path.expanduser(SECRET_FILE)).read().strip() == secret
257 # HTTP
258 # ----
260 urls = (
261 '/', 'index',
262 '/(\d{4})/(\d{2})/(\d{2})', 'view_day',
263 '/(\d{4})/(\d{2})', 'view_month',
264 '/(\d{4})', 'view_year',
265 '/feed', 'feed',
266 '/post/(.*)', 'post',
267 '/bubble', 'bubble',
270 def link_to(*segs):
271 # prefixurl is not "the right" way of generating
272 # urls, because we need "absolute" urls
273 # but anyways...
274 # FIXME: root = web.prefixurl()
275 root = "http://localhost:8080/" #"http://nearfar.org/stream/"
276 return root + "/".join(map(str, segs))
278 class index:
279 def GET(self):
280 groups= PostGroup.recent()
281 print render("view.html",
282 title="stream",
283 year=None,month=None,day=None,
284 groups=groups,
285 single_group=False)
287 class view_day:
288 def GET(self, year, month, day):
289 year, month, day = map(int, [year, month, day])
290 g = PostGroup.for_day(datetime.date(year, month, day))
291 print render("view.html",
292 title=g.title(),
293 year=year, month=month, day=day,
294 groups=[g],
295 single_group=True)
297 class view_month:
298 def GET(self, year, month):
299 year, month= map(int, [year, month])
301 wd, nr_days = calendar.monthrange(year, month)
302 end_day = datetime.date(year, month, nr_days)
303 start_day = datetime.date(year, month, 1)
305 groups = PostGroup.for_range(start_day, end_day)
306 title = start_day.strftime("%b, %Y")
308 print render("view.html",
309 title=title,
310 year=year, month=month, day=None,
311 groups=groups,
312 single_group=False)
314 class view_year:
315 def GET(self, year):
316 web.header("Content-Type","text/html; charset=utf-8")
317 year = int(year)
318 count_hash = PostGroup.months(year)
320 print render("year.html",
321 title=str(year),
322 year=year, month=None, day=None,
323 months=count_hash)
325 class feed:
326 def GET(self):
327 """Return atom feed of recent entries (but
328 excluding the 'partial today')
330 print render("feed.xml",
331 content_type="application/atom+xml",
332 stream_kind="xml",
333 self_href=link_to('feed'),
334 groups=PostGroup.recent(skip_today=True))
336 class post:
337 GET = web.autodelegate('GET_')
338 POST = web.autodelegate('POST_')
340 def GET_new(self):
341 template("edit", content="",
342 id="-1",
343 title_prefix="New Entry")
345 def GET_edit(self, post_id):
346 post_id = int(post_id[1:])
347 session = create_session()
348 post = session.query(Post).get_by_post_id(post_id)
350 template("edit", content=post.content,
351 id=str(post_id),
352 title_prefix="Edit Entry #%d" % post_id)
354 def POST_save(self):
355 input = web.input()
356 post_id = int(input.id)
357 if not match_secret(input.secret):
358 return T.p[ "Hmm.. ya cant touch that." ]
360 session = create_session()
362 if post_id == -1:
363 # new post
364 post = Post()
365 post.content = input.content
366 else:
367 query = session.query(Post)
368 post = query.get_by(post_id=post_id)
369 post.content = input.content
371 session.save(post)
372 session.flush()
374 web.seeother(link_to())
376 class bubble:
377 def POST(self):
378 input = web.input()
379 date = datetime.date(*strptime(input.day, "%Y%m%d")[0:3]
380 ).strftime("%A %b %d, %Y")
381 content = input.content
382 email = input.email
384 if not self.valid_email(email):
385 template(["That was not a valid email address. Sorry."])
387 elif send_email(email, ["Sridhar.Ratna+stream@gmail.com"],
388 "[Stream] %s" % date,
389 content):
390 template(["Your comment was sent by email. Thanks. ",
391 T.a(href=link_to())["Go back"]
393 else:
394 template(["There was an ", T.b["error"],
395 " sending your comment. I will look into it shortly. ",
396 T.a(href=link_to())["Go back"]
399 def valid_email(self, email):
400 """validate email address
401 - http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/65215
403 EMAIL_RE = "^.+\\@(\\[?)[a-zA-Z0-9\\-\\.]+\\.([a-zA-Z]{2,3}|[0-9]{1,3})(\\]?)$"
404 if not(len(email) > 7 and re.match(EMAIL_RE, email)):
405 return False
406 return True
409 def send_email(from_, toaddrs, subject, body):
410 SENDMAIL = "sendmail" # sendmail location
411 p = os.popen("%s -t" % SENDMAIL, "w")
412 p.write("From: %s\n" % from_)
413 p.write("To: %s\n" % ",".join(toaddrs))
414 p.write("Subject: %s\n" % subject)
415 p.write("\n")
416 p.write(body)
417 sts = p.close()
418 if sts is not None and sts != 0:
419 logging.error("sendmail failed with exit status: %d. Message follows,",
420 sts)
421 logging.info("From: %s", from_)
422 logging.info("Subject: %s", subject)
423 logging.info("")
424 logging.info(body)
425 return False
426 return True