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

{{ chemical.name }}

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

{{ title }}

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

No chemicals are available.

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

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

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

{% block page_title %}{% endblock %}

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

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

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

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

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

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

-

Add entry

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

Add entry

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

{{ record['ID'] }}

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

- Edit record

-{% endblock %} -- 2.26.2