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
9 # Remember to delete the templates directory after making alterations,
10 # because this program only generates templates if they are missing.
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
19 # md5, for hashing passwords
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
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'
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
46 from mk_simple_certs import get_cert_filenames
47 server_name = 'chem_web'
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.
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',
64 # limit the display width of selected fields
65 WIDTH_MAX = {'CAS#':12}
67 # set the display width of entry fields
72 # when the server recieves the command "GET '/view', call view.get('view')
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'.
82 for record in db.records() :
83 id = int(record['ID'])
84 record['ID'] = '<a href="/edit/%d">%s</a>' \
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'])
90 print render.view('Chemical inventory',
91 db.len_records(), db.field_list(), recs)
94 "Provide methods for handling requests related to edit pages"
97 Render the edit.html template using the specified record number.
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) :
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):
117 Read the form input and update the database accordingly.
118 Then redirect to /view.
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={})
129 for field in db.field_list() :
130 if newvals[field] != record[field] :
131 # TODO: add validation!
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)
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']))
154 db.set_record(int(name), record, backup=True)
155 # redirect to view all
156 web.seeother('/view')
158 class MSDS (object) :
159 "Serve MSDS files by ID"
161 "Serve MSDS files by ID"
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() ,
168 print render.error(id)
170 class docs (object) :
171 "Generate and serve assorted official documents"
174 List the available documents.
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):
187 Read the form input and print the appropriate file.
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() ,
196 class login (object) :
197 "Print an alphabetized index of all existing pages"
199 print "Not yet implemented"
200 #print render.stat('login')
202 class markup (object) :
203 "Convert text to html, using Markdown with a bit of preformatting"
205 # [[optional display text|name with or_without_spaces]]
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) :
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]) )
244 def link2markup(self, text, name) :
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.
251 # convert to Markdown pretty link syntax
252 if text : # remove trailing |
253 text = text.rstrip('|')
255 text = self.name2str(name)
256 name = self.str2name(name)
257 return str('['+text+'](/'+name+')')
258 def name2str(self, name) :
260 usage: string = name2str(name)
261 takes the string name of a wiki page,
262 and converts it to display format
265 w_spaces = self.uscore.sub(' ', name)
267 def str2name(self, string) :
269 usage: string = name2str(name)
270 Converts strings to the relevent wiki page name.
273 wo_spaces = self.space.sub('_', string)
274 cap_first = upper(wo_spaces[0]) + wo_spaces[1:]
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'
281 def make_table(self, fields, records, width_max=WIDTH_MAX,
284 >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]),
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>
295 for field in fields :
296 string += '<td><b>%s<b></td>' % self.htmlize(field)
299 for record in records :
301 for field in fields :
302 rstring = record[field]
303 # truncate if the width is regulated...
304 if field in width_max :
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
315 string += '</table>\n'
317 def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH):
319 >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}),
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>
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)
341 ## add an MSDS radio, share menu, and file upload fields
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' \
348 string += ' <input type="radio" id="%s" name="%s" value="share">Share</td></tr>\n' \
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)
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' \
367 lfstring = self.htmlize(MSDS['Name'])
369 string += ' <option value="%d">%d: %s</option>\n' \
371 string += ' </select></td></tr>\n'
373 string += '</table><br>\n'
376 class templt (object) :
377 "Handle some template wrapping"
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'
391 ' $:name2str(name) \n'
396 ' $:name2str(name)\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'
403 ' $:make_add_button(next_db_id)\n'
404 ' $:make_table(fields, records)\n'
405 ' $:make_add_button(next_db_id)\n'
408 file(self.dir+'view.html', 'w').write(view)
409 if not os.path.exists(self.dir+'edit.html') :
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'
417 ' $:htmlize(record["Name"]) \n'
421 ' <a style="cursor:pointer;" onclick="history.back()"><h1>\n'
422 ' Editing: $:htmlize(record["Name"]) \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'
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'
441 ' There is currently no MSDS file for \n'
445 file(self.dir+'error.html', 'w').write(stat)
446 if not os.path.exists(self.dir+'docs.html') :
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'
460 ' <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>\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'
468 ' <form action="" method="post" enctype="multipart/form-data">\n'
470 ' <tr><td>Location regexp</td><td>:</td>\n'
471 ' <td><input type="text" size="50" id="regexp" name="regexp"></td></tr>\n'
473 ' <input type="submit" value="Submit" />\n'
477 file(self.dir+'docs.html', 'w').write(docs)
480 class chem_web_daemon (daemon.Daemon) :
487 if self.pkey_file == None or self.cert_file == None :
488 logging.info("http://%s:%d/" % web.validip(self.options.ip))
490 logging.info("https://%s:%d/" % web.validip(self.options.ip))
492 ## web.py should give detailed error messages, not just `internal error'
493 web.internalerror = web.debugerror
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)
507 def read_basic_config(self) :
509 def parse_options(self):
510 from optparse import OptionParser
512 usage_string = ('%prog [options]\n'
514 '2008, W. Trevor King.\n')
515 version_string = '%%prog %s' % __version__
516 parser = OptionParser(usage=usage_string, version=version_string)
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')
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",
540 self.options, self.args = parser.parse_args()
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]
552 self.pkey_file = None
553 self.cert_file = None
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)
562 ## set up the templates
564 # Tell templator where to look for templates
565 # to provide a framework for the generated webpages
566 render = web.template.render(tmplt.dir)
568 ## Define markup functions
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
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()