From: W. Trevor King Date: Sun, 1 Apr 2012 16:24:29 +0000 (-0400) Subject: Convert chemdb from CherryPy to a Django app (easier database management). X-Git-Url: http://git.tremily.us/?a=commitdiff_plain;h=b5f2de1a01ccc1bc13a1167a461ebf44e08653a7;p=chemdb.git Convert chemdb from CherryPy to a Django app (easier database management). --- diff --git a/.gitignore b/.gitignore index 7b798e3..27ffc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ *.pyc -chem_web.log* -backup/ +build diff --git a/README b/README index fe82315..b772f7e 100644 --- a/README +++ b/README @@ -1,133 +1,161 @@ -Web, python, and command-line interfaces for managing a chemical inventory. +.. -*- coding: utf-8 -*- -Commands -======== +Chemdb is a chemical inventory system written in Python_ using the +Django_ framework. It makes it easy for us to keep track of what we +have in the lab. It also generates the required safety information +automatically (door warnings and inventories). -The web interface (:file:`bin/chem_web.py`) is a web-daemon using -CherryPy. Standard command for starting the daemon:: +Installation +============ - $ bin/chem_web.py -a 192.168.1.2 -p 55555 +Packages +-------- + +Gentoo +~~~~~~ + +I've packaged chemdb for Gentoo_. You need layman_ and my `wtk +overlay`_. Install with:: + + # emerge -av app-portage/layman + # layman --add wtk + # emerge -av dev-python/chemdb + +Dependencies +------------ + +If you're installing by hand or packaging calibcant for another +distribution, you'll need the following dependencies: + +=========== ================= =================================== +Package Debian_ Gentoo_ +=========== ================= =================================== +Django_ python-django dev-python/django +Grappelli_ dev-python/django-grappelli [#wtk]_ +=========== ================= =================================== + +.. [#wtk] In the `wtk overlay`_. + +Installing by hand +------------------ + +Chemdb is available as a Git_ repository:: + + $ git clone git://tremily.us/chemdb.git + +See the homepage_ for details. To install the checkout, run the +standard:: + + $ python setup.py install + +Usage +===== + +Setup +----- + +If you don't have a Django project and you just want to run chemdb as +a stand-alone service, you can use the example project written up in +``example``. Set up the project (once):: -Standard command for stopping the daemon:: + $ python example/manage.py syncdb - $ bin/chem_web.py --stop +See the `Django documentation`_ for more details. -From the command line, you can validate CAS#s (and possibly other fields) with:: +You may also want to load some example data, to make your initial +browsing more interesting:: - $ bin/chem_db.py -f example/inventory.db -V + $ python example/manage.py loaddata example_data +Running +------- -Database format +Run the app on your local host (as many times as you like):: + + $ python example/manage.py runserver + +You may need to add the current directory to ``PYTHONPATH`` so +``python`` can find the ``chemdb`` package. If you're running +``bash``, that will look like:: + + $ PYTHONPATH=".:$PYTHONPATH" python example/manage.py runserver + +Hacking +======= + +This project was largely build following the `Django tutorial`_. +That's a good place to start if you're new to Django. + +Other resources =============== -:mod:`chemdb.db.text` 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 the -ChemDB interface. A brief example :file:`inventory.db` file is -included in the examples directory. +You can `search CAS Registry numbers`_ at NIST. This is useful for +decoding MSDS information. -* 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. +NFPA fire diamond +----------------- -The fields H, F, R, and S are the NFPA Health, Fire, Reactivity, and -Special Hazards (NFPA diamond). +These are the meanings of the various NFPA warnings: * 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 + + 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..) -* Unofficial Special Hazard codes: - * ACID acid - * ALK base - * COR corrosive - * BIO Biohazard - * POI Poison - * CRY Cyrogenic - - -Contents -======== - - AUTHORS (list of authors contributing code to ChemDB) - bin (executibles) - |-- chem_db.py (command line interface) - `-- chem_web.py (web interface) - chemdb (python package) - contrib (useful but non-critical utilities) - `-- ssl (SSL key/certificate generation) - COPYING (GNU GPLv3 license) - DEPENDENCIES (description of software dependencies) - example (example data for demonstrations and testing) - |-- inventory.db (example inventory database) - `-- static (example directory for statically served content) - `-- MSDS (example MSDS storage location) - `-- 0.html (dummy MSDS for acetic acid) - README (this file) - template - |-- doc (LaTeX source for PDF generation) - `-- web (Jinja2 templates for HTML generation) - .update-opyright.conf (configuration for copyright blurb maintenance) - - -Generated files and directories -------------------------------- - -============ ======================================================================= -backup store database file snapshots from every change -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. + 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) -History -======= +* Reactivity hazards: -The original web fron-end was based on Adam Bachman's simplewiki.py_. + 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 -.. simplewiki.py: http://bachman.infogami.com/another_simple_wiki +* Special Hazards have the following codes: + * OX strong oxidizer + * W̶ water reactive + * SA simple asphyxiants (The only gases for which this symbol is + permitted are nitrogen, helium, neon, argon, krypton, and xenon.) -License +Licence ======= -ChemDB is released under the GNU General Public License version 3. -See :file:`COPYING` for details. +This project is distributed under the `GNU General Public License +Version 3`_ or greater. + +Author +====== + +W. Trevor King +wking@drexel.edu + +.. _Python: http://www.python.org/ +.. _Django: https://www.djangoproject.com/ +.. _Gentoo: http://www.gentoo.org/ +.. _layman: http://layman.sourceforge.net/ +.. _wtk overlay: http://blog.tremily.us/posts/Gentoo_overlay/ +.. _Debian: http://www.debian.org/ +.. _Grappelli: https://github.com/sehmaschine/django-grappelli +.. _Git: http://git-scm.com/ +.. _homepage: http://blog.tremily.us/posts/ChemDB/ +.. _Django documentation: https://docs.djangoproject.com/ +.. _Django tutorial: https://docs.djangoproject.com/en/1.3/intro/tutorial01/ +.. _search CAS Registry numbers: + http://webbook.nist.gov/chemistry/casf-ser.html +.. _GNU General Public License Version 3: http://www.gnu.org/licenses/gpl.txt diff --git a/chemdb/__init__.py b/chemdb/__init__.py index dd5bf60..f354e18 100644 --- a/chemdb/__init__.py +++ b/chemdb/__init__.py @@ -1,16 +1,12 @@ -# Copyright (C) 2010 W. Trevor King -# -# This file is part of ChemDB. -# -# ChemDB is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# ChemDB is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with ChemDB. If not, see . +# Copyright + +import logging as _logging +import logging.handlers as _logging_handlers + + +__version__ = '0.5' + + +LOG = _logging.getLogger('chemdb') +LOG.setLevel(_logging.ERROR) +LOG.addHandler(_logging_handlers.SysLogHandler(address='/dev/log')) diff --git a/chemdb/admin.py b/chemdb/admin.py new file mode 100644 index 0000000..a387776 --- /dev/null +++ b/chemdb/admin.py @@ -0,0 +1,60 @@ +# Copyright + +from django import forms as _forms +from django.contrib import admin as _admin +from django.db import models as _django_models + +from . import models as _models + + +#class ChemicalInline (_admin.TabularInline): +# model = _models.Chemical +# extra = 0 +# +# +#class IngredientBlockAdmin (_admin.ModelAdmin): +# fieldsets = [ +# (None, {'fields': ['name']}), +# ('Directions', {'fields': ['directions_markdown'], +# 'classes': ['collapse']}), +# ] +# inlines = [IngredientInline] +# +# list_display = ['name', 'recipe'] +# extra = 0 +# +# +#class IngredientBlockInline (admin.TabularInline): +# model = models.IngredientBlock +# fieldsets = [ +# (None, {'fields': ['name', 'position']}), +# ] +# sortable_field_name = 'position' +# inlines = [IngredientInline] +# list_display = ['name'] +# extra = 0 +# show_edit_link = True # https://code.djangoproject.com/ticket/13163 +# # work around 13163 +# #template = 'admin/edit_inline/tabular-13163.html' +# +#class RecipeAdmin (admin.ModelAdmin): +# fieldsets = [ +# (None, {'fields': ['name']}), +# ('Metadata', {'fields': ['author', 'source', 'url', 'tags'], +# 'classes': ['collapse']}), +# ('Yield', {'fields': ['unit', 'value', 'min_value', 'max_value'], +# 'classes': ['collapse']}), +# ('Directions', {'fields': ['directions_markdown']}), +# ] +# inlines = [IngredientBlockInline] +# +# list_display = ['name'] +# + +#_admin.site.register(_models.Recipe, RecipeAdmin) +#_admin.site.register(_models.IngredientBlock, IngredientBlockAdmin) +_admin.site.register(_models.NFPASpecial) +_admin.site.register(_models.Chemical) +_admin.site.register(_models.Location) +_admin.site.register(_models.Vendor) +_admin.site.register(_models.ChemicalInstance) diff --git a/chemdb/chemdb.py b/chemdb/chemdb.py deleted file mode 100644 index 5f13f5f..0000000 --- a/chemdb/chemdb.py +++ /dev/null @@ -1,406 +0,0 @@ -# Copyright (C) 2010 W. Trevor King -# -# This file is part of ChemDB. -# -# ChemDB is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# ChemDB is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with ChemDB. If not, see . - -"""Utilities for chemical inventories. - -Includes a CAS number validator and document generation. -""" - -import re -import os -import os.path -import time -import types -from sys import stdin, stdout, stderr - -from .db.text import DBPrettyPrinter - - -def valid_CASno(cas_string, debug=False): - """Validate CAS numbers. - - Check `N..NN-NN-N` format, and the `checksum digit`_ for valid CAS - number structure. for - - .. math:: - N_n .. N_4 N_3 - N_2 N_1 - R - - The checksum digit is - - .. math:: - R = remainder([sum_{i=1}^n i N_i ] / 10 ) - - .. _checksum digit: - http://www.cas.org/expertise/cascontent/registry/checkdig.html - - >>> valid_CASno('107-07-3') - True - >>> valid_CASno('107-08-3') - False - >>> valid_CASno('107-083') - False - - Sometimes we don't have a CAS number, or a product will contain - secret, non-hazardous ingredients. Therefore we treat the strings - `na` and `+secret-non-hazardous` as valid CAS numbers. - - >>> valid_CASno('na') - True - >>> valid_CASno('+secret-non-hazardous') - True - """ - if cas_string in ['na', '+secret-non-hazardous']: - return True - # check format, - # \A matches the start of the string - # \Z matches the end of the string - regexp = re.compile('\A[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 = [int(d) for d in ''.join(cas_string.split('-'))] - sumdigs = casdigs[:-1] - sumdigs.reverse() - s = sum([(i+1)*d for i,d in enumerate(sumdigs)]) - check = s % 10 - if check == casdigs[-1]: - return True - elif debug: - print >> stderr, ( - "invalid CAS# check: '%s' (expected %d)" % (cas_string, check)) - return False - -class MSDSManager (object): - """Manage Material Saftey Data Sheets (MSDSs). - """ - def __init__(self, db, dir="./MSDS/"): - self.db = db - self.dir = dir - self.MIMEs = { - 'application/pdf': ['pdf'], - 'text/html': ['html'], - 'text/plain': ['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 isinstance(id, int), ( - '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 isinstance(id, int), ( - 'id must be an integer, not %s (%s)' % (type(id), str(id))) - return "./%d" % id - - def MIME_ext(self, mime): - if mime in self.MIMEs.keys(): - return self.MIMEs[mime][0] - for values in self.MIMEs.values(): - if mime in values: - return mime - raise ValueError( - "invalid MIME type '%s'\nshould be one of %s" % (mime, self.MIMEs)) - - 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 = MSDSManager(db=None) - >>> print m.has_MSDS_MIME(102, 'pdf') # test on html - False - >>> print m.has_MSDS_MIME(102, 'html') # test on html - True - >>> print m.has_MSDS_MIME(6, 'pdf') # test on pdf symlink - True - """ - return os.path.exists(self.path(id, mime)) - - def get_MSDS_path(self, id): - """ - >>> m = MSDSManager(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 = MSDSManager(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, doc_root=os.path.join('template', 'doc')): - self.db = db - self.doc_root = doc_root - - def _latex_safe(self, string): - string = string.replace('%', '\%') - string = string.replace('>', '$>$') - string = string.replace('<', '$<$') - return string - - def _set_main_target(self, target): - print >> file(os.path.join(self.doc_root, 'main.tex'), 'w'), ( - """\documentclass[letterpaper]{article} - -\input{%s} -""" % target) - - def _make_pdf(self, target_file): - os.system('cd %s && make pdf' % self.doc_root) - path = os.path.join(self.doc_root, target_file) - os.system('cp %s %s' % (os.path.join(self.doc_root, 'main.pdf'), path)) - return path - - def inventory(self, title=None, - namewidth='a', sort_field='db_id', - valid_record=lambda r: r['Disposed'] == ''): - """Create a pdf list of all maching chemicals. The default is to - match all currently owned chemicals. Matching chemicals can be sorted - by any field (defaults to 'ID').""" - if title == None: - title == 'Inventory' - pp = DBPrettyPrinter(self.db) - active_ids = [] - for record in self.db.records(): - if valid_record(record) : # get ids for matching chemicals - active_ids.append(record['db_id']) - active_ids.sort(cmp=lambda a,b: cmp(self.db.record(a)[sort_field], - self.db.record(b)[sort_field])) - 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(os.path.join(self.doc_root, 'inventory_title.tex'), 'w'), title - print >> file(os.path.join(self.doc_root, '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, - valid_record=lambda r: r['Disposed'] == ''): - """create a warning NFPA diamond and list of the most dangerous - chemicals for which valid_record(record) is true. For - example, to generate a door warning for the front door use - door_warning(lambda r: r['Disposed'] == '') - or to generate the warning for the fridge - door_warning(lambda r: r['Disposed'] == '' and r['Location'] == 'Refrigerator') - Note that valid_record defaults to the first example. - """ - # 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 valid_record(record): - 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 valid_record(record): - 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 - # setup the NFPA grapic - oxstring = 'oxidizer' if 'OX' in NFPA_maxs['O'] else None - wstring = 'nowater' if 'W' in NFPA_maxs['O'] else None - extra_specials = [ - x for x in NFPA_maxs['O'] if x not in ['OX', 'W']] - esstring = None - if len(extra_specials) > 0: - esstring = 'special={%s}' % ( - ','.join([x for x in extra_specials])) - NFPA_maxs['special_args'] = ', '.join([ - x for x in [oxstring, wstring, esstring] if x != None]) - string = """ -\\begin{center} - \\Huge - \\firediamond{health=%(H)s, flammability=%(F)s, reactivity=%(R)s, - %(special_args)s} -\\end{center} -""" % NFPA_maxs - # now generate a list of the nasties ( Amount & ID & Name ) - string += """ -\\vspacer - -\\contfont - -\\begin{tabular}{r r l} -""" - for field,name,array in zip(['H', 'F', 'R', 'O'], - ['Health', 'Fire', - 'Reactivity', 'Other'], - [Healths, Fires, - Reactivities, Others]): - if (not hasattr(NFPA_maxs[field], '__len__')) \ - or len(NFPA_maxs[field]) > 0: - string += " \multicolumn{3}{c}{\Tstrut %s : %s} \\\\\n" \ - % (name, NFPA_maxs[field]) - else : # Print "Other" instead of "Other : []" - string += " \multicolumn{3}{c}{\Tstrut %s} \\\\\n" \ - % (name) - 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(os.path.join(self.doc_root, '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() - -if __name__ == "__main__": - _test() diff --git a/chemdb/daemon.py b/chemdb/daemon.py deleted file mode 100644 index 16a5831..0000000 --- a/chemdb/daemon.py +++ /dev/null @@ -1,468 +0,0 @@ -"""Daemon base class -Shane Hathaway -http://hathawaymix.org/Software/Sketches/daemon.py - -Provides a framework for daemonizing a process. Features: - - - reads the command line - - - reads a configuration file - - - configures logging - - - calls root-level setup code - - - drops privileges - - - calls user-level setup code - - - detaches from the controlling terminal - - - checks and writes a pidfile - - -Example daemon: - -import daemon -import logging -import time - -class HelloDaemon(daemon.Daemon): - default_conf = '/etc/hellodaemon.conf' - section = 'hello' - - def run(self): - while True: - logging.info('The daemon says hello') - time.sleep(1) - -if __name__ == '__main__': - HelloDaemon().main() - - -Example hellodaemon.conf: - -[hello] -uid = -gid = -pidfile = ./hellodaemon.pid -logfile = ./hellodaemon.log -loglevel = info - -""" - -import ConfigParser -import errno -import grp -import logging, logging.handlers -import optparse -import os -import pwd -import signal -import sys -import time - - -class Daemon(object): - """Daemon base class""" - - default_conf = '' # override this - section = 'daemon' # override this - - def setup_root(self): - """Override to perform setup tasks with root privileges. - - When this is called, logging has been initialized, but the - terminal has not been detached and the pid of the long-running - process is not yet known. - """ - - def setup_user(self): - """Override to perform setup tasks with user privileges. - - Like setup_root, the terminal is still attached and the pid is - temporary. However, the process has dropped root privileges. - """ - - def run(self): - """Override. - - The terminal has been detached at this point. - """ - - def main(self): - """Read the command line and either start or stop the daemon""" - self.parse_options() - action = self.options.action - self.read_basic_config() - if action == 'start': - self.start() - elif action == 'stop': - self.stop() - else: - raise ValueError(action) - - def parse_options(self): - """Parse the command line""" - p = optparse.OptionParser() - p.add_option('--start', dest='action', - action='store_const', const='start', default='start', - help='Start the daemon (the default action)') - p.add_option('-s', '--stop', dest='action', - action='store_const', const='stop', default='start', - help='Stop the daemon') - p.add_option('-c', dest='config_filename', - action='store', default=self.default_conf, - help='Specify alternate configuration file name') - p.add_option('-n', '--nodaemon', dest='daemonize', - action='store_false', default=True, - help='Run in the foreground') - self.options, self.args = p.parse_args() - if not os.path.exists(self.options.config_filename): - p.error('configuration file not found: %s' - % self.options.config_filename) - - def read_basic_config(self): - """Read basic options from the daemon config file""" - self.config_filename = self.options.config_filename - cp = ConfigParser.ConfigParser() - cp.read([self.config_filename]) - self.config_parser = cp - - try: - self.uid, self.gid = get_uid_gid(cp, self.section) - except ValueError, e: - sys.exit(str(e)) - - self.pidfile = cp.get(self.section, 'pidfile') - self.logfile = cp.get(self.section, 'logfile') - self.loglevel = cp.get(self.section, 'loglevel') - - def on_sigterm(self, signalnum, frame): - """Handle segterm by treating as a keyboard interrupt""" - raise KeyboardInterrupt('SIGTERM') - - def add_signal_handlers(self): - """Register the sigterm handler""" - signal.signal(signal.SIGTERM, self.on_sigterm) - - def start(self): - """Initialize and run the daemon""" - # The order of the steps below is chosen carefully. - # - don't proceed if another instance is already running. - self.check_pid() - # - start handling signals - self.add_signal_handlers() - # - create log file and pid file directories if they don't exist - self.prepare_dirs() - - # - start_logging must come after check_pid so that two - # processes don't write to the same log file, but before - # setup_root so that work done with root privileges can be - # logged. - self.start_logging() - try: - # - set up with root privileges - self.setup_root() - # - drop privileges - self.set_uid() - # - check_pid_writable must come after set_uid in order to - # detect whether the daemon user can write to the pidfile - self.check_pid_writable() - # - set up with user privileges before daemonizing, so that - # startup failures can appear on the console - self.setup_user() - - # - daemonize - if self.options.daemonize: - daemonize() - except: - logging.exception("failed to start due to an exception") - raise - - # - write_pid must come after daemonizing since the pid of the - # long running process is known only after daemonizing - self.write_pid() - try: - logging.info("started") - try: - self.run() - except (KeyboardInterrupt, SystemExit): - pass - except: - logging.exception("stopping with an exception") - raise - finally: - self.remove_pid() - logging.info("stopped") - - def stop(self): - """Stop the running process""" - if self.pidfile and os.path.exists(self.pidfile): - pid = int(open(self.pidfile).read()) - os.kill(pid, signal.SIGTERM) - # wait for a moment to see if the process dies - for n in range(10): - time.sleep(0.25) - try: - # poll the process state - os.kill(pid, 0) - except OSError, why: - if why[0] == errno.ESRCH: - # process has died - break - else: - raise - else: - sys.exit("pid %d did not die" % pid) - else: - sys.exit("not running") - - def prepare_dirs(self): - """Ensure the log and pid file directories exist and are writable""" - for fn in (self.pidfile, self.logfile): - if not fn: - continue - parent = os.path.dirname(fn) - if not os.path.exists(parent): - os.makedirs(parent) - self.chown(parent) - - def set_uid(self): - """Drop root privileges""" - if self.gid: - try: - os.setgid(self.gid) - except OSError, (code, message): - sys.exit("can't setgid(%d): %s, %s" % - (self.gid, code, message)) - if self.uid: - try: - os.setuid(self.uid) - except OSError, (code, message): - sys.exit("can't setuid(%d): %s, %s" % - (self.uid, code, message)) - - def chown(self, fn): - """Change the ownership of a file to match the daemon uid/gid""" - if self.uid or self.gid: - uid = self.uid - if not uid: - uid = os.stat(fn).st_uid - gid = self.gid - if not gid: - gid = os.stat(fn).st_gid - try: - os.chown(fn, uid, gid) - except OSError, (code, message): - sys.exit("can't chown(%s, %d, %d): %s, %s" % - (repr(fn), uid, gid, code, message)) - - def start_logging(self): - """Configure the logging module""" - try: - level = int(self.loglevel) - except ValueError: - level = int(logging.getLevelName(self.loglevel.upper())) - - handlers = [] - if self.logfile: - handlers.append(logging.handlers.RotatingFileHandler( \ - self.logfile, maxBytes=10000, backupCount=5)) - self.chown(self.logfile) - if not self.options.daemonize: - # also log to stderr - handlers.append(logging.StreamHandler()) - - log = logging.getLogger() - log.setLevel(level) - for h in handlers: - h.setFormatter(logging.Formatter( - "%(asctime)s %(process)d %(levelname)s %(message)s")) - log.addHandler(h) - - def check_pid(self): - """Check the pid file. - - Stop using sys.exit() if another instance is already running. - If the pid file exists but no other instance is running, - delete the pid file. - """ - if not self.pidfile: - return - # based on twisted/scripts/twistd.py - if os.path.exists(self.pidfile): - try: - pid = int(open(self.pidfile).read().strip()) - except ValueError: - msg = 'pidfile %s contains a non-integer value' % self.pidfile - sys.exit(msg) - try: - os.kill(pid, 0) - except OSError, (code, text): - if code == errno.ESRCH: - # The pid doesn't exist, so remove the stale pidfile. - os.remove(self.pidfile) - else: - msg = ("failed to check status of process %s " - "from pidfile %s: %s" % (pid, self.pidfile, text)) - sys.exit(msg) - else: - msg = ('another instance seems to be running (pid %s), ' - 'exiting' % pid) - sys.exit(msg) - - def check_pid_writable(self): - """Verify the user has access to write to the pid file. - - Note that the eventual process ID isn't known until after - daemonize(), so it's not possible to write the PID here. - """ - if not self.pidfile: - return - if os.path.exists(self.pidfile): - check = self.pidfile - else: - check = os.path.dirname(self.pidfile) - if not os.access(check, os.W_OK): - msg = 'unable to write to pidfile %s' % self.pidfile - sys.exit(msg) - - def write_pid(self): - """Write to the pid file""" - if self.pidfile: - open(self.pidfile, 'wb').write(str(os.getpid())) - - def remove_pid(self): - """Delete the pid file""" - if self.pidfile and os.path.exists(self.pidfile): - os.remove(self.pidfile) - - -def get_uid_gid(cp, section): - """Get a numeric uid/gid from a configuration file. - - May return an empty uid and gid. - """ - uid = cp.get(section, 'uid') - if uid: - try: - int(uid) - except ValueError: - # convert user name to uid - try: - uid = pwd.getpwnam(uid)[2] - except KeyError: - raise ValueError("user is not in password database: %s" % uid) - - gid = cp.get(section, 'gid') - if gid: - try: - int(gid) - except ValueError: - # convert group name to gid - try: - gid = grp.getgrnam(gid)[2] - except KeyError: - raise ValueError("group is not in group database: %s" % gid) - - return uid, gid - -class PrintLogger(object): - ''' - This class by Peter Parente, for the Jambu project - http://www.oatsoft.org/trac/jambu/browser/trunk/JambuLog.py?rev=1 - - @author: Peter Parente - @organization: IBM Corporation - @copyright: Copyright (c) 2005, 2007 IBM Corporation - @license: The BSD License - - All rights reserved. This program and the accompanying materials are made - available under the terms of the BSD license which accompanies - this distribution, and is available at - U{http://www.opensource.org/licenses/bsd-license.php} - - Provides a dirt-simple interface compatible with stdout and stderr. When - assigned to sys.stdout or sys.stderr, an instance of this class redirects - print statements to the logging system. This means the result of the - print statements can be silenced, sent to a file, etc. using the command - line options to LSR. The log level used is defined by the L{LEVEL} constant - in this class. - - @cvar LEVEL: Logging level for writes directed through this class, above - DEBUG and below INFO - @type LEVEL: integer - @ivar log: Reference to the Print log channel - @type log: logging.Logger - - With minor adjustments by WTK - ''' - LEVEL = 20 - - def __init__(self, logger=None): - ''' - Create the logger. - ''' - if logger == None: - self.log = logging.getLogger('print') - else: - self.log = logger - self.chunks = [] - self.flush = None - - def write(self, data): - ''' - Write the given data at the debug level to the logger. Stores chunks of - text until a new line is encountered, then sends to the logger. - - @param data: Any object that can be converted to a string - @type data: stringable - ''' - s = data.encode('utf-8') - if s.endswith('\n'): - self.chunks.append(s[:-1]) - s = ''.join(self.chunks) - self.log.log(self.LEVEL, s) - self.chunks = [] - else: - self.chunks.append(s) - -def daemonize(): - """Detach from the terminal and continue as a daemon. - Added support for redirecting stdout & stderr to logs. - See - http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012 - WTK 2008.""" - # swiped from twisted/scripts/twistd.py - # See http://www.erlenstar.demon.co.uk/unix/faq_toc.html#TOC16 - if os.fork(): # launch child and... - os._exit(0) # kill off parent - os.setsid() - # some people os.chdir('/') here so they don't hog the start directory. - if os.fork(): # launch child and... - os._exit(0) # kill off parent again. - os.umask(077) - # flush any pending output - #sys.stdin.flush()? - sys.stdout.flush() - sys.stderr.flush() - - # hook up to /dev/null - null=os.open('/dev/null', os.O_RDWR) - for i in range(3): - try: - os.dup2(null, i) - except OSError, e: - if e.errno != errno.EBADF: - raise - os.close(null) - # I'd like to hook up to the logfiles instead, but they don't have filenos... - #os.dup2(out_log.fileno(), sys.stdout.fileno()) - #os.dup2(err_log.fileno(), sys.stderr.fileno()) - #os.dup2(dev_null.fileno(), sys.stdin.fileno()) - - # I can hook up sys.stdin and sys.stderr to the logfiles - log = logging.getLogger() - #sys.stdout = PrintLogger(log) # let web.py's stdout through... - sys.stderr = PrintLogger(log) diff --git a/chemdb/db/__init__.py b/chemdb/db/__init__.py deleted file mode 100644 index dd5bf60..0000000 --- a/chemdb/db/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (C) 2010 W. Trevor King -# -# This file is part of ChemDB. -# -# ChemDB is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# ChemDB is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with ChemDB. If not, see . diff --git a/chemdb/db/text.py b/chemdb/db/text.py deleted file mode 100644 index 5536b61..0000000 --- a/chemdb/db/text.py +++ /dev/null @@ -1,545 +0,0 @@ -# Copyright (C) 2008-2010 W. Trevor King -# -# This file is part of ChemDB. -# -# ChemDB is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# ChemDB is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with ChemDB. If not, see . - -"""Database-style interface with a text-delimited, single files. - -Use this if, for example, your coworkers insist on keeping data -compatible with M$ Excel. -""" - -import copy -import os -import os.path -import shutil -import stat -from sys import stdin, stdout, stderr -import time -import types - - -FILE = 'default.db' -STANDARD_TAB = 8 - - -class IndexStringError (Exception): - "invalid index string format" - pass - - -class FieldError (Exception): - "database fields are not unique" - pass - - -class MissingDatabaseFile (Exception): - "Specified database file does not exist" - def __init__(self, path): - msg = "Missing database file %s" % path - Exception.__init__(self, msg) - self.path = path - - -class TextDB (object): - """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" - 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. - """ - if not self.exists(): - raise MissingDatabaseFile(self.curpath()) - - def _open(self): - "Load the database from disk" - self._assert_exists() - 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." - 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 - tname = '%s.%d' % (self.filename, 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 = TextDB(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 = TextDB(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.pop(0), assert_unique=True) - self._long_header = None - self._records = [] - if len(reclines) == 0: - return # Only a header - # check for a long-header line - if len(reclines[0]) > 0 and reclines[0].startswith(self.COM_CHAR): - self._long_header = self._get_header(reclines.pop(0)) - for recline in reclines: - if len(recline) == 0 or recline.startswith(self.COM_CHAR): - continue # ignore blank lines and comments - 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 = TextDB(FS=':', RS=';', filename=None) - - >>> rs = '2-Propanol:4 L:67-63-0:Fisher:6/6/2004:2:3:0' - >>> db._file_record_string(db._get_fields(rs)) - '2-Propanol:4 L:67-63-0:Fisher:6/6/2004:2:3:0;' - """ - 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 a 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 isinstance(db_id, int), "id %s not an int!" % str(db_id) - assert db_id >= 0 and 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 with data from the field-name-keyed dict `newvals`. - - Overwrites any preexisting data. - """ - 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 DBPrettyPrinter (object): - """Define some pretty-print functions for :class:`TextDB` objects. - """ - def __init__(self, db): - self.db = db - - def _norm_active_fields(self, active_fields_in=None): - """Normalize the active field parameter - - >>> from pprint import pprint - >>> db = TextDB(FS=':', RS=' ; ', filename=None) - >>> pp = DBPrettyPrinter(db) - >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") - - >>> pprint(pp._norm_active_fields(None)) - {'Amount': True, 'CAS#': True, 'Name': True, 'Vendor': True} - >>> pprint(pp._norm_active_fields(['Vendor', 'Amount'])) - {'Amount': True, 'CAS#': False, 'Name': False, 'Vendor': True} - >>> pprint(pp._norm_active_fields('1:3')) - {'Amount': True, 'CAS#': True, 'Name': False, 'Vendor': False} - """ - if active_fields_in == None: - active_fields = {} - for field in self.db.field_list(): - active_fields[field] = True - return active_fields - elif isinstance(active_fields_in, types.StringTypes): - 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 isinstance(active_fields_in, list): - active_fields = {} - for field in active_fields_in: - active_fields[field] = True - elif isinstance(active_fields_in, dict): - active_fields = active_fields_in - assert isinstance(active_fields, dict), '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 = TextDB(FS=':', RS=' ; ', filename=None) - >>> pp = DBPrettyPrinter(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 = DBPrettyPrinter('dummy') - >>> pp._istr2ilist('0,2,89,4') - [0, 2, 89, 4] - >>> pp._istr2ilist('1:6') - [1, 2, 3, 4, 5] - >>> 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 isinstance(width_in, int) 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 isinstance(record_ids, types.StringTypes): - 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 = TextDB(FS=':', RS=' ; ', filename=None) - - >>> pp = DBPrettyPrinter(db) - >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") - >>> 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 = TextDB(FS=':', RS=' ; ', filename=None) - - >>> pp = DBPrettyPrinter(db) - >>> db._parse("#Name:Amount:CAS#:Vendor ; 2-Propanol:4 L:67-63-0:Fisher") - >>> 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() diff --git a/chemdb/doc.py b/chemdb/doc.py new file mode 100644 index 0000000..477c121 --- /dev/null +++ b/chemdb/doc.py @@ -0,0 +1,130 @@ +# Copyright (C) 2010 W. Trevor King +# +# This file is part of ChemDB. +# +# ChemDB is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# ChemDB is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ChemDB. If not, see . + +"""Generate inventories and other required documentation. +""" + +import collections as _collections +import os as _os +import os.path as _os_path +import shutil as _shutil +import subprocess as _subprocess +import tempfile as _tempfile +import time as _time +import types as _types + +from django.template import loader as _loader + +from . import LOG as _LOG +from .templatetags import latex as _latex + + +class DocGen (object): + "Generate the officially required documents" + def __init__(self, chemical_instances): + self.chemical_instances = chemical_instances + + def _make_pdf(self, files): + assert 'main.tex' in files, files.keys() + tmp_dir = None + try: + tmp_dir = _tempfile.mkdtemp() + for filename,content in files.items(): + with open(_os_path.join(tmp_dir, filename), 'wb') as f: + f.write(content) + status,stdout,stderr = self._invoke_pdflatex(cwd=tmp_dir) + for i in range(3): + if 'Rerun to get cross-references right' not in stdout: + break + status,stdout,stderr = self._invoke_pdflatex(cwd=tmp_dir) + pdf = open(_os_path.join(tmp_dir, 'main.pdf'), 'rb').read() + finally: + if tmp_dir: + _shutil.rmtree(tmp_dir) + return pdf + + def _invoke_pdflatex(self, **kwargs): + p = _subprocess.Popen( + ['pdflatex', '-interaction=nonstopmode', 'main.tex'], + stdin=_subprocess.PIPE, stdout=_subprocess.PIPE, + stderr=_subprocess.PIPE, shell=False, close_fds=True, **kwargs) + stdout,stderr = p.communicate() + status = p.wait() + if status: + raise RuntimeError((status, stdout, stderr)) + return (status, stdout, stderr) + + def inventory(self, title=None): + """Create a pdf list of all chemicals. + """ + if title == None: + title == 'Inventory' + tex = _loader.render_to_string( + 'chemdb/doc/inventory.tex', { + 'title': title, + 'chemical_instances': self.chemical_instances, + }) + nfpa_704 = _loader.render_to_string('chemdb/doc/nfpa_704.sty') + return self._make_pdf({'main.tex': tex, 'nfpa_704.sty': nfpa_704}) + + def door_warning(self): + """Create an NFPA diamond and list of the most dangerous chemicals. + """ + nfpa_max = {'health':0, 'fire':0, 'reactivity':0, 'special':set()} + nasties = _collections.defaultdict(list) + nasties['special'] = set() + for chemical_instance in self.chemical_instances: + for attr in ['health', 'fire', 'reactivity', 'special', + 'mutagen', 'carcinogen', 'teratogen']: + value = getattr(chemical_instance.chemical, attr) + if not value: + continue + if attr in ['health', 'fire', 'reactivity']: + if value > nfpa_max[attr]: + nfpa_max[attr] = value + nasties[attr] = [chemical_instance] + elif value == nfpa_max[attr]: + nasties[attr].append(chemical_instance) + elif attr == 'special': + if value.count(): + nfpa_max[attr].update(value.all()) + nasties[attr].add(chemical_instance) + else: + nasties[attr].append(chemical_instance) + + instance_groups = [ + ('Health: {}'.format(nfpa_max['health']), nasties['health']), + ('Fire: {}'.format(nfpa_max['fire']), nasties['fire']), + ('Reactivity: {}'.format(nfpa_max['reactivity']), + nasties['reactivity']), + ('Special: {}'.format(_latex.latex_specials(nfpa_max['special'])), + nasties['special']), + ('Mutagen', nasties['mutagen']), + ('Carcinogen', nasties['carcinogen']), + ('Teratogen', nasties['teratogen']), + ] + tex = _loader.render_to_string( + 'chemdb/doc/door.tex', { + 'health': nfpa_max['health'], + 'fire': nfpa_max['fire'], + 'reactivity': nfpa_max['reactivity'], + 'special': nfpa_max['special'], + 'instance_groups': instance_groups, + }) + + nfpa_704 = _loader.render_to_string('chemdb/doc/nfpa_704.sty') + return self._make_pdf({'main.tex': tex, 'nfpa_704.sty': nfpa_704}) diff --git a/chemdb/fixtures/example_data.yaml b/chemdb/fixtures/example_data.yaml new file mode 100644 index 0000000..3f913ab --- /dev/null +++ b/chemdb/fixtures/example_data.yaml @@ -0,0 +1,269 @@ +- model: chemdb.CASNumber + pk: 1 + fields: + name: Acetic Acid (CH₃COOH) + abbrev: Acetic Acid + cas: 64-19-7 +- model: chemdb.CASNumber + pk: 2 + fields: + name: Ammonium Persulfate ((NH₄)₂S₂O₈) + abbrev: Ammonium Persulfate + cas: 7727-54-0 +- model: chemdb.CASNumber + pk: 3 + fields: + name: Glycerin (C₃H₈O₃) + abbrev: Glycerin + cas: 56-81-5 +- model: chemdb.CASNumber + pk: 4 + fields: + name: Hydrogen Chloride (HCl) + abbrev: Hydrogen Chloride + cas: 7647-01-0 +- model: chemdb.CASNumber + pk: 5 + fields: + name: Water (H₂O) + abbrev: Water + cas: 7732-18-5 +- model: chemdb.CASNumber + pk: 6 + fields: + name: Glycine (NH₂CH₂COOH) + abbrev: Glycine + cas: 56-40-6 +- model: chemdb.CASNumber + pk: 7 + fields: + name: Phenol (C₆H₅OH) + abbrev: Phenol + cas: 108-95-2 +- model: chemdb.CASNumber + pk: 8 + fields: + name: Sodium Chloride (NaCl) + abbrev: Sodium Chloride + cas: 7647-14-5 +- model: chemdb.CASNumber + pk: 9 + fields: + name: Sodium Hydroxide (NaOH) + abbrev: Sodium Hydroxide + cas: 1310-73-2 + +- model: chemdb.Chemical + pk: 1 + fields: + name: Acetic Acid (CH₃COOH) + abbrev: Acetic Acid + cas: [1] + msds: msds/Acetic_Acid.pdf + health: 3 + fire: 2 + reactivity: 1 + mutagen: no + carcinogen: no + teratogen: no +- model: chemdb.Chemical + pk: 2 + fields: + name: Ammonium Persulfate ((NH₄)₂S₂O₈) + abbrev: Ammonium Persulfate + cas: [2] + health: 3 + fire: 0 + reactivity: 3 + special: [1] + mutagen: no + carcinogen: no + teratogen: no +- model: chemdb.Chemical + pk: 3 + fields: + name: Glycerin Solution 50% (C₃H₅(OH)₃) + abbrev: 50% Glycerin + cas: [3, 4, 5] + health: 1 + fire: 0 + reactivity: 1 + special: [4] + mutagen: yes + carcinogen: no + teratogen: yes + note: Labled H2 F0 R0 in NFPA diamond on the bottle. Avoid strong bases and oxidizers. +- model: chemdb.Chemical + pk: 4 + fields: + name: Glycine (NH₂CH₂COOH) + abbrev: Glycine + cas: [6] + health: 1 + fire: 0 + reactivity: 0 + special: [4] + mutagen: no + carcinogen: no + teratogen: no + note: Avoid strong oxidizers. +- model: chemdb.Chemical + pk: 5 + fields: + name: Phenol (C₆H₅OH) + abbrev: Phenol + cas: [7] + health: 3 + fire: 2 + reactivity: 0 + special: [4] + mutagen: yes + carcinogen: yes + teratogen: yes + note: Avoid strong oxidizers (especially calcium hypochlorite), acids, and halogens. +- model: chemdb.Chemical + pk: 6 + fields: + name: Sodium Chloride (NaCl) + abbrev: Sodium Chloride + cas: [8] + health: 1 + fire: 0 + reactivity: 0 + mutagen: no + carcinogen: no + teratogen: no +- model: chemdb.Chemical + pk: 7 + fields: + name: Sodium Hydroxide (NaOH) 50% (w/w) solution + abbrev: 50% Sodium Hydroxide + cas: [9, 5] + health: 3 + fire: 0 + reactivity: 2 + special: [2] + mutagen: no + carcinogen: no + teratogen: no + +- model: chemdb.Location + pk: 1 + fields: + name: acidic liquids + abbrev: acidic liquids +- model: chemdb.Location + pk: 2 + fields: + name: basic liquids + abbrev: basic liquids +- model: chemdb.Location + pk: 3 + fields: + name: corrosive liquids + abbrev: corrosive liquid +- model: chemdb.Location + pk: 4 + fields: + name: non-hazardous + abbrev: non-hazardous +- model: chemdb.Location + pk: 5 + fields: + name: oxidizing solids + abbrev: oxidizing solids +- model: chemdb.Location + pk: 6 + fields: + name: reactive solids + abbrev: reactive solids + +- model: chemdb.Vendor + pk: 1 + fields: + name: 'Fisher Scientific International Inc. (NYSE:FSH)' + abbrev: Fisher + url: http://www.fishersci.com/ +- model: chemdb.Vendor + pk: 2 + fields: + name: 'Life Technologies Coperation (NASDAQ:LIFE)' + abbrev: Life Tech. + url: http://www.lifetechnologies.com/ +- model: chemdb.Vendor + pk: 3 + fields: + name: 'Sigma-Aldrich Corperation (NASDAQ:SIAL)' + abbrev: Sigma + url: http://www.sigmaaldrich.com/ +- model: chemdb.Vendor + pk: 4 + fields: + name: VWR International, LLC + abbrev: VWR + url: https://www.vwr.com/ + note: VWR International, LLC is a principle operating subsidiary of VWR Funding, which in turn is a portfolio company of private equity firm Madison Dearborn Partners. + +- model: chemdb.ChemicalInstance + pk: 1 + fields: + chemical: 1 + location: 1 + amount: 500 mL + vendor: 1 + catalog: A35-500 + recieved: 2001-07-05 +- model: chemdb.ChemicalInstance + pk: 2 + fields: + chemical: 2 + location: 5 + amount: 100 g + vendor: 2 + catalog: 15523-012 + recieved: 2000-01-16 +- model: chemdb.ChemicalInstance + pk: 3 + fields: + chemical: 3 + location: 3 + amount: 500 mL + vendor: 4 + catalog: VW3410-2 + recieved: 2000-01-17 +- model: chemdb.ChemicalInstance + pk: 4 + fields: + chemical: 4 + location: 3 + amount: 500 g + vendor: 2 + catalog: 15527-013 + recieved: 2000-01-16 +- model: chemdb.ChemicalInstance + pk: 5 + fields: + chemical: 5 + location: 6 + amount: 100 g + vendor: 3 + catalog: P1037 + recieved: 2006-01-17 +- model: chemdb.ChemicalInstance + pk: 6 + fields: + chemical: 6 + location: 4 + amount: 500 g + vendor: 4 + catalog: JT4058-1 + recieved: 2000-10-22 +- model: chemdb.ChemicalInstance + pk: 7 + fields: + chemical: 7 + location: 2 + amount: 500 mL + vendor: 4 + catalog: JT3727-1 + recieved: 2000-10-22 diff --git a/chemdb/fixtures/initial_data.yaml b/chemdb/fixtures/initial_data.yaml new file mode 100644 index 0000000..811bb66 --- /dev/null +++ b/chemdb/fixtures/initial_data.yaml @@ -0,0 +1,55 @@ +- model: chemdb.NFPASpecial + pk: 1 + fields: + name: oxidizer + abbrev: OX +- model: chemdb.NFPASpecial + pk: 2 + fields: + name: water reactive + abbrev: -W- + symbol: W̶ +- model: chemdb.NFPASpecial + pk: 3 + fields: + name: asphixiant + abbrev: AS +- model: chemdb.NFPASpecial + pk: 4 + fields: + name: corrosive + abbrev: COR +- model: chemdb.NFPASpecial + pk: 5 + fields: + name: strong acid + abbrev: ACID +- model: chemdb.NFPASpecial + pk: 6 + fields: + name: strong base + abbrev: ALK +- model: chemdb.NFPASpecial + pk: 7 + fields: + name: biological hazard + abbrev: BIO + symbol: ☣ +- model: chemdb.NFPASpecial + pk: 8 + fields: + name: cryogenic + abbrev: CRY + symbol: ❄ +- model: chemdb.NFPASpecial + pk: 9 + fields: + name: poisonous + abbrev: POI + symbol: ☠ +- model: chemdb.NFPASpecial + pk: 10 + fields: + name: radioactive + abbrev: RAD + symbol: ☢ diff --git a/chemdb/forms.py b/chemdb/forms.py new file mode 100644 index 0000000..07f6baa --- /dev/null +++ b/chemdb/forms.py @@ -0,0 +1,14 @@ +# Copyright + +from django import forms as _forms + +from . import models as _models + + +class LocationsForm (_forms.Form): + def __init__(self, *args, **kwargs): + choices = [(x.id, x.name) for x in _models.Location.objects.all()] + super(LocationsForm, self).__init__(*args, **kwargs) + self.fields['location'] = _forms.MultipleChoiceField( + widget=_forms.CheckboxSelectMultiple, + choices=choices, label='Locations') diff --git a/chemdb/models.py b/chemdb/models.py new file mode 100644 index 0000000..b0087cc --- /dev/null +++ b/chemdb/models.py @@ -0,0 +1,97 @@ +# Copyright + +from django.db import models as _models +from django.forms import ValidationError as _ValidationError + +from . import LOG as LOG +from . import util as _util + + +class NamedItem (_models.Model): + name = _models.CharField(max_length=100) + abbrev = _models.CharField('abbreviation', max_length=20) + + class Meta: + abstract = True + ordering = ['name'] + + def __unicode__(self): + return u'{0.abbrev}'.format(self) + + +class NFPASpecial (NamedItem): + """An NFPA Special rating (e.g. 'OX', '-W-', 'SA', ...). + """ + symbol = _models.CharField(max_length=3, blank=True, null=True) + + def __unicode__(self): + if self.symbol: + return u'{0.symbol}'.format(self) + return super(NFPASpecial, self).__unicode__() + + +class CASNumber (NamedItem): + "Chemical Abstracts Service registery number" + cas = _models.CharField( + 'CAS#', max_length=20) + + def clean(self): + if not _util.valid_CASno(self.cas): + raise _ValidationError("invalid CAS number '{}'".format(self.cas)) + + +class Chemical (NamedItem): + """A chemical (in the abstract, not an instance of the chemical) + + Separating ``Chemical``\s from ``ChemicalInstance``\s avoids + duplicate information (e.g. you can have two bottles of acetic + acid). + """ + cas = _models.ManyToManyField(CASNumber, blank=True, null=True) + msds = _models.FileField( + 'Material safety data sheet', upload_to=_util.chemical_upload_to, + blank=True, null=True) + health = _models.PositiveIntegerField('NFPA health rating') + fire = _models.PositiveIntegerField('NFPA fire rating') + reactivity = _models.PositiveIntegerField('NFPA reactivity rating') + special = _models.ManyToManyField(NFPASpecial, blank=True, null=True) + mutagen = _models.NullBooleanField() + carcinogen = _models.NullBooleanField() + teratogen = _models.NullBooleanField() + note = _models.TextField('notes', blank=True, null=True) + + def cas_numbers(self): + if self.cas.count() == 0: + return 'unknown' + return ', '.join(cas.cas for cas in self.cas.all()) + + def specials(self): + return ' '.join(str(special) for special in self.special.all()) + + +class Location (NamedItem): + "A chemical storage location (e.g. 'acidic liquids')" + pass + + +class Vendor (NamedItem): + "A chemical supplier" + url = _models.URLField('URL', blank=True, null=True) + note = _models.TextField('notes', blank=True, null=True) + + +class ChemicalInstance (_models.Model): + """An instance of a ``Chemical`` + + For example, 1L of acetic acid from Vendor X. + """ + chemical = _models.ForeignKey(Chemical, related_name='chemical_instances') + location = _models.ForeignKey(Location, related_name='chemical_instances') + amount = _models.CharField(max_length=100) + vendor = _models.ForeignKey(Vendor, related_name='chemical_instances') + catalog = _models.CharField('vendor catalog number', max_length=100) + recieved = _models.DateField(auto_now_add=True, editable=True) + disposed = _models.DateField(blank=True, null=True) + + class Meta: + ordering = ['chemical', 'recieved', 'disposed', 'id'] diff --git a/chemdb/server.py b/chemdb/server.py deleted file mode 100644 index 2db25a3..0000000 --- a/chemdb/server.py +++ /dev/null @@ -1,376 +0,0 @@ -# Copyright (C) 2010 W. Trevor King -# -# This file is part of ChemDB. -# -# ChemDB is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# ChemDB is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with ChemDB. If not, see . - -"""Chemical inventory web interface. -""" - -from __future__ import with_statement - -from cgi import escape -import os -import os.path -import re -import sys, logging - -import cherrypy -from jinja2 import Environment, FileSystemLoader - -from .daemon import Daemon -from .db.text import TextDB -from .chemdb import valid_CASno, MSDSManager, DocGen - - -__version__ = "0.4" - - -class Server (object): - """ChemDB web interface.""" - def __init__(self, db=None, MSDS_manager=None, docs=None, - template_root='template', static_dir='static', - static_url='static'): - self.db = db - self.MSDS_manager = MSDS_manager - self.docs = docs - self.static_dir = static_dir - self.static_url = static_url - self.env = Environment(loader=FileSystemLoader(template_root)) - self.width_max = {'CAS#':12} - self.entry_width = 50 # display width of entry fields - self.raw_fields = ['ID','Name'] - self.MSDS_content_types = [ - 'text/plain', 'text/html', 'application/pdf'] - - @cherrypy.expose - def index(self, id=None): - """Chemical index page. - """ - self.db._refresh() - if id == None: - return self._index_page() - else: - return self._view_page(id=id) - - def _index_page(self): - records = self.db.records() - for record in records: - for field in self.db.field_list(): - rstring = record[field] - if field in self.width_max: # truncate if width is regulated... - w = self.width_max[field] - if len(rstring) > w: # ... and full string is too long. - rstring = "%s..." % rstring[:w-3] - # if the field isn't raw, protect special chars - if not field in self.raw_fields: - rstring = escape(rstring) - if self.MSDS_manager.has_MSDS(record['db_id']): - # link the id to the MSDS - record['Name'] = ( - '%s' - % (self._MSDS_path(record['db_id']), record['Name'])) - template = self.env.get_template('index.html') - return template.render(fields=self.db.field_list(), records=records) - - def _view_page(self, id): - db_id = int(id) - record = self.db.record(db_id) - MSDS_link = None - if self.MSDS_manager.has_MSDS(db_id): - path = self._MSDS_path(db_id) - MSDS_link = '%s' % ( - path, os.path.basename(path)) - template = self.env.get_template('record.html') - return template.render(fields=self.db.field_list(), - long_fields=self.db.long_fields(), - record=record, - MSDS=MSDS_link, - escape=escape) - - def _MSDS_path(self, db_id): - path = self.MSDS_manager.get_MSDS_path(db_id) - path = os.path.relpath(path, self.static_dir) - path = os.path.join(self.static_url, path) - return path - - @cherrypy.expose - def edit(self, id=None, **kwargs): - """Render the edit-record.html template for the specified record. - """ - self.db._refresh() - if kwargs: - self._update_record(id=id, **kwargs) - raise cherrypy.HTTPRedirect(u'.?id=%s' % id, status=303) - MSDSs = self.MSDS_manager.get_all(simlinks=False) - if id in [None, '-1']: - record = dict([(field,'') for field in self.db.field_list()]) - record['db_id'] = '-1' - record['ID'] = str(record['db_id']) - else: - db_id = int(id) - record = self.db.record(db_id) - if self.MSDS_manager.has_MSDS(db_id): - MSDSs = None # only ask for an MSDS if we still need one - template = self.env.get_template('edit-record.html') - return template.render(fields=self.db.field_list(), - long_fields=self.db.long_fields(), - record=record, - MSDSs=MSDSs, - entry_width=self.entry_width, - escape=escape) - - def _update_record(self, id=None, **kwargs): - """Update a record with form input. - """ - self.db._refresh() - if id in [None, '-1']: - record = self.db.new_record() - logging.info('new record %s' % record['db_id']) - else: - db_id = int(id) - record = self.db.record(db_id) - update = False - for field in self.db.field_list(): - if kwargs[field] != record[field]: - # TODO: add validation! - update = True - record[field] = kwargs[field] - if kwargs.get('MSDS source', None) == 'upload': - # Handle any MSDS file actions - f = kwargs['MSDS upload'] - contents = f.file.read() - if len(contents) > 0 and f.type in self.MSDS_content_types: - self.MSDS_manager.save(int(record['ID']), contents, f.type) - elif kwargs.get('MSDS source', None) == 'share': - logging.info('linking MSDS %d to %d' - % (int(record['ID']), - int(kwargs['MSDS share'])) ) - self.MSDS_manager.link(int(record['ID']), - int(kwargs['MSDS share'])) - if update: - self.db.set_record(record['db_id'], record, backup=True) - - -class Docs (object): - "Generate and serve assorted official documents." - def __init__(self, db=None, docgen=None, template_root='template'): - self.db = db - self.docgen = docgen - self.namewidth = 40 - self.env = Environment(loader=FileSystemLoader(template_root)) - - @cherrypy.expose - def index(self): - """List the available documents. - """ - template = self.env.get_template('docs.html') - return template.render() - - @cherrypy.expose - def inventory_pdf(self): - self.db._refresh() - path = self.docgen.inventory(namewidth=self.namewidth) - cherrypy.response.headers['Content-Type'] = 'application/pdf' - return file(path, 'rb').read() - - @cherrypy.expose - def door_warning_pdf(self, location=None): - self.db._refresh() - if location == None: - valid = lambda r: r['Disposed'] == '' - else: - regexp = re.compile(location, re.I) # Case insensitive - valid = lambda r: r['Disposed'] == '' and regexp.match(r['Location']) - path = self.docgen.door_warning(valid_record=valid) - cherrypy.response.headers['Content-Type'] = 'application/pdf' - return file(path, 'rb').read() - - -class ServerDaemon (Daemon): - def __init__(self): - super(ServerDaemon, self).__init__() - self.gid = None - self.uid = None - self.pidfile = './chem_web.pid' - self.logfile = './chem_web.log' - self.loglevel = 'INFO' - - def run(self): - if cherrypy.__version__.startswith('3.'): - cherrypy.quickstart(root=self.server, config=self.app_config) - -# if self.pkey_file == None or self.cert_file == None: -# logging.info("http://%s:%d/" % web.validip(self.ip_address)) -# else: -# logging.info("https://%s:%d/" % web.validip(self.ip_address)) -# -# webpy_func = web.webpyfunc(urls, globals(), False) -# wsgi_func = web.wsgifunc(webpy_func) -# web.httpserver.runsimple(wsgi_func, -# web.validip(self.ip_address), -# 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__ - p = OptionParser(usage=usage_string, version=version_string) - - # Non-server options - p.add_option('-t', '--test', dest='test', default=False, - action='store_true', help='Run internal tests and exit.') - - # Daemon options - p.add_option('--start', dest='action', - action='store_const', const='start', default='start', - help='Start the daemon (the default action).') - p.add_option('--stop', dest='action', - action='store_const', const='stop', default='start', - help='Stop the daemon.') - p.add_option('-n', '--nodaemon', dest='daemonize', - action='store_false', default=True, - help='Run in the foreground.') - - # Server options - p.add_option('-a', '--address', dest='address', default='127.0.0.1', - metavar='ADDR', - help='Address that the server will bind to (%default).') - p.add_option('-p', '--port', dest='port', default=8080, type='int', - metavar='PORT', - help='Port that the server will listen on (%default).') - p.add_option('-s', '--secure', dest="secure", - help="Run in secure (HTTPS) mode.", - type='string', metavar="PKEY_FILE:CERT_FILE") - p.add_option('-v', '--verbose', dest="verbose", action="store_true", - help="Print lots of debugging information.", - default=False) - p.add_option('--static', dest='static', metavar='PATH', - help="Path to the directory of static files (%default).", - default=os.path.join('example', 'static')) - p.add_option('--template', dest='template', metavar='PATH', - help="Path to the directory of template files (%default).", - default=os.path.join('template', 'web')) - p.add_option('--doc', dest='doc', metavar='PATH', - help="Path to the directory of document generation files (%default).", - default=os.path.join('template', 'doc')) - p.add_option('--htaccess', dest='htaccess', metavar='FILE', - help="Path to the htaccess file (%default).", - default='.htaccess') - - # Database options - p.add_option('--database', dest='database', metavar='FILE', - help="Path to the database file (%default).", - default=os.path.join('example', 'inventory.db')) - - self.options, args = p.parse_args() - p.destroy() - - if self.options.test == True: - _test() - sys.exit(0) - - if self.options.verbose: - self.loglevel = 'DEBUG' - - # get self.options for httpserver - if self.options.secure != None: - split = self.options.secure.split(':', 1) - assert len(split) == 2, ( - "Invalid secure argument '%s'" % self.options.secure) - self.pkey_file = split[0] - self.cert_file = split[1] - - # HACK! to ensure we *always* get utf-8 output - #reload(sys) - #sys.setdefaultencoding('utf-8') - - dirname,filename = os.path.split( - os.path.abspath(self.options.database)) - static_dir = os.path.abspath(self.options.static) - MSDS_dir = os.path.join(static_dir, 'MSDS') - template_dir = os.path.abspath(self.options.template) - doc_dir = os.path.abspath(self.options.doc) - db = TextDB(filename=filename, current_dir=dirname) - MSDS_manager = MSDSManager(db=db, dir=MSDS_dir) - docgen = DocGen(db=db, doc_root=doc_dir) - docs = Docs(db=db, docgen=docgen, template_root=template_dir) - server = Server(db=db, MSDS_manager=MSDS_manager, docs=docs, - template_root=template_dir, static_dir=static_dir, - static_url='/static') - - if cherrypy.__version__.startswith('3.'): - cherrypy.config.update({ # http://www.cherrypy.org/wiki/ConfigAPI - 'server.socket_host': self.options.address, - 'server.socket_port': self.options.port, - 'tools.decode.on': True, - 'tools.encode.on': True, - 'tools.encode.encoding': 'utf8', - 'tools.staticdir.root': static_dir, - }) - digest_auth = None - if cherrypy.__version__.startswith('3.2'): - try: - get_ha1 = cherrypy.lib.auth_digest.get_ha1_file_htdigest( - self.options.htaccess) - except IOError: - pass - else: - digest_auth = { - 'tools.auth_digest.on': True, - 'tools.auth_digest.realm': 'chemdb', - 'tools.auth_digest.get_ha1': get_ha1, - 'tools.auth_digest.key': str(uuid.uuid4()), - } - else: - passwds = {} - try: - with open(self.options.htaccess, 'r') as f: - for line in f: - user,realm,ha1 = line.strip().split(':') - passwds[user] = ha1 # use the ha1 as the password - except IOError: - pass - else: - digest_auth = { - 'tools.digest_auth.on': True, - 'tools.digest_auth.realm': 'chemdb', - 'tools.digest_auth.users': passwds, - } - app_config = { - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': '', - }, - } - if digest_auth != None: - for url in ['edit']: - app_config[url] = digest_auth - self.server = server - self.app_config = app_config - else: - raise NotImplementedError( - 'Unsupported CherryPy version %s' % cherrypy.__version__) - - -def _test(): - import doctest - doctest.testmod() diff --git a/chemdb/static/chemdb/style.css b/chemdb/static/chemdb/style.css new file mode 100644 index 0000000..30a24af --- /dev/null +++ b/chemdb/static/chemdb/style.css @@ -0,0 +1,110 @@ +/* */ + +body { + background: #eee; +} + +.fullclear { + width:100%; + height:1px; + margin:0; + padding:0; + clear:both; +} + +/* */ + +/* */ + +/*
*/ + +#content { + width: 100%; + margin: 0; + padding: 0; + padding-top: 1em; + padding-bottom: 1em; + background: #fff; +} + +table { + border-collapse: collapse; +} + +table.centered { + margin: auto; +} + +td, th { + padding-right: 1em; + text-align: left; +} + +table.wide tr:nth-child(odd) { + background: #eee; +} + +table.wide thead tr:nth-child(odd) { + background: #ccc; +} + +span.number { + float: right; /* so decimal points match up */ + font-family: monospace; /* so that n-place digits line up */ + text-align: '.'; /* should work by itself, but browser support is bad */ +} + +span.positive { + color: green; +} + +span.negative { + color: red; +} + +/*
*/ diff --git a/chemdb/templates/chemdb/base.html b/chemdb/templates/chemdb/base.html new file mode 100644 index 0000000..945c4ea --- /dev/null +++ b/chemdb/templates/chemdb/base.html @@ -0,0 +1,29 @@ + + + {% block title %}ChemDB{% endblock %} + + + + + + +
+ +
+ {% block content %}{% endblock %} +
+
+ + + + diff --git a/chemdb/templates/chemdb/chemical.html b/chemdb/templates/chemdb/chemical.html new file mode 100644 index 0000000..fc15324 --- /dev/null +++ b/chemdb/templates/chemdb/chemical.html @@ -0,0 +1,31 @@ +{% extends "chemdb/base.html" %} + +{% block content %} +

