...
[limo.git] / modules / core.py
blobb68edf3be7a0e29a9e2ed96c662040891f913626
1 #!/usr/bin/env python
3 from __future__ import with_statement
4 import re, datetime, time, os, sys, gzip, math, compress, hashlib, httplib2
5 from cache import Cache
6 from template import Template
7 from db import Query
8 from errors import *
9 from session import User
10 from settings import Settings
11 from model import LimoModel
12 from theme import Theme
13 from limoutil import *
15 class LimoBlock(Loggable):
16 """ LimoBlock is the very basic block object, it represents some block on content, and its associated actions.
17 Most people will want to inherit from HTMLBlock, which adds several html specific capabilities.
18 """
19 def __init__(self, request, response, session):
20 self.id = self.__class__.__name__.lower().replace('block','')
21 self.request = request
22 self.response = response
23 self.session = session
24 self.url = [u for u in request["REQUEST_URI"].split("/") if u is not '']
25 self.args = {}
26 # build self.args from both the query_string and the post_data
27 for p in request["QUERY_STRING"].split("&"):
28 t = p.split("=")
29 if len(t) == 2:
30 self.args[t[0]] = t[1]
31 else:
32 self.args[p] = True
33 for p in request["POST_DATA"].split("&"):
34 t = p.split("=")
35 if len(t) == 2:
36 self.args[t[0]] = t[1]
37 elif len(p) > 0:
38 self.args[p] = True
39 def init(self):
40 """
41 this is not __init__()
42 its not for object creation, its used as phase-1 of the rendering process
43 do things like requireCSS, requireJS, set headers, cookies, etc. here
44 """
45 pass
46 def render(self, next=None):
47 """ render() is a generator function that yields some block of content.
48 If this Block is rendering as part of a pipeline of blocks,
49 'next' will be a generator for getting the rest of the stuff from the pipe
50 """
51 yield next
52 def action(self):
53 """ Sub-classes that define action() are expected to raise exceptions.
54 HTTPRedirect, and HTTPStatusError exceptions will be written back to the browser,
55 any other exception is considered an action failure and the exception is propagated.
56 """
57 pass
60 class HTMLBlock(LimoBlock):
61 def __init__(self, request, response, session):
62 LimoBlock.__init__(self, request, response, session)
63 self.title = ''
64 self.body = None
65 self.before = ''
66 self.after = ''
68 def init(self):
69 self.requireCSS("main.css")
71 def render(self, next=None):
72 """ render() is a generator function that yields some block of content.
73 If this Block is rendering as part of a pipeline of blocks,
74 'next' will be a generator for getting all the stuff rendered before us in the pipe
75 """
76 if self.body is not None:
77 yield self.template("block", {'id': self.id, 'body': self.body, 'title': self.title, 'after': self.after, 'before': self.before })
78 yield next
80 def getFile(self, file,_theme=None):
81 if _theme is None:
82 _theme = self.session.data.get('theme','default')
83 return Theme().getFile(file, _theme)
85 def setPageTitle(self, title, append=True):
86 """ Set some text for the page title. """
87 HeadBlock.setTitle(title,append)
89 def _getRemoteFile(self, file):
90 """ file is a remote url, which will be fetched with httplib2, written to disk, and its file name returned. """
91 fname = file.split("?")[0]
92 fname = re.sub("[^A-Za-z0-9_]","_", fname)
93 fname = os.path.sep.join((Settings.cacheDir,"_".join((fname, hashlib.sha1(file).hexdigest()))))
94 if not os.path.isfile(fname):
95 self.log("Downloading %s into %s" % (file,fname))
96 (response, content) = httplib2.Http().request(file)
97 with open(fname, "wb") as out:
98 out.write(content)
99 return fname
101 def requireCSS(self, file):
102 """ Add a CSS file to the aggreggator for this page. """
103 if file[:6] in ("http:/","https:"):
104 fname = self._getRemoteFile(file)
105 Cache.CSS.defaultFile(fname)
106 else:
107 Cache.CSS.defaultFile(self.getFile(("css",file,)))
109 def requireJS(self, file):
110 """ Add a JS file to the aggreggator for this page. """
111 if file[:6] in ("http:/","https:"):
112 fname = self._getRemoteFile(file)
113 Cache.JS.defaultFile(fname)
114 else:
115 Cache.JS.defaultFile(self.getFile(("js",file,)))
117 def template(self, name, params):
118 """ Render the named template using the specified params.
120 if not name.endswith(".tpl"):
121 name += ".tpl"
122 yield Template.render(self.getFile(("templates",name,)), params)
124 def meta(self, **kw):
125 """ Add a <meta> tag to the <head>.
126 All keyword arguments become attributes.
128 HeadBlock.addMeta(**kw)
130 def link(self, **kw):
131 """ Add a <link> tag to the <head>.
132 All keyword arguments become attributes.
134 HeadBlock.addLink(**kw)
136 class TestBlockA(LimoBlock):
137 def render(self, next=None):
138 yield "rendered A: "
139 yield LimoBlock.render(self, next)
140 class TestBlockB(LimoBlock):
141 def render(self, next=None):
142 yield "rendered B: "
143 yield LimoBlock.render(self, next)
144 class TestBlockC(HTMLBlock):
145 def render(self, next=None):
146 self.title = "TestBlock C"
147 self.body = next
148 self.after = "after C"
149 yield HTMLBlock.render(self, next)
151 class EmptyBlock(LimoBlock):
152 def render(self, next=None):
153 yield None
155 class SessionBlock(HTMLBlock):
156 def action(self):
157 # self.log("args = %s" % self.args)
158 if self.args.get('action', False) == 'login':
159 u = User.getUser(self.args.get('username',None), self.args.get('password',None))
160 assert u, "Login failed: No such username/password"
161 self.session.user = u
162 assert self.session.hasRole('login'), "Account does not have login permission"
163 self.session.uid = u.uid
164 self.session.addMessage("%s, Thank you for logging in!" % u.name)
165 elif self.args.get('action', False) == 'edit':
166 uid = int(self.args.get('uid', 0))
167 assert uid > 0, "No user"
168 assert (self.session.uid == uid or self.session.hasRole('admin')), "Forbidden"
169 user = User(uid)
170 if self.args.get('newpass', False):
171 user.setPassword(self.args['newpass'])
172 self.session.addMessage("Password updated.")
173 elif self.args.get('action', False) == 'logout':
174 if self.session.user is not None:
175 self.session.uid = 0
176 self.session.user = None
177 self.session.addMessage("Goodbye!")
178 def render(self, next=None):
179 if self.session.user is None:
180 args = { 'username': '' }
181 args.update(self.args)
182 self.title = self.template("title-login", args)
183 self.body = self.template("form-login", args)
184 else:
185 self.title = self.template("title-welcome", {'name': self.session.user.name})
186 self.body = self.template("body-welcome", {'name': self.session.user.name})
187 yield HTMLBlock.render(self, next)
189 class VisitBlock(HTMLBlock):
190 def render(self, next=None):
191 page = int(self.args.get('p',1))
192 perpage = int(self.args.get('pp',10))
193 rows = Query("select * from visits order by vid desc limit %d, %d" %((page-1)*perpage, perpage))
194 pages = Query("select count(*) from visits")[0][0]
195 pages = int(pages/perpage) + 1
196 self.title = "Visits"
197 self.body = self.template("block-visits", locals())
198 return HTMLBlock.render(self, next)
200 class DebugBlock(HTMLBlock):
201 def init(self):
202 self.requireJS("jquery.js")
203 self.requireJS("debug.js")
204 self.requireCSS("debug.css")
205 def render(self, next=None):
206 if self.session.hasRole('admin'):
207 self.title = "Debug Block"
208 queries = [(sql,ms) for (sql,ms) in Query.getQueryLog()]
209 Query.clearQueryLog()
210 self.body = self.template("body-debug", \
211 { 'counts': Template.debug_counts, \
212 'timings': Template.debug_timing, \
213 'queries': queries, \
214 'request': self.request, \
215 'response': self.response, \
216 'session': htmlescape(str(self.session)), \
218 # HACK: TODO replace this
219 Template.debug_timing.clear()
220 Template.debug_counts.clear()
221 yield HTMLBlock.render(self, next)
223 class HeadBlock(HTMLBlock):
224 tags = []
225 title = ''
226 @staticmethod
227 def addMeta(**kw):
228 HeadBlock.tags.append( ('meta', kw) )
229 @staticmethod
230 def addLink(**kw):
231 HeadBlock.tags.append( ('link', kw) )
232 @staticmethod
233 def setTitle( text, append=True ):
234 if append:
235 HeadBlock.title += text
236 else:
237 HeadBlock.title = text
239 def __del__(self):
240 HeadBlock.title = ''
241 HeadBlock.tags = []
243 def render(self, next=None):
244 if self.request.get('HTTPS','') == 'on':
245 # dont bother downloading css or js over ssl? probably should have the option... to avoid a browser warning
246 # TODO
247 js_file = "http://"+self.request.get('HTTP_HOST','server')+"/"+Cache.JS.getOutputFile().replace(os.path.sep,"/")
248 css_file = "http://"+self.request.get('HTTP_HOST','server')+"/"+Cache.CSS.getOutputFile().replace(os.path.sep,"/")
249 else:
250 js_file = "/"+Cache.JS.getOutputFile().replace(os.path.sep,"/")
251 css_file = "/"+Cache.CSS.getOutputFile().replace(os.path.sep,"/")
253 yield self.template("block-head", {
254 'title': HeadBlock.title,
255 'css_file':css_file,
256 'js_file':js_file,
257 'tags': ( ['<',tag[0]] + [' %s="%s"' % (k,v) for k,v in tag[1].items()] + [' />'] \
258 for tag in HeadBlock.tags ),
261 class StaticBlock(LimoBlock):
262 """ Serves static files.
263 Does file-cached compression along the way.
265 # date format: Thu, 01 Dec 1994 16:00:00 GMT
266 http_date_format = "%a, %d %b %Y %H:%M:%S"
267 http_date_format_tz = "%a, %d %b %Y %H:%M:%S %Z"
268 def __init__(self, request, response, session):
269 LimoBlock.__init__(self, request, response, session)
270 self.typemap = {
271 'xml': 'text/xml',
272 'png': 'image/png',
273 'ico': 'image/png',
274 'jpg': 'image/jpeg',
275 'jpeg': 'image/jpeg',
276 'gif': 'image/gif',
277 'css': 'text/css',
278 'js': 'text/javascript',
279 'html': 'text/html',
280 'htm': 'text/html'
283 def _setContentType(self, file):
284 ext = file.split(".")
285 if ext[-1] in ("gzip","deflate","gz"):
286 ext = ext[-2]
287 else:
288 ext = ext[-1]
289 try:
290 type = self.typemap[ext]
291 # set the content-type
292 # self.log("Setting Content-Type: %s" % type)
293 self.response.headers['Content-Type'] = type
294 except KeyError:
295 raise HTTPStatusError(404, "Cant determine content-type for file: %s" % file)
297 def _setExpires(self, file):
298 # set an expires date one year in the future
299 self.response.headers['Expires'] = (datetime.datetime.utcnow() + datetime.timedelta(365)).strftime(self.http_date_format_tz)
301 def _setLastModified(self, file):
302 # set the last-modified time
303 try:
304 mtime = datetime.datetime.fromtimestamp(os.path.getmtime(file))
305 mtime = mtime.replace(microsecond=0)
306 # convert the mtime to utc
307 mtime = mtime - datetime.timedelta(seconds=time.timezone)
308 # self.log("Checking mtime of file: %s %s" % (file, mtime))
309 if self.request.get('HTTP_IF_MODIFIED_SINCE',False):
310 try:
311 since = datetime.datetime.strptime(self.request['HTTP_IF_MODIFIED_SINCE'], self.http_date_format)
312 # self.log("Checking If-Modified-Since: %s" % since)
313 except ValueError:
314 try:
315 since = datetime.datetime.strptime(self.request['HTTP_IF_MODIFIED_SINCE'], self.http_date_format_tz)
316 # self.log("Checking If-Modified-Since: %s" % since)
317 except ValueError:
318 raise HTTPStatusError(400, "Invalid Header If-Modified-Since: '%s' is not a proper date format." % (self.request['HTTP_IF_MODIFIED_SINCE']))
319 # self.log("Comparing %s >= %s" % (since, mtime))
320 if since >= mtime:
321 raise HTTPStatusError(304, "Not Modified")
322 except OSError, e:
323 # always local time here because os.path.getmtime is always going to return local as well
324 self.log("Ignoring OSError: %s" % str(e))
325 mtime = datetime.datetime.utcnow()
326 if Settings.supportEtags:
327 etag = hashlib.sha1(str(mtime)).hexdigest()
328 if self.request.get('HTTP_IF_NONE_MATCH','') == etag:
329 raise HTTPStatusError(304, "Not Modified")
330 self.response.headers['ETag'] = etag
331 self.response.headers['Last-Modified'] = mtime.strftime(self.http_date_format_tz)
333 def _setCompression(self, file):
334 self.compression = Settings.getCompression(self.request.get('HTTP_USER_AGENT',''))
335 if self.request.get('HTTP_ACCEPT_ENCODING','').find(self.compression) == -1:
336 self.compression = None
337 self.zfile = None
338 elif self.response.headers.get('Content-Encoding',None) == self.compression:
339 # if there is already a compression layer in place
340 # just pass along the right cache file name
341 self.zfile = self.file+"."+self.compression
342 self.request['LIMO_CACHE_FILE'] = self.file
343 else:
344 self.response.headers['Content-Encoding'] = self.compression
345 self.response.headers['Vary'] = 'Accept-Encoding'
346 # log("core: Using compression: %s" % self.compression);
348 def init(self):
349 url = [u for u in self.url if u is not '']
350 url[0] = Settings.cacheDir
351 self.file = os.path.sep.join(url)
352 self._setContentType(self.file)
353 self._setExpires(self.file)
354 self._setLastModified(self.file)
355 self._setCompression(self.file)
357 def render(self, next=None):
358 try:
359 if None not in (self.compression, self.zfile):
360 yield compress.generate_gzip(compress.generate_file(self.file),cache_file=self.zfile)
361 else:
362 yield compress.generate_file(self.file)
363 except OSError, e:
364 if Settings.showVerbose404:
365 raise HTTPStatusError(404, s + " -> OSError: %s" % str(e))
366 else:
367 raise HTTPStatusError(404, s)
368 except IOError, e:
369 if Settings.showVerbose404:
370 raise HTTPStatusError(404, s + " -> IOError: %s" % str(e))
371 else:
372 raise HTTPStatusError(404, s)
374 class HeaderBlock(HTMLBlock):
375 def render(self, next=None):
376 self.title = ""
377 self.body = self.template("header", locals())
378 return HTMLBlock.render(self, next);
380 class RegisterBlock(HTMLBlock):
381 def action(self):
382 if self.args.get('action', False) == 'register':
383 name = self.args.get('name', False)
384 login = self.args.get('login', False)
385 password = self.args.get('password', False)
386 email = self.args.get('email', False)
387 assert name and login and password and email, "Missing required fields"
388 assert len(self.args['password']) > 4, "Your password was too short."
389 assert self.args['password'] == self.args['password2'], "Your passwords did not match."
390 assert len(self.args['email']) > 4, "Your email was too short."
391 assert self.args['email'] == self.args['email2'], "Your emails did not match."
392 uid = User.createUser(name, login, password, email)
393 assert uid, "Failed to create user, database error."
394 self.session.addMessage("<div class='success'>You are now registered as %s, your login is '%s'." % (name, login))
395 def render(self, next=None):
396 self.title = "Registration Form"
397 name = self.args.get('name', '')
398 email = self.args.get('email','')
399 email2 = self.args.get('email2','')
400 login = self.args.get('login','')
401 self.body = self.template("form-register", locals())
402 return HTMLBlock.render(self, next)
404 class AdminMenuBlock(HTMLBlock):
405 def render(self, next=None):
406 if self.session.hasRole('admin'):
407 self.title = 'Admin Menu'
408 self.body = self.template("admin-menu", {})
409 else:
410 self.title = ''
411 self.body = ''
412 yield HTMLBlock.render(self, next)
414 class ModuleBlock(HTMLBlock):
415 def init(self):
416 self.modules = Query("select mid, name, active from modules")
417 self.requireCSS("modules.css")
418 def action(self):
419 if self.args.get('action',False):
420 if self.session.hasRole('admin'):
421 name = self.args.get('name',False)
422 assert name, "'name' is required"
423 assert self.args['action'] in ("enable","disable"), "Unknown action: %s" % self.args['action']
424 if self.args['action'] == 'enable':
425 LimoModel().enableModule(name)
426 self.session.addMessage("Module enabled: %s" % name)
427 elif self.args['action'] == 'disable':
428 LimoModel().disableModule(name)
429 self.session.addMessage("Module disabled: %s" % name)
430 else:
431 raise HTTPStatusError(403, "Forbidden")
432 def render(self, next=None):
433 self.title = "Modules"
434 def body():
435 yield """
436 <ul id="module-list" class="module-list">
438 for row in modules:
439 yield self.template("module-row", {"row": row})
440 yield """
441 </ul>
443 self.body = body()
444 return HTMLBlock.render(self, next)
446 if __name__ == "__main__":
447 import doctest
448 doctest.testmod()