Convert chemdb from CherryPy to a Django app (easier database management).
authorW. Trevor King <wking@drexel.edu>
Sun, 1 Apr 2012 16:24:29 +0000 (12:24 -0400)
committerW. Trevor King <wking@drexel.edu>
Sun, 1 Apr 2012 16:24:29 +0000 (12:24 -0400)
47 files changed:
.gitignore
README
chemdb/__init__.py
chemdb/admin.py [new file with mode: 0644]
chemdb/chemdb.py [deleted file]
chemdb/daemon.py [deleted file]
chemdb/db/__init__.py [deleted file]
chemdb/db/text.py [deleted file]
chemdb/doc.py [new file with mode: 0644]
chemdb/fixtures/example_data.yaml [new file with mode: 0644]
chemdb/fixtures/initial_data.yaml [new file with mode: 0644]
chemdb/forms.py [new file with mode: 0644]
chemdb/models.py [new file with mode: 0644]
chemdb/server.py [deleted file]
chemdb/static/chemdb/style.css [new file with mode: 0644]
chemdb/templates/chemdb/base.html [new file with mode: 0644]
chemdb/templates/chemdb/chemical.html [new file with mode: 0644]
chemdb/templates/chemdb/chemical_instances.html [new file with mode: 0644]
chemdb/templates/chemdb/doc.html [new file with mode: 0644]
chemdb/templates/chemdb/doc/Makefile [moved from template/doc/Makefile with 100% similarity]
chemdb/templates/chemdb/doc/base.tex [moved from template/doc/door_template.tex with 67% similarity]
chemdb/templates/chemdb/doc/door.tex [new file with mode: 0644]
chemdb/templates/chemdb/doc/inventory.tex [new file with mode: 0644]
chemdb/templates/chemdb/doc/nfpa_704.sty [moved from template/doc/nfpa_704.sty with 99% similarity]
chemdb/templates/chemdb/doc/nfpa_704.tex [moved from template/doc/nfpa_704.tex with 97% similarity]
chemdb/templatetags/__init__.py [new file with mode: 0644]
chemdb/templatetags/latex.py [new file with mode: 0644]
chemdb/tests.py [new file with mode: 0644]
chemdb/urls.py [new file with mode: 0644]
chemdb/util.py [new file with mode: 0644]
chemdb/views.py [new file with mode: 0644]
example/__init__.py [new file with mode: 0644]
example/data/.gitignore [new file with mode: 0644]
example/data/media/msds/Acetic_Acid.pdf [new file with mode: 0644]
example/inventory.db [deleted file]
example/manage.py [new file with mode: 0644]
example/settings.py [new file with mode: 0644]
example/static/MSDS/0.html [deleted file]
setup.py [new file with mode: 0644]
template/doc/README [deleted file]
template/doc/contact.tex [deleted file]
template/doc/inventory_template.tex [deleted file]
template/web/base.html [deleted file]
template/web/docs.html [deleted file]
template/web/edit-record.html [deleted file]
template/web/index.html [deleted file]
template/web/record.html [deleted file]

index 7b798e3d135b31314b4cb5e9f293acf06caf5465..27ffc2f17fb6e4c4c082d942fe8fb9d2a676ff77 100644 (file)
@@ -1,3 +1,2 @@
 *.pyc