{{ chemical.name }}

+ +{% block metadata %} + + + + + + + + + + +{% if chemical.msds %} + +{% endif %} + +{% endblock %} + +{% block note %} +{% if chemical.note %} +
+{{ chemical.note|safe }} +
+{% endif %} +{% endblock %} +{% endblock %} diff --git a/chemdb/templates/chemdb/chemical_instances.html b/chemdb/templates/chemdb/chemical_instances.html new file mode 100644 index 0000000..a24f0b6 --- /dev/null +++ b/chemdb/templates/chemdb/chemical_instances.html @@ -0,0 +1,36 @@ +{% extends "chemdb/base.html" %} + +{% block title %} +{{ title }} +{% endblock %} + +{% block content %} +

{{ title }}

+{% if chemical_instances %} + + + + + + + + + {% for chemical_instance in chemical_instances %} + + + + + + + + + + + {% endfor %} + +
ChemicalCAS#LocationAmountVendorCatalog#RecievedDisposed
+ {{ chemical_instance.chemical.abbrev }}{{ chemical_instance.chemical.cas_numbers }}{{ chemical_instance.location }}{{ chemical_instance.amount }}{{ chemical_instance.vendor }}{{ chemical_instance.catalog }}{{ chemical_instance.recieved }}{{ chemical_instance.disposed|default:'' }}
+{% else %} +

