From 60b66f4783fc7c9bf562ece5e988ad09f4808df4 Mon Sep 17 00:00:00 2001 From: wking Date: Thu, 28 Aug 2008 18:48:43 -0400 Subject: [PATCH] Repository created. --- README | 104 +++++++ certgen.py | 80 ++++++ chem_db.py | 438 ++++++++++++++++++++++++++++ chem_web.py | 560 ++++++++++++++++++++++++++++++++++++ chem_web_secure.py | 560 ++++++++++++++++++++++++++++++++++++ docs/Makefile | 167 +++++++++++ docs/README | 7 + docs/contact.tex | 5 + docs/door_template.tex | 54 ++++ docs/inventory_template.tex | 54 ++++ docs/mp/Makefile | 30 ++ docs/mp/NFPA_c.mp | 70 +++++ docs/mp/README | 13 + docs/mp/gen_NFPA.sh | 76 +++++ docs/mp/sample.tex | 13 + examples/inventory.db | 5 + mk_simple_certs.py | 35 +++ text_db.py | 491 +++++++++++++++++++++++++++++++ 18 files changed, 2762 insertions(+) create mode 100644 README create mode 100644 certgen.py create mode 100644 chem_db.py create mode 100755 chem_web.py create mode 100755 chem_web_secure.py create mode 100644 docs/Makefile create mode 100644 docs/README create mode 100644 docs/contact.tex create mode 100644 docs/door_template.tex create mode 100644 docs/inventory_template.tex create mode 100644 docs/mp/Makefile create mode 100644 docs/mp/NFPA_c.mp create mode 100644 docs/mp/README create mode 100755 docs/mp/gen_NFPA.sh create mode 100644 docs/mp/sample.tex create mode 100644 examples/inventory.db create mode 100644 mk_simple_certs.py create mode 100644 text_db.py diff --git a/README b/README new file mode 100644 index 0000000..69ba196 --- /dev/null +++ b/README @@ -0,0 +1,104 @@ +Web, python, and command-line interfaces for managing a chemical inventory. + +COMMANDS + +The web interface (chem_web.py) is a web-daemon using web.py and it's build in htmlserver. +Standard command for starting the daemon + $ chem_web.py -a 192.168.1.2:55555 +Standard command for stopping the daemon + $ chem_web.py --stop + +From the command line, you can validate CAS#s (and possibly other fields) with + $ python chem_db.py -f current/inventory.db -V + + +DATABASE FORMAT + +text_db.py provides the python interface to this format. The basic +idea was to produce and use files which were M$-Excel-compatible, +since future users might not want to maintain this interface. A brief +example inventory.db file is included in the examples directory. + +Tab-delimited ('\t') fields +Endline-delimited ('\n') records +The first line is the header (starts with a pound sign '#'). +The header should be a tab-delimited list of short field names. +If the second line also begins with a pound sign, +it contains a tab-delimiteded list of long field names. +Blank lines are ignored. + +The fields H, F, R, and S are the NFPA Health, Fire, Reactivity, and Special Hazards (NFPA diamond). + +Blue: Health Hazard + 0 Hazard no greater than ordinary material + 1 May cause irritation; minimal residual injury + 2 Intense or prolonged exposure may cause incapacitation; + Residual injury may occur if not treated + 3 Exposure could cause serious injury even if treated + 4 Exposure may cause death +Red: Fire Hazard + 0 Will not burn + 1 Must be preheated for ignition; flashpoint above 200°F (93°C) + 2 Must be moderately heated for ignition, flashpoint above 100°F (38°C) + 3 Ignition may occur under most ambient conditions, + Flashpoint below 100°F (38°C) + 4 Extremely flammable and will readily disperse through air under standard + conditions, flashpoint below 73°F (23°C) +Reactivity hazards: + 0 Stable + 1 May become unstable at elevated temperatures and pressures. + May be mildly water reactive + 2 Unstable; may undergo violent decomposition, but will not detonate. + May form explosive mixtures with water + 3 Detonates with strong ignition source + 4 Readily detonates +Special Hazards have the following codes: + OX strong oxidizer + -W- water reactive + SA simple ascphyxiants + (The only gases for which this symbol is permitted are nitrogen, helium, + neon, argon, krypton, and xenon..) +Non-official Special Hazard codes: + ACID acid + ALK base + COR corrosive + BIO Biohazard + POI Poison + CRY Cyrogenic + + +CONTENTS + +README this file +text_db.py python interface to the above db format +chem_db.py chem-inventory specific functionality such as + * CAS # validation, + * a Material Saftey Data Sheet (MSDS) manager, + * document-generation drivers, and + * a simple command line interface. +current store the current database file. +backup store previous (timestamped) database files. + You may want to clean this out occasionally. +MSDS store Material Saftey Data Sheets by database index number +docs latex (and metapost) sources for document generation. +chem_web.py daemonized web bindings to chem_web.py +ssl Secure Socket Layer (SSL) key and certificate generation tests. + + +GENERATED FILES AND DIRECTORIES + +templates quasi-HTML templates for pages generated chem_web.py +chem_web.pid store the Process ID (PID) for a daemonized chem_web.py process +chem_web.log log chem_web activity (maintained between runs, so remove periodically) + + +TODO + +Security: +I can create my own self-signed certificate. +Protects against eavesdropping, but not man-in-the-middle attacks. +Solution for now: hide from external world, trust everyone inside, backup before any change. + + +Print columns in a particular order from the database with + $ awk -F ' ' '{print $1, "\t", $9, "\t", , "\t", $2}' current/inventory.db | less diff --git a/certgen.py b/certgen.py new file mode 100644 index 0000000..04b9d5b --- /dev/null +++ b/certgen.py @@ -0,0 +1,80 @@ +# +# certgen.py +# +# Copyright (C) Martin Sjogren and AB Strakt 2001, All rights reserved +# +# $Id: certgen.py,v 1.2 2004/07/22 12:01:25 martin Exp $ +# +""" +Certificate generation module. +""" + +from OpenSSL import crypto + +TYPE_RSA = crypto.TYPE_RSA +TYPE_DSA = crypto.TYPE_DSA + +def createKeyPair(type, bits): + """ + Create a public/private key pair. + + Arguments: type - Key type, must be one of TYPE_RSA and TYPE_DSA + bits - Number of bits to use in the key + Returns: The public/private key pair in a PKey object + """ + pkey = crypto.PKey() + pkey.generate_key(type, bits) + return pkey + +def createCertRequest(pkey, digest="md5", **name): + """ + Create a certificate request. + + Arguments: pkey - The key to associate with the request + digest - Digestion method to use for signing, default is md5 + **name - The name of the subject of the request, possible + arguments are: + C - Country name + ST - State or province name + L - Locality name + O - Organization name + OU - Organizational unit name + CN - Common name + emailAddress - E-mail address + Returns: The certificate request in an X509Req object + """ + req = crypto.X509Req() + subj = req.get_subject() + + for (key,value) in name.items(): + setattr(subj, key, value) + + req.set_pubkey(pkey) + req.sign(pkey, digest) + return req + +def createCertificate(req, (issuerCert, issuerKey), serial, (notBefore, notAfter), digest="md5"): + """ + Generate a certificate given a certificate request. + + Arguments: req - Certificate reqeust to use + issuerCert - The certificate of the issuer + issuerKey - The private key of the issuer + serial - Serial number for the certificate + notBefore - Timestamp (relative to now) when the certificate + starts being valid + notAfter - Timestamp (relative to now) when the certificate + stops being valid + digest - Digest method to use for signing, default is md5 + Returns: The signed certificate in an X509 object + """ + cert = crypto.X509() + cert.set_serial_number(serial) + cert.gmtime_adj_notBefore(notBefore) + cert.gmtime_adj_notAfter(notAfter) + cert.set_issuer(issuerCert.get_subject()) + cert.set_subject(req.get_subject()) + cert.set_pubkey(req.get_pubkey()) + cert.sign(issuerKey, digest) + return cert + diff --git a/chem_db.py b/chem_db.py new file mode 100644 index 0000000..181d066 --- /dev/null +++ b/chem_db.py @@ -0,0 +1,438 @@ +#!/usr/bin/python + +""" +Extend text_db with a CAS# validator, a command line interface, and document generation. +""" + +from text_db import * +import re +import os +import os.path +import time + +def valid_CASno(cas_string, debug=False): + """ + Check N..NN-NN-N format, and the checksum digit for valid CAS number structure. + see http://www.cas.org/expertise/cascontent/registry/checkdig.html + for N_n .. N_4 N_3 - N_2 N_1 - R + R = remainder([sum_{i=1}^n i N_i ] / 10 ) + Ignores 'na' and '+secret-non-hazardous' + >>> valid_CASno('107-07-3') + True + >>> valid_CASno('107-08-3') + False + >>> valid_CASno('107-083') + False + """ + for string in ['na', '+secret-non-hazardous']: + # the first marks a non-existent CAS# + # the last marks items with secret, non-hazardous ingredients for which we have no CAS# + if cas_string == string: + return True + # check format, + # ^ matches the start of the string + # \Z matches the end of the string + regexp = re.compile('^[0-9]{2,}[-][0-9]{2}[-][0-9]\Z') + if regexp.match(cas_string) == None: + if debug : print >> stderr, "invalid CAS# format: '%s'" % cas_string + return False + # generate check digit + casdigs = "".join(cas_string.split('-')) # remove '-' + sumdigs = list(casdigs[:-1]) + sumdigs.reverse() + sum=0 + for i in range(len(sumdigs)) : + sum += (i+1)*int(sumdigs[i]) + check = sum % 10 + if int(casdigs[-1]) == check : + return True + else : + if debug : print >> stderr, "invalid CAS# check: '%s' (expected %d)" % (cas_string, check) + return False + +class MSDS_manager (object) : + """ + Manage Material Saftey Data Sheets (MSDSs) + """ + def __init__(self, db, dir="./MSDS/") : + self.db = db + self.dir = dir + self.MIMEs = ['application/pdf', + 'text/html', + 'text/plain'] + self.MIME_exts = ['pdf', 'html', 'txt'] + self.check_dir() + def check_dir(self) : + "Create the MSDS directory if it's missing" + if os.path.isdir(self.dir) : + return # all set to go + elif os.path.exists(self.dir) : + raise Exception, "Error: a non-directory file exists at %s" % self.dir + else : + os.mkdir(self.dir) + def basepath(self, id) : + assert type(id) == type(1), 'id must be an integer, not %s (%s)' \ + % (type(id), str(id)) + return os.path.join(self.dir, "%d" % id) + def local_basepath(self, id) : # for symbolic links + assert type(id) == type(1), 'id must be an integer, not %s (%s)' \ + % (type(id), str(id)) + return "./%d" % id + def MIME_ext(self, mime) : + assert mime in self.MIMEs, \ + "invalid MIME type '%s'\nshould be one of %s" % (mime, self.MIMEs) + i = self.MIMEs.index(mime) + ext = self.MIME_exts[i] + return ext + def path(self, id, mime) : + return "%s.%s" % (self.basepath(id), self.MIME_ext(mime)) + def local_path(self, id, mime) : + return "%s.%s" % (self.local_basepath(id), self.MIME_ext(mime)) + def save(self, id, filetext, mime='application/pdf') : + "Save the binary byte string FILE to the path for ID" + print >> file(self.path(id, mime), 'wb'), filetext, + def link(self, id, target_id) : + # target_id already exists, create a symlink to it for id. + target_mime = self.get_MSDS_MIME(target_id) + target_path = self.local_path(target_id, target_mime) + path = self.path(id, target_mime) + #os.link(self.path(target_id), self.path(id)) # hard link... + os.symlink(target_path, path) # ... or soft link + def has_MSDS_MIME(self, id, mime) : + """ + >>> m = MSDS_manager(db=None) + >>> print m.has_MSDS_type(102, 'pdf') # test on html + False + >>> print m.has_MSDS_type(102, 'html') # test on html + True + >>> print m.has_MSDS_type(6, 'pdf') # test on pdf symlink + True + """ + return os.path.exists(self.path(id, mime)) + def get_MSDS_path(self, id) : + """ + >>> m = MSDS_manager(db=None) + >>> print m.get_MSDS_path(102) # test on html + ./MSDS/102.html + >>> print m.get_MSDS_path(1) # test on pdf + ./MSDS/1.pdf + >>> print m.get_MSDS_path(6) # test on pdf symlink + ./MSDS/6.pdf + """ + for mime in self.MIMEs : + if self.has_MSDS_MIME(id, mime) : + return self.path(id, mime) + return None + def get_MSDS_MIME(self, id) : + """ + >>> m = MSDS_manager(db=None) + >>> print m.get_MSDS_MIME(102) # test on html + text/html + >>> print m.get_MSDS_MIME(1) # test on pdf + application/pdf + >>> print m.get_MSDS_MIME(6) # test on pdf symlink + application/pdf + """ + for mime in self.MIMEs : + if self.has_MSDS_MIME(id, mime) : + return mime + return None + def has_MSDS(self, id) : + if self.get_MSDS_path(id) == None : + return False + return True + def get_all(self, simlinks=True) : + ret = [] + for record in self.db.records() : + p = self.get_MSDS_path( int(record['ID']) ) + if p != None : + if simlinks == False and os.path.islink( p ) : + continue # ignore the symbolic link + ret.append({'ID':record['ID'], 'Name':record['Name']}) + return ret + +class docgen (object) : + "Generate the officially required documents" + def __init__(self, db) : + self.db = db + def _latex_safe(self, string): + string = string.replace('%', '\%') + string = string.replace('>', '$>$') + string = string.replace('<', '$<$') + return string + def _set_main_target(self, target): + print >> file('./docs/main.tex', 'w'), \ + """\documentclass[letterpaper]{article} + +\input{%s} +""" % target + def _make_pdf(self, target_file): + os.system('cd ./docs && make pdf') + path = os.path.join('./docs/', target_file) + os.system('cp ./docs/main.pdf %s' % path) + return path + def inventory(self, namewidth='a') : + pp = db_pretty_printer(self.db) + active_ids = [] + for record in self.db.records() : + if record['Disposed'] == '' : # get ids for chemicals we still have + active_ids.append(record['db_id']) + active_fields = ['ID', 'Name', 'Amount', + 'H', 'F', 'R', 'O', 'M', 'C', 'T'] + width = {} + for field in active_fields : + width[field] = 'a' + width['Name'] = namewidth + ## Plain text method + #string = "Chemical inventory:\t\tGenerated on %s\n\n" \ + # % time.strftime('%Y-%m-%d') + #string += pp.multi_record_string(active_ids, active_fields, + # width=width, FS=' ') + # return string + ## Latex method + string = "\\begin{longtable}{l l l c c c c c c c}\n" + string += ('%% The header for the remaining page(s) of the table...\n' + 'ID & Name & Amount & H & F & R & O & M & C & T \\\\\n' + '\\hline\n' + '\\endhead\n') + for db_id in active_ids : + record = self.db.record(db_id) + string += " %s & %s & %s & %s & %s & %s & %s & %s & %s & %s \\\\\n" \ + % (self._latex_safe(record['ID']), + self._latex_safe(record['Name']), + self._latex_safe(record['Amount']), + self._latex_safe(record['H']), + self._latex_safe(record['F']), + self._latex_safe(record['R']), + self._latex_safe(record['O']), + self._latex_safe(record['M']), + self._latex_safe(record['C']), + self._latex_safe(record['T'])) + string += "\\end{longtable}\n" + print >> file('./docs/inventory_data.tex', 'w'), string + ## alter main.tex to point to the inventory template. + self._set_main_target('inventory_template') + ## run latex + path = self._make_pdf('inventory.pdf') + return path + def door_warning(self) : + pp = db_pretty_printer(self.db) + all_ids = range(self.db.len_records()) + + # Search the database to find the nasties + NFPA_maxs = {'H':0, 'F':0, 'R':0, 'O':[]} + Mutagens = [] + Carcinogens = [] + Teratogens = [] + Healths = [] + Fires = [] + Reactivities = [] + Others = [] + for record in self.db.records() : + if record['Disposed'] == '' : # chemicals we still have + for field in ['H', 'F', 'R', 'O'] : + r = record[field] + if r != '' and r != '?' : + if field != 'O' and int(r) > NFPA_maxs[field] : + NFPA_maxs[field] = int(r) + elif field == 'O' and not r in NFPA_maxs['O'] : + NFPA_maxs[field].append(r) + for field,array in zip(['M','C','T'], + [Mutagens, + Carcinogens, + Teratogens]) : + if record[field] != '' and record[field] != '?': + array.append(record['db_id']) + # now that we've found the max NFPAs, + # find all the chemicals at those levels + for record in self.db.records() : + if record['Disposed'] == '' : # chemicals we still have + for field,array in zip(['H', 'F', 'R', 'O'], + [Healths, Fires, + Reactivities, Others]) : + r = record[field] + if r != '' and r != '?' : + if field != 'O' and int(r) == NFPA_maxs[field] : + array.append(record['db_id']) + elif field == 'O' and r in NFPA_maxs['O'] : + array.append(record['db_id']) + + ## generate the output + # first, update the NFPA grapic code + if 'OX' in NFPA_maxs['O'] : OX = 'y' + else : OX = 'n' + if 'W' in NFPA_maxs['O'] : W = 'y' + else : W = 'n' + os.system('./docs/mp/gen_NFPA.sh %d %d %d %s %s > ./docs/mp/NFPA.mp' + % (NFPA_maxs['H'], NFPA_maxs['F'], NFPA_maxs['R'], OX, W)) + # now generate a list of the nasties ( Amount & ID & Name ) + string = "\\begin{tabular}{r r l}\n" + for field,name,array in zip(['H', 'F', 'R', 'O'], + ['Health', 'Fire', + 'Reactivity', 'Other'], + [Healths, Fires, + Reactivities, Others]) : + string += " \multicolumn{3}{c}{\Tstrut %s : %s} \\\\\n" \ + % (name, NFPA_maxs[field]) + for db_id in array : + record = self.db.record(db_id) + string += " %s & %s & %s \\\\\n" \ + % (self._latex_safe(record['Amount']), + self._latex_safe(record['ID']), + self._latex_safe(record['Name'])) + if len(array) == 0 : + string += " \multicolumn{3}{c}{ --- } \\\\\n" + for hazard,array in zip(['Mutagens','Carcinogens','Teratogens'], + [Mutagens, Carcinogens, Teratogens]) : + string += " \multicolumn{3}{c}{\Tstrut %s} \\\\\n" % (hazard) + for db_id in array : + record = self.db.record(db_id) + string += " %s & %s & %s \\\\\n" \ + % (self._latex_safe(record['Amount']), + self._latex_safe(record['ID']), + self._latex_safe(record['Name'])) + if len(array) == 0 : + string += " \multicolumn{3}{c}{ --- } \\\\\n" + string += "\\end{tabular}\n" + print >> file('./docs/door_data.tex', 'w'), string + ## alter main.tex to point to the door template. + self._set_main_target('door_template') + ## run latex + path = self._make_pdf('door_warning.pdf') + return path + +def _test(): + import doctest + doctest.testmod() + +def open_IOfiles(ifilename=None, ofilename=None, debug=False): + if ifilename : + if debug : print >> stderr, "open input file '%s'" % ifilename + ifile = file(ifilename, 'r') + else : + ifile = stdin + if ofilename : + if debug : print >> stderr, "open output file '%s'" % ofilename + ofile = file(ofilename, 'w') + else : + ofile = stdout + return (ifile, ofile) + +def close_IOfiles(ifilename=None, ifile=stdin, + ofilename=None, ofile=stdout, + debug=False): + if ifilename : + if debug : print >> stderr, "close input file '%s'" % ifilename + ifile.close() + if ofilename : + if debug : print >> stderr, "close output file '%s'" % ofilename + ofile.close() + + +if __name__ == "__main__" : + from optparse import OptionParser + + parser = OptionParser(usage="usage: %prog [options]", version="%prog 0.1") + + parser.add_option('-f', '--input-file', dest="ifilename", + help="Read input from FILE (default stdin)", + type='string', metavar="FILE") + parser.add_option('-o', '--output-file', dest="ofilename", + help="Write output to FILE (default stdout)", + type='string', metavar="FILE") + parser.add_option('-d', '--delimiter', dest="FS", # field seperator + help="Set field delimiter (default '%default')", + type='string', metavar="DELIM", default='\t') + parser.add_option('-p', '--print-fields', dest="print_fields", + help="Only print certain fields (e.g. 0,3,4,2)", + type='string', metavar="FIELDS") + parser.add_option('-r', '--print-records', dest="print_records", + help="Only print certain records (e.g. 0:3)", + type='string', metavar="RECORDS") + parser.add_option('-w', '--column-width', dest="width", + help="Set column width for short-format output.", + type='string', metavar="WIDTH") + parser.add_option('-L', '--long-format', dest="long_format", + help="Print long format (several lines per record)", + action='store_true', default=False) + parser.add_option('-l', '--short-format', dest="long_format", + help="Print short format (default) (one lines per record)", + action='store_false', default=False) + parser.add_option('-t', '--test', dest="test", + help="Run docutils tests on db.py", + action="store_true", default=False) + parser.add_option('-V', '--validate', dest="validate", + help="Validate CAS#s (no other output)", + action="store_true", default=False) + parser.add_option('-v', '--verbose', dest="verbose", + help="Print lots of debugging information", + action="store_true", default=False) + + (options, args) = parser.parse_args() + parser.destroy() + + ifile,ofile = open_IOfiles(options.ifilename, options.ofilename, + options.verbose) + + if options.test : + _test() + elif options.validate : + db = text_db(filename=None) + pp = db_pretty_printer(db) + + # read in and parse the file + db._parse(ifile.read()) + + CAS_DELIM = ',' # seperate CAS entries for chemicals with multiple CAS numbers + PERCENT_DELIM = ':' # seperate CAS number from ingredient percentage + for record in db.records() : + valid = True + cas = record['CAS#'] + if len(cas.split(CAS_DELIM)) == 0 : # cas = 'N...N-NN-N' + if not valid_CASno(cas, options.verbose) : + valid = False + print >> ofile, "Invalid CAS# in record: '%s'" % cas + else : # cas = 'N...N-NN-N:X%,N...N-NN-N:Y%,...' + for casterm in cas.split(CAS_DELIM) : # casterm = 'N...N-NN-N:X%' + c = casterm.split(PERCENT_DELIM)[0] # c = 'N...N-NN-N' + if not valid_CASno(c, options.verbose) : + valid = False + print >> ofile, "Invalid CAS* in record: '%s'" % c + if not valid : + print >> ofile, "in record %s: %s" % (record['ID'], record['Name']) + #pp.full_record_string(record) + + else : + db = text_db(filename=None) + + # read in and parse the file + db._parse(ifile.read()) + pp = db_pretty_printer(db) + if options.long_format : + for id in pp._norm_record_ids(options.print_records) : + string = pp.full_record_string_id(id) + else : + # pythonize the width option + if (options.width != None + and options.width != 'a' + and len(options.width.split(':')) == 1 + ) : + width = int(options.width) + elif len(options.width.split(':')) > 1 : + width = {} + for kv in options.width.split(',') : + spl = kv.split(':') + assert len(spl) == 2, 'invalid width "%s" in "%s"' % (kv, options.width) + if spl[1] == 'a' : + width[spl[0]] = spl[1] + else : + width[spl[0]] = int(spl[1]) + + string = pp.multi_record_string(options.print_records, + options.print_fields, + width, + options.FS) + print >> ofile, string, + + close_IOfiles(options.ifilename, ifile, + options.ofilename, ofile, options.verbose) diff --git a/chem_web.py b/chem_web.py new file mode 100755 index 0000000..ea362c5 --- /dev/null +++ b/chem_web.py @@ -0,0 +1,560 @@ +#!/usr/bin/python +# +# Web application for posting additions/modifications to the chem-inventory +# derived from simplewiki and wiki_py, by Adam Bachman +# ( http://bachman.infogami.com/ ) +# Authentication following Snakelets +# http://snakelets.sourceforge.net/manual/authorization.html + +# Remember to delete the templates directory after making alterations, +# because this program only generates templates if they are missing. + + +## Bring in some useful goodies: +# web, because we're running a web server, and generating html; +# time, for timestamping backups; +# os, for creating the template directories; and +# re, for replacing internal node links with proper markup +import web, os, re +# md5, for hashing passwords +#import md5 + +# database must support the following methods : +# field_list() : return an ordered list of available fields (fields unique) +# long_field() : return an dict of long field names (keyed by field names) +# record(id) : return a record dict (keyed by field names) +# records() : return an ordered list of available records. +# backup() : save a copy of the current database somehow. +# set_record(i, newvals) : set a record by overwriting any preexisting data +# : with data from the field-name-keyed dict NEWVALS +import text_db +database = text_db.text_db +from chem_db import valid_CASno, MSDS_manager, docgen +DB_FILE = 'inventory.db' +import sys, daemon, logging +PID_FILE = './chem_web.pid' +LOG_FILE = './chem_web.log' + +## Also import a markup language, and associated regexp functions +from markdown import markdown +## for converting the first character in record names, and stripping | +from string import upper, rstrip + +## for SSL +SSL = False +if SSL : + from mk_simple_certs import get_cert_filenames + server_name = 'chem_web' + +__version__ = "0.2" + +## For web.py, when a URL matches '/view', handle with class 'view' +# when it matches '/edit', handle with class 'edit', +# and when it matches '/login', handle with class 'login', +# Note, there are no .html extension. +# +# link to edit pages using db_id +# link to MSDSs with ID +urls = ('/(view)?', 'view', + '/edit/([0-9]+)', 'edit', + '/MSDS/([0-9]+)', 'MSDS', + '/docs/([a-z._]*)', 'docs', + '/login', 'login') + +# limit the display width of selected fields +WIDTH_MAX = {'CAS#':12} + +# set the display width of entry fields +ENTRY_WIDTH = 50 + +class view (object) : + "Print the database" + # when the server recieves the command "GET '/view', call view.get('view') + def GET(self, name): + """ + Render the view.html template using name and the previous text. + If the file doesn't exist, use default text '%s doesn't exist'. + """ + if name == None : + name = 'view' + db._refresh() + recs = [] + for record in db.records() : + id = int(record['ID']) + record['ID'] = '%s' \ + % (id, record['ID']) + if MSDSman.has_MSDS(id) : # link the id to the MSDS + record['Name'] = '%s' \ + % (record['db_id'], record['Name']) + recs.append(record) + print render.view('Chemical inventory', + db.len_records(), db.field_list(), recs) + +class edit (object) : + "Provide methods for handling requests related to edit pages" + def GET(self, name): + """ + Render the edit.html template using the specified record number. + """ + db._refresh() + db_id = int(name) + if db_id >= db.len_records() : + logging.info("new record") + assert db_id == db.len_records(), \ + 'invalid id: %d (max %d)' % (db_id, db.len_records()) + record = db.new_record() + record['ID'] = str(record['db_id']) + db.set_record(db_id, record) + if MSDSman.has_MSDS(db_id) : + MSDSs = None + else : + # only ask for an MSDS if we still need one + MSDSs = MSDSman.get_all(simlinks=False) + print render.edit(int(name), db.field_list(), + db.long_fields(), db.record(int(name)), MSDSs) + def POST(self, name): + """ + Read the form input and update the database accordingly. + Then redirect to /view. + """ + db._refresh() + record = db.record(int(name)) + ## Generate a new record from the form input + # 'MSDS={}' to storify MSDS as a FieldStorage (dict-like) instance, + # otherwise it's saved as a string, and we loose info about it. + # e.g. newvals['MSDS'].type = 'text/html' + # The contents of the file are saved to newvals['MSDS'].value + newvals = web.input(MSDS={}) + update = False + for field in db.field_list() : + if newvals[field] != record[field] : + update=True + record[field] = newvals[field] + if 'MSDS source' in newvals : + # Handle any MSDS file actions + if newvals['MSDS source'] == 'upload' : + if len(newvals['MSDS'].filename) > 0 and len(newvals['MSDS'].value) > 0: + # only save if there is a file there to save ;) + #print >> stderr, web.ctx.env['CONTENT_TYPE'] + #print >> stderr, web.ctx.keys() + #print >> stderr, dir(newvals['MSDS']) + #print >> stderr, type(newvals['MSDS']) + #print >> stderr, newvals['MSDS'].filename + #print >> stderr, newvals['MSDS'].type + MSDSman.save(int(name), newvals['MSDS'].value, + newvals['MSDS'].type) + else : + logging.info('linking MSDS %d to %d' \ + % (int(record['ID']), + int(newvals['MSDS share'])) ) + MSDSman.link(int(record['ID']), + int(newvals['MSDS share'])) + if update : + db.set_record(int(name), record, backup=True) + # redirect to view all + web.seeother('/view') + +class MSDS (object) : + "Serve MSDS files by ID" + def GET(self, name): + "Serve MSDS files by ID" + id = int(name) + if MSDSman.has_MSDS(id) : + mime = MSDSman.get_MSDS_MIME(id) + web.header("Content-Type", mime) + print file(MSDSman.get_MSDS_path(id), 'rb').read() , + else : + print render.error(id) + +class docs (object) : + "Generate and serve assorted official documents" + def GET(self, name): + """ + List the available documents. + """ + db._refresh() + if name == '' : + print render.docs() + if name == 'inventory.pdf' : + path = dgen.inventory(namewidth=40) + print file(path, 'rb').read() , + if name == 'door_warning.pdf' : + path = dgen.door_warning() + print file(path, 'rb').read() , + + +class login (object) : + "Print an alphabetized index of all existing pages" + def GET(self): + print "Not yet implemented" + #print render.stat('login') + +class markup (object) : + "Convert text to html, using Markdown with a bit of preformatting" + def __init__(self) : + # [[optional display text|name with or_without_spaces]] + linkregexp = ('\[\[' + +'([^\|\[\]]*\|)?' + +'([a-zA-Z0-9-_ ]*)' + +'\]\]') + #print linkregexp + self.linkexp = re.compile(linkregexp) + self.linebreak = ' \n' + self.uscore = re.compile('_') + self.space = re.compile(' ') + def htmlize(self, text) : + return text + pre_markup = self._preformat(text) + #print "Preformatted '"+pre_markup+"'" + return self._markup( pre_markup ) + def _markup(self, text) : + # markdown() implements the text->html Markdown semantics + # the str() builtin ensures a nice, printable string + return str(markdown(text)) + def _preformat(self, text) : + #print "Preformatting '"+text+"'" + return self._autolink(text) + def _autolink(self, text) : + "Replace linkexps in text with Markdown syntax versions" + # sub passes a match object to link2markup for each match + # see http://docs.python.org/lib/match-objects.html + return self.linkexp.sub(self.matchlink2markup, text) + def matchlink2markup(self, match) : + # The name is the first (and only) matched region + #print match.groups() + name = match.groups()[1] + text = match.groups()[0] + return self.link2markup(text, name) + def linklist(self, text) : + linklist = [] # empty list + for m in self.linkexp.finditer(text) : + linklist.append( self.str2name(m.groups()[1]) ) + #print linklist + return linklist + def link2markup(self, text, name) : + """ + usage: string = link2markup(text, target) + takes the string name of a wiki page, + and returns a string that will be markuped into a link to that page + displaying the text text. + """ + # convert to Markdown pretty link syntax + if text : # remove trailing | + text = text.rstrip('|') + else : + text = self.name2str(name) + name = self.str2name(name) + return str('['+text+'](/'+name+')') + def name2str(self, name) : + """ + usage: string = name2str(name) + takes the string name of a wiki page, + and converts it to display format + See str2name() + """ + w_spaces = self.uscore.sub(' ', name) + return w_spaces + def str2name(self, string) : + """ + usage: string = name2str(name) + Converts strings to the relevent wiki page name. + See str2name() + """ + wo_spaces = self.space.sub('_', string) + cap_first = upper(wo_spaces[0]) + wo_spaces[1:] + return cap_first + def make_add_button(self, next_db_id) : + string = '
\n' % next_db_id + string += ' \n' + string += '