-chem_web.log*
-backup/
+build
diff --git a/README b/README
index fe8231531bc420ba3fc15035f85ca6b1302498d1..b772f7e5c230d0f6519dde10bf3c31f7c8e68325 100644 (file)
--- a/README
+++ b/README
-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
index dd5bf605d22cb8a0a5250ca1b25f78a1bcdc4654..f354e18faa49e7a9b3de4d6d081848b1a5dcad87 100644 (file)
@@ -1,16 +1,12 @@
-# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
-#
-# 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 <http://www.gnu.org/licenses/>.
+# 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 (file)
index 0000000..a387776
--- /dev/null
@@ -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 (file)
index 5f13f5f..0000000
+++ /dev/null
@@ -1,406 +0,0 @@
-# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""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 (file)
index 16a5831..0000000
+++ /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 (file)
index dd5bf60..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
-#
-# 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 <http://www.gnu.org/licenses/>.
diff --git a/chemdb/db/text.py b/chemdb/db/text.py
deleted file mode 100644 (file)
index 5536b61..0000000
+++ /dev/null
@@ -1,545 +0,0 @@
-# Copyright (C) 2008-2010 W. Trevor King <wking@drexel.edu>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""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 (file)
index 0000000..477c121
--- /dev/null
@@ -0,0 +1,130 @@
+# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""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 (file)
index 0000000..3f913ab
--- /dev/null
@@ -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 (file)
index 0000000..811bb66
--- /dev/null
@@ -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 (file)
index 0000000..07f6baa
--- /dev/null
@@ -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 (file)
index 0000000..b0087cc
--- /dev/null
@@ -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 (file)
index 2db25a3..0000000
+++ /dev/null
@@ -1,376 +0,0 @@
-# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
-#
-# 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 <http://www.gnu.org/licenses/>.
-
-"""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'] = (
-                    '<a href="%s">%s</a>'
-                    % (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 = '<a href="%s">%s</a>' % (
-                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 (file)
index 0000000..30a24af
--- /dev/null
@@ -0,0 +1,110 @@
+/* <body> */
+
+body {
+  background: #eee;
+}
+
+.fullclear {
+  width:100%;
+  height:1px;
+  margin:0;
+  padding:0;
+  clear:both;
+}
+
+/* </body> */
+
+/* <div id="navigation"> */
+
+#navigation {
+  margin: 0;
+  padding: 4px 0 0 0;
+}
+
+#navigation ul {
+  list-style: none;
+  margin: 0;
+  padding: 0 0 0 12px;
+}
+
+#navigation ul li {
+  margin: 0;
+       margin-left: 2em;
+  padding: 0;
+  float: left;
+}
+
+#navigation ul li a {
+  display: block;
+  font-weight: bold;
+  text-decoration: none;
+  margin: 0;
+  padding: 5px 5px;
+  background-color: #eee;
+}
+
+#navigation ul li a:hover {
+  background-color: #ccc;
+}
+
+#navigation ul li.CurrentSection a {
+  background-color: #FFF;
+}
+
+#navigation ul li.CurrentSection a:hover {
+  background-color: #FFF;
+}
+
+#subnavigation ul li.CurrentSection a {
+  border-width: 0 0 1px 0;
+}
+
+/* </div id="navigation"> */
+
+/* <div id="content"> */
+
+#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;
+}
+
+/* </div id="content"> */
diff --git a/chemdb/templates/chemdb/base.html b/chemdb/templates/chemdb/base.html
new file mode 100644 (file)
index 0000000..945c4ea
--- /dev/null
@@ -0,0 +1,29 @@
+<html>
+<head>
+       <title>{% block title %}ChemDB{% endblock %}</title>
+       <link rel="stylesheet" href="{{ STATIC_URL }}chemdb/style.css" />
+       <link rel="shortcut icon" href="{{ STATIC_URL }}chemdb/chemdb.ico" />
+</head>
+
+<body>
+       <div id="navigation">
+               {% block navigation %}
+               <ul>
+                       <li><a href="/">Chemicals</a></li>
+                       <li><a href="/doc/">Document generation</a></li>
+                       <li><a href="/admin/">Admin</a></li>
+               </ul>
+               {% endblock %}
+       </div>
+       <div class="fullclear"></div>
+
+       <div id="content">
+               {% block content %}{% endblock %}
+       </div>
+       <div class="fullclear"></div>
+
+       <div id="footer">
+               <p>powered by <a href="http://blog.tremily.us/posts/ChemDB/">chemdb</a></p>
+       </div>
+</body>
+</html>
diff --git a/chemdb/templates/chemdb/chemical.html b/chemdb/templates/chemdb/chemical.html
new file mode 100644 (file)
index 0000000..fc15324
--- /dev/null
@@ -0,0 +1,31 @@
+{% extends "chemdb/base.html" %}
+
+{% block content %}
+<h1>{{ chemical.name }}</h1>
+
+{% block metadata %}
+<table class="metadata">
+       <tr><td>Abbreviation</td><td>{{ chemical.abbrev }}</td></tr>
+       <tr><td>CAS#</td><td>{{ chemical.cas_numbers }}</td></tr>
+       <tr><td>NFPA health rating</td><td>{{ chemical.health }}</td></tr>
+       <tr><td>NFPA fire rating</td><td>{{ chemical.fire }}</td></tr>
+       <tr><td>NFPA reactivity rating</td><td>{{ chemical.reactivity }}</td></tr>
+       <tr><td>NFPA special rating</td><td>{{ chemical.specials }}</td></tr>
+       <tr><td>Mutagen</td><td>{{ chemical.mutagen }}</td></tr>
+       <tr><td>Carcinogen</td><td>{{ chemical.carcinogen }}</td></tr>
+       <tr><td>Teratogen</td><td>{{ chemical.teratogen }}</td></tr>
+{% if chemical.msds %}
+       <tr><td>MSDS</td><td><a href="{{ chemical.msds.url }}">
+               {{ chemical.msds }}</a></td></tr>
+{% endif %}
+</table>
+{% endblock %}
+
+{% block note %}
+{% if chemical.note %}
+<div class="note">
+{{ chemical.note|safe }}
+</div>
+{% endif %}
+{% endblock %}
+{% endblock %}
diff --git a/chemdb/templates/chemdb/chemical_instances.html b/chemdb/templates/chemdb/chemical_instances.html
new file mode 100644 (file)
index 0000000..a24f0b6
--- /dev/null
@@ -0,0 +1,36 @@
+{% extends "chemdb/base.html" %}
+
+{% block title %}
+{{ title }}
+{% endblock %}
+
+{% block content %}
+<h1>{{ title }}</h1>
+{% if chemical_instances %}
+       <table>
+               <thead>
+                       <tr>
+                               <th>Chemical</th><th>CAS#</th><th>Location</th><th>Amount</th>
+                               <th>Vendor</th><th>Catalog#</th><th>Recieved</th><th>Disposed</th>
+                       </tr>
+               </thead>
+               <tbody>
+       {% for chemical_instance in chemical_instances %}
+                       <tr>
+                               <td><a href="{% url chemical chemical_instance.chemical.id %}">
+                                       {{ chemical_instance.chemical.abbrev }}</a></td>
+                               <td>{{ chemical_instance.chemical.cas_numbers }}</td>
+                               <td>{{ chemical_instance.location }}</td>
+                               <td>{{ chemical_instance.amount }}</td>
+                               <td>{{ chemical_instance.vendor }}</td>
+                               <td>{{ chemical_instance.catalog }}</td>
+                               <td>{{ chemical_instance.recieved }}</td>
+                               <td>{{ chemical_instance.disposed|default:'' }}</td>
+                       </tr>
+       {% endfor %}
+               </tbody>
+       </table>
+{% else %}
+       <p>No chemicals are available.</p>
+{% endif %}
+{% endblock %}
diff --git a/chemdb/templates/chemdb/doc.html b/chemdb/templates/chemdb/doc.html
new file mode 100644 (file)
index 0000000..75765a8
--- /dev/null
@@ -0,0 +1,31 @@
+{% extends "chemdb/base.html" %}
+
+{% block title %}
+  Documents
+{% endblock %}
+
+{% block content %}
+<ul>
+  <li>
+    <a href="{% url doc target="inventory" %}">Inventory</a>
+    in accordance with the 
+    <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">
+      Chemical Hygiene Plan Section E-7</a>.
+  </li>
+  <li>
+    <a href="{% url doc target="door" %}">Door warning</a>
+  in accordance with the Chemical Hygiene Plan Sections
+    <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">E-7</a>
+    and 
+    <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>.
+  </li>
+</ul>
+<p>
+  For door warnings for a particular location or locations, use the
+  form below:
+</p>
+<form action="{% url doc target="inventory" %}" method="get">
+       {{ locations_form }}
+       <input type="submit" value="Submit" />
+</form>
+{% endblock %}
similarity index 67%
rename from template/doc/door_template.tex
rename to chemdb/templates/chemdb/doc/base.tex
index 45e4a25847af35e6637238fb96ee58df3a7c6b7c..614f9bbc27ce7b979100b8744f4f6b866e6ef2ba 100644 (file)
 % You should have received a copy of the GNU General Public License
 % along with ChemDB.  If not, see <http://www.gnu.org/licenses/>.
 
-% 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 (file)
index 0000000..1908f75
--- /dev/null
@@ -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 (file)
index 0000000..4c724ef
--- /dev/null
@@ -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 %}
similarity index 99%
rename from template/doc/nfpa_704.sty
rename to chemdb/templates/chemdb/doc/nfpa_704.sty
index 3b05b2805d1aad908777cbbd104ae10b3f354b3c..b448f5cc98ac690d8f5a5475cc584bcb59387e79 100644 (file)
@@ -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 wouldnt 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
similarity index 97%
rename from template/doc/nfpa_704.tex
rename to chemdb/templates/chemdb/doc/nfpa_704.tex
index f613f04dee34fe87e1a9e5180e115d847eac8f7e..fa12ae284e7f1ab76736d4fb2d651a606989643e 100644 (file)
@@ -1,3 +1,5 @@
+% Test the nfpa_704 package
+%
 % Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
 %
 % This file is part of ChemDB.
diff --git a/chemdb/templatetags/__init__.py b/chemdb/templatetags/__init__.py
new file mode 100644 (file)
index 0000000..a59fb1a
--- /dev/null
@@ -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 (file)
index 0000000..baaf5b3
--- /dev/null
@@ -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 (file)
index 0000000..b143291
--- /dev/null
@@ -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 (file)
index 0000000..d54b99f
--- /dev/null
@@ -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<pk>\d+)/$', _generic.DetailView.as_view(
+            model=_models.Chemical, template_name='chemdb/chemical.html'),
+        name='chemical'),
+    _defaults.url(r'^doc/(?P<target>\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 (file)
index 0000000..7910dbf
--- /dev/null
@@ -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 (file)
index 0000000..d6d32f7
--- /dev/null
@@ -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 (file)
index 0000000..b98f164
--- /dev/null
@@ -0,0 +1 @@
+# Copyright
diff --git a/example/data/.gitignore b/example/data/.gitignore
new file mode 100644 (file)
index 0000000..548d434
--- /dev/null
@@ -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 (file)
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 (file)
index 778f98d..0000000
+++ /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 (file)
index 0000000..3e4eedc
--- /dev/null
@@ -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 (file)
index 0000000..e9182ba
--- /dev/null
@@ -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 (file)
index 551e48c..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<html>
-  <head>
-  </head>
-  <body>
-    Acetic Acid MSDS.
-  </body>
-</html>
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
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
+# <http://www.gnu.org/licenses/>.
+
+"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 (file)
index dedbeb9..0000000
+++ /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 (file)
index 8c34a8a..0000000
+++ /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 (file)
index e40f047..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-% Copyright (C) 2008-2010 W. Trevor King <wking@drexel.edu>
-%
-% 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 <http://www.gnu.org/licenses/>.
-
-% 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 (file)
index dfd2abd..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
-         "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"
-         xml:lang="en">
-
-<html>
-  <head>
-    <title>ChemDB</title>
-<!--
-    <link rel="stylesheet" type="text/css" media="screen"
-          href="/static/style.css"/>
--->
-  </head>
-
-  <body>
-    <div id="main-pane">
-      <div id="header" class="inside-main-pane">
-        <h1><a href="./">ChemDB</a></h1>
-      </div>
-      <div id="content-pane" class="inside-main-pane">
-        <h1>{% block page_title %}{% endblock %}</h1>
-        {% block content %}{% endblock %}
-      </div>
-      <div id="footer" class="inside-main-pane">
-        <p>
-         Powered by
-          <a href="http://www.physics.drexel.edu/">Trevor King's</a>
-         <a href="http://www.physics.drexel.edu/~wking/code/git/git.php?p=chemdb.git">ChemDB</a>.
-          Built using <a href="http://cherrypy.org/">CherryPy</a>
-          and <a href="http://jinja.pocoo.org/2/">Jinja2</a>.
-        </p>
-      </div>
-    </div>
-  </body>
-</html>
diff --git a/template/web/docs.html b/template/web/docs.html
deleted file mode 100644 (file)
index 6bf5731..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-{% extends "base.html" %}
-
-{% block page_title %}
-  Documents
-{% endblock %}
-
-{% block content %}
-<ul>
-  <li>
-    <a href="/docs/inventory.pdf">Inventory</a>
-    in accordance with the 
-    <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">
-      Chemical Hygiene Plan Section E-7</a>.
-  </li>
-  <li>
-    <a href="/docs/door_warning.pdf">Door warning</a>
-  in accordance with the Chemical Hygiene Plan Sections
-    <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe7">E-7</a>
-    and 
-    <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm#chpe10">E-10</a>.
-  </li>
-</ul>
-<p>
-  For door warnings for subsections of the whole room, please give a
-  location regexp in the form below.  For example: ".*liquids" or
-  "refrigerator".
-</p>
-<form action="door_warning.pdf" method="get">
-  <table>
-    <tr><td>Location regexp</td><td>:</td>
-      <td><input type="text" size="50" id="location" name="location"></td></tr>
-  </table>
-  <input type="submit" value="Submit" />
-</form>
-{% endblock %}
diff --git a/template/web/edit-record.html b/template/web/edit-record.html
deleted file mode 100644 (file)
index 6e5a5b7..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-{% extends "base.html" %}
-
-{% block page_title %}
-    Edit {{ record['ID'] }}
-{% endblock %}
-
-{% block content %}
-  <h1>Editing: {{ record['ID'] }}</h1>
-  <!-- the form encoding type 'enctype="multipart/form-data">
-  is only required because of the MSDS file-upload field. -->
-  <form action="" method="post" enctype="multipart/form-data">
-    <table>
-      <tr>
-       <th>Field</td><td>Value</td>
-      </tr>
-      {% for field in fields %}
-        <tr>
-         <td><label for="{{ escape(field, quote=True) }}">
-             {{ escape(long_fields[field]) }}</label></td>
-         <td><input type="text" size="{{ entry_width }}"
-                    id="{{ escape(field, quote=True) }}"
-                    name="{{ escape(field, quote=True) }}"
-                    value="{{ escape(record[field], quote=True) }}">
-         </td>
-       </tr>
-      {% endfor %}
-      {% if MSDSs %}
-        <tr>
-         <td><label for="MSDS source">MSDS source</label></td>
-          <td>
-           <input type="radio" name="MSDS source" value="upload"
-                  checkked="checked">Upload
-            <input type="radio" name="MSDS source" value="share">Share
-         </td>
-       </tr>
-       <tr>
-          <td><label for="MSDS upload">Upload file</label></td>
-          <td><input type="file" size="{{ entry_width }}"
-                    id="MSDS upload" name="MSDS upload"
-                    accept="text/plain,text/html,application/pdf" value="">
-         </td>
-       </tr>
-       <tr>
-          <td><label for="MSDS share">Use an already uploaded MSDS</label></td>
-         <td>
-           <select size="1" id="MSDS share" name="MSDS share">
-             {% for MSDS in MSDSs %}
-               <option value="{{ MSDS['ID'] }}">
-                 {{ MSDS['ID'] }}: {{ escape(MSDS['Name']) }}</option>
-             {% endfor %}
-            </select>
-         </td>
-       </tr>
-      {% endif %}
-    </table>
-    <input type="submit" value="Submit" />
-  </form>
-{% endblock %}
diff --git a/template/web/index.html b/template/web/index.html
deleted file mode 100644 (file)
index 5bff6ae..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-{% extends "base.html" %}
-
-{% block page_title %}
-  Index
-{% endblock %}
-
-{% block content %}
-  <p>See <a href="http://www.drexelsafetyandhealth.com/lab-chem.htm">the
-     rules</a> for more information.  See
-     the <a href="/docs/">docs</a> page to generate required
-     documents.
-  </p>
-  <p><a href="edit">Add entry</a></p>
-  {% if records %}
-    <table id="record-list">
-      <tr>
-        {% for field in fields %}
-        <td>{{ field }}</td>
-        {% endfor %}
-      </tr>
-      {% for record in records %}
-      <tr>
-        {% for field in fields %}
-          {% if field == 'ID' %}
-            <td>
-              <a href="?id={{ record['ID'] }}">
-                {{ record[field] }}
-              </a>
-            </td>
-          {% else %}
-            <td>{{ record[field] }}</td>
-          {% endif %}
-        {% endfor %}
-      </tr>
-      {% endfor %}
-    </table>
-  {% endif %}
-  <p><a href="edit">Add entry</a></p>
-{% endblock %}
diff --git a/template/web/record.html b/template/web/record.html
deleted file mode 100644 (file)
index 2be4e64..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-{% extends "base.html" %}
-
-{% block page_title %}
-    {{ record['ID'] }}
-{% endblock %}
-
-{% block content %}
-  <h1>{{ record['ID'] }}</h1>
-  <table>
-    <tr>
-       <th>Field</td><td>Value</td>
-    </tr>
-    {% for field in fields %}
-      <tr>
-       <td>{{ escape(long_fields[field]) }}</td>
-       <td>{{ escape(record[field]) }}</td>
-      </tr>
-    {% endfor %}
-      <tr>
-       <td><label for="MSDS">MSDS</label></td>
-        <td>{{ MSDS }}</td>
-      </tr>
-  </table>
-  <p><a href="edit?id={{ record['ID'] }}">
-      Edit record</a></p>
-{% endblock %}