No chemicals are available.

+{% endif %} +{% endblock %} diff --git a/chemdb/templates/chemdb/doc.html b/chemdb/templates/chemdb/doc.html new file mode 100644 index 0000000..75765a8 --- /dev/null +++ b/chemdb/templates/chemdb/doc.html @@ -0,0 +1,31 @@ +{% extends "chemdb/base.html" %} + +{% block title %} + Documents +{% endblock %} + +{% block content %} + +

+ For door warnings for a particular location or locations, use the + form below: +

+
+ {{ locations_form }} + +
+{% endblock %} diff --git a/template/doc/Makefile b/chemdb/templates/chemdb/doc/Makefile similarity index 100% rename from template/doc/Makefile rename to chemdb/templates/chemdb/doc/Makefile diff --git a/template/doc/door_template.tex b/chemdb/templates/chemdb/doc/base.tex similarity index 67% rename from template/doc/door_template.tex rename to chemdb/templates/chemdb/doc/base.tex index 45e4a25..614f9bb 100644 --- a/template/doc/door_template.tex +++ b/chemdb/templates/chemdb/doc/base.tex @@ -15,48 +15,30 @@ % You should have received a copy of the GNU General Public License % along with ChemDB. If not, see . -% Paper for posting on the lab door -% as per -% http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7 -% and -% href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10 +\documentclass[letterpaper]{article} -% setup the margins +{% block comment %}{% endblock %} + +% setup the margins, \topmargin -0.5in \headheight 0.0in \headsep 0.0in -\textheight 10in +\textheight 9.5in % leave a bit of extra room for page numbers \oddsidemargin -0.5in \textwidth 7.5in -\usepackage{nfpa_704} % \firediamond - % 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} - -\input{door_data} - -\vfill +\usepackage{nfpa_704} % \firediamond -{\headfont Contact} -\vskip 10pt -\contfont -\input{contact} -\vspacer +{% block preamble %}{% endblock %} -Generated \today\ by ChemDB. +\begin{document} -\end{center} +{% block document %}{% endblock %} \end{document} diff --git a/chemdb/templates/chemdb/doc/door.tex b/chemdb/templates/chemdb/doc/door.tex new file mode 100644 index 0000000..1908f75 --- /dev/null +++ b/chemdb/templates/chemdb/doc/door.tex @@ -0,0 +1,52 @@ +{% extends "chemdb/doc/base.tex" %} +{% load latex %} + +{% block comment %} +% Paper for posting on the lab door +% as per +% http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7 +% and +% href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10 +{% endblock %} + +{% block document %} +\thispagestyle{empty} % suppress page numbering +\sffamily % switch to sans-serif + +\begin{center} + +{\Huge + \firediamond{health={{ health }}, flammability={{ fire }}, + reactivity={{ reactivity }}, {{ special|latex_special_args }} } } + +\vspacer + +\contfont +\begin{tabular}{r r l} +{% for title,instance_group in instance_groups %} +{% if instance_group %} +\multicolumn{3}{c}{\Tstrut {{ title }} } \\ +{% for chemical_instance in instance_group %} +{{ chemical_instance.amount }} & {{ chemical_instance.id }} & + {{ chemical_instance.chemical.name|latex_safe }} \\ +{% endfor %} +{% endif %} +{% endfor %} +\end{tabular} + +\vfill + +{\headfont Contact} +\vskip 10pt +\contfont +\begin{tabular}{l} + W. Trevor King + 215-895-1818 \\ + Disque Hall 927 \\ +\end{tabular} +\vspacer + +Generated \today\ by ChemDB. + +\end{center} +{% endblock %} diff --git a/chemdb/templates/chemdb/doc/inventory.tex b/chemdb/templates/chemdb/doc/inventory.tex new file mode 100644 index 0000000..4c724ef --- /dev/null +++ b/chemdb/templates/chemdb/doc/inventory.tex @@ -0,0 +1,55 @@ +{% extends "chemdb/doc/base.tex" %} +{% load latex %} + +{% block comment %} +% Chemical inventory as per +% http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7 +{% endblock %} + +{% block preamble %} +% 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} +{% endblock %} + +{% block document %} + +\begin{center} +{\headfont {{ title }} }\\ +\contfont +Generated \today\ by ChemDB.\\ +\vskip 10pt +\end{center} + +{% if chemical_instances %} +\footnotesize +\begin{longtable}{l l c c c c c c c c} +% Header for the remaining page(s) of the table... +ID & Name & Amount & H & F & R & O & M & C & T \\ +\hline +\endhead{% for chemical_instance in chemical_instances %} +{{ chemical_instance.id }} & + {{ chemical_instance.chemical.name|latex_safe }} & + {{ chemical_instance.amount }} & + {{ chemical_instance.chemical.health }} & + {{ chemical_instance.chemical.fire }} & + {{ chemical_instance.chemical.reactivity }} & + {{ chemical_instance.chemical.special.all|latex_specials }} & + {{ chemical_instance.chemical.mutagen|default:"" }} & + {{ chemical_instance.chemical.carcinogen|default:"" }} & + {{ chemical_instance.chemical.teratogen|default:"" }} \\ +{% endfor %}\end{longtable} +{% else %} +No chemicals are available. +{% endif %} + +{% endblock %} diff --git a/template/doc/nfpa_704.sty b/chemdb/templates/chemdb/doc/nfpa_704.sty similarity index 99% rename from template/doc/nfpa_704.sty rename to chemdb/templates/chemdb/doc/nfpa_704.sty index 3b05b28..b448f5c 100644 --- a/template/doc/nfpa_704.sty +++ b/chemdb/templates/chemdb/doc/nfpa_704.sty @@ -98,7 +98,7 @@ % ... % SUBSTANTIATION: The committee expressed concern that for the % relatively few chemicals requiring both an "OX" and "W" symbols in -% the special hazards quadrant, there wouldn’t be enough room for +% the special hazards quadrant, there wouldn't be enough room for % both to appear in the prescribed sizes for clear visibility. The % committee believed the "W" is the primary hazard and should be % displayed inside the quadrant, with the "OX" outside the diff --git a/template/doc/nfpa_704.tex b/chemdb/templates/chemdb/doc/nfpa_704.tex similarity index 97% rename from template/doc/nfpa_704.tex rename to chemdb/templates/chemdb/doc/nfpa_704.tex index f613f04..fa12ae2 100644 --- a/template/doc/nfpa_704.tex +++ b/chemdb/templates/chemdb/doc/nfpa_704.tex @@ -1,3 +1,5 @@ +% Test the nfpa_704 package +% % Copyright (C) 2010 W. Trevor King % % This file is part of ChemDB. diff --git a/chemdb/templatetags/__init__.py b/chemdb/templatetags/__init__.py new file mode 100644 index 0000000..a59fb1a --- /dev/null +++ b/chemdb/templatetags/__init__.py @@ -0,0 +1,7 @@ +# Copyright + +"""Custom template tags and filters + +https://docs.djangoproject.com/en/dev/howto/custom-template-tags/ +""" + diff --git a/chemdb/templatetags/latex.py b/chemdb/templatetags/latex.py new file mode 100644 index 0000000..baaf5b3 --- /dev/null +++ b/chemdb/templatetags/latex.py @@ -0,0 +1,72 @@ +# Copyright + +import sys as _sys + +from django import template as _template + + +register = _template.Library() + + +LATEX_REPLACEMENTS = [('%', '\%'), ('>', '$>$'), ('<', '$<$')] +_SUPERSCRIPT_CODEPOINTS = {1: 0x00B9, 2: 0x00B2, 3: 0x00B3} +for i in range(9): + subscript_codepoint = 0x2080 + i + superscript_codepoint = _SUPERSCRIPT_CODEPOINTS.get(i, 0x2070 + i) + if _sys.version_info >= (3,): + subscript = chr(subscript_codepoint) + superscript = chr(superscript_codepoint) + else: + subscript = unichr(subscript_codepoint) + superscript = unichr(superscript_codepoint) + LATEX_REPLACEMENTS.extend([ + (subscript, '$_{{{}}}$'.format(i)), + (superscript, '$^{{{}}}$'.format(i)), + ]) +del i, subscript_codepoint, superscript_codepoint, subscript, superscript + +LATEX_SPECIALS = { # abbrev -> (latex, nfpa_704:firediamond argument) + 'OX': (r'\oxidizer', 'oxidizer'), + '-W-': (r'\nowater', 'nowater'), + 'SA': (r'\asphixiant', 'asphixiant'), + } # others default to ('{abbrev}', '{abbrev}') + + +@register.filter +def latex_safe(string): + for a,b in LATEX_REPLACEMENTS: + string = string.replace(a, b) + return string + +@register.filter +def latex_specials(specials): + """Format specials for general LaTeX usage + """ + special_abbrevs = set(special.abbrev for special in specials) + latex_specials = [] + for abbrev in ['OX', '-W-', 'SA']: + if abbrev in special_abbrevs: + latex_specials.append(LATEX_SPECIALS[abbrev][0]) + special_abbrevs.remove(abbrev) + if special_abbrevs: # leftovers: + special_abbrevs = sorted(special_abbrevs) + for abbrev in special_abbrevs: + latex_specials.append( + LATEX_SPECIALS.get(abbrev, ('{{{}}}'.format(abbrev), None))[0]) + return '~'.join(latex_specials) + +@register.filter +def latex_special_args(specials): + """Format specials for the NFPA firediamond. + """ + special_abbrevs = set(special.abbrev for special in specials) + latex_specials = [] + for abbrev in ['OX', '-W-', 'SA']: + if abbrev in special_abbrevs: + latex_specials.append(LATEX_SPECIALS[abbrev][1]) + special_abbrevs.remove(abbrev) + if special_abbrevs: # leftovers: + special_abbrevs = sorted(special_abbrevs) + latex_specials.append('special={{{}}}'.format( + ','.join(special_abbrevs))) + return ', '.join(latex_specials) diff --git a/chemdb/tests.py b/chemdb/tests.py new file mode 100644 index 0000000..b143291 --- /dev/null +++ b/chemdb/tests.py @@ -0,0 +1,95 @@ +# Copyright + +"""Run tests with ``manage.py test``. +""" + +from django.forms import ValidationError as _ValidationError +from django.test import _TestCase as _TestCase +from . import models as _models + + +#class UnitTest(TestCase): +# def setUp(self): +# self.F = models.Unit( +# name=u'degree Farenheit', abbrev=u'\u00b0F', type=u'temperature', +# system=models.US, scale=1/1.8, offset=32) +# self.gal = models.Unit( +# name=u'gallon', abbrev=u'gal', type=u'volume', +# system=models.US, scale=3.78541, offset=0) +# +# def test_conversion_from_si(self): +# "Test from-SI conversion" +# self.assertEqual(self.F.convert_from_si(0), 32) +# self.assertEqual(self.F.convert_from_si(100), 212) +# self.assertEqual(self.gal.convert_from_si(1), 0.26417217685798894) +# +# def test_conversion_to_si(self): +# "Test to-SI conversion" +# self.assertEqual(self.F.convert_to_si(32), 0) +# self.assertEqual(self.F.convert_to_si(212), 100) +# self.assertEqual(self.gal.convert_to_si(1), 3.78541) +# +# def test_formatting(self): +# "Test amount formatting" +# self.assertEqual(unicode(self.gal), u'gal') +# self.assertEqual(unicode(self.F), u'\u00b0F') +# +# +#class AmountTest(TestCase): +# def setUp(self): +# self.unit = models.Unit( +# name=u'gallon', abbrev=u'gal', type=u'volume', +# system=models.US, scale=3.78541, offset=0) +# self.amount = models.Amount() +# +# def test_formatting(self): +# "Test amount formatting" +# for v,minv,maxv,unit,result in ( +# (None, None, None, None, u'-'), +# (None, None, None, self.unit, u'- gal'), +# (2, None, None, self.unit, u'2 gal'), +# (2, 1.5, None, self.unit, u'1.5-2 gal'), +# (2, None, 2.5, self.unit, u'2-2.5 gal'), +# (2, 1.5, 2.5, self.unit, u'2 (1.5-2.5) gal'), +# (None, 1.5, 2.5, self.unit, u'1.5-2.5 gal'), +# ): +# self.amount.unit = unit +# self.amount.value = v +# self.amount.min_value = minv +# self.amount.max_value = maxv +# self.assertEqual(self.amount.format_amount(), result) +# +# def test_invalid_formatting(self): +# "Test amount formatting which raises errors" +# for v,minv,maxv,unit,result in ( +# (None, 1.5, None, self.unit, u'2 gal'), +# (None, None, 2.5, self.unit, u'2 gal'), +# ): +# self.amount.unit = unit +# self.amount.value = v +# self.amount.min_value = minv +# self.amount.max_value = maxv +# self.assertRaises(ValidationError, self.amount.format_amount) +# +# def test_validation(self): +# "Test amount validation" +# for valid,v,minv,maxv,unit in ( +# (True, None, None, None, None), +# (True, None, None, None, self.unit), +# (True, 2, None, None, self.unit), +# (True, 2, 1.5, None, self.unit), +# (True, 2, None, 2.5, self.unit), +# (True, 2, 1.5, 2.5, self.unit), +# (True, None, 1.5, 2.5, self.unit), +# (False, None, 1.5, None, self.unit), +# (False, None, None, 2.5, self.unit), +# ): +# self.amount.unit = unit +# self.amount.value = v +# self.amount.min_value = minv +# self.amount.max_value = maxv +# if valid: +# self.amount.clean() +# else: +# self.assertRaises(ValidationError, self.amount.clean) + diff --git a/chemdb/urls.py b/chemdb/urls.py new file mode 100644 index 0000000..d54b99f --- /dev/null +++ b/chemdb/urls.py @@ -0,0 +1,45 @@ +# Copyright + +from django.conf import settings as _settings +from django.conf.urls import defaults as _defaults +from django.views import generic as _generic +from django.contrib import admin as _admin + +# If you're not serving the static content via some other server +from django.conf.urls.static import static as _static + +from . import models as _models +from . import views as _views + + +_admin.autodiscover() + + +urlpatterns = _defaults.patterns( + '', + _defaults.url(r'^$', _views.static_context_list_view_factory( + extra_context={'title': 'Chemical instances'}, + ).as_view( + queryset=_models.ChemicalInstance.objects.all(), + context_object_name='chemical_instances', + template_name='chemdb/chemical_instances.html'), + name='chemical_instances'), + _defaults.url(r'^chemical/(?P\d+)/$', _generic.DetailView.as_view( + model=_models.Chemical, template_name='chemdb/chemical.html'), + name='chemical'), + _defaults.url(r'^doc/(?P\w*)$', _views.doc_page, name='doc'), + + # Uncomment the admin/doc line below to enable admin documentation: + #_defaults.url( + # r'^admin/doc/', _defaults.include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + _defaults.url( + r'^admin/', _defaults.include(_admin.site.urls), name='admin'), + _defaults.url( + r'^grappelli/', _defaults.include('grappelli.urls'), name='admin'), + + _defaults.url(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', + kwargs={'url': _settings.STATIC_URL + 'chemdb.ico'}), + ) + _static(_settings.MEDIA_URL, document_root=_settings.MEDIA_ROOT) + diff --git a/chemdb/util.py b/chemdb/util.py new file mode 100644 index 0000000..7910dbf --- /dev/null +++ b/chemdb/util.py @@ -0,0 +1,74 @@ +# Copyright + +import os.path as _os_path +import re as _re + +from . import LOG as _LOG + + +CAS_REGEXP = _re.compile('\A[0-9]{2,}[-][0-9]{2}[-][0-9]\Z') + + +def valid_CASno(cas_string): + """Validate CAS numbers. + + Check `N..NN-NN-N` format, and the `checksum digit`_ for valid CAS + number structure. for + + .. math:: + N_n .. N_4 N_3 - N_2 N_1 - R + + The checksum digit is + + .. math:: + R = remainder([sum_{i=1}^n i N_i ] / 10 ) + + .. _checksum digit: + http://www.cas.org/expertise/cascontent/registry/checkdig.html + + >>> valid_CASno('107-07-3') + True + >>> valid_CASno('107-08-3') + False + >>> valid_CASno('107-083') + False + + Sometimes we don't have a CAS number, or a product will contain + secret, non-hazardous ingredients. Therefore we treat the strings + `na` and `+secret-non-hazardous` as valid CAS numbers. + + >>> valid_CASno('na') + True + >>> valid_CASno('+secret-non-hazardous') + True + """ + if cas_string in [None, 'na', '+secret-non-hazardous']: + return True + # check format, + # \A matches the start of the string + # \Z matches the end of the string + if CAS_REGEXP.match(cas_string) == None: + _LOG.debug("invalid CAS# format: '%s'".format(cas_string)) + return False + # generate check digit + casdigs = [int(d) for d in ''.join(cas_string.split('-'))] + sumdigs = casdigs[:-1] + sumdigs.reverse() + s = sum([(i+1)*d for i,d in enumerate(sumdigs)]) + check = s % 10 + if check == casdigs[-1]: + return True + _LOG.debug("invalid CAS# check: '{}' (expected {})".format( + cas_string, check)) + return False + +def sanitize_path(string): + for a,b in [(' ', '-'), ('..', '-')]: + string = string.replace(a, b) + return string + +def chemical_upload_to(instance, filename, storage=None): + basename,extension = _os_path.splitext(filename) + if extension not in ['.pdf', '.html', '.txt']: + raise ValueError(filename) + return 'msds/{}{}'.format(sanitize_path(instance.abbrev), extension) diff --git a/chemdb/views.py b/chemdb/views.py new file mode 100644 index 0000000..d6d32f7 --- /dev/null +++ b/chemdb/views.py @@ -0,0 +1,57 @@ +# Copyright + +from django import http as _http +from django import shortcuts as _shortcuts +from django import template as _template +from django.views import generic as _generic + +from . import doc as _doc +from . import forms as _forms +from . import models as _models + + +class StaticContextListView (_generic.ListView): + _name_counter = 0 + def get_context_data(self, **kwargs): + context = super(StaticContextListView, self).get_context_data(**kwargs) + context.update(self._extra_context) + return context + +def static_context_list_view_factory(extra_context): + class_name = 'StaticContextListView_{:d}'.format( + StaticContextListView._name_counter) + StaticContextListView._name_counter += 1 + class_bases = (StaticContextListView,) + class_dict = dict(StaticContextListView.__dict__) + new_class = type(class_name, class_bases, class_dict) + new_class._extra_context = extra_context + return new_class + +def doc_page(request, target=None): + context = _template.RequestContext(request) + if target in [None, '', 'index']: + context['locations_form'] = _forms.LocationsForm() + return _shortcuts.render_to_response('chemdb/doc.html', context) + elif target in ['inventory', 'door']: + pass + else: + raise _http.Http404() + locations = [_shortcuts.get_object_or_404(_models.Location, pk=int(id)) + for id in request.GET.getlist('location')] + if locations: + chemical_instances = [] + for location in locations: + chemical_instances.extend(location.chemical_instances.all()) + chemical_instances.sort() + else: + chemical_instances = _models.ChemicalInstance.objects.all() + dg = _doc.DocGen(chemical_instances=chemical_instances) + if target == 'inventory': + pdf = dg.inventory(title='Inventory') + else: + pdf = dg.door_warning() + response = _http.HttpResponse(mimetype='application/pdf') + response['Content-Disposition'] = 'attachment; filename={}.pdf'.format( + target) + response.write(pdf) + return response diff --git a/example/__init__.py b/example/__init__.py new file mode 100644 index 0000000..b98f164 --- /dev/null +++ b/example/__init__.py @@ -0,0 +1 @@ +# Copyright diff --git a/example/data/.gitignore b/example/data/.gitignore new file mode 100644 index 0000000..548d434 --- /dev/null +++ b/example/data/.gitignore @@ -0,0 +1,2 @@ +sqlite3.db +templates diff --git a/example/data/media/msds/Acetic_Acid.pdf b/example/data/media/msds/Acetic_Acid.pdf new file mode 100644 index 0000000..541fddc Binary files /dev/null and b/example/data/media/msds/Acetic_Acid.pdf differ diff --git a/example/inventory.db b/example/inventory.db deleted file mode 100644 index 778f98d..0000000 --- a/example/inventory.db +++ /dev/null @@ -1,9 +0,0 @@ -#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 (CH3COOH) 500 ml 64-19-7 A35-500 Fisher 7/5/2001 acidic liquids 3 2 1 -1 Ammonium Persulfate ((NH4)2S2O8) 100 g 7727-54-0 15523-012 Life Tech. 1/16/2000 oxidizing solids 3 0 3 OX -2 Glycerin Solution 50% (C3H5(OH)3) 500 ml 56-81-5,7647-01-0,7732-18-5 VW3410-2 VWR 1/17/2001 corrosive liquids 1 0 1 M T Labled H2 F0 R0 in NFPA diamond on the bottle. Corrosive. Avoid strong bases and oxidizers. -3 Glycine (NH2CH2COOH) 500 g 56-40-6 15527-013 Life Tech. 1/16/2000 non-hazardous 1 0 0 Avoid strong oxidizers -4 Phenol (C6H5OH) 100 g 108-95-2 P1037 Sigma 1/17/2006 reactive solids 3 2 0 M C T Avoid strong oxidizers (especially calcium hypochlorite), acids, and halogens. -5 Sodium Chloride (NaCl) 500 g 7647-14-5 JT4058-1 VWR/Fisher 10/22/2000 non-hazardous 1 0 0 -6 Sodium Hydroxide (NaOH) 50% (w/w) solution 500 ml 1310-73-2,7732-18-5 JT3727-1 VWR/JTBaker 10/22/2000 basic liquids 3 0 2 W diff --git a/example/manage.py b/example/manage.py new file mode 100644 index 0000000..3e4eedc --- /dev/null +++ b/example/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +import imp +try: + imp.find_module('settings') # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) + sys.exit(1) + +import settings + +if __name__ == "__main__": + execute_manager(settings) diff --git a/example/settings.py b/example/settings.py new file mode 100644 index 0000000..e9182ba --- /dev/null +++ b/example/settings.py @@ -0,0 +1,176 @@ +# Copyright + +# Django settings for web project. + +import os +import os.path + + +# This is where we'll put the non-Python portions of the app +# (e.g. templates) and store state information (e.g. the SQLite +# database). This should be an absolute path. +DEFAULT_DATA_DIRECTORY = os.path.abspath(os.path.join( + os.path.dirname(__file__), 'data')) +DATA_DIRECTORY = os.environ.get('DJANGO_CHEMDB_DATA', DEFAULT_DATA_DIRECTORY) + + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + ('W. Trevor King', 'wking@drexel.edu'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + #'ENGINE': 'django.db.backends.', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(DATA_DIRECTORY, 'sqlite3.db'), # Or path to database file if using sqlite3. + 'USER': '', # Not used with sqlite3. + 'PASSWORD': '', # Not used with sqlite3. + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = None # 'America/New_York' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale +USE_L10N = True + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/media/" +MEDIA_ROOT = os.path.join(DATA_DIRECTORY, 'media') + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://media.lawrence.com/media/", "http://example.com/media/" +MEDIA_URL = '/media/' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = os.path.join(DATA_DIRECTORY, 'static') + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + +# URL prefix for admin static files -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +#ADMIN_MEDIA_PREFIX = STATIC_URL + 'admin/' +ADMIN_MEDIA_PREFIX = STATIC_URL + 'grappelli/' + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +# 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'sywBDVfqIMc1CQ43bu4LF2Rj0dDfcMsBVtshxZ65OpolclSKvhbXq8HXVStGpx6R' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +# 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +ROOT_URLCONF = 'chemdb.urls' + +TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. + os.path.join(DATA_DIRECTORY, 'templates'), +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Uncomment the next line to enable the admin: + 'grappelli', + 'django.contrib.admin', + # Uncomment the next line to enable admin documentation: + # 'django.contrib.admindocs', + 'chemdb', +) + +# Required for render_table +# http://django-tables2.readthedocs.org/en/latest/#render-table +TEMPLATE_CONTEXT_PROCESSORS = ( + "django.contrib.auth.context_processors.auth", + "django.core.context_processors.debug", + "django.core.context_processors.i18n", + "django.core.context_processors.media", + "django.core.context_processors.static", + "django.contrib.messages.context_processors.messages", + "django.core.context_processors.request", +) + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} diff --git a/example/static/MSDS/0.html b/example/static/MSDS/0.html deleted file mode 100644 index 551e48c..0000000 --- a/example/static/MSDS/0.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - Acetic Acid MSDS. - - diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..fafc55c --- /dev/null +++ b/setup.py @@ -0,0 +1,61 @@ +# Copyright (C) 2012 W. Trevor King +# +# This file is part of update-copyright. +# +# update-copyright is free software: you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# update-copyright is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with update-copyright. If not, see +# . + +"Track chemical inventories and produce inventories and door warnings." + +import codecs as _codecs +from distutils.core import setup as _setup +import os.path as _os_path + +from chemdb import __version__ + + +_this_dir = _os_path.dirname(__file__) + +_setup( + name='chemdb', + version=__version__, + maintainer='W. Trevor King', + maintainer_email='wking@drexel.edu', + url='http://blog.tremily.us/posts/ChemDB/', + download_url='http://git.tremily.us/?p=chemdb.git;a=snapshot;h={};sf=tgz'.format(__version__), + license='GNU General Public License (GPL)', + platforms=['all'], + description=__doc__, + long_description=_codecs.open( + _os_path.join(_this_dir, 'README'), 'r', encoding='utf-8').read(), + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Framework :: Django', + 'Intended Audience :: Science/Research', + 'Operating System :: OS Independent', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Programming Language :: Python', + 'Topic :: Database', + 'Topic :: Scientific/Engineering :: Chemistry', + ], + packages=['chemdb', 'chemdb.templatetags'], + provides=['chemdb', 'chemdb.templatetags'], + package_data = { + 'chemdb': [ + 'fixtures/*', + 'static/chemdb/*', + 'templates/chemdb/*.html', + 'templates/chemdb/doc/*', + ]}, + ) diff --git a/template/doc/README b/template/doc/README deleted file mode 100644 index dedbeb9..0000000 --- a/template/doc/README +++ /dev/null @@ -1,6 +0,0 @@ -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 `nfpa_704.sty' package contains LaTeX code for the \firediamond macro. -For usage samples, see `nfpa_704.tex'. diff --git a/template/doc/contact.tex b/template/doc/contact.tex deleted file mode 100644 index 8c34a8a..0000000 --- a/template/doc/contact.tex +++ /dev/null @@ -1,5 +0,0 @@ -\begin{tabular}{l} - Professor Guoliang Yang\\ - 215-895-6669 \\ - Disque Hall 926 \\ -\end{tabular} diff --git a/template/doc/inventory_template.tex b/template/doc/inventory_template.tex deleted file mode 100644 index e40f047..0000000 --- a/template/doc/inventory_template.tex +++ /dev/null @@ -1,64 +0,0 @@ -% Copyright (C) 2008-2010 W. Trevor King -% -% This file is part of ChemDB. -% -% ChemDB is free software: you can redistribute it and/or modify it -% under the terms of the GNU General Public License as published by the -% Free Software Foundation, either version 3 of the License, or (at your -% option) any later version. -% -% ChemDB is distributed in the hope that it will be useful, -% but WITHOUT ANY WARRANTY; without even the implied warranty of -% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -% GNU General Public License for more details. -% -% You should have received a copy of the GNU General Public License -% along with ChemDB. If not, see . - -% Chemical inventory as per -% http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7 - -% 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 - -\usepackage{nfpa_704} % \firediamond - -% 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 \input{inventory_title}}\\ -\contfont -Generated \today\ by ChemDB.\\ -\vskip 10pt -\end{center} - -\footnotesize -\input{inventory_data} - -\end{document} diff --git a/template/web/base.html b/template/web/base.html deleted file mode 100644 index dfd2abd..0000000 --- a/template/web/base.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - ChemDB - - - - -
- -
-

{% block page_title %}{% endblock %}

- {% block content %}{% endblock %} -
- -
- - diff --git a/template/web/docs.html b/template/web/docs.html deleted file mode 100644 index 6bf5731..0000000 --- a/template/web/docs.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - Documents -{% endblock %} - -{% block content %} - -

- For door warnings for subsections of the whole room, please give a - location regexp in the form below. For example: ".*liquids" or - "refrigerator". -

-
- - - -
Location regexp:
- -
-{% endblock %} diff --git a/template/web/edit-record.html b/template/web/edit-record.html deleted file mode 100644 index 6e5a5b7..0000000 --- a/template/web/edit-record.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - Edit {{ record['ID'] }} -{% endblock %} - -{% block content %} -

Editing: {{ record['ID'] }}

- -
- - - - - {% for field in fields %} - - - - - {% endfor %} - {% if MSDSs %} - - - - - - - - - - - - - {% endif %} -
FieldValue
-
- Upload - Share -
-
- -
- -
-{% endblock %} diff --git a/template/web/index.html b/template/web/index.html deleted file mode 100644 index 5bff6ae..0000000 --- a/template/web/index.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - Index -{% endblock %} - -{% block content %} -

See the - rules for more information. See - the docs page to generate required - documents. -

-

Add entry

- {% if records %} - - - {% for field in fields %} - - {% endfor %} - - {% for record in records %} - - {% for field in fields %} - {% if field == 'ID' %} - - {% else %} - - {% endif %} - {% endfor %} - - {% endfor %} -
{{ field }}
- - {{ record[field] }} - - {{ record[field] }}
- {% endif %} -

Add entry

-{% endblock %} diff --git a/template/web/record.html b/template/web/record.html deleted file mode 100644 index 2be4e64..0000000 --- a/template/web/record.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - {{ record['ID'] }} -{% endblock %} - -{% block content %} -

{{ record['ID'] }}

- - - - - {% for field in fields %} - - - - - {% endfor %} - - - - -
FieldValue
{{ escape(long_fields[field]) }}{{ escape(record[field]) }}
{{ MSDS }}
-

- Edit record

-{% endblock %}