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
20 from sqlalchemy
import *
21 import logging
# this masks sqlalchemy's logging variable
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',
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
)
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",
64 posts_table
.create(checkfirst
=True)
66 ONE_DAY
= datetime
.timedelta(1)
69 # this is accepted by sqlite
71 # http://www.sqlite.org/cvstrac/wiki?p=DateAndTimeFunctions
72 return d
.strftime("%Y-%m-%d")
76 """A PostGroup refers to a day's posts.
79 class NoPosts(Exception): pass
81 def __init__(self
, day
, posts
):
85 `day' is the day corresponding to this post group.
86 `posts' is the ordered list of posts belonging to that day.
92 prev_post
= self
.posts
[-1].prev()
96 return PostGroup
.for_day(prev_post
.day())
99 next_post
= self
.posts
[0].next()
100 if next_post
is None:
103 return PostGroup
.for_day(next_post
.day())
105 def permalink(self
, month_view
=False):
107 return link_to("%d/%.2d#%.2d" %
108 (self
.day
.year
, self
.day
.month
, self
.day
.day
))
110 return link_to("%d/%.2d/%.2d" %
111 (self
.day
.year
, self
.day
.month
, self
.day
.day
))
114 "Uniq id for this group"
115 return self
.day
.strftime("%Y%m%d")
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
)
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
,
136 ).execute().fetchall()
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
,
150 ).execute().fetchall()
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
)],
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
,
176 if skip_today
and end_date
== Post
.today():
179 return PostGroup
.for_range(start_date
, end_date
)
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
)
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
)
198 if grouped_posts
and post
.day() == grouped_posts
[-1].day
:
199 grouped_posts
[-1].posts
.append(post
)
201 grouped_posts
.append( PostGroup(post
.day(), [post
]) )
207 "Return PostGroup for the given day."
208 first
, = PostGroup
.for_range(day
, day
)
215 return datetime
.date(self
.time
.year
,
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
)])
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
)])
233 "Return the last/recent post"
234 query
= create_session().query(Post
)
235 return query
.selectfirst(order_by
=[desc(posts_table
.c
.time
)])
239 "Return the first/old post"
240 query
= create_session().query(Post
)
241 return query
.selectfirst(order_by
=[asc(posts_table
.c
.time
)])
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
262 '/(\d{4})/(\d{2})/(\d{2})', 'view_day',
263 '/(\d{4})/(\d{2})', 'view_month',
264 '/(\d{4})', 'view_year',
266 '/post/(.*)', 'post',
271 # prefixurl is not "the right" way of generating
272 # urls, because we need "absolute" urls
274 # FIXME: root = web.prefixurl()
275 root
= "http://localhost:8080/" #"http://nearfar.org/stream/"
276 return root
+ "/".join(map(str, segs
))
280 groups
= PostGroup
.recent()
281 print render("view.html",
283 year
=None,month
=None,day
=None,
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",
293 year
=year
, month
=month
, day
=day
,
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",
310 year
=year
, month
=month
, day
=None,
316 web
.header("Content-Type","text/html; charset=utf-8")
318 count_hash
= PostGroup
.months(year
)
320 print render("year.html",
322 year
=year
, month
=None, day
=None,
327 """Return atom feed of recent entries (but
328 excluding the 'partial today')
330 print render("feed.xml",
331 content_type
="application/atom+xml",
333 self_href
=link_to('feed'),
334 groups
=PostGroup
.recent(skip_today
=True))
337 GET
= web
.autodelegate('GET_')
338 POST
= web
.autodelegate('POST_')
341 template("edit", content
="",
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
,
352 title_prefix
="Edit Entry #%d" % post_id
)
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()
365 post
.content
= input.content
367 query
= session
.query(Post
)
368 post
= query
.get_by(post_id
=post_id
)
369 post
.content
= input.content
374 web
.seeother(link_to())
379 date
= datetime
.date(*strptime(input.day
, "%Y%m%d")[0:3]
380 ).strftime("%A %b %d, %Y")
381 content
= input.content
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
,
390 template(["Your comment was sent by email. Thanks. ",
391 T
.a(href
=link_to())["Go back"]
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
)):
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
)
418 if sts
is not None and sts
!= 0:
419 logging
.error("sendmail failed with exit status: %d. Message follows,",
421 logging
.info("From: %s", from_
)
422 logging
.info("Subject: %s", subject
)