\n' + return string + def make_table(self, fields, records, width_max=WIDTH_MAX, + raw_fields=()): + """ + >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]), + + + + +
ABC
ab
def
+ """ + # open the table + string = '\n' + # add the header + string += '' + for field in fields : + string += '' % self.htmlize(field) + string += '\n' + # add the records + for record in records : + string += '' + for field in fields : + rstring = record[field] + # truncate if the width is regulated... + if field in width_max : + w = width_max[field] + # ... and the full string is too long + if len(rstring) > w : + rstring = "%s..." % rstring[:w] + # if the field isn't raw, protect special chars + if not field in raw_fields : + rstring = self.htmlize(rstring) + string += '' % rstring + string += '\n' + # close the table + string += '
%s
%s
\n' + return string + def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH): + """ + >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}), + + + + + + + +
:
:
:
+ """ + # open the table + string = '\n' + # add the record fields + for field in fields : + # get the record string + rstring = self.htmlize(record[field]) + fstring = self.htmlize(field) + lfstring = self.htmlize(lfields[field]) + string += ' \n' % (fstring, lfstring) + string += ' \n' \ + % (entry_width, fstring, fstring, rstring) + if MSDSs != None : + ## add an MSDS radio, share menu, and file upload fields + # radio + lfstring = fstring = 'MSDS source' + string += ' \n' \ + % (fstring, lfstring) + string += ' \n' \ + % (fstring, fstring) + + # file upload + fstring = 'MSDS' + lfstring = 'Upload MSDS' + string += ' \n' \ + % (fstring, lfstring) + string += ' \n' \ + % (entry_width, fstring, fstring) + + # file share menu + fstring = 'MSDS share' + lfstring = 'Use an already uploaded MSDS' + string += ' \n' \ + % (fstring, lfstring) + string += ' \n' + # close the table + string += '
:
:Upload\n' \ + % (fstring, fstring) + string += ' Share
:
:

\n' + return string + +class templt (object) : + "Handle some template wrapping" + def __init__(self) : + self.dir = 'templates/' + if not os.path.exists(self.dir): os.mkdir(self.dir) + ## Write simple template html pages if they don't already exist. + # see ( http://webpy.org/templetor ) for the syntax. + html=('\n' + '\n') + if not os.path.exists(self.dir+'view.html') : + view=('$def with (name, next_db_id, fields, records)\n' + +html+ + ' \n' + ' \n' + ' $:name2str(name) \n' + ' \n' + ' \n' + ' \n' + '

\n' + ' $:name2str(name)\n' + '

\n' + ' See \n' + ' the rules\n' + ' for more information.\n' + ' See the docs page to generate required documents.
\n' + '
\n' + ' $:make_add_button(next_db_id)\n' + ' $:make_table(fields, records)\n' + ' $:make_add_button(next_db_id)\n' + ' \n' + '\n') + file(self.dir+'view.html', 'w').write(view) + if not os.path.exists(self.dir+'edit.html') : + # note: + # the form encoding type 'enctype="multipart/form-data">\n' + # is only required because of the MSDS file-upload field. + edit=('$def with (index, fields, lfields, record, MSDSs)\n' + +html+ + ' \n' + ' \n' + ' $:htmlize(record["Name"]) \n' + ' \n' + ' \n' + ' \n' + '

\n' + ' Editing: $:htmlize(record["Name"]) \n' + '

\n' + '
\n' + ' $:record_form(index, fields, lfields, record, MSDSs)
\n' + ' \n' + '
\n' + ' \n' + '\n') + file(self.dir+'edit.html', 'w').write(edit) + if not os.path.exists(self.dir+'error.html') : + # like view, but no edit ability + # since the content is generated, + # or backlink list, since there would be so many. + stat=('$def with (id)\n' + +html+ + ' \n' + ' \n' + ' \n' + '

Error

\n' + ' There is currently no MSDS file for \n' + ' $:htmlize(id)\n' + ' \n' + '\n') + file(self.dir+'error.html', 'w').write(stat) + if not os.path.exists(self.dir+'docs.html') : + docs=(html + + 'Inventory\n' + ' in accordance with the \n' + ' \n' + ' Chemical Hygiene Plan Section E-7.
\n' + 'Door warning\n' + ' in accordance with the Chemical Hygiene Plan Sections\n' + ' E-7\n' + ' and \n' + ' E-10\n' + '.
\n' + ' \n' + '\n') + file(self.dir+'docs.html', 'w').write(docs) + + +class chem_web_daemon (daemon.Daemon) : + gid = None + uid = None + pidfile = PID_FILE + logfile = LOG_FILE + loglevel = 'INFO' + def run(self) : + if self.pkey_file == None or self.cert_file == None : + logging.info("http://%s:%d/" % web.validip(self.options.ip)) + else : + logging.info("https://%s:%d/" % web.validip(self.options.ip)) + + ## web.py should give detailed error messages, not just `internal error' + web.internalerror = web.debugerror + + ## How we'd start webpy server if we didn't need command line args or SSL + # Pass web my URL regexps, and the globals from this script + # You can also pass it web.profiler to help optimize for speed + #web.run(urls, globals()) + ## How we have to start it now + webpy_func = web.webpyfunc(urls, globals(), False) + wsgi_func = web.wsgifunc(webpy_func) + web.httpserver.runsecure(wsgi_func, + web.validip(self.options.ip), + ssl_private_key_filename=self.pkey_file, + ssl_certificate_filename=self.cert_file) + + def read_basic_config(self) : + pass + def parse_options(self): + from optparse import OptionParser + + usage_string = ('%prog [options]\n' + '\n' + '2008, W. Trevor King.\n') + version_string = '%%prog %s' % __version__ + parser = OptionParser(usage=usage_string, version=version_string) + + # Daemon options + parser.add_option('--start', dest='action', + action='store_const', const='start', default='start', + help='Start the daemon (the default action)') + parser.add_option('--stop', dest='action', + action='store_const', const='stop', default='start', + help='Stop the daemon') + parser.add_option('-n', '--nodaemon', dest='daemonize', + action='store_false', default=True, + help='Run in the foreground') + + # Server options + parser.add_option('-a', '--ip-address', dest="ip", + help="IP address (default '%default')", + type='string', metavar="IP", default='0.0.0.0:8080') + parser.add_option('-s', '--secure', dest="secure", + help="Run in secure (HTTPS) mode.", + type='string', metavar="PKEY_FILE:CERT_FILE") + parser.add_option('-v', '--verbose', dest="verbose", action="store_true", + help="Print lots of debugging information", + default=False) + + self.options, self.args = parser.parse_args() + parser.destroy() + + if self.options.verbose : + self.loglevel = 'DEBUG' + # get options for httpserver + if self.options.secure != None : + split = self.options.secure.split(':') + assert len(split) == 2, "Invalid secure argument '%s'" + self.pkey_file = split[0] + self.cert_file = split[1] + else : + self.pkey_file = None + self.cert_file = None + +### the following instances and definitions must have +## global scope. because they are called from inside web +# create the database +db = database(filename=DB_FILE) +MSDSman = MSDS_manager(db) +dgen = docgen(db) + +## set up the templates +tmplt = templt() +# Tell templator where to look for templates +# to provide a framework for the generated webpages +render = web.template.render(tmplt.dir) + +## Define markup functions +mkup = markup() +htmlize = lambda t : mkup.htmlize(t) +name2str = lambda n : mkup.name2str(n) +mktable = lambda h,r : mkup.make_table(h,r,WIDTH_MAX,raw_fields=('ID','Name')) +mkadd = lambda ni : mkup.make_add_button(ni) +record_form = lambda i,f,l,r,M : mkup.record_form_entries(i,f,l,r,M) +## Give templates access to the htmlize, make_table, and name2str functions +# ( see http://webpy.org/templetor and our view.html ) +web.template.Template.globals['htmlize'] = htmlize +web.template.Template.globals['make_table'] = mktable +web.template.Template.globals['make_add_button'] = mkadd +web.template.Template.globals['name2str'] = name2str +web.template.Template.globals['record_form'] = record_form + + +## If this script is run from the command line, +# use the tools we've just defined to host the chemical inventory +if __name__=="__main__": + chem_web_daemon().main() diff --git a/chem_web_secure.py b/chem_web_secure.py new file mode 100755 index 0000000..d17395d --- /dev/null +++ b/chem_web_secure.py @@ -0,0 +1,560 @@ +#!/usr/bin/python +# +# Web application for posting additions/modifications to the chem-inventory +# derived from simplewiki and wiki_py, by Adam Bachman +# ( http://bachman.infogami.com/ ) +# Authentication following Snakelets +# http://snakelets.sourceforge.net/manual/authorization.html + +# Remember to delete the templates directory after making alterations, +# because this program only generates templates if they are missing. + + +## Bring in some useful goodies: +# web, because we're running a web server, and generating html; +# time, for timestamping backups; +# os, for creating the template directories; and +# re, for replacing internal node links with proper markup +import web, os, re +# md5, for hashing passwords +#import md5 + +# database must support the following methods : +# field_list() : return an ordered list of available fields (fields unique) +# long_field() : return an dict of long field names (keyed by field names) +# record(id) : return a record dict (keyed by field names) +# records() : return an ordered list of available records. +# backup() : save a copy of the current database somehow. +# set_record(i, newvals) : set a record by overwriting any preexisting data +# : with data from the field-name-keyed dict NEWVALS +import text_db +database = text_db.text_db +from chem_db import valid_CASno, MSDS_manager, docgen +DB_FILE = 'inventory.db' +import sys, daemon, logging +PID_FILE = './chem_web_secure.pid' +LOG_FILE = './chem_web_secure.log' + +## Also import a markup language, and associated regexp functions +from markdown import markdown +## for converting the first character in record names, and stripping | +from string import upper, rstrip + +## for SSL +SSL = True +if SSL : + from mk_simple_certs import get_cert_filenames + server_name = 'chem_web' + +__version__ = "0.2" + +## For web.py, when a URL matches '/view', handle with class 'view' +# when it matches '/edit', handle with class 'edit', +# and when it matches '/login', handle with class 'login', +# Note, there are no .html extension. +# +# link to edit pages using db_id +# link to MSDSs with ID +urls = ('/(view)?', 'view', + '/edit/([0-9]+)', 'edit', + '/MSDS/([0-9]+)', 'MSDS', + '/docs/([a-z._]*)', 'docs', + '/login', 'login') + +# limit the display width of selected fields +WIDTH_MAX = {'CAS#':12} + +# set the display width of entry fields +ENTRY_WIDTH = 50 + +class view (object) : + "Print the database" + # when the server recieves the command "GET '/view', call view.get('view') + def GET(self, name): + """ + Render the view.html template using name and the previous text. + If the file doesn't exist, use default text '%s doesn't exist'. + """ + if name == None : + name = 'view' + db._refresh() + recs = [] + for record in db.records() : + id = int(record['ID']) + record['ID'] = '%s' \ + % (id, record['ID']) + if MSDSman.has_MSDS(id) : # link the id to the MSDS + record['Name'] = '%s' \ + % (record['db_id'], record['Name']) + recs.append(record) + print render.view('Chemical inventory', + db.len_records(), db.field_list(), recs) + +class edit (object) : + "Provide methods for handling requests related to edit pages" + def GET(self, name): + """ + Render the edit.html template using the specified record number. + """ + db._refresh() + db_id = int(name) + if db_id >= db.len_records() : + logging.info("new record") + assert db_id == db.len_records(), \ + 'invalid id: %d (max %d)' % (db_id, db.len_records()) + record = db.new_record() + record['ID'] = str(record['db_id']) + db.set_record(db_id, record) + if MSDSman.has_MSDS(db_id) : + MSDSs = None + else : + # only ask for an MSDS if we still need one + MSDSs = MSDSman.get_all(simlinks=False) + print render.edit(int(name), db.field_list(), + db.long_fields(), db.record(int(name)), MSDSs) + def POST(self, name): + """ + Read the form input and update the database accordingly. + Then redirect to /view. + """ + db._refresh() + record = db.record(int(name)) + ## Generate a new record from the form input + # 'MSDS={}' to storify MSDS as a FieldStorage (dict-like) instance, + # otherwise it's saved as a string, and we loose info about it. + # e.g. newvals['MSDS'].type = 'text/html' + # The contents of the file are saved to newvals['MSDS'].value + newvals = web.input(MSDS={}) + update = False + for field in db.field_list() : + if newvals[field] != record[field] : + update=True + record[field] = newvals[field] + if 'MSDS source' in newvals : + # Handle any MSDS file actions + if newvals['MSDS source'] == 'upload' : + if len(newvals['MSDS'].filename) > 0 and len(newvals['MSDS'].value) > 0: + # only save if there is a file there to save ;) + #print >> stderr, web.ctx.env['CONTENT_TYPE'] + #print >> stderr, web.ctx.keys() + #print >> stderr, dir(newvals['MSDS']) + #print >> stderr, type(newvals['MSDS']) + #print >> stderr, newvals['MSDS'].filename + #print >> stderr, newvals['MSDS'].type + MSDSman.save(int(name), newvals['MSDS'].value, + newvals['MSDS'].type) + else : + logging.info('linking MSDS %d to %d' \ + % (int(record['ID']), + int(newvals['MSDS share'])) ) + MSDSman.link(int(record['ID']), + int(newvals['MSDS share'])) + if update : + db.set_record(int(name), record, backup=True) + # redirect to view all + web.seeother('/view') + +class MSDS (object) : + "Serve MSDS files by ID" + def GET(self, name): + "Serve MSDS files by ID" + id = int(name) + if MSDSman.has_MSDS(id) : + mime = MSDSman.get_MSDS_MIME(id) + web.header("Content-Type", mime) + print file(MSDSman.get_MSDS_path(id), 'rb').read() , + else : + print render.error(id) + +class docs (object) : + "Generate and serve assorted official documents" + def GET(self, name): + """ + List the available documents. + """ + db._refresh() + if name == '' : + print render.docs() + if name == 'inventory.pdf' : + path = dgen.inventory(namewidth=40) + print file(path, 'rb').read() , + if name == 'door_warning.pdf' : + path = dgen.door_warning() + print file(path, 'rb').read() , + + +class login (object) : + "Print an alphabetized index of all existing pages" + def GET(self): + print "Not yet implemented" + #print render.stat('login') + +class markup (object) : + "Convert text to html, using Markdown with a bit of preformatting" + def __init__(self) : + # [[optional display text|name with or_without_spaces]] + linkregexp = ('\[\[' + +'([^\|\[\]]*\|)?' + +'([a-zA-Z0-9-_ ]*)' + +'\]\]') + #print linkregexp + self.linkexp = re.compile(linkregexp) + self.linebreak = ' \n' + self.uscore = re.compile('_') + self.space = re.compile(' ') + def htmlize(self, text) : + return text + pre_markup = self._preformat(text) + #print "Preformatted '"+pre_markup+"'" + return self._markup( pre_markup ) + def _markup(self, text) : + # markdown() implements the text->html Markdown semantics + # the str() builtin ensures a nice, printable string + return str(markdown(text)) + def _preformat(self, text) : + #print "Preformatting '"+text+"'" + return self._autolink(text) + def _autolink(self, text) : + "Replace linkexps in text with Markdown syntax versions" + # sub passes a match object to link2markup for each match + # see http://docs.python.org/lib/match-objects.html + return self.linkexp.sub(self.matchlink2markup, text) + def matchlink2markup(self, match) : + # The name is the first (and only) matched region + #print match.groups() + name = match.groups()[1] + text = match.groups()[0] + return self.link2markup(text, name) + def linklist(self, text) : + linklist = [] # empty list + for m in self.linkexp.finditer(text) : + linklist.append( self.str2name(m.groups()[1]) ) + #print linklist + return linklist + def link2markup(self, text, name) : + """ + usage: string = link2markup(text, target) + takes the string name of a wiki page, + and returns a string that will be markuped into a link to that page + displaying the text text. + """ + # convert to Markdown pretty link syntax + if text : # remove trailing | + text = text.rstrip('|') + else : + text = self.name2str(name) + name = self.str2name(name) + return str('['+text+'](/'+name+')') + def name2str(self, name) : + """ + usage: string = name2str(name) + takes the string name of a wiki page, + and converts it to display format + See str2name() + """ + w_spaces = self.uscore.sub(' ', name) + return w_spaces + def str2name(self, string) : + """ + usage: string = name2str(name) + Converts strings to the relevent wiki page name. + See str2name() + """ + wo_spaces = self.space.sub('_', string) + cap_first = upper(wo_spaces[0]) + wo_spaces[1:] + return cap_first + def make_add_button(self, next_db_id) : + string = '
\n' % next_db_id + string += ' \n' + string += '

\n' + return string + def make_table(self, fields, records, width_max=WIDTH_MAX, + raw_fields=()): + """ + >>> print make_table(['A','B','C'],[{'A':'a','B':'b'},{'A':'d','B':'e','C':'f'}]), + + + + +
ABC
ab
def
+ """ + # open the table + string = '\n' + # add the header + string += '' + for field in fields : + string += '' % self.htmlize(field) + string += '\n' + # add the records + for record in records : + string += '' + for field in fields : + rstring = record[field] + # truncate if the width is regulated... + if field in width_max : + w = width_max[field] + # ... and the full string is too long + if len(rstring) > w : + rstring = "%s..." % rstring[:w] + # if the field isn't raw, protect special chars + if not field in raw_fields : + rstring = self.htmlize(rstring) + string += '' % rstring + string += '\n' + # close the table + string += '
%s
%s
\n' + return string + def record_form_entries(self, index, fields, lfields, record, MSDSs=None, entry_width=ENTRY_WIDTH): + """ + >>> print record_form_entries(4,['A','B','MSDS'],{'A':'AA','B':'BB','MSDS'},{'A':'a','B':'b'}), + + + + + + + +
:
:
:
+ """ + # open the table + string = '\n' + # add the record fields + for field in fields : + # get the record string + rstring = self.htmlize(record[field]) + fstring = self.htmlize(field) + lfstring = self.htmlize(lfields[field]) + string += ' \n' % (fstring, lfstring) + string += ' \n' \ + % (entry_width, fstring, fstring, rstring) + if MSDSs != None : + ## add an MSDS radio, share menu, and file upload fields + # radio + lfstring = fstring = 'MSDS source' + string += ' \n' \ + % (fstring, lfstring) + string += ' \n' \ + % (fstring, fstring) + + # file upload + fstring = 'MSDS' + lfstring = 'Upload MSDS' + string += ' \n' \ + % (fstring, lfstring) + string += ' \n' \ + % (entry_width, fstring, fstring) + + # file share menu + fstring = 'MSDS share' + lfstring = 'Use an already uploaded MSDS' + string += ' \n' \ + % (fstring, lfstring) + string += ' \n' + # close the table + string += '
:
:Upload\n' \ + % (fstring, fstring) + string += ' Share
:
:

\n' + return string + +class templt (object) : + "Handle some template wrapping" + def __init__(self) : + self.dir = 'templates/' + if not os.path.exists(self.dir): os.mkdir(self.dir) + ## Write simple template html pages if they don't already exist. + # see ( http://webpy.org/templetor ) for the syntax. + html=('\n' + '\n') + if not os.path.exists(self.dir+'view.html') : + view=('$def with (name, next_db_id, fields, records)\n' + +html+ + ' \n' + ' \n' + ' $:name2str(name) \n' + ' \n' + ' \n' + ' \n' + '

\n' + ' $:name2str(name)\n' + '

\n' + ' See \n' + ' the rules\n' + ' for more information.\n' + ' See the docs page to generate required documents.
\n' + '
\n' + ' $:make_add_button(next_db_id)\n' + ' $:make_table(fields, records)\n' + ' $:make_add_button(next_db_id)\n' + ' \n' + '\n') + file(self.dir+'view.html', 'w').write(view) + if not os.path.exists(self.dir+'edit.html') : + # note: + # the form encoding type 'enctype="multipart/form-data">\n' + # is only required because of the MSDS file-upload field. + edit=('$def with (index, fields, lfields, record, MSDSs)\n' + +html+ + ' \n' + ' \n' + ' $:htmlize(record["Name"]) \n' + ' \n' + ' \n' + ' \n' + '

\n' + ' Editing: $:htmlize(record["Name"]) \n' + '

\n' + '
\n' + ' $:record_form(index, fields, lfields, record, MSDSs)
\n' + ' \n' + '
\n' + ' \n' + '\n') + file(self.dir+'edit.html', 'w').write(edit) + if not os.path.exists(self.dir+'error.html') : + # like view, but no edit ability + # since the content is generated, + # or backlink list, since there would be so many. + stat=('$def with (id)\n' + +html+ + ' \n' + ' \n' + ' \n' + '

Error

\n' + ' There is currently no MSDS file for \n' + ' $:htmlize(id)\n' + ' \n' + '\n') + file(self.dir+'error.html', 'w').write(stat) + if not os.path.exists(self.dir+'docs.html') : + docs=(html + + 'Inventory\n' + ' in accordance with the \n' + ' \n' + ' Chemical Hygiene Plan Section E-7.
\n' + 'Door warning\n' + ' in accordance with the Chemical Hygiene Plan Sections\n' + ' E-7\n' + ' and \n' + ' E-10\n' + '.
\n' + ' \n' + '\n') + file(self.dir+'docs.html', 'w').write(docs) + + +class chem_web_daemon (daemon.Daemon) : + gid = None + uid = None + pidfile = PID_FILE + logfile = LOG_FILE + loglevel = 'INFO' + def run(self) : + if self.pkey_file == None or self.cert_file == None : + logging.info("http://%s:%d/" % web.validip(self.options.ip)) + else : + logging.info("https://%s:%d/" % web.validip(self.options.ip)) + + ## web.py should give detailed error messages, not just `internal error' + web.internalerror = web.debugerror + + ## How we'd start webpy server if we didn't need command line args or SSL + # Pass web my URL regexps, and the globals from this script + # You can also pass it web.profiler to help optimize for speed + #web.run(urls, globals()) + ## How we have to start it now + webpy_func = web.webpyfunc(urls, globals(), False) + wsgi_func = web.wsgifunc(webpy_func) + web.httpserver.runsecure(wsgi_func, + web.validip(self.options.ip), + ssl_private_key_filename=self.pkey_file, + ssl_certificate_filename=self.cert_file) + + def read_basic_config(self) : + pass + def parse_options(self): + from optparse import OptionParser + + usage_string = ('%prog [options]\n' + '\n' + '2008, W. Trevor King.\n') + version_string = '%%prog %s' % __version__ + parser = OptionParser(usage=usage_string, version=version_string) + + # Daemon options + parser.add_option('--start', dest='action', + action='store_const', const='start', default='start', + help='Start the daemon (the default action)') + parser.add_option('--stop', dest='action', + action='store_const', const='stop', default='start', + help='Stop the daemon') + parser.add_option('-n', '--nodaemon', dest='daemonize', + action='store_false', default=True, + help='Run in the foreground') + + # Server options + parser.add_option('-a', '--ip-address', dest="ip", + help="IP address (default '%default')", + type='string', metavar="IP", default='0.0.0.0:8080') + parser.add_option('-s', '--secure', dest="secure", + help="Run in secure (HTTPS) mode.", + type='string', metavar="PKEY_FILE:CERT_FILE") + parser.add_option('-v', '--verbose', dest="verbose", action="store_true", + help="Print lots of debugging information", + default=False) + + self.options, self.args = parser.parse_args() + parser.destroy() + + if self.options.verbose : + self.loglevel = 'DEBUG' + # get options for httpserver + if self.options.secure != None : + split = self.options.secure.split(':') + assert len(split) == 2, "Invalid secure argument '%s'" + self.pkey_file = split[0] + self.cert_file = split[1] + else : + self.pkey_file = None + self.cert_file = None + +### the following instances and definitions must have +## global scope. because they are called from inside web +# create the database +db = database(filename=DB_FILE) +MSDSman = MSDS_manager(db) +dgen = docgen(db) + +## set up the templates +tmplt = templt() +# Tell templator where to look for templates +# to provide a framework for the generated webpages +render = web.template.render(tmplt.dir) + +## Define markup functions +mkup = markup() +htmlize = lambda t : mkup.htmlize(t) +name2str = lambda n : mkup.name2str(n) +mktable = lambda h,r : mkup.make_table(h,r,WIDTH_MAX,raw_fields=('ID','Name')) +mkadd = lambda ni : mkup.make_add_button(ni) +record_form = lambda i,f,l,r,M : mkup.record_form_entries(i,f,l,r,M) +## Give templates access to the htmlize, make_table, and name2str functions +# ( see http://webpy.org/templetor and our view.html ) +web.template.Template.globals['htmlize'] = htmlize +web.template.Template.globals['make_table'] = mktable +web.template.Template.globals['make_add_button'] = mkadd +web.template.Template.globals['name2str'] = name2str +web.template.Template.globals['record_form'] = record_form + + +## If this script is run from the command line, +# use the tools we've just defined to host the chemical inventory +if __name__=="__main__": + chem_web_daemon().main() diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..1b02257 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,167 @@ +# Produce html, pdf, and dvi output from latex source +# W. Trevor King, 2008, version 0.2 +# This file is in the public domain. +# +# exposed targets : +# all : generate each of the outputs (pdf, html, dvi) and call view +# view : call '$(PDF_VIEWER) main.pdf &' to see the pdf file +# install : call install_% for pdf, html, dvi, and dist +# clean : semi-clean and remove the $(GENERATED_FILES) as well +# semi-clean : remove all intermediate $(TEMP_FILES) used during generation +# +# pdf : generate the pdf file (Portable Document Format) +# install_pdf : install the dvi file (currently no action) +# html : generate html directory ready for posting +# install_html : scp the html directory to $(INSTALL_HTML) +# dvi : generate main.dvi (DeVice Independent file) +# install_dvi : install the dvi file (currently no action) +# dist : gen. $(DOC_NAME)-$(VERSION).tar.gz containing $(DIST_FILES) +# install_dist : install the dist file (currently no action) +# +# images : call for generation of all images in $(IMAGES) + +DOC_NAME = chem_inventory +VERSION = 0.2 + +# I like to keep my images in seperate directories, where any processing +# they may need woln't clutter up the base directory with temp files. +# To control these, this Makefile calls the Makefiles in the IMAGE_DIRS, +# so make sure they exist, and have the following targets: +# clean : remove all generated files +# semi-clean : remove all intermediate files +# for each image in IMAGE, you must have a rule that generates it, +# see 'image generation rules' below +IMAGES = mp/NFPA.1 +IMAGE_DIRS = mp + +# Non image source files +SOURCE_FILES = main.tex *.tex +# And anything else you'd like to distribute +OTHER_FILES = README Makefile + +DIST_FILES = $(SOURCE_FILES) $(OTHER_FILES) $(IMAGE_DIRS) + +# Select where to put things when you call the dist and install targets +DIST_DIR = $(DOC_NAME)-$(VERSION) +DIST_NAME = $(DOC_NAME)-$(VERSION).tar.gz +INSTALL_PDF = $(HOME)/rsrch/notes +INSTALL_DVI = +INSTALL_HTML = einstein:./public_html/rsrch/papers/$(DOC_NAME) +# interpreted as latex by latex2html, so escape the '~' to '\~{}' +TILDE = "%7E" +CSS_PATH = "/$(TILDE)wking/shared/style_l2h.css" + +# Files removed on clean or semi-clean +TEMP_FILES = *[.]aux *[.]log *[.]out *[.]bak \ + *.png *.eps +# Files removed only on clean +GENERATED_FILES = $(DIST_NAME) \ + rm -f main.pdf main.dvi html *.pdf + +PDF_VIEWER = xpdf # you can also try evince, acroread, 'xpdf -view H', etc. + +## image generation rules, to handle the images in $(IMAGES) + +mp/NFPA.1 : + $(MAKE) -C mp images + + + +### The remaining rules shouldn't need to be changed + +# pdf, dvi, and html depend on images +images : $(IMAGES) + + +## Big targets +all : pdf html dvi view + +view : pdf + $(PDF_VIEWER) main.pdf & + +install : install_pdf install_html install_dvi install_dist + +clean : semi-clean $(IMAGE_DIRS:%=%_clean) + rm -rf $(GENERATED_FILES) + +semi-clean : $(IMAGE_DIRS:%=%_semi-clean) + rm -f $(TEMP_FILES) + + +## Mid-level indirection targets + +# indirection, so we can have short names like 'pdf' +# without rebuilding when it's not neccessary +pdf : main.pdf +dvi : main.dvi +dist : $(DIST_NAME) + + +## Installation targets + +install_pdf : +install_html : html clean_html_image_dirs + scp -r html $(INSTALL_HTML) +install_dvi : +install_dist : + + +## The meat of the generation + +# generate the pdf output directly with pdflatex +main.pdf : main.aux $(SOURCE_FILE) images + pdflatex main.tex + +# generate html files, including the DIST_FILES in a subdir. +# latex2html requires main.aux to generate figure numbers +# main.pdf is for the '$(DOC_NAME).pdf' link in the footer +# (see ~/.latex2html-init) +html : $(SOURCE_FILES) main.aux main.pdf + latex2html -split 1 -white -notransparent -html_version 3.2 \ + -t $(DOC_NAME) -dir html -mkdir \ + -noshort_extn -top_navigation -bottom_navigation \ + -up_url '../' -up_title 'Papers' \ + -show_section_numbers \ + -style $(CSS_PATH) \ + main.tex # generate the html + if [ ! -d html/src ]; then mkdir html/src ; fi # mk src if it doesn't exist yet + cp -r $(DIST_FILES) html/src/ # move the source files into html/src + cp main.pdf html/$(DOC_NAME).pdf # move the pdf in so we can link to it + +# generate the .aux file which contains labels for referencing +# needed to correctly number references in the pdf and html output. +# (Clash between latex and pdflatex's .aux files? semi-clean fixes) +# -draftmode makes pdflatex ignore images and not make a pdf (faster) +main.aux : *.tex images + pdflatex -draftmode main.tex + +# to stretch our .tex files a bit more, make a dvi as well :p +# run twice to straighten out and TOC or references issues +main.dvi : $(SOURCE_FILES) images + latex main.tex + latex main.tex + +# generate a gzipped tar file containing the DIST_FILES +$(DIST_NAME) : $(DIST_FILES) + mkdir $(DIST_DIR) + cp $(DIST_FILES) $(DIST_DIR) + tar -chozf $(DIST_NAME) $(DIST_DIR) + rm -rf $(DIST_DIR) + + +## IMAGE_DIR handling rules + +# call clean for any image directory +%_clean : % + $(MAKE) -C $< clean + +# call semi-clean for any image directory +%_semi-clean : % + $(MAKE) -C $< semi-clean + +# clean and junk that was in the working image directories +clean_html_image_dirs : $(IMAGE_DIRS:%=%_html_dir_clean) + +# call clean for any image directory in the html/src directory +%_html_dir_clean : % + $(MAKE) -C html/src/$< clean diff --git a/docs/README b/docs/README new file mode 100644 index 0000000..338876a --- /dev/null +++ b/docs/README @@ -0,0 +1,7 @@ +The bulk of the content is generated dynamically from the database. +This directory just contains the latex and infrastructure to make nice +documents from the data. + +The `mp' directory contains metapost source and assorted +infrastructure for building and previewing the graphics. + diff --git a/docs/contact.tex b/docs/contact.tex new file mode 100644 index 0000000..8c34a8a --- /dev/null +++ b/docs/contact.tex @@ -0,0 +1,5 @@ +\begin{tabular}{l} + Professor Guoliang Yang\\ + 215-895-6669 \\ + Disque Hall 926 \\ +\end{tabular} diff --git a/docs/door_template.tex b/docs/door_template.tex new file mode 100644 index 0000000..b669ec5 --- /dev/null +++ b/docs/door_template.tex @@ -0,0 +1,54 @@ +% \documentclass[letterpaper]{article} % line moved to main. +% Paper for posting on the lab door +% as per + +% setup the margins, +\topmargin -0.5in +\headheight 0.0in +\headsep 0.0in +\textheight 10in +\oddsidemargin -0.5in +\textwidth 7.5in + +%% Graphics packages + +\usepackage{graphicx} % to include images +% if pdftex doesn't recognize the type, it's mps +% DeclareGraphicsRule{ext}{type}{sizefile}{command} +\DeclareGraphicsRule{*}{mps}{*}{} +% latex and latex2html can handle Metapost's eps output without modification + +% define a vertical strut for table spacing +\newcommand{\Tstrut}{\rule{0pt}{2.6ex}} +\newcommand{\headfont}{\Large} +\newcommand{\contfont}{\large} +\newcommand{\vspacer}{\vskip 12pt} + +%-----------------------end preamble------------------------- + +\begin{document} +\thispagestyle{empty} % suppress page numbering +\sffamily % switch to sans-serif + + +\begin{center} + +\includegraphics[width=3in]{mp/NFPA.1} +\vspacer + +\contfont +\input{door_data} + +\vfill + +{\headfont Contact} +\vskip 10pt +\contfont +\input{contact} +\vspacer + +Generated \today\ by chem\_db.py + +\end{center} + +\end{document} diff --git a/docs/inventory_template.tex b/docs/inventory_template.tex new file mode 100644 index 0000000..77f5e77 --- /dev/null +++ b/docs/inventory_template.tex @@ -0,0 +1,54 @@ +% \documentclass[letterpaper]{article} % line moved to main. +% Paper for posting on the lab door +% as per + +% setup the margins, +\topmargin -0.5in +\headheight 0.0in +\headsep 0.0in +\textheight 9.5in % leave a bit of extra room for page numbers +\oddsidemargin -0.5in +\textwidth 7.5in + +%% Graphics packages + +\usepackage{graphicx} % to include images +% if pdftex doesn't recognize the type, it's mps +% DeclareGraphicsRule{ext}{type}{sizefile}{command} +\DeclareGraphicsRule{*}{mps}{*}{} +% latex and latex2html can handle Metapost's eps output without modification + +% N of M style page numbering +\usepackage{fancyhdr} +\pagestyle{fancy} +\usepackage{lastpage} +\cfoot{\thepage\ of \pageref{LastPage}} +% Turn off the silly head and foot rules +\renewcommand{\headrule}{\relax} +\renewcommand{\footrule}{\relax} + +% Break the table across several pages +% requires 2 latex runs. +\usepackage{longtable} + +% define a vertical strut for table spacing +\newcommand{\Tstrut}{\rule{0pt}{2.6ex}} +\newcommand{\headfont}{\Large} +\newcommand{\contfont}{\large} +\newcommand{\vspacer}{\vskip 12pt} + +%-----------------------end preamble------------------------- + +\begin{document} + +\begin{center} +{\headfont Inventory}\\ +\contfont +Generated \today\ by chem\_db.py\\ +\vskip 10pt +\end{center} + +\footnotesize +\input{inventory_data} + +\end{document} diff --git a/docs/mp/Makefile b/docs/mp/Makefile new file mode 100644 index 0000000..511e5ca --- /dev/null +++ b/docs/mp/Makefile @@ -0,0 +1,30 @@ +MPS = NFPA + +TEMP_FILES = $(MPS:%=%.mp[a-z]) $(MPS:%=%.log) mp* texnum.mpx +GENERATED_FILES = $(MPS:%=%.[0-9]*) sample.log sample.aux sample.pdf + +#PDFVIEWER = gv +PDFVIEWER = xpdf +#PDFVIEWER = evince + +images : $(MPS:%=%.1) + +all : view + +view : sample.pdf + $(PDFVIEWER) sample.pdf & + +clean : semi-clean + rm -f $(GENERATED_FILES) + +semi-clean : + rm -f $(TEMP_FILES) + +# generate a pdf containing all mp images for previewing-troubleshooting +# depend on the first image from each mp file +sample.pdf : sample.tex images + pdflatex sample.tex + +# if we call for the first image from an mp file, make them all +%.1 : %.mp %_c.mp + mpost "\$(^:%=input %;)" diff --git a/docs/mp/NFPA_c.mp b/docs/mp/NFPA_c.mp new file mode 100644 index 0000000..53cf421 --- /dev/null +++ b/docs/mp/NFPA_c.mp @@ -0,0 +1,70 @@ + +%% NFPA warning triangle +boolean labels; +labels := false; % turn on debugging labels + +% sizing +numeric u; + +u := 2cm; % unit, for overall scaling + +% line thicknesses +numeric cyl_thickness, wire_thickness; +border := 1pt; % thickness of the box outlines + +% colors +color hcolor, fcolor, rcolor, ocolor, bcolor; +hcolor := (0,0,1); % health +fcolor := (1,0,0); % fire +rcolor := (1,1,0); % reactivity +ocolor := white; % other +bcolor := black; % border + +% return a diamond path +vardef diamond(expr radius) = + save P; + path P; + P := (-radius,0) -- (0,radius) -- (radius,0) -- (0,-radius) -- cycle; + P +enddef; + +def NFPA(expr center, radius, h, f, r, o) = + save p, hr, qr; + pair p; + numeric hr, hr; + hr := radius/2; + qr := radius/4; + % draw the background colors + fill diamond(hr) shifted (center-(hr,0)) withcolor hcolor; + fill diamond(hr) shifted (center+(0,hr)) withcolor fcolor; + fill diamond(hr) shifted (center+(hr,0)) withcolor rcolor; + fill diamond(hr) shifted (center-(0,hr)) withcolor ocolor; + % draw the borders + draw diamond(radius) shifted center withpen pencircle scaled border + withcolor bcolor; + p := (hr,hr); + draw p -- -p shifted center withpen pencircle scaled border + withcolor bcolor; + p := (hr,-hr); + draw p -- -p shifted center withpen pencircle scaled border + withcolor bcolor; + % add the text + label(h, center-(hr,0)); + label(f, center+(0,hr)); + label(r, center+(hr,0)); + % btex 4 etex, + % btex ox etex; + % btex \sout{W} etex; + label(o, center-(0,hr)); +enddef; + +beginfig(1) + NFPA(origin, u, + h, + f, + r, + o); + +endfig; + +end diff --git a/docs/mp/README b/docs/mp/README new file mode 100644 index 0000000..d09f0c1 --- /dev/null +++ b/docs/mp/README @@ -0,0 +1,13 @@ +NFPA.mp and NFPA_c.mp are for generating an NFPA warning diamond. The +diamond code and figure definitions are in NFPA_c.mp (c for code :p). +NFPA.mp extracts the TeX for creating the symbols for the corners. +Because this infomation will change with time (depending on the +chemicals in the lab), you have to recreate NFPA.mp to get an image +with the current values. Calling + $ gen_NFPA.sh > NFPA.mp +will generate the proper NFPA.mp file. As an explicit example, +try the complete pdf generation and viewing commands + $ gen_NFPA.sh 4 3 3 y y > NFPA.mp + $ make view +NFPA_c.mp contains the common code for drawing warning diamonds. +sample.tex is just a simple latex script for previewing diamonds. diff --git a/docs/mp/gen_NFPA.sh b/docs/mp/gen_NFPA.sh new file mode 100755 index 0000000..8057467 --- /dev/null +++ b/docs/mp/gen_NFPA.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# +# Generate NFPA.mp for given hazard levels + +HEALTH=$1 +FIRE=$2 +REACT=$3 +OX=$4 +W=$5 + +REDIRECT=0 # if != 0, then print directly to OFILE +OFILE="NFPA.mp" + +# stdout redirection from Advanced Bash Scripting Guide +# http://tldp.org/LDP/abs/html/x16355.html#REASSIGNSTDOUT +if [ $REDIRECT -ne 0 ] +then + exec 6>&1 # Link file descriptor #6 with stdout. + # Saves stdout. + # stdout replaced with file "logfile.txt". + exec > $OFILE +fi + +echo "verbatimtex + +\font\bigfont=cmss17 at 50pt +\font\medfont=cmss17 at 25pt +\font\smfont=cmss17 at 20pt +\bigfont + +% center two hboxes (containing e.g. \OX and \W) +\def\vert#1#2{\vbox{\halign{\hfil##\hfil\cr +\hbox{#1}\cr +\hbox{#2}\cr}}} + +% strikethrough (or strikeout) from Chapter 21 of the TeXbook +% the height of the strikeout has been raised from 0.8 to 0.9ex +% to make it look nicer when escaping a purely capital letter (W). +\def\sout#1{{% + \setbox0=\hbox{#1}% + \dimen0 0.9ex\dimen1\dimen0\advance\dimen1 by 0.4pt + \rlap{\leaders\hrule height \dimen1 depth -\dimen0\hskip\wd0}% + \box0 +}} + +\def\OX{{OX}} +\def\W{{\sout{W}}} + +etex + +%% NFPA values : +% set in the call to mpost, e.g. +% $ mpost 'input NFPA.mp; input NFPA_c.mp' +picture h,f,r,o; +h := btex $HEALTH etex; +f := btex $FIRE etex; +r := btex $REACT etex;" + +if [ "$OX" == "y" ] && [ "$W" == "y" ] +then + echo "o := btex {\smfont \vert{\OX}{\W}} etex;" +elif [ "$OX" == "y" ] +then + echo "o := btex {\medfont \OX} etex;" +elif [ "$W" == "y" ] +then + echo "o := btex {\medfont \W} etex;" +fi + +if [ $REDIRECT -ne 0 ] +then + # Restore stdout and close file descriptor #6. + exec 1>&6 6>&- +fi + +exit 0 diff --git a/docs/mp/sample.tex b/docs/mp/sample.tex new file mode 100644 index 0000000..90dc3e7 --- /dev/null +++ b/docs/mp/sample.tex @@ -0,0 +1,13 @@ +\documentclass{article} +\usepackage{graphicx} +\usepackage{fullpage} + +\DeclareGraphicsRule{*}{mps}{*}{} % if tex doesn't recognize the type, it's mps + +% NFPA for 'NFPA.mp image' +\newcommand{\NFPA}[1]{piezo.#1 \includegraphics{NFPA.#1}\vspace{1cm} \\} + +\begin{document} +\centering +\NFPA{1} +\end{document} diff --git a/examples/inventory.db b/examples/inventory.db new file mode 100644 index 0000000..0868db7 --- /dev/null +++ b/examples/inventory.db @@ -0,0 +1,5 @@ +#ID Name Amount CAS# Cat# Vendor Recieved Location H F R O M C T Disposed Note +#ID Name Amount CAS# Catalog # Vendor Date Recieved Location NFPA Health NFPA Fire NFPA Reactivity NFPA Other Mutagen ('M' or '') Carcinogen ('C' or '') Teratogen ('T' or '') Date disposed Note +0 Acetic Acid 500 ml 64-19-7 A35-500 Fisher 7/5/2001 acidic liquids 3 2 1 +1 Glycine 500 g 56-40-6 15527-013 Life Tech. 1/16/2000 non-hazardous 1 0 0 Avoid strong oxidizers +2 Sodium Chloride (NaCl) 500 g 7647-14-5 JT4058-1 VWR/Fisher 10/22/2000 non-hazardous 1 0 0 diff --git a/mk_simple_certs.py b/mk_simple_certs.py new file mode 100644 index 0000000..ecff515 --- /dev/null +++ b/mk_simple_certs.py @@ -0,0 +1,35 @@ +""" +From pyOpenSSL examples with a bit of wrapping. +Create certificates and private keys for the 'simple' example. +""" + +from OpenSSL import crypto +from certgen import * # yes yes, I know, I'm lazy + +def get_cert_filenames(server_name) : + """ + Generate private key and certification filesnames. + mk_certs(server_name) -> (pkey_filename, cert_filename) + """ + pkey_file = '%s.pkey' % server_name + cert_file = '%s.cert' % server_name + return (pkey_file, cert_file) + +def mk_certs(server_name) : + """ + Generate private key and certification files. + mk_certs(server_name) -> (pkey_filename, cert_filename) + """ + pkey_file,cert_file = get_cert_filenames(server_name) + + cakey = createKeyPair(TYPE_RSA, 1024) + careq = createCertRequest(cakey, CN='Certificate Authority') + cacert = createCertificate(careq, (careq, cakey), 0, (0, 60*60*24*365*5)) # five years + open(pkey_file, 'w').write(crypto.dump_privatekey(crypto.FILETYPE_PEM, cakey)) + open(cert_file, 'w').write(crypto.dump_certificate(crypto.FILETYPE_PEM, cacert)) + +if __name__ == "__main__" : + import sys + + mk_certs(sys.argv[1]) + diff --git a/text_db.py b/text_db.py new file mode 100644 index 0000000..bc9f20d --- /dev/null +++ b/text_db.py @@ -0,0 +1,491 @@ +#!/usr/bin/python +""" +Simple database-style interface to text-delimited, single files. +Use this if, for example, your coworkers insist on keeping data compatible with M$ Excel. +""" + +import copy +from sys import stdin, stdout, stderr + +# import os, shutil, and chem_db for managing the database files +import os, shutil, stat, time +import os.path + +FILE = 'default.db' +STANDARD_TAB = 8 + +class fieldError (Exception) : + "database fields are not unique" + pass + +class text_db (object) : + """ + Define a simple database interface for a spread-sheet style database file. + + field_list() : return an ordered list of available fields (fields unique) + long_fields() : return an dict of long field names (keyed by field names) + record(id) : return a record dict (keyed by field names) + records() : return an ordered list of available records. + backup() : save a copy of the current database somehow. + set_record(i, newvals) : set a record by overwriting any preexisting data + : with data from the field-name-keyed dict NEWVALS + new_record() : add a blank record (use set_record to change its values) + """ + def __init__(self, filename=FILE, COM_CHAR='#', FS='\t', RS='\n', + current_dir='./current/', backup_dir='./backup/' ) : + self.filename = filename + self.COM_CHAR = COM_CHAR # comment character (also signals header row) + self.FS = FS # field seperator + self.RS = RS # record seperator + self.db_id_field = "db_id" # add a record field for the database index + # define directories used by the database + self.cur = current_dir + self.bak = backup_dir + + if self.filename == None : + return # for testing, don't touch the file system + + ## Generate the neccessary directory structure if neccessary + for d in [self.cur,self.bak] : + self.check_dir(d) + + self._open() + + # directory and file IO operations + def check_dir(self. dir) : + "Create the database directory if it's missing" + if os.path.isdir(dir) : + return # all set to go + elif os.path.exists(dir) : + raise Exception, "Error: a non-directory file exists at %s" % dir + else : + os.mkdir(dir) + def curpath(self) : + "Return the path to the current database file." + return os.path.join(self.cur, self.filename) + def _get_mtime(self) : + "Get the timestamp of the last modification to the database." + s = os.stat(self.curpath()) + return s[stat.ST_MTIME] + def exists(self) : + "Check if the database exists" + # for some reason, my system's os.path.exists + # returns false for valid symbolic links... + return os.path.exists(self.curpath()) + def _assert_exists(self) : + """ + Assert that the database exists on disk. + Print a reasonable error if it does not. + """ + assert self.exists(), "Missing database file %s" % self.curpath() + def _open(self) : + "Load the database from disk" + self._assert_exists() + # precedence AND > OR + fulltext = file(self.curpath(), 'r').read() + self._mtime = self._get_mtime() + self._parse(fulltext) + def iscurrent(self) : + "Check if our memory-space db is still syncd with the disk-space db." + return self._mtime == self._get_mtime() + def _refresh(self) : + "If neccessary, reload the database from disk." + if not self.iscurrent() : + self._open() + def _save(self) : + "Create a new database file from a header and list of records." + # save the new text + fid = file(self.curpath(), 'w') + fid.write( self._file_header_string(self._header) ) + if self._long_header : + fid.write( self._file_header_string(self._long_header) ) + for record in self._records : + fid.write( self._file_record_string(record) ) + fid.close() + def backup(self) : + "Back up database file" + if not self.exists(): return None # nothing to back up. + # Append a timestamp to the file & copy to self.bak + # the str() builtin ensures a nice, printable string + tname = self.filename+'.'+str(int(time.time())) + tpath = os.path.join(self.bak, tname) + spath = self.curpath() + shutil.copy(spath, tpath) + + # file-text to memory operations + def _get_header(self, head_line, assert_unique=False, + assert_no_db_id_field=True) : + """ + Parse a header line (starts with the comment character COM_CHAR). + + Because doctest doesn't play well with tabs, use colons as field seps. + >>> db = text_db(FS=':', filename=None) + >>> print db._get_header("#Name:Field 1:ID: another field") + ['Name', 'Field 1', 'ID', ' another field'] + >>> try : + ... x = db._get_header("#Name:Field 1:Name: another field", assert_unique=True) + ... except fieldError, s : + ... print s + fields 0 and 2 both 'Name' + """ + assert len(head_line) > 0, 'empty header' + assert head_line[0] == self.COM_CHAR, 'bad header: "%s"' % head_line + fields = head_line[1:].split(self.FS) + if assert_unique : + for i in range(len(fields)) : + for j in range(i+1,len(fields)) : + if fields[i] == fields[j] : + raise fieldError, "fields %d and %d both '%s'" \ + % (i,j,fields[i]) + if assert_no_db_id_field : + for i in range(len(fields)) : + if fields[i] == self.db_id_field : + raise fieldError, "fields %d uses db_id field '%s'" \ + % (i,fields[i]) + return fields + def _get_fields(self, line, num_fields=None) : + """ + Parse a record line. + + Because doctest doesn't play well with tabs, use colons as field seps. + >>> db = text_db(FS=':', filename=None) + >>> print db._get_fields("2-Propanol:4 L:67-63-0:Fisher:6/6/2004",7) + ['2-Propanol', '4 L', '67-63-0', 'Fisher', '6/6/2004', '', ''] + """ + vals = line.split(self.FS) + if num_fields != None : + assert len(vals) <= num_fields, "Too many values in '%s'" % line + for i in range(len(vals), num_fields) : + vals.append('') # pad with empty strings if neccessary + return vals + def _parse(self, text) : + reclines = text.split(self.RS) + assert len(reclines) > 0, "Empty database file" + self._header = self._get_header(reclines[0], assert_unique=True) + self._long_header = None + self._records = [] + if len(reclines) == 1 : + return # Only a header + # check for a long-header line + if reclines[1][0] == self.COM_CHAR : + self._long_header = self._get_header(reclines[1]) + startline = 2 + else : + startline = 1 + for recline in reclines[startline:] : + if len(recline) == 0 : + continue # ignore blank lines + self._records.append(self._get_fields(recline, len(self._header))) + + + # memory to file-text operations + def _file_record_string(self, record) : + """ + Format record for creating a new database file. + + Because doctest doesn't play well with tabs, use colons as field seps. + >>> db = text_db(FS=':', RS=';', filename=None) + >>> rs="2-Propanol:4 L:67-63-0:BPA426P-4:Fisher:6/6/2004:2:3:0" + >>> print db._file_record_string( db._get_fields(rs)) == (rs+";") + True + """ + return "%s%s" % (self.FS.join(record), self.RS) + def _file_header_string(self, header) : + """ + Format header for creating a new database file. + """ + return "%s%s%s" % (self.COM_CHAR, self.FS.join(header), self.RS) + + + # nice, stable api for our users + def field_list(self) : + "return an ordered list of available fields (fields unique)" + return copy.copy(self._header) + def long_fields(self) : + "return an dict of long field names (keyed by field names)" + if self._long_header : + return dict(zip(self._header, self._long_header)) + else : # default to the standard field names + return dict(zip(self._header, self._header)) + def record(self, db_id) : + "return a record dict (keyed by field names)" + assert type(db_id) == type(1), "id %s not an int!" % str(db_id) + assert db_id < len(self._records), "record %d does not exist" % db_id + d = dict(zip(self._header, self._records[db_id])) + d['db_id'] = db_id + return d + def records(self) : + "return an ordered list of available records." + ret = [] + for id in range(len(self._records)) : + ret.append(self.record(id)) + return ret + def len_records(self) : + "return len(self.records()), but more efficiently" + return len(self._records) + def set_record(self, db_id, newvals, backup=True) : + """ + set a record by overwriting any preexisting data + with data from the field-name-keyed dict NEWVALS + """ + if backup : + self.backup() + for k,v in newvals.items() : + if k == self.db_id_field : + assert int(v) == db_id, \ + "don't set the db_id field! (attempted %d -> %d)" \ + % (db_id, int(v)) + continue + # get the index for the specified field + assert k in self._header, "unrecognized field '%s'" % k + fi = self._header.index(k) + # overwrite the record value + self._records[db_id][fi] = v + self._save() + def new_record(self, db_id=None) : + """ + create a blank new record and return it. + """ + record = {} + for field in self._header : + record[field] = "" + record[self.db_id_field] = len(self._records) + self._records.append(['']*len(self._header)) + return record + +class indexStringError (Exception) : + "invalid index string format" + pass + + +class db_pretty_printer (object) : + """ + Define some pretty-print functions for text_db objects. + """ + def __init__(self, db) : + self.db = db + def _norm_active_fields(self, active_fields_in=None) : + """ + Normalize the active field parameter + + >>> db = text_db(FS=':', RS=' ; ', filename=None) + >>> pp = db_pretty_printer(db) + >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") + >>> print pp._norm_active_fields(None) == {'Name':True, 'Amount':True, 'CAS#':True, 'Vendor':True} + True + >>> print pp._norm_active_fields(['Vendor', 'Amount']) == {'Name':False, 'Amount':True, 'CAS#':False, 'Vendor':True} + True + >>> print pp._norm_active_fields('1:3') == {'Name':False, 'Amount':True, 'CAS#':True, 'Vendor':False} + True + """ + if active_fields_in == None : + active_fields = {} + for field in self.db.field_list() : + active_fields[field] = True + return active_fields + elif type(active_fields_in) == type('') : + active_i = self._istr2ilist(active_fields_in) + active_fields = {} + fields = self.db.field_list() + for i in range(len(fields)) : + if i in active_i : + active_fields[fields[i]] = True + else : + active_fields[fields[i]] = False + else : + if type(active_fields_in) == type([]) : + active_fields = {} + for field in active_fields_in : + active_fields[field] = True + elif type(active_fields_in) == type({}) : + active_fields = active_fields_in + assert type(active_fields) == type({}), 'by this point, should be a dict' + for field in self.db.field_list() : + if not field in active_fields : + active_fields[field] = False + return active_fields + def full_record_string(self, record, active_fields=None) : + """ + Because doctest doesn't play well with tabs, use colons as field seps. + >>> db = text_db(FS=':', RS=' ; ', filename=None) + >>> pp = db_pretty_printer(db) + >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") + >>> print pp.full_record_string( db.record(0) ), + Name : 2-Propanol + Amount : 4 L + CAS# : 67-63-0 + Vendor : Fisher + """ + fields = self.db.field_list() + long_fields = self.db.long_fields() + active_fields = self._norm_active_fields(active_fields) + # scan through and determine the width of the largest field + w = 1 + for field in fields : + if active_fields[field] and len(field) > w : + w = len(field) + # generate the pretty-print string + string = "" + for field in fields : + if field in active_fields and active_fields[field] : + string += "%*.*s : %s\n" \ + % (w, w, long_fields[field], record[field]) + return string + def full_record_string_id(self, id, active_fields=None) : + record = self.db.record(id) + return self.full_record_string(record, active_fields) + def _istr2ilist(self, index_string) : + """ + Generate index lists from assorted string formats. + + Parse index strings + >>> pp = db_pretty_printer('dummy') + >>> print pp._istr2ilist('0,2,89,4') + [0, 2, 89, 4] + >>> print pp._istr2ilist('1:6') + [1, 2, 3, 4, 5] + >>> print pp._istr2ilist('0,3,6:9,2') + [0, 3, 6, 7, 8, 2] + """ + ret = [] + for spl in index_string.split(',') : + s = spl.split(':') + if len(s) == 1 : + ret.append(int(spl)) + elif len(s) == 2 : + for i in range(int(s[0]),int(s[1])) : + ret.append(i) + else : + raise indexStringError, "unrecognized index '%s'" % spl + return ret + def _norm_width(self, width_in=None, active_fields=None, skinny=True) : + "Normalize the width parameter" + active_fields = self._norm_active_fields(active_fields) + if type(width_in) == type(1) or width_in == 'a' : # constant width + width = {} # set all fields to this width + for field in active_fields.keys() : + width[field] = width_in + else : + if width_in == None : + width_in = {} + width = {} + for field in active_fields.keys() : + # fill in the gaps in the current width + if field in width_in : + width[field] = width_in[field] + else : # field doesn't exist + if skinny : # set to a fixed width + # -1 to leave room for FS + width[field] = STANDARD_TAB-1 + else : # set to automatic + width[field] = 'a' + return width + def _norm_record_ids(self, record_ids=None) : + "Normalize the record_ids parameter" + if record_ids == None : + record_ids = range(len(self.db.records())) + if type(record_ids) == type('') : + record_ids = self._istr2ilist(record_ids) + stderr.flush() + return record_ids + def _line_record_string(self, record, width=None, active_fields=None, + FS=None, RS=None, TRUNC_STRING=None) : + """ + Because doctest doesn't play well with tabs, use colons as field seps. + >>> db = text_db(FS=':', RS=' ; ', filename=None) + >>> pp = db_pretty_printer(db) + >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") + >>> print pp._line_record_string_id(0) + 2-Propa: 4 L:67-63-0: Fisher ; + """ + fields = self.db.field_list() + active_fields = self._norm_active_fields(active_fields) + width = self._norm_width(width) + if FS == None : + FS = self.db.FS + if RS == None : + RS = self.db.RS + for field in fields : + if field in active_fields and active_fields[field] : + lastfield = field + # generate the pretty-print string + string = "" + for field in fields : + if field in active_fields and active_fields[field] : + w = width[field] + string += "%*.*s" % (w, w, record[field]) + if field != lastfield : + string += "%s" % (FS) + string += RS + return string + def _line_record_string_id(self, id, width=None, active_fields=None, + FS=None, RS=None, TRUNC_STRING=None) : + return self._line_record_string(self.db.record(id), + width, active_fields, FS, RS, + TRUNC_STRING) + def _get_field_width(self, record_ids, field) : + """ + Return the width of the longest value in FIELD + for all the records with db_ids in record_ids. + """ + width = 1 + for i in record_ids : + w = len(self.db.record(i)[field]) + if w > width : + width = w + return width + def _get_width(self, width_in, active_fields=None, record_ids=None) : + """ + Return the width of the largest value in FIELD + for all the records with db_ids in record_ids. + """ + active_fields = self._norm_active_fields(active_fields) + width = self._norm_width(width_in, active_fields) + record_ids = self._norm_record_ids(record_ids) + + for field in active_fields : + if width[field] == 'a' : + width[field] = self._get_field_width(record_ids, field) + return width + def multi_record_string(self, record_ids=None, active_fields=None, + width=None, FS=None, RS=None, COM_CHAR=None, + TRUNC_STRING=None) : + """ + Because doctest doesn't play well with tabs, use colons as field seps. + >>> db = text_db(FS=':', RS=' ; ', filename=None) + >>> pp = db_pretty_printer(db) + >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") + >>> print pp.multi_record_string('0'), + Name: Amount: CAS#: Vendor ; 2-Propa: 4 L:67-63-0: Fisher ; + """ + if FS == None : + FS = self.db.FS + if RS == None : + RS = self.db.RS + if COM_CHAR == None : + COM_CHAR = self.db.COM_CHAR + active_fields = self._norm_active_fields(active_fields) + record_ids = self._norm_record_ids(record_ids) + width = self._get_width(width, active_fields, record_ids) + # generate the pretty-print string + string = "" + # print a header line: + fields = self.db.field_list() + hvals = dict(zip(fields,fields)) + string += "%s" % self._line_record_string(hvals, width, + active_fields, FS, RS, + TRUNC_STRING=TRUNC_STRING) + # print the records + for id in record_ids : + string += self._line_record_string_id(id, width, + active_fields, FS, RS, + TRUNC_STRING=TRUNC_STRING) + return string + + +def _test(): + import doctest + doctest.testmod() + +if __name__ == "__main__" : + _test() -- 2.26.2