5494e6e44748dbcdaf493d6baa1a14cb49861c7e
[chemdb.git] / chem_web.py
1 #!/usr/bin/python
2 #
3 # Web application for posting additions/modifications to the chem-inventory
4 # derived from simplewiki and wiki_py, by Adam Bachman
5 #  ( http://bachman.infogami.com/ )
6 # Authentication following Snakelets
7 # http://snakelets.sourceforge.net/manual/authorization.html
8
9 # Remember to delete the templates directory after making alterations,
10 # because this program only generates templates if they are missing.
11
12
13 ## Bring in some useful goodies:
14 # web, because we're running a web server, and generating html;
15 # time, for timestamping backups;
16 # os, for creating the template directories; and
17 # re, for replacing internal node links with proper markup
18 import web, os, re
19 # md5, for hashing passwords
20 #import md5
21
22 # database must support the following methods :
23 #   field_list() : return an ordered list of available fields (fields unique)
24 #   long_field() : return an dict of long field names (keyed by field names)
25 #   record(id)   : return a record dict (keyed by field names)
26 #   records()    : return an ordered list of available records.
27 #   backup()     : save a copy of the current database somehow.
28 #   set_record(i, newvals) : set a record by overwriting any preexisting data
29 #                          : with data from the field-name-keyed dict NEWVALS
30 import text_db
31 database = text_db.text_db
32 from chem_db import valid_CASno, MSDS_manager, docgen
33 DB_FILE = 'inventory.db'
34 import sys, daemon, logging
35 PID_FILE = './chem_web.pid'
36 LOG_FILE = './chem_web.log'
37
38 ## Also import a markup language, and associated regexp functions
39 from markdown import markdown
40 ## for converting the first character in record names, and stripping |
41 from string import upper, rstrip
42
43 ## for SSL
44 SSL = False
45 if SSL :
46       from mk_simple_certs import get_cert_filenames
47       server_name = 'chem_web'
48
49 __version__ = "0.2"
50
51 ## For web.py, when a URL matches '/view', handle with class 'view'
52 # when it matches '/edit', handle with class 'edit',
53 # and when it matches '/login', handle with class 'login',
54 # Note, there are no .html extension.
55 #
56 # link to edit pages using db_id
57 # link to MSDSs with ID
58 urls = ('/(view)?', 'view',
59         '/edit/([0-9]+)', 'edit',
60         '/MSDS/([0-9]+)', 'MSDS',
61         '/docs/([a-z._]*)', 'docs',
62         '/login', 'login')
63
64 # limit the display width of selected fields
65 WIDTH_MAX = {'CAS#':12}
66
67 # set the display width of entry fields
68 ENTRY_WIDTH = 50
69
70 class view (object) :
71       "Print the database"
72       # when the server recieves the command "GET '/view', call view.get('view')
73       def GET(self, name):
74             """
75             Render the view.html template using name and the previous text.
76             If the file doesn't exist, use default text '%s doesn't exist'.
77             """
78             if name == None :
79                   name = 'view'
80             db._refresh()
81             recs = []
82             for record in db.records() :
83                   id = int(record['ID'])
84                   record['ID'] = '<a href="/edit/%d">%s</a>' \
85                                  % (id, record['ID'])
86                   if MSDSman.has_MSDS(id) : # link the id to the MSDS
87                         record['Name'] = '<a href="/MSDS/%d">%s</a>' \
88                                  % (record['db_id'], record['Name'])
89                   recs.append(record)
90             print render.view('Chemical inventory',
91                               db.len_records(), db.field_list(), recs)
92
93 class edit (object) :
94       "Provide methods for handling requests related to edit pages"
95       def GET(self, name):
96             """
97             Render the edit.html template using the specified record number.
98             """
99             db._refresh()
100             db_id = int(name)
101             if db_id >= db.len_records() :
102                   logging.info("new record")
103                   assert db_id == db.len_records(), \
104                       'invalid id: %d (max %d)' % (db_id, db.len_records())
105                   record = db.new_record()
106                   record['ID'] = str(record['db_id'])
107                   db.set_record(db_id, record)
108             if MSDSman.has_MSDS(db_id) :
109                   MSDSs = None
110             else :
111                   # only ask for an MSDS if we still need one
112                   MSDSs = MSDSman.get_all(simlinks=False)
113             print render.edit(int(name), db.field_list(),
114                               db.long_fields(), db.record(int(name)), MSDSs)
115       def POST(self, name):
116             """
117             Read the form input and update the database accordingly.
118             Then redirect to /view.
119             """
120             db._refresh()
121             record = db.record(int(name))
122             ## Generate a new record from the form input
123             # 'MSDS={}' to storify MSDS as a FieldStorage (dict-like) instance,
124             # otherwise it's saved as a string, and we loose info about it.
125             # e.g. newvals['MSDS'].type = 'text/html'
126             # The contents of the file are saved to newvals['MSDS'].value
127             newvals = web.input(MSDS={})
128             update = False
129             for field in db.field_list() :
130                   if newvals[field] != record[field] :
131                         # TODO: add validation!
132                         update=True
133                         record[field] = newvals[field]
134             if 'MSDS source' in newvals :
135                   # Handle any MSDS file actions
136                   if newvals['MSDS source'] == 'upload' :
137                         if len(newvals['MSDS'].filename) > 0 and len(newvals['MSDS'].value) > 0:
138                               # only save if there is a file there to save ;)
139                               #print >> stderr, web.ctx.env['CONTENT_TYPE']
140                               #print >> stderr, web.ctx.keys()
141                               #print >> stderr, dir(newvals['MSDS']) 
142                               #print >> stderr, type(newvals['MSDS'])
143                               #print >> stderr, newvals['MSDS'].filename 
144                               #print >> stderr, newvals['MSDS'].type
145                               MSDSman.save(int(name), newvals['MSDS'].value,
146                                            newvals['MSDS'].type)
147                   else :
148                         logging.info('linking MSDS %d to %d' \
149                                          % (int(record['ID']),
150                                             int(newvals['MSDS share'])) )
151                         MSDSman.link(int(record['ID']),
152                                      int(newvals['MSDS share']))
153             if update :
154                   db.set_record(int(name), record, backup=True)
155             # redirect to view all
156             web.seeother('/view')
157
158 class MSDS (object) :
159       "Serve MSDS files by ID"
160       def GET(self, name):
161             "Serve MSDS files by ID"
162             id = int(name)
163             if MSDSman.has_MSDS(id) :
164                   mime = MSDSman.get_MSDS_MIME(id)
165                   web.header("Content-Type", mime)
166                   print file(MSDSman.get_MSDS_path(id), 'rb').read() ,
167             else :
168                   print render.error(id)
169
170 class docs (object) :
171       "Generate and serve assorted official documents"
172       def GET(self, name):
173             """
174             List the available documents.
175             """
176             db._refresh()
177             if name == '' :
178                   print render.docs()
179             if name == 'inventory.pdf' :
180                   path = dgen.inventory(namewidth=40)
181                   print file(path, 'rb').read() ,
182             if name == 'door_warning.pdf' :
183                   path = dgen.door_warning()
184                   print file(path, 'rb').read() ,
185       def POST(self, name):
186             """
187             Read the form input and print the appropriate file.
188             """
189             db._refresh()
190             formdata = web.input()
191             user_regexp = formdata['regexp']
192             regexp = re.compile(user_regexp, 'I') # Case insensitive
193             path = dgen.door_warning(lambda r: regexp.match(r['Location']))
194             print file(path, 'rb').read() ,
195
196 class login (object) :
197       "Print an alphabetized index of all existing pages"
198       def GET(self):
199             print "Not yet implemented"
200             #print render.stat('login')
201
202 class markup (object) :
203       "Convert text to html, using Markdown with a bit of preformatting"
204       def __init__(self) :
205             # [[optional display text|name with or_without_spaces]]
206             linkregexp = ('\[\['
207                           +'([^\|\[\]]*\|)?'
208                           +'([a-zA-Z0-9-_ ]*)'
209                           +'\]\]')
210             #print linkregexp
211             self.linkexp = re.compile(linkregexp)
212             self.linebreak = '  \n'
213             self.uscore = re.compile('_')
214             self.space = re.compile(' ')
215       def htmlize(self, text) :
216             return text
217             pre_markup = self._preformat(text)
218             #print "Preformatted '"+pre_markup+"'"
219             return self._markup( pre_markup )
220       def _markup(self, text) :
221             # markdown() implements the text->html Markdown semantics
222             # the str() builtin ensures a nice, printable string
223             return str(markdown(text))
224       def _preformat(self, text) :
225             #print "Preformatting '"+text+"'"
226             return self._autolink(text)
227       def _autolink(self, text) :
228             "Replace linkexps in text with Markdown syntax versions"
229             # sub passes a match object to link2markup for each match
230             # see http://docs.python.org/lib/match-objects.html
231             return self.linkexp.sub(self.matchlink2markup, text)
232       def matchlink2markup(self, match) :
233             # The name is the first (and only) matched region
234             #print match.groups()
235             name = match.groups()[1]
236             text = match.groups()[0]
237             return self.link2markup(text, name)
238       def linklist(self, text) :
239             linklist = [] # empty list
240             for m in self.linkexp.finditer(text) :
241                   linklist.append( self.str2name(m.groups()[1]) )
242             #print linklist
243             return linklist
244       def link2markup(self, text, name) :
245             """
246             usage: string = link2markup(text, target)
247             takes the string name of a wiki page,
248             and returns a string that will be markuped into a link to that page
249             displaying the text text.
250             """
251             # convert to Markdown pretty link syntax
252             if text : # remove trailing |
253                   text = text.rstrip('|')
254             else :
255                   text = self.name2str(name)
256             name = self.str2name(name)
257             return str('['+text+'](/'+name+')')
258       def name2str(self, name) :
259             """
260             usage: string = name2str(name)
261             takes the string name of a wiki page,
262             and converts it to display format
263             See str2name()
264             """
265             w_spaces = self.uscore.sub(' ', name)
266             return w_spaces
267       def str2name(self, string) :
268             """
269             usage: string = name2str(name)
270             Converts strings to the relevent wiki page name.
271             See str2name()
272             """
273             wo_spaces = self.space.sub('_', string)
274             cap_first = upper(wo_spaces[0]) + wo_spaces[1:]
275             return cap_first
276       def make_add_button(self, next_db_id) :
277             string =  '<form action="/edit/%d" method="get">\n' % next_db_id
278             string += ' <input type="submit" value="Add entry">\n'
279             string += '</form><br>\n'
280             return string
281       def make_table(self, fields, records, width_max=WIDTH_MAX, 
282                      raw_fields=()):
283             """
284             >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]),
285             <table>
286             <tr><td><b>A</b></td><td><b>B</b></td><td><b>C</b></td></tr>
287             <tr><td>a</td><td>b</td><td></td></tr>
288             <tr><td>d</td><td>e</td><td>f</td></tr>
289             </table>
290             """
291             # open the table
292             string = '<table>\n'
293             # add the header
294             string += '<tr>'
295             for field in fields :
296                   string += '<td><b>%s<b></td>' % self.htmlize(field)
297             string += '</tr>\n'
298             # add the records
299             for record in records :
300                   string += '<tr>'
301                   for field in fields :
302                         rstring = record[field]
303                         # truncate if the width is regulated...
304                         if field in width_max :
305                               w = width_max[field]
306                               # ... and the full string is too long
307                               if len(rstring) > w :
308                                     rstring = "%s..." % rstring[:w]
309                         # if the field isn't raw, protect special chars
310                         if not field in raw_fields :
311                               rstring = self.htmlize(rstring)
312                         string += '<td>%s</td>' % rstring
313                   string += '</tr>\n'
314             # close the table
315             string += '</table>\n'
316             return string
317       def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH):
318             """
319             >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}),
320             <table>
321              <tr><td><label for="A">AA</label></td><td>:</td>
322               <td><input type="text" size="50" id="A" name="A" value="a"></td></tr>
323              <tr><td><label for="B">BB</label></td><td>:</td>
324               <td><input type="text" size="50" id="B" name="B" value="b"></td></tr>
325              <tr><td><label for="MSDS">MSDS</label></td><td>:</td>
326               <td><input type="file" size="50" id="MSDS" name="MSDS" value=""></td></tr>
327             </table>
328             """
329             # open the table
330             string = '<table>\n'
331             # add the record fields
332             for field in fields :
333                   # get the record string
334                   rstring = self.htmlize(record[field])
335                   fstring = self.htmlize(field)
336                   lfstring = self.htmlize(lfields[field])                  
337                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' % (fstring, lfstring)
338                   string += '  <td><input type="text" size="%d" id="%s" name="%s" value="%s"></td></tr>\n' \
339                             % (entry_width, fstring, fstring, rstring)
340             if MSDSs != None :
341                   ## add an MSDS radio, share menu, and file upload fields
342                   # radio
343                   lfstring = fstring = 'MSDS source'
344                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
345                             % (fstring, lfstring)
346                   string += '  <td><input type="radio" id="%s" name="%s" value="upload" checked>Upload\n' \
347                             % (fstring, fstring)
348                   string += '      <input type="radio" id="%s" name="%s" value="share">Share</td></tr>\n' \
349                             % (fstring, fstring)
350
351                   # file upload
352                   fstring = 'MSDS'
353                   lfstring = 'Upload MSDS'
354                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
355                             % (fstring, lfstring)
356                   string += '  <td><input type="file" size="%d" id="%s" name="%s" accept="text/plain,text/html,application/pdf" value=""></td></tr>\n' \
357                             % (entry_width, fstring, fstring)
358
359                   # file share menu
360                   fstring = 'MSDS share'
361                   lfstring = 'Use an already uploaded MSDS'
362                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
363                             % (fstring, lfstring)
364                   string += '  <td><select size="1" id="%s" name="%s">\n' \
365                             % (fstring, fstring)
366                   for MSDS in MSDSs :
367                         lfstring = self.htmlize(MSDS['Name'])
368                         id = int(MSDS['ID'])
369                         string += '   <option value="%d">%d: %s</option>\n' \
370                                   % (id, id, lfstring)
371                   string += '   </select></td></tr>\n'
372             # close the table
373             string += '</table><br>\n'
374             return string
375
376 class templt (object) :
377      "Handle some template wrapping"
378      def __init__(self) :
379            self.dir = 'templates/'
380            if not os.path.exists(self.dir): os.mkdir(self.dir)
381            ## Write simple template html pages if they don't already exist.
382            # see ( http://webpy.org/templetor ) for the syntax.
383            html=('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
384                  ' "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n'
385                  '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">\n')
386            if not os.path.exists(self.dir+'view.html') :
387                  view=('$def with (name, next_db_id, fields, records)\n'
388                        +html+
389                        ' <head>\n'
390                        '  <title>\n'
391                        '   $:name2str(name) \n'
392                        '  </title>\n'
393                        ' </head>\n'
394                        ' <body>\n'
395                        '  <h1>\n'
396                        '   $:name2str(name)\n'
397                        '  </h1>\n'
398                        '  See \n'
399                        '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm">the rules</a>\n'
400                        '   for more information.\n'
401                        '  See the <a href="/docs/">docs</a> page to generate required documents.<br>\n'
402                        '  <br>\n'
403                        '  $:make_add_button(next_db_id)\n'
404                        '  $:make_table(fields, records)\n'
405                        '  $:make_add_button(next_db_id)\n'
406                        ' </body>\n'
407                        '</html>\n')
408                  file(self.dir+'view.html', 'w').write(view)
409            if not os.path.exists(self.dir+'edit.html') :
410                  # note:
411                  # the form encoding type 'enctype="multipart/form-data">\n'
412                  # is only required because of the MSDS file-upload field.
413                  edit=('$def with (index, fields, lfields, record, MSDSs)\n'
414                        +html+
415                        ' <head>\n'
416                        '  <title>\n'
417                        '   $:htmlize(record["Name"]) \n'
418                        '  </title>\n'
419                        ' </head>\n'
420                        ' <body>\n'
421                        '  <a style="cursor:pointer;" onclick="history.back()"><h1>\n'
422                        '   Editing: $:htmlize(record["Name"]) \n'
423                        '  </h1></a>\n'
424                        '  <form action="" method="post" enctype="multipart/form-data">\n'
425                        '   $:record_form(index, fields, lfields, record, MSDSs)<br />\n'
426                        '   <input type="submit" value="Submit" />\n'
427                        '  </form>\n'
428                        ' </body>\n'
429                        '</html>\n')
430                  file(self.dir+'edit.html', 'w').write(edit)
431            if not os.path.exists(self.dir+'error.html') :
432                  # like view, but no edit ability
433                  #  since the content is generated,
434                  # or backlink list, since there would be so many.
435                  stat=('$def with (id)\n'
436                        +html+
437                        ' <head>\n'
438                        ' </head>\n'
439                        ' <body>\n'
440                        '  <h1>Error</h1>\n'
441                        '  There is currently no MSDS file for \n'
442                        '  $:htmlize(id)\n'
443                        ' </body>\n'
444                        '</html>\n')
445                  file(self.dir+'error.html', 'w').write(stat)
446            if not os.path.exists(self.dir+'docs.html') :
447                  docs=(html +
448                        ' <head>\n'
449                        ' </head>\n'
450                        ' <body>\n'
451                        '  <p>\n'
452                        '  <a href="/docs/inventory.pdf">Inventory</a>\n'
453                        '   in accordance with the \n'
454                        '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">\n'
455                        '   Chemical Hygiene Plan Section E-7</a>.<br>\n'
456                        '  <a href="/docs/door_warning.pdf">Door warning</a>\n'
457                        '   in accordance with the Chemical Hygiene Plan Sections\n'
458                        '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">E-7</a>\n'
459                        '   and \n'
460                        '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>\n'
461                        '   .<br>\n'
462                        '  </p>\n'
463                        '  <p>\n'
464                        '  For door warnings for subsections of the whole room,\n'
465                        '   please give a location regexp in the form below.\n'
466                        '  For example: ".*liquids" or "refrigerator".\n'
467                        '  </p>\n'
468                        '  <form action="" method="post" enctype="multipart/form-data">\n'
469                        '   <table>\n'
470                        '    <tr><td>Location regexp</td><td>:</td>\n'
471                        '     <td><input type="text" size="50" id="regexp" name="regexp"></td></tr>\n'
472                        '   </table>\n'
473                        '   <input type="submit" value="Submit" />\n'
474                        '  </form>\n'
475                        ' </body>\n'
476                        '</html>\n')
477                  file(self.dir+'docs.html', 'w').write(docs)
478
479
480 class chem_web_daemon (daemon.Daemon) :
481       gid = None
482       uid = None
483       pidfile = PID_FILE
484       logfile = LOG_FILE
485       loglevel = 'INFO'
486       def run(self) :
487             if self.pkey_file == None or self.cert_file == None :
488                   logging.info("http://%s:%d/" % web.validip(self.options.ip))
489             else :
490                   logging.info("https://%s:%d/" % web.validip(self.options.ip))
491
492             ## web.py should give detailed error messages, not just `internal error'
493             web.internalerror = web.debugerror
494
495             ## How we'd start webpy server if we didn't need command line args or SSL
496             # Pass web my URL regexps, and the globals from this script
497             # You can also pass it web.profiler to help optimize for speed
498             #web.run(urls, globals())
499             ## How we have to start it now
500             webpy_func = web.webpyfunc(urls, globals(), False)
501             wsgi_func = web.wsgifunc(webpy_func)
502             web.httpserver.runsecure(wsgi_func,
503                                      web.validip(self.options.ip),
504                                      ssl_private_key_filename=self.pkey_file,
505                                      ssl_certificate_filename=self.cert_file)
506
507       def read_basic_config(self) :
508             pass
509       def parse_options(self):
510             from optparse import OptionParser
511             
512             usage_string = ('%prog [options]\n'
513                             '\n'
514                             '2008, W. Trevor King.\n')
515             version_string = '%%prog %s' % __version__
516             parser = OptionParser(usage=usage_string, version=version_string)
517             
518             # Daemon options
519             parser.add_option('--start', dest='action',
520                               action='store_const', const='start', default='start',
521                               help='Start the daemon (the default action)')
522             parser.add_option('--stop', dest='action',
523                               action='store_const', const='stop', default='start',
524                               help='Stop the daemon')
525             parser.add_option('-n', '--nodaemon', dest='daemonize',
526                               action='store_false', default=True,
527                               help='Run in the foreground')
528             
529             # Server options
530             parser.add_option('-a', '--ip-address', dest="ip",
531                               help="IP address (default '%default')",
532                               type='string', metavar="IP", default='0.0.0.0:8080')
533             parser.add_option('-s', '--secure', dest="secure",
534                               help="Run in secure (HTTPS) mode.",
535                               type='string', metavar="PKEY_FILE:CERT_FILE")
536             parser.add_option('-v', '--verbose', dest="verbose", action="store_true",
537                               help="Print lots of debugging information",
538                               default=False)
539             
540             self.options, self.args = parser.parse_args()
541             parser.destroy()
542             
543             if self.options.verbose :
544                   self.loglevel = 'DEBUG'
545             # get options for httpserver
546             if self.options.secure != None :
547                   split = self.options.secure.split(':')
548                   assert len(split) == 2, "Invalid secure argument '%s'"
549                   self.pkey_file = split[0]
550                   self.cert_file = split[1]
551             else :
552                   self.pkey_file = None
553                   self.cert_file = None
554
555 ### the following instances and definitions must have
556 ## global scope. because they are called from inside web
557 # create the database
558 db = database(filename=DB_FILE)
559 MSDSman = MSDS_manager(db)
560 dgen = docgen(db)
561
562 ## set up the templates
563 tmplt = templt()
564 # Tell templator where to look for templates
565 # to provide a framework for the generated webpages
566 render = web.template.render(tmplt.dir)
567
568 ## Define markup functions
569 mkup = markup()
570 htmlize = lambda t : mkup.htmlize(t)
571 name2str = lambda n : mkup.name2str(n)
572 mktable = lambda h,r : mkup.make_table(h,r,WIDTH_MAX,raw_fields=('ID','Name'))
573 mkadd = lambda ni : mkup.make_add_button(ni)
574 record_form = lambda i,f,l,r,M : mkup.record_form_entries(i,f,l,r,M)
575 ## Give templates access to the htmlize, make_table, and name2str functions
576 # ( see http://webpy.org/templetor and our view.html )
577 web.template.Template.globals['htmlize'] = htmlize
578 web.template.Template.globals['make_table'] = mktable
579 web.template.Template.globals['make_add_button'] = mkadd
580 web.template.Template.globals['name2str'] = name2str
581 web.template.Template.globals['record_form'] = record_form
582
583
584 ## If this script is run from the command line,
585 # use the tools we've just defined to host the chemical inventory
586 if __name__=="__main__":
587       chem_web_daemon().main()