Document storage: implementation of simple rename
[odoo/odoo.git] / addons / document / document_storage.py
1 # -*- encoding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #
6 #    Copyright (C) P. Christeas, 2009, all rights reserved
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU General Public License as published by
10 #    the Free Software Foundation, either version 3 of the License, or
11 #    (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU General Public License for more details.
17 #
18 #    You should have received a copy of the GNU General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 from osv import osv, fields
24 import os
25 import tools
26 import base64
27 import errno
28 import logging
29 from StringIO import StringIO
30
31 from tools.misc import ustr
32 from tools.translate import _
33
34 from osv.orm import except_orm
35
36 import random
37 import string
38 import pooler
39 import netsvc
40 import nodes
41 from content_index import cntIndex
42
43 DMS_ROOT_PATH = tools.config.get('document_path', os.path.join(tools.config.get('root_path'), 'filestore'))
44
45
46 """ The algorithm of data storage
47
48 We have to consider 3 cases of data /retrieval/:
49  Given (context,path) we need to access the file (aka. node).
50  given (directory, context), we need one of its children (for listings, views)
51  given (ir.attachment, context), we needs its data and metadata (node).
52
53 For data /storage/ we have the cases:
54  Have (ir.attachment, context), we modify the file (save, update, rename etc).
55  Have (directory, context), we create a file.
56  Have (path, context), we create or modify a file.
57  
58 Note that in all above cases, we don't explicitly choose the storage media,
59 but always require a context to be present.
60
61 Note that a node will not always have a corresponding ir.attachment. Dynamic
62 nodes, for once, won't. Their metadata will be computed by the parent storage
63 media + directory.
64
65 The algorithm says that in any of the above cases, our first goal is to locate
66 the node for any combination of search criteria. It would be wise NOT to 
67 represent each node in the path (like node[/] + node[/dir1] + node[/dir1/dir2])
68 but directly jump to the end node (like node[/dir1/dir2]) whenever possible.
69
70 We also contain all the parenting loop code in one function. This is intentional,
71 because one day this will be optimized in the db (Pg 8.4).
72
73
74 """
75
76 def random_name():
77     random.seed()
78     d = [random.choice(string.ascii_letters) for x in xrange(10) ]
79     name = "".join(d)
80     return name
81
82 INVALID_CHARS = {'*':str(hash('*')), '|':str(hash('|')) , "\\":str(hash("\\")), '/':'__', ':':str(hash(':')), '"':str(hash('"')), '<':str(hash('<')) , '>':str(hash('>')) , '?':str(hash('?'))}
83
84
85 def create_directory(path):
86     dir_name = random_name()
87     path = os.path.join(path, dir_name)
88     os.makedirs(path)
89     return dir_name
90
91 class nodefd_file(nodes.node_descriptor):
92     """ A descriptor to a real file
93
94     Inheriting directly from file doesn't work, since file exports
95     some read-only attributes (like 'name') that we don't like.
96     """
97     def __init__(self, parent, path, mode):
98         nodes.node_descriptor.__init__(self, parent)
99         self.__file = open(path, mode)
100         
101         for attr in ('closed', 'read', 'write', 'seek', 'tell'):
102             setattr(self,attr, getattr(self.__file, attr))
103
104     def close(self):
105         # TODO: locking in init, close()
106         self.__file.close()
107
108     
109 class nodefd_db(StringIO, nodes.node_descriptor):
110     """ A descriptor to db data
111     """
112     def __init__(self, parent, ira_browse, mode):
113         nodes.node_descriptor.__init__(self, parent)
114         if mode.endswith('b'):
115             mode = mode[:-1]
116         
117         if mode in ('r', 'r+'):
118             cr.execute('SELECT db_datas FROM ir_attachment WHERE id = %s', ira_browse.id)
119             data = cr.fetchone()[0]
120             StringIO.__init__(self, data)
121         elif mode in ('w', 'w+'):
122             StringIO.__init__(self, None)
123             # at write, we start at 0 (= overwrite), but have the original
124             # data available, in case of a seek()
125         elif mode == 'a':
126             StringIO.__init__(self, None)
127         else:
128             logging.getLogger('document.storage').error("Incorrect mode %s specified", mode)
129             raise IOError(errno.EINVAL, "Invalid file mode")
130         self.mode = mode
131
132     def close(self):
133         # we now open a *separate* cursor, to update the data.
134         # FIXME: this may be improved, for concurrency handling
135         par = self._get_parent()
136         uid = par.context.uid
137         cr = pooler.get_db(par.context.dbname).cursor()
138         try:
139             if self.mode in ('w', 'w+', 'r+'):
140                 out = self.getvalue()
141                 cr.execute("UPDATE ir_attachment SET db_datas = decode(%s,'escape'), file_size=%s WHERE id = %s",
142                     (out, len(out), par.file_id))
143             elif self.mode == 'a':
144                 out = self.getvalue()
145                 cr.execute("UPDATE ir_attachment " \
146                     "SET db_datas = COALESCE(db_datas,'') || decode(%s, 'escape'), " \
147                     "    file_size = COALESCE(file_size, 0) + %s " \
148                     " WHERE id = %s",
149                     (out, len(out), par.file_id))
150             cr.commit()
151         except Exception, e:
152             logging.getLogger('document.storage').exception('Cannot update db file #%d for close:', par.file_id)
153             raise
154         finally:
155             cr.close()
156         StringIO.close(self)
157
158 class nodefd_db64(StringIO, nodes.node_descriptor):
159     """ A descriptor to db data, base64 (the old way)
160     
161         It stores the data in base64 encoding at the db. Not optimal, but
162         the transparent compression of Postgres will save the day.
163     """
164     def __init__(self, parent, ira_browse, mode):
165         nodes.node_descriptor.__init__(self, parent)
166         if mode.endswith('b'):
167             mode = mode[:-1]
168         
169         if mode in ('r', 'r+'):
170             StringIO.__init__(self, base64.decodestring(ira_browse.db_datas))
171         elif mode in ('w', 'w+'):
172             StringIO.__init__(self, None)
173             # at write, we start at 0 (= overwrite), but have the original
174             # data available, in case of a seek()
175         elif mode == 'a':
176             StringIO.__init__(self, None)
177         else:
178             logging.getLogger('document.storage').error("Incorrect mode %s specified", mode)
179             raise IOError(errno.EINVAL, "Invalid file mode")
180         self.mode = mode
181
182     def close(self):
183         # we now open a *separate* cursor, to update the data.
184         # FIXME: this may be improved, for concurrency handling
185         par = self._get_parent()
186         uid = par.context.uid
187         cr = pooler.get_db(par.context.dbname).cursor()
188         try:
189             if self.mode in ('w', 'w+', 'r+'):
190                 out = self.getvalue()
191                 cr.execute('UPDATE ir_attachment SET db_datas = %s::bytea, file_size=%s WHERE id = %s',
192                     (base64.encodestring(out), len(out), par.file_id))
193             elif self.mode == 'a':
194                 out = self.getvalue()
195                 # Yes, we're obviously using the wrong representation for storing our
196                 # data as base64-in-bytea
197                 cr.execute("UPDATE ir_attachment " \
198                     "SET db_datas = encode( (COALESCE(decode(encode(db_datas,'escape'),'base64'),'') || decode(%s, 'base64')),'base64')::bytea , " \
199                     "    file_size = COALESCE(file_size, 0) + %s " \
200                     " WHERE id = %s",
201                     (base64.encodestring(out), len(out), par.file_id))
202             cr.commit()
203         except Exception, e:
204             logging.getLogger('document.storage').exception('Cannot update db file #%d for close:', par.file_id)
205             raise
206         finally:
207             cr.close()
208         StringIO.close(self)
209
210 class document_storage(osv.osv):
211     """ The primary object for data storage.
212     Each instance of this object is a storage media, in which our application
213     can store contents. The object here controls the behaviour of the storage
214     media.
215     The referring document.directory-ies will control the placement of data
216     into the storage.
217     
218     It is a bad idea to have multiple document.storage objects pointing to
219     the same tree of filesystem storage.
220     """
221     _name = 'document.storage'
222     _description = 'Storage Media'
223     _doclog = logging.getLogger('document')
224
225     _columns = {
226         'name': fields.char('Name', size=64, required=True, select=1),
227         'write_date': fields.datetime('Date Modified', readonly=True),
228         'write_uid':  fields.many2one('res.users', 'Last Modification User', readonly=True),
229         'create_date': fields.datetime('Date Created', readonly=True),
230         'create_uid':  fields.many2one('res.users', 'Creator', readonly=True),
231         'user_id': fields.many2one('res.users', 'Owner'),
232         'group_ids': fields.many2many('res.groups', 'document_storage_group_rel', 'item_id', 'group_id', 'Groups'),
233         'dir_ids': fields.one2many('document.directory', 'parent_id', 'Directories'),
234         'type': fields.selection([('db', 'Database'), ('filestore', 'Internal File storage'),
235             ('realstore', 'External file storage'), ('virtual', 'Virtual storage')], 'Type', required=True),
236         'path': fields.char('Path', size=250, select=1, help="For file storage, the root path of the storage"),
237         'online': fields.boolean('Online', help="If not checked, media is currently offline and its contents not available", required=True),
238         'readonly': fields.boolean('Read Only', help="If set, media is for reading only"),
239     }
240
241     def _get_rootpath(self, cr, uid, context=None):
242         return os.path.join(DMS_ROOT_PATH, cr.dbname)
243
244     _defaults = {
245         'user_id': lambda self, cr, uid, ctx: uid,
246         'online': lambda *args: True,
247         'readonly': lambda *args: False,
248         # Note: the defaults below should only be used ONCE for the default
249         # storage media. All other times, we should create different paths at least.
250         'type': lambda *args: 'filestore',
251         'path': _get_rootpath,
252     }
253     _sql_constraints = [
254         # SQL note: a path = NULL doesn't have to be unique.
255         ('path_uniq', 'UNIQUE(type,path)', "The storage path must be unique!")
256         ]
257
258     def get_data(self, cr, uid, id, file_node, context=None, fil_obj=None):
259         """ retrieve the contents of some file_node having storage_id = id
260             optionally, fil_obj could point to the browse object of the file
261             (ir.attachment)
262         """
263         if not context:
264             context = {}
265         boo = self.browse(cr, uid, id, context)
266         if fil_obj:
267             ira = fil_obj
268         else:
269             ira = self.pool.get('ir.attachment').browse(cr, uid, file_node.file_id, context=context)
270         return self.__get_data_3(cr, uid, boo, ira, context)
271
272     def get_file(self, cr, uid, id, file_node, mode, context=None):
273         if context is None:
274             context = {}
275         boo = self.browse(cr, uid, id, context)
276         if not boo.online:
277             raise RuntimeError('media offline')
278         
279         ira = self.pool.get('ir.attachment').browse(cr, uid, file_node.file_id, context=context)
280         if boo.type == 'filestore':
281             if not ira.store_fname:
282                 # On a migrated db, some files may have the wrong storage type
283                 # try to fix their directory.
284                 if ira.file_size:
285                     self._doclog.warning( "ir.attachment #%d does not have a filename, but is at filestore, fix it!" % ira.id)
286                 raise IOError(errno.ENOENT, 'No file can be located')
287             fpath = os.path.join(boo.path, ira.store_fname)
288             if self._debug:
289                 self._doclog.debug("Trying to read \"%s\".."% fpath)
290             return nodefd_file(file_node, path=fpath, mode=mode)
291
292         elif boo.type == 'db':
293             # TODO: we need a better api for large files
294             if self._debug:
295                 self._doclog.debug("Trying to obtain db_datas for ir.attachment[%d]", ira.id)
296             return nodefd_db64(file_node, ira_browse=ira, mode=mode)
297
298         elif boo.type == 'realstore':
299             if not ira.store_fname:
300                 # On a migrated db, some files may have the wrong storage type
301                 # try to fix their directory.
302                 if ira.file_size:
303                     self._doclog.warning("ir.attachment #%d does not have a filename, trying the name." %ira.id)
304                 sfname = ira.name
305             fpath = os.path.join(boo.path,ira.store_fname or ira.name)
306             if not os.path.exists(fpath):
307                 raise IOError("File not found: %s" % fpath)
308             return nodefd_file(file_node, path=fpath, mode=mode)
309
310         else:
311             raise TypeError("No %s storage" % boo.type)
312
313     def __get_data_3(self, cr, uid, boo, ira, context):
314         if not boo.online:
315             raise RuntimeError('media offline')
316         if boo.type == 'filestore':
317             if not ira.store_fname:
318                 # On a migrated db, some files may have the wrong storage type
319                 # try to fix their directory.
320                 if ira.file_size:
321                     self._doclog.warning( "ir.attachment #%d does not have a filename, but is at filestore, fix it!" % ira.id)
322                 return None
323             fpath = os.path.join(boo.path, ira.store_fname)
324             return file(fpath, 'rb').read()
325         elif boo.type == 'db':
326             # TODO: we need a better api for large files
327             if ira.db_datas:
328                 out = base64.decodestring(ira.db_datas)
329             else:
330                 out = ''
331             return out
332         elif boo.type == 'realstore':
333             if not ira.store_fname:
334                 # On a migrated db, some files may have the wrong storage type
335                 # try to fix their directory.
336                 if ira.file_size:
337                     self._doclog.warning("ir.attachment #%d does not have a filename, trying the name." %ira.id)
338                 sfname = ira.name
339             fpath = os.path.join(boo.path,ira.store_fname or ira.name)
340             if os.path.exists(fpath):
341                 return file(fpath,'rb').read()
342             elif not ira.store_fname:
343                 return None
344             else:
345                 raise IOError("File not found: %s" % fpath)
346         else:
347             raise TypeError("No %s storage" % boo.type)
348
349     def set_data(self, cr, uid, id, file_node, data, context=None, fil_obj=None):
350         """ store the data.
351             This function MUST be used from an ir.attachment. It wouldn't make sense
352             to store things persistently for other types (dynamic).
353         """
354         if not context:
355             context = {}
356         boo = self.browse(cr, uid, id, context)
357         if fil_obj:
358             ira = fil_obj
359         else:
360             ira = self.pool.get('ir.attachment').browse(cr, uid, file_node.file_id, context=context)
361
362         if not boo.online:
363             raise RuntimeError('media offline')
364         self._doclog.debug( "Store data for ir.attachment #%d" % ira.id)
365         store_fname = None
366         fname = None
367         if boo.type == 'filestore':
368             path = boo.path
369             try:
370                 flag = None
371                 # This can be improved  
372                 if os.path.isdir(path):
373                     for dirs in os.listdir(path):
374                         if os.path.isdir(os.path.join(path, dirs)) and len(os.listdir(os.path.join(path, dirs))) < 4000:
375                             flag = dirs
376                             break
377                 flag = flag or create_directory(path)
378                 filename = random_name()
379                 fname = os.path.join(path, flag, filename)
380                 fp = file(fname, 'wb')
381                 fp.write(data)
382                 fp.close()
383                 self._doclog.debug( "Saved data to %s" % fname)
384                 filesize = len(data) # os.stat(fname).st_size
385                 store_fname = os.path.join(flag, filename)
386
387                 # TODO Here, an old file would be left hanging.
388
389             except Exception, e:
390                 self._doclog.warning( "Couldn't save data to %s", path, exc_info=True)
391                 raise except_orm(_('Error!'), str(e))
392         elif boo.type == 'db':
393             filesize = len(data)
394             # will that work for huge data? TODO
395             out = base64.encodestring(data)
396             cr.execute('UPDATE ir_attachment SET db_datas = %s WHERE id = %s',
397                 (out, file_node.file_id))
398         elif boo.type == 'realstore':
399             try:
400                 file_node.fix_ppath(cr, ira)
401                 npath = file_node.full_path() or []
402                 # npath may contain empty elements, for root directory etc.
403                 for i, n in enumerate(npath):
404                     if n == None:
405                         del npath[i]
406                 for n in npath:
407                     for ch in ('*', '|', "\\", '/', ':', '"', '<', '>', '?', '..'):
408                         if ch in n:
409                             raise ValueError("Invalid char %s in path %s" %(ch, n))
410                 dpath = [boo.path,]
411                 dpath += npath[:-1]
412                 path = os.path.join(*dpath)
413                 if not os.path.isdir(path):
414                     os.makedirs(path)
415                 fname = os.path.join(path, npath[-1])
416                 fp = file(fname,'wb')
417                 fp.write(data)
418                 fp.close()
419                 self._doclog.debug("Saved data to %s", fname)
420                 filesize = len(data) # os.stat(fname).st_size
421                 store_fname = os.path.join(*npath)
422                 # TODO Here, an old file would be left hanging.
423             except Exception,e :
424                 self._doclog.warning("Couldn't save data:", exc_info=True)
425                 raise except_orm(_('Error!'), str(e))
426         else:
427             raise TypeError("No %s storage" % boo.type)
428
429         # 2nd phase: store the metadata
430         try:
431             icont = ''
432             mime = ira.file_type
433             if not mime:
434                 mime = ""
435             try:
436                 mime, icont = cntIndex.doIndex(data, ira.datas_fname,
437                 ira.file_type or None, fname)
438             except Exception:
439                 self._doclog.debug('Cannot index file:', exc_info=True)
440                 pass
441
442             try:
443                 icont_u = ustr(icont)
444             except UnicodeError:
445                 icont_u = ''
446
447             # a hack: /assume/ that the calling write operation will not try
448             # to write the fname and size, and update them in the db concurrently.
449             # We cannot use a write() here, because we are already in one.
450             cr.execute('UPDATE ir_attachment SET store_fname = %s, file_size = %s, index_content = %s, file_type = %s WHERE id = %s',
451                 (store_fname, filesize, icont_u, mime, file_node.file_id))
452             file_node.content_length = filesize
453             file_node.content_type = mime
454             return True
455         except Exception, e :
456             self._doclog.warning("Couldn't save data:", exc_info=True)
457             # should we really rollback once we have written the actual data?
458             # at the db case (only), that rollback would be safe
459             raise except_orm(_('Error at doc write!'), str(e))
460
461     def prepare_unlink(self, cr, uid, storage_bo, fil_bo):
462         """ Before we unlink a file (fil_boo), prepare the list of real
463         files that have to be removed, too. """
464
465         if not storage_bo.online:
466             raise RuntimeError('media offline')
467
468         if storage_bo.type == 'filestore':
469             fname = fil_bo.store_fname
470             if not fname:
471                 return None
472             path = storage_bo.path
473             return (storage_bo.id, 'file', os.path.join(path, fname))
474         elif storage_bo.type == 'db':
475             return None
476         elif storage_bo.type == 'realstore':
477             fname = fil_bo.store_fname
478             if not fname:
479                 return None
480             path = storage_bo.path
481             return ( storage_bo.id, 'file', os.path.join(path, fname))
482         else:
483             raise TypeError("No %s storage" % storage_bo.type)
484
485     def do_unlink(self, cr, uid, unres):
486         for id, ktype, fname in unres:
487             if ktype == 'file':
488                 try:
489                     os.unlink(fname)
490                 except Exception, e:
491                     self._doclog.warning("Could not remove file %s, please remove manually.", fname, exc_info=True)
492             else:
493                 self._doclog.warning("Unknown unlink key %s" % ktype)
494
495         return True
496
497     def simple_rename(self, cr, uid, file_node, new_name, context=None):
498         """ A preparation for a file rename.
499             It will not affect the database, but merely check and perhaps
500             rename the realstore file.
501             
502             @return the dict of values that can safely be be stored in the db.
503         """
504         sbro = self.browse(cr, uid, file_node.storage_id, context=context)
505         assert sbro, "The file #%d didn't provide storage" % file_node.file_id
506         
507         if sbro.type in ('filestore', 'db'):
508             # nothing to do for a rename, allow to change the db field
509             return { 'name': new_name, 'datas_fname': new_name }
510         elif sbro.type == 'realstore':
511             fname = fil_bo.store_fname
512             if not fname:
513                 return ValueError("Tried to rename a non-stored file")
514             path = storage_bo.path
515             oldpath = os.path.join(path, fname)
516             
517             for ch in ('*', '|', "\\", '/', ':', '"', '<', '>', '?', '..'):
518                 if ch in new_name:
519                     raise ValueError("Invalid char %s in name %s" %(ch, new_name))
520                 
521             file_node.fix_ppath(cr, ira)
522             npath = file_node.full_path() or []
523             dpath = [path,]
524             dpath.extend(npath[:-1])
525             dpath.append(new_name)
526             newpath = os.path.join(*dpath)
527             # print "old, new paths:", oldpath, newpath
528             os.rename(oldpath, newpath)
529             return { 'name': new_name, 'datas_fname': new_name, 'store_fname': new_name }
530         else:
531             raise TypeError("No %s storage" % boo.type)
532
533
534 document_storage()
535
536
537 #eof