Added valid_record option to door_warning()
[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                         update=True
132                         record[field] = newvals[field]
133             if 'MSDS source' in newvals :
134                   # Handle any MSDS file actions
135                   if newvals['MSDS source'] == 'upload' :
136                         if len(newvals['MSDS'].filename) > 0 and len(newvals['MSDS'].value) > 0:
137                               # only save if there is a file there to save ;)
138                               #print >> stderr, web.ctx.env['CONTENT_TYPE']
139                               #print >> stderr, web.ctx.keys()
140                               #print >> stderr, dir(newvals['MSDS']) 
141                               #print >> stderr, type(newvals['MSDS'])
142                               #print >> stderr, newvals['MSDS'].filename 
143                               #print >> stderr, newvals['MSDS'].type
144                               MSDSman.save(int(name), newvals['MSDS'].value,
145                                            newvals['MSDS'].type)
146                   else :
147                         logging.info('linking MSDS %d to %d' \
148                                          % (int(record['ID']),
149                                             int(newvals['MSDS share'])) )
150                         MSDSman.link(int(record['ID']),
151                                      int(newvals['MSDS share']))
152             if update :
153                   db.set_record(int(name), record, backup=True)
154             # redirect to view all
155             web.seeother('/view')
156
157 class MSDS (object) :
158       "Serve MSDS files by ID"
159       def GET(self, name):
160             "Serve MSDS files by ID"
161             id = int(name)
162             if MSDSman.has_MSDS(id) :
163                   mime = MSDSman.get_MSDS_MIME(id)
164                   web.header("Content-Type", mime)
165                   print file(MSDSman.get_MSDS_path(id), 'rb').read() ,
166             else :
167                   print render.error(id)
168
169 class docs (object) :
170       "Generate and serve assorted official documents"
171       def GET(self, name):
172             """
173             List the available documents.
174             """
175             db._refresh()
176             if name == '' :
177                   print render.docs()
178             if name == 'inventory.pdf' :
179                   path = dgen.inventory(namewidth=40)
180                   print file(path, 'rb').read() ,
181             if name == 'door_warning.pdf' :
182                   path = dgen.door_warning()
183                   print file(path, 'rb').read() ,
184                   
185
186 class login (object) :
187       "Print an alphabetized index of all existing pages"
188       def GET(self):
189             print "Not yet implemented"
190             #print render.stat('login')
191
192 class markup (object) :
193       "Convert text to html, using Markdown with a bit of preformatting"
194       def __init__(self) :
195             # [[optional display text|name with or_without_spaces]]
196             linkregexp = ('\[\['
197                           +'([^\|\[\]]*\|)?'
198                           +'([a-zA-Z0-9-_ ]*)'
199                           +'\]\]')
200             #print linkregexp
201             self.linkexp = re.compile(linkregexp)
202             self.linebreak = '  \n'
203             self.uscore = re.compile('_')
204             self.space = re.compile(' ')
205       def htmlize(self, text) :
206             return text
207             pre_markup = self._preformat(text)
208             #print "Preformatted '"+pre_markup+"'"
209             return self._markup( pre_markup )
210       def _markup(self, text) :
211             # markdown() implements the text->html Markdown semantics
212             # the str() builtin ensures a nice, printable string
213             return str(markdown(text))
214       def _preformat(self, text) :
215             #print "Preformatting '"+text+"'"
216             return self._autolink(text)
217       def _autolink(self, text) :
218             "Replace linkexps in text with Markdown syntax versions"
219             # sub passes a match object to link2markup for each match
220             # see http://docs.python.org/lib/match-objects.html
221             return self.linkexp.sub(self.matchlink2markup, text)
222       def matchlink2markup(self, match) :
223             # The name is the first (and only) matched region
224             #print match.groups()
225             name = match.groups()[1]
226             text = match.groups()[0]
227             return self.link2markup(text, name)
228       def linklist(self, text) :
229             linklist = [] # empty list
230             for m in self.linkexp.finditer(text) :
231                   linklist.append( self.str2name(m.groups()[1]) )
232             #print linklist
233             return linklist
234       def link2markup(self, text, name) :
235             """
236             usage: string = link2markup(text, target)
237             takes the string name of a wiki page,
238             and returns a string that will be markuped into a link to that page
239             displaying the text text.
240             """
241             # convert to Markdown pretty link syntax
242             if text : # remove trailing |
243                   text = text.rstrip('|')
244             else :
245                   text = self.name2str(name)
246             name = self.str2name(name)
247             return str('['+text+'](/'+name+')')
248       def name2str(self, name) :
249             """
250             usage: string = name2str(name)
251             takes the string name of a wiki page,
252             and converts it to display format
253             See str2name()
254             """
255             w_spaces = self.uscore.sub(' ', name)
256             return w_spaces
257       def str2name(self, string) :
258             """
259             usage: string = name2str(name)
260             Converts strings to the relevent wiki page name.
261             See str2name()
262             """
263             wo_spaces = self.space.sub('_', string)
264             cap_first = upper(wo_spaces[0]) + wo_spaces[1:]
265             return cap_first
266       def make_add_button(self, next_db_id) :
267             string =  '<form action="/edit/%d" method="get">\n' % next_db_id
268             string += ' <input type="submit" value="Add entry">\n'
269             string += '</form><br>\n'
270             return string
271       def make_table(self, fields, records, width_max=WIDTH_MAX, 
272                      raw_fields=()):
273             """
274             >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]),
275             <table>
276             <tr><td><b>A</b></td><td><b>B</b></td><td><b>C</b></td></tr>
277             <tr><td>a</td><td>b</td><td></td></tr>
278             <tr><td>d</td><td>e</td><td>f</td></tr>
279             </table>
280             """
281             # open the table
282             string = '<table>\n'
283             # add the header
284             string += '<tr>'
285             for field in fields :
286                   string += '<td><b>%s<b></td>' % self.htmlize(field)
287             string += '</tr>\n'
288             # add the records
289             for record in records :
290                   string += '<tr>'
291                   for field in fields :
292                         rstring = record[field]
293                         # truncate if the width is regulated...
294                         if field in width_max :
295                               w = width_max[field]
296                               # ... and the full string is too long
297                               if len(rstring) > w :
298                                     rstring = "%s..." % rstring[:w]
299                         # if the field isn't raw, protect special chars
300                         if not field in raw_fields :
301                               rstring = self.htmlize(rstring)
302                         string += '<td>%s</td>' % rstring
303                   string += '</tr>\n'
304             # close the table
305             string += '</table>\n'
306             return string
307       def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH):
308             """
309             >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}),
310             <table>
311              <tr><td><label for="A">AA</label></td><td>:</td>
312               <td><input type="text" size="50" id="A" name="A" value="a"></td></tr>
313              <tr><td><label for="B">BB</label></td><td>:</td>
314               <td><input type="text" size="50" id="B" name="B" value="b"></td></tr>
315              <tr><td><label for="MSDS">MSDS</label></td><td>:</td>
316               <td><input type="file" size="50" id="MSDS" name="MSDS" value=""></td></tr>
317             </table>
318             """
319             # open the table
320             string = '<table>\n'
321             # add the record fields
322             for field in fields :
323                   # get the record string
324                   rstring = self.htmlize(record[field])
325                   fstring = self.htmlize(field)
326                   lfstring = self.htmlize(lfields[field])                  
327                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' % (fstring, lfstring)
328                   string += '  <td><input type="text" size="%d" id="%s" name="%s" value="%s"></td></tr>\n' \
329                             % (entry_width, fstring, fstring, rstring)
330             if MSDSs != None :
331                   ## add an MSDS radio, share menu, and file upload fields
332                   # radio
333                   lfstring = fstring = 'MSDS source'
334                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
335                             % (fstring, lfstring)
336                   string += '  <td><input type="radio" id="%s" name="%s" value="upload" checked>Upload\n' \
337                             % (fstring, fstring)
338                   string += '      <input type="radio" id="%s" name="%s" value="share">Share</td></tr>\n' \
339                             % (fstring, fstring)
340
341                   # file upload
342                   fstring = 'MSDS'
343                   lfstring = 'Upload MSDS'
344                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
345                             % (fstring, lfstring)
346                   string += '  <td><input type="file" size="%d" id="%s" name="%s" accept="text/plain,text/html,application/pdf" value=""></td></tr>\n' \
347                             % (entry_width, fstring, fstring)
348
349                   # file share menu
350                   fstring = 'MSDS share'
351                   lfstring = 'Use an already uploaded MSDS'
352                   string += ' <tr><td><label for="%s">%s</label></td><td>:</td>\n' \
353                             % (fstring, lfstring)
354                   string += '  <td><select size="1" id="%s" name="%s">\n' \
355                             % (fstring, fstring)
356                   for MSDS in MSDSs :
357                         lfstring = self.htmlize(MSDS['Name'])
358                         id = int(MSDS['ID'])
359                         string += '   <option value="%d">%d: %s</option>\n' \
360                                   % (id, id, lfstring)
361                   string += '   </select></td></tr>\n'
362             # close the table
363             string += '</table><br>\n'
364             return string
365
366 class templt (object) :
367      "Handle some template wrapping"
368      def __init__(self) :
369            self.dir = 'templates/'
370            if not os.path.exists(self.dir): os.mkdir(self.dir)
371            ## Write simple template html pages if they don't already exist.
372            # see ( http://webpy.org/templetor ) for the syntax.
373            html=('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
374                  ' "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n'
375                  '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">\n')
376            if not os.path.exists(self.dir+'view.html') :
377                  view=('$def with (name, next_db_id, fields, records)\n'
378                        +html+
379                        ' <head>\n'
380                        '  <title>\n'
381                        '   $:name2str(name) \n'
382                        '  </title>\n'
383                        ' </head>\n'
384                        ' <body>\n'
385                        '  <h1>\n'
386                        '   $:name2str(name)\n'
387                        '  </h1>\n'
388                        '  See \n'
389                        '   <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm">the rules</a>\n'
390                        '   for more information.\n'
391                        '  See the <a href="/docs/">docs</a> page to generate required documents.<br>\n'
392                        '  <br>\n'
393                        '  $:make_add_button(next_db_id)\n'
394                        '  $:make_table(fields, records)\n'
395                        '  $:make_add_button(next_db_id)\n'
396                        ' </body>\n'
397                        '</html>\n')
398                  file(self.dir+'view.html', 'w').write(view)
399            if not os.path.exists(self.dir+'edit.html') :
400                  # note:
401                  # the form encoding type 'enctype="multipart/form-data">\n'
402                  # is only required because of the MSDS file-upload field.
403                  edit=('$def with (index, fields, lfields, record, MSDSs)\n'
404                        +html+
405                        ' <head>\n'
406                        '  <title>\n'
407                        '   $:htmlize(record["Name"]) \n'
408                        '  </title>\n'
409                        ' </head>\n'
410                        ' <body>\n'
411                        '  <a style="cursor:pointer;" onclick="history.back()"><h1>\n'
412                        '   Editing: $:htmlize(record["Name"]) \n'
413                        '  </h1></a>\n'
414                        '  <form action="" method="post" enctype="multipart/form-data">\n'
415                        '   $:record_form(index, fields, lfields, record, MSDSs)<br />\n'
416                        '   <input type="submit" value="Submit" />\n'
417                        '  </form>\n'
418                        ' </body>\n'
419                        '</html>\n')
420                  file(self.dir+'edit.html', 'w').write(edit)
421            if not os.path.exists(self.dir+'error.html') :
422                  # like view, but no edit ability
423                  #  since the content is generated,
424                  # or backlink list, since there would be so many.
425                  stat=('$def with (id)\n'
426                        +html+
427                        ' <head>\n'
428                        ' </head>\n'
429                        ' <body>\n'
430                        '  <h1>Error</h1>\n'
431                        '  There is currently no MSDS file for \n'
432                        '  $:htmlize(id)\n'
433                        ' </body>\n'
434                        '</html>\n')
435                  file(self.dir+'error.html', 'w').write(stat)
436            if not os.path.exists(self.dir+'docs.html') :
437                  docs=(html +
438                        '<a href="/docs/inventory.pdf">Inventory</a>\n'
439                        ' in accordance with the \n'
440                        ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">\n'
441                        '  Chemical Hygiene Plan Section E-7</a>.<br>\n'
442                        '<a href="/docs/door_warning.pdf">Door warning</a>\n'
443                        ' in accordance with the Chemical Hygiene Plan Sections\n'
444                        ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">E-7</a>\n'
445                        ' and \n'
446                        ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>\n'
447                        '.<br>\n'
448                        ' </body>\n'
449                        '</html>\n')
450                  file(self.dir+'docs.html', 'w').write(docs)
451
452
453 class chem_web_daemon (daemon.Daemon) :
454       gid = None
455       uid = None
456       pidfile = PID_FILE
457       logfile = LOG_FILE
458       loglevel = 'INFO'
459       def run(self) :
460             if self.pkey_file == None or self.cert_file == None :
461                   logging.info("http://%s:%d/" % web.validip(self.options.ip))
462             else :
463                   logging.info("https://%s:%d/" % web.validip(self.options.ip))
464
465             ## web.py should give detailed error messages, not just `internal error'
466             web.internalerror = web.debugerror
467
468             ## How we'd start webpy server if we didn't need command line args or SSL
469             # Pass web my URL regexps, and the globals from this script
470             # You can also pass it web.profiler to help optimize for speed
471             #web.run(urls, globals())
472             ## How we have to start it now
473             webpy_func = web.webpyfunc(urls, globals(), False)
474             wsgi_func = web.wsgifunc(webpy_func)
475             web.httpserver.runsecure(wsgi_func,
476                                      web.validip(self.options.ip),
477                                      ssl_private_key_filename=self.pkey_file,
478                                      ssl_certificate_filename=self.cert_file)
479
480       def read_basic_config(self) :
481             pass
482       def parse_options(self):
483             from optparse import OptionParser
484             
485             usage_string = ('%prog [options]\n'
486                             '\n'
487                             '2008, W. Trevor King.\n')
488             version_string = '%%prog %s' % __version__
489             parser = OptionParser(usage=usage_string, version=version_string)
490             
491             # Daemon options
492             parser.add_option('--start', dest='action',
493                               action='store_const', const='start', default='start',
494                               help='Start the daemon (the default action)')
495             parser.add_option('--stop', dest='action',
496                               action='store_const', const='stop', default='start',
497                               help='Stop the daemon')
498             parser.add_option('-n', '--nodaemon', dest='daemonize',
499                               action='store_false', default=True,
500                               help='Run in the foreground')
501             
502             # Server options
503             parser.add_option('-a', '--ip-address', dest="ip",
504                               help="IP address (default '%default')",
505                               type='string', metavar="IP", default='0.0.0.0:8080')
506             parser.add_option('-s', '--secure', dest="secure",
507                               help="Run in secure (HTTPS) mode.",
508                               type='string', metavar="PKEY_FILE:CERT_FILE")
509             parser.add_option('-v', '--verbose', dest="verbose", action="store_true",
510                               help="Print lots of debugging information",
511                               default=False)
512             
513             self.options, self.args = parser.parse_args()
514             parser.destroy()
515             
516             if self.options.verbose :
517                   self.loglevel = 'DEBUG'
518             # get options for httpserver
519             if self.options.secure != None :
520                   split = self.options.secure.split(':')
521                   assert len(split) == 2, "Invalid secure argument '%s'"
522                   self.pkey_file = split[0]
523                   self.cert_file = split[1]
524             else :
525                   self.pkey_file = None
526                   self.cert_file = None
527
528 ### the following instances and definitions must have
529 ## global scope. because they are called from inside web
530 # create the database
531 db = database(filename=DB_FILE)
532 MSDSman = MSDS_manager(db)
533 dgen = docgen(db)
534
535 ## set up the templates
536 tmplt = templt()
537 # Tell templator where to look for templates
538 # to provide a framework for the generated webpages
539 render = web.template.render(tmplt.dir)
540
541 ## Define markup functions
542 mkup = markup()
543 htmlize = lambda t : mkup.htmlize(t)
544 name2str = lambda n : mkup.name2str(n)
545 mktable = lambda h,r : mkup.make_table(h,r,WIDTH_MAX,raw_fields=('ID','Name'))
546 mkadd = lambda ni : mkup.make_add_button(ni)
547 record_form = lambda i,f,l,r,M : mkup.record_form_entries(i,f,l,r,M)
548 ## Give templates access to the htmlize, make_table, and name2str functions
549 # ( see http://webpy.org/templetor and our view.html )
550 web.template.Template.globals['htmlize'] = htmlize
551 web.template.Template.globals['make_table'] = mktable
552 web.template.Template.globals['make_add_button'] = mkadd
553 web.template.Template.globals['name2str'] = name2str
554 web.template.Template.globals['record_form'] = record_form
555
556
557 ## If this script is run from the command line,
558 # use the tools we've just defined to host the chemical inventory
559 if __name__=="__main__":
560       chem_web_daemon().main()