aadc551bc506792907eecc041d5236d81fe5e1f3
[chemdb.git] / chemdb / server.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of ChemDB.
4 #
5 # ChemDB is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the
7 # Free Software Foundation, either version 3 of the License, or (at your
8 # option) any later version.
9 #
10 # ChemDB is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with ChemDB.  If not, see <http://www.gnu.org/licenses/>.
17
18 """Chemical inventory web interface.
19 """
20
21 from __future__ import with_statement
22
23 from cgi import escape
24 import os
25 import os.path
26 import re
27 import sys, logging
28
29 import cherrypy
30 from jinja2 import Environment, FileSystemLoader
31
32 from .daemon import Daemon
33 from .db.text import TextDB
34 from .chemdb import valid_CASno, MSDSManager, DocGen
35
36
37 __version__ = "0.4"
38
39
40 class Server (object):
41     """ChemDB web interface."""
42     def __init__(self, db=None, MSDS_manager=None, docs=None,
43                  template_root='template', static_dir='static',
44                  static_url='static'):
45         self.db = db
46         self.MSDS_manager = MSDS_manager
47         self.docs = docs
48         self.static_dir = static_dir
49         self.static_url = static_url
50         self.env = Environment(loader=FileSystemLoader(template_root))
51         self.width_max = {'CAS#':12}
52         self.entry_width = 50  # display width of entry fields
53         self.raw_fields = ['ID','Name']
54         self.MSDS_content_types = [
55             'text/plain', 'text/html', 'application/pdf']
56
57     @cherrypy.expose
58     def index(self, id=None):
59         """Chemical index page.
60         """
61         self.db._refresh()
62         if id == None:
63             return self._index_page()
64         else:
65             return self._view_page(id=id)
66
67     def _index_page(self):
68         records = self.db.records()
69         for record in records:
70             for field in self.db.field_list():
71                 rstring = record[field]
72                 if field in self.width_max: # truncate if width is regulated...
73                     w = self.width_max[field]
74                     if len(rstring) > w:    # ... and full string is too long.
75                         rstring = "%s..." % rstring[:w-3]
76                 # if the field isn't raw, protect special chars
77                 if not field in self.raw_fields:
78                     rstring = escape(rstring)
79             if self.MSDS_manager.has_MSDS(record['db_id']):
80                 # link the id to the MSDS
81                 record['Name'] = (
82                     '<a href="%s">%s</a>'
83                     % (self._MSDS_path(record['db_id']), record['Name']))
84         template = self.env.get_template('index.html')
85         return template.render(fields=self.db.field_list(), records=records)
86
87     def _view_page(self, id):
88         db_id = int(id)
89         record = self.db.record(db_id)
90         MSDS_link = None
91         if self.MSDS_manager.has_MSDS(db_id):
92             path = self._MSDS_path(db_id)
93             MSDS_link = '<a href="%s">%s</a>' % (
94                 path, os.path.basename(path))
95         template = self.env.get_template('record.html')
96         return template.render(fields=self.db.field_list(),
97                                long_fields=self.db.long_fields(),
98                                record=record,
99                                MSDS=MSDS_link,
100                                escape=escape)
101
102     def _MSDS_path(self, db_id):
103         path = self.MSDS_manager.get_MSDS_path(db_id)
104         path = os.path.relpath(path, self.static_dir)
105         path = os.path.join(self.static_url, path)
106         return path
107
108     @cherrypy.expose
109     def edit(self, id=None, **kwargs):
110         """Render the edit-record.html template for the specified record.
111         """
112         self.db._refresh()
113         if kwargs:
114             self._update_record(id=id, **kwargs)
115             raise cherrypy.HTTPRedirect(u'.?id=%s' % id, status=303)
116         MSDSs = self.MSDS_manager.get_all(simlinks=False)
117         if id in [None, '-1']:
118             record = dict([(field,'') for field in self.db.field_list()])
119             record['db_id'] = '-1'
120             record['ID'] = str(record['db_id'])
121         else:
122             db_id = int(id)
123             record = self.db.record(db_id)
124             if self.MSDS_manager.has_MSDS(db_id):
125                 MSDSs = None  # only ask for an MSDS if we still need one
126         template = self.env.get_template('edit-record.html')
127         return template.render(fields=self.db.field_list(),
128                                long_fields=self.db.long_fields(),
129                                record=record,
130                                MSDSs=MSDSs,
131                                entry_width=self.entry_width,
132                                escape=escape)
133
134     def _update_record(self, id=None, **kwargs):
135         """Update a record with form input.
136         """
137         self.db._refresh()
138         if id in [None, '-1']:
139             record = self.db.new_record()
140             logging.info('new record %s' % record['db_id'])
141         else:
142             db_id = int(id)
143             record = self.db.record(db_id)
144         update = False
145         for field in self.db.field_list():
146             if kwargs[field] != record[field]:
147                 # TODO: add validation!
148                 update = True
149                 record[field] = kwargs[field]
150         if kwargs.get('MSDS source', None) == 'upload':
151             # Handle any MSDS file actions
152             f = kwargs['MSDS upload']
153             contents = f.file.read()
154             if len(contents) > 0 and f.type in self.MSDS_content_types:
155                 self.MSDS_manager.save(int(record['ID']), contents, f.type)
156         elif kwargs.get('MSDS source', None) == 'share':
157             logging.info('linking MSDS %d to %d'
158                          % (int(record['ID']),
159                             int(kwargs['MSDS share'])) )
160             self.MSDS_manager.link(int(record['ID']),
161                                    int(kwargs['MSDS share']))
162         if update:
163             self.db.set_record(record['db_id'], record, backup=True)
164
165
166 class Docs (object):
167     "Generate and serve assorted official documents."
168     def __init__(self, db=None, docgen=None, template_root='template'):
169         self.db = db
170         self.docgen = docgen
171         self.namewidth = 40
172         self.env = Environment(loader=FileSystemLoader(template_root))
173
174     @cherrypy.expose
175     def index(self):
176         """List the available documents.
177         """
178         template = self.env.get_template('docs.html')
179         return template.render()
180
181     @cherrypy.expose
182     def inventory_pdf(self):
183         self.db._refresh()
184         path = self.docgen.inventory(namewidth=self.namewidth)
185         cherrypy.response.headers['Content-Type'] = 'application/pdf'
186         return file(path, 'rb').read()
187
188     @cherrypy.expose
189     def door_warning_pdf(self, location=None):
190         self.db._refresh()
191         if location == None:
192             valid = lambda r: r['Disposed'] == ''
193         else:
194             regexp = re.compile(location, re.I) # Case insensitive
195             valid = lambda r: r['Disposed'] == '' and regexp.match(r['Location'])
196         path = self.docgen.door_warning(valid_record=valid)
197         cherrypy.response.headers['Content-Type'] = 'application/pdf'
198         return file(path, 'rb').read()
199
200
201 class ServerDaemon (Daemon):
202     def __init__(self):
203         super(ServerDaemon, self).__init__()
204         self.gid = None
205         self.uid = None
206         self.pidfile = './chem_web.pid'
207         self.logfile = './chem_web.log'
208         self.loglevel = 'INFO'
209
210     def run(self):
211         if cherrypy.__version__.startswith('3.'):
212             cherrypy.quickstart(root=self.server, config=self.app_config)
213
214 #        if self.pkey_file == None or self.cert_file == None:
215 #            logging.info("http://%s:%d/" % web.validip(self.ip_address))
216 #        else:
217 #            logging.info("https://%s:%d/" % web.validip(self.ip_address))
218 #
219 #        webpy_func = web.webpyfunc(urls, globals(), False)
220 #        wsgi_func = web.wsgifunc(webpy_func)
221 #        web.httpserver.runsimple(wsgi_func,
222 #                                 web.validip(self.ip_address),
223 #                                 ssl_private_key_filename=self.pkey_file,
224 #                                 ssl_certificate_filename=self.cert_file)
225
226     def read_basic_config(self):
227         pass
228
229     def parse_options(self):
230         from optparse import OptionParser
231             
232         usage_string = ('%prog [options]\n'
233                         '\n'
234                         '2008, W. Trevor King.\n')
235         version_string = '%%prog %s' % __version__
236         p = OptionParser(usage=usage_string, version=version_string)
237
238         # Non-server options
239         p.add_option('-t', '--test', dest='test', default=False,
240                      action='store_true', help='Run internal tests and exit.')
241
242         # Daemon options
243         p.add_option('--start', dest='action',
244                      action='store_const', const='start', default='start',
245                      help='Start the daemon (the default action).')
246         p.add_option('--stop', dest='action',
247                      action='store_const', const='stop', default='start',
248                      help='Stop the daemon.')
249         p.add_option('-n', '--nodaemon', dest='daemonize',
250                      action='store_false', default=True,
251                      help='Run in the foreground.')
252
253         # Server options
254         p.add_option('-a', '--address', dest='address', default='127.0.0.1',
255                      metavar='ADDR',
256                      help='Address that the server will bind to (%default).')
257         p.add_option('-p', '--port', dest='port', default='8080',
258                      metavar='PORT',
259                      help='Port that the server will listen on (%default).')
260         p.add_option('-s', '--secure', dest="secure",
261                      help="Run in secure (HTTPS) mode.",
262                      type='string', metavar="PKEY_FILE:CERT_FILE")
263         p.add_option('-v', '--verbose', dest="verbose", action="store_true",
264                      help="Print lots of debugging information.",
265                      default=False)
266         p.add_option('--static', dest='static', metavar='PATH',
267                      help="Path to the directory of static files (%default).",
268                      default=os.path.join('example', 'static'))
269         p.add_option('--template', dest='template', metavar='PATH',
270                      help="Path to the directory of template files (%default).",
271                      default=os.path.join('template', 'web'))
272         p.add_option('--doc', dest='doc', metavar='PATH',
273                      help="Path to the directory of document generation files (%default).",
274                      default=os.path.join('template', 'doc'))
275         p.add_option('--htaccess', dest='htaccess', metavar='FILE',
276                      help="Path to the htaccess file (%default).",
277                      default='.htaccess')
278
279         # Database options
280         p.add_option('--database', dest='database', metavar='FILE',
281                      help="Path to the database file (%default).",
282                      default=os.path.join('example', 'inventory.db'))
283
284         self.options, args = p.parse_args()
285         p.destroy()
286
287         if self.options.test == True:
288             _test()
289             sys.exit(0)
290
291         if self.options.verbose:
292             self.loglevel = 'DEBUG'
293
294         # get self.options for httpserver
295         if self.options.secure != None:
296             split = self.options.secure.split(':', 1)
297             assert len(split) == 2, (
298                 "Invalid secure argument '%s'" % self.options.secure)
299             self.pkey_file = split[0]
300             self.cert_file = split[1]
301
302         # HACK! to ensure we *always* get utf-8 output
303         #reload(sys)
304         #sys.setdefaultencoding('utf-8')
305
306         dirname,filename = os.path.split(
307             os.path.abspath(self.options.database))
308         static_dir = os.path.abspath(self.options.static)
309         MSDS_dir = os.path.join(static_dir, 'MSDS')
310         template_dir = os.path.abspath(self.options.template)
311         doc_dir = os.path.abspath(self.options.doc)        
312         db = TextDB(filename=filename, current_dir=dirname)
313         MSDS_manager = MSDSManager(db=db, dir=MSDS_dir)
314         docgen = DocGen(db=db, doc_root=doc_dir)
315         docs = Docs(db=db, docgen=docgen, template_root=template_dir)
316         server = Server(db=db, MSDS_manager=MSDS_manager, docs=docs,
317                         template_root=template_dir, static_dir=static_dir,
318                         static_url='/static')
319
320         if cherrypy.__version__.startswith('3.'):
321             cherrypy.config.update({ # http://www.cherrypy.org/wiki/ConfigAPI
322                     'server.socket_host': self.options.address,
323                     'server.socket_port': int(self.options.port),
324                     'tools.decode.on': True,
325                     'tools.encode.on': True,
326                     'tools.encode.encoding': 'utf8',
327                     'tools.staticdir.root': static_dir,
328                     })
329             digest_auth = None
330             if cherrypy.__version__.startswith('3.2'):
331                 try:
332                     get_ha1 = cherrypy.lib.auth_digest.get_ha1_file_htdigest(
333                         self.options.htaccess)
334                 except IOError:
335                     pass
336                 else:
337                     digest_auth = {
338                         'tools.auth_digest.on': True,
339                         'tools.auth_digest.realm': 'cookbook',
340                         'tools.auth_digest.get_ha1': get_ha1,
341                         'tools.auth_digest.key': str(uuid.uuid4()),
342                         }
343             else:
344                 passwds = {}
345                 try:
346                     with open(self.options.htaccess, 'r') as f:
347                         for line in f:
348                             user,realm,ha1 = line.strip().split(':')
349                             passwds[user] = ha1  # use the ha1 as the password
350                 except IOError:
351                     pass
352                 else:
353                     digest_auth = {
354                         'tools.digest_auth.on': True,
355                         'tools.digest_auth.realm': 'cookbook',
356                         'tools.digest_auth.users': passwds,
357                         }
358             app_config = {
359                 '/static': {
360                     'tools.staticdir.on': True,
361                     'tools.staticdir.dir': '',
362                     },
363                 }
364             if digest_auth != None:
365                 for url in ['edit']:
366                     app_config[url] = digest_auth
367             self.server = server
368             self.app_config = app_config
369         else:
370             raise NotImplementedError(
371                 'Unsupported CherryPy version %s' % cherrypy.__version__)
372
373
374 def _test():
375     import doctest
376     doctest.testmod()