""" A form action adapter that saves form submissions for download """ __author__ = 'Steve McMahon <steve@dcn.org>' __docformat__ = 'plaintext' from AccessControl import ClassSecurityInfo from BTrees.IOBTree import IOBTree try: from BTrees.LOBTree import LOBTree SavedDataBTree = LOBTree except ImportError: SavedDataBTree = IOBTree from BTrees.Length import Length from zope.contenttype import guess_content_type import plone.protect from Products.CMFCore.utils import getToolByName from Products.CMFCore.permissions import ModifyPortalContent from Products.CMFPlone.utils import base_hasattr, safe_hasattr from Products.Archetypes.public import * from Products.Archetypes.utils import contentDispositionHeader from Products.ATContentTypes.content.base import registerATCT from Products.PloneFormGen import PloneFormGenMessageFactory as _ from Products.PloneFormGen.config import * from Products.PloneFormGen.content.actionAdapter import \ FormActionAdapter, FormAdapterSchema import logging import time from DateTime import DateTime import csv from StringIO import StringIO from types import StringTypes logger = logging.getLogger("PloneFormGen") ExLinesField = LinesField class FormSaveDataAdapter(FormActionAdapter): """A form action adapter that will save form input data and return it in csv- or tab-delimited format.""" schema = FormAdapterSchema.copy() + Schema(( LinesField('showFields', required=0, searchable=0, vocabulary='allFieldDisplayList', widget=PicklistWidget( label=_(u'label_savefields_text', default=u"Saved Fields"), description=_(u'help_savefields_text', default=u""" Pick the fields whose inputs you'd like to include in the saved data. If empty, all fields will be saved. """), ), ), LinesField('ExtraData', widget=MultiSelectionWidget( label=_(u'label_savedataextra_text', default='Extra Data'), description=_(u'help_savedataextra_text', default=u""" Pick any extra data you'd like saved with the form input. """), format='checkbox', ), vocabulary='vocabExtraDataDL', ), StringField('DownloadFormat', searchable=0, required=1, default='csv', vocabulary='vocabFormatDL', widget=SelectionWidget( label=_(u'label_downloadformat_text', default=u'Download Format'), ), ), BooleanField("UseColumnNames", required=False, searchable=False, widget=BooleanWidget( label=_(u'label_usecolumnnames_text', default=u"Include Column Names"), description=_(u'help_usecolumnnames_text', default=u"Do you wish to have column names on the first line of downloaded input?"), ), ), ExLinesField('SavedFormInput', edit_accessor='getSavedFormInputForEdit', mutator='setSavedFormInput', searchable=0, required=0, primary=1, schemata="saved data", read_permission=DOWNLOAD_SAVED_PERMISSION, widget=TextAreaWidget( label=_(u'label_savedatainput_text', default=u"Saved Form Input"), description=_(u'help_savedatainput_text'), ), ), )) schema.moveField('execCondition', pos='bottom') meta_type = 'FormSaveDataAdapter' portal_type = 'FormSaveDataAdapter' archetype_name = 'Save Data Adapter' immediate_view = 'fg_savedata_view_p3' default_view = 'fg_savedata_view_p3' suppl_views = ('fg_savedata_tabview_p3', 'fg_savedata_recview_p3',) security = ClassSecurityInfo() def _migrateStorage(self): # we're going to use an LOBTree for storage. we need to # consider the possibility that self is from an # older version that uses the native Archetypes storage # or the former IOBTree (<= 1.6.0b2 ) # in the SavedFormInput field. updated = base_hasattr(self, '_inputStorage') and \ base_hasattr(self, '_inputItems') and \ base_hasattr(self, '_length') if not updated: try: saved_input = self.getSavedFormInput() except AttributeError: saved_input = [] self._inputStorage = SavedDataBTree() i = 0 self._inputItems = 0 self._length = Length() if len(saved_input): for row in saved_input: self._inputStorage[i] = row i += 1 self.SavedFormInput = [] self._inputItems = i self._length.set(i) security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'getSavedFormInput') def getSavedFormInput(self): """ returns saved input as an iterable; each row is a sequence of fields. """ if base_hasattr(self, '_inputStorage'): return self._inputStorage.values() else: return self.SavedFormInput security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'getSavedFormInputItems') def getSavedFormInputItems(self): """ returns saved input as an iterable; each row is an (id, sequence of fields) tuple """ if base_hasattr(self, '_inputStorage'): return self._inputStorage.items() else: return enumerate(self.SavedFormInput) security.declareProtected(ModifyPortalContent, 'getSavedFormInputForEdit') def getSavedFormInputForEdit(self, **kwargs): """ returns saved as CSV text """ delimiter = self.csvDelimiter() sbuf = StringIO() writer = csv.writer(sbuf, delimiter=delimiter) for row in self.getSavedFormInput(): writer.writerow(row) res = sbuf.getvalue() sbuf.close() return res security.declareProtected(ModifyPortalContent, 'setSavedFormInput') def setSavedFormInput(self, value, **kwargs): """ expects value as csv text string, stores as list of lists """ self._migrateStorage() self._inputStorage.clear() i = 0 self._inputItems = 0 self._length.set(0) if len(value): delimiter = self.csvDelimiter() sbuf = StringIO(value) reader = csv.reader(sbuf, delimiter=delimiter) for row in reader: if row: self._inputStorage[i] = row i += 1 self._inputItems = i self._length.set(i) sbuf.close() # logger.debug("setSavedFormInput: %s items" % self._inputItems) def _clearSavedFormInput(self): # convenience method to clear input buffer self._migrateStorage() self._inputStorage.clear() self._inputItems = 0 self._length.set(0) security.declareProtected(ModifyPortalContent, 'clearSavedFormInput') def clearSavedFormInput(self, **kwargs): """ clear input buffer TTW """ plone.protect.CheckAuthenticator(self.REQUEST) plone.protect.PostOnly(self.REQUEST) self._clearSavedFormInput() self.REQUEST.response.redirect(self.absolute_url()) security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'getSavedFormInputById') def getSavedFormInputById(self, id): """ Return the data stored for record with 'id' """ lst = [field.replace('\r', '').replace('\n', r'\n') for field in self._inputStorage[id]] return lst security.declareProtected(ModifyPortalContent, 'manage_saveData') def manage_saveData(self, id, data): """ Save the data for record with 'id' """ plone.protect.CheckAuthenticator(self.REQUEST) plone.protect.PostOnly(self.REQUEST) self._migrateStorage() lst = list() for i in range(0, len(self.getColumnNames())): lst.append(getattr(data, 'item-%d' % i, '').replace(r'\n', '\n')) self._inputStorage[id] = lst self.REQUEST.RESPONSE.redirect(self.absolute_url() + '/view') security.declareProtected(ModifyPortalContent, 'manage_deleteData') def manage_deleteData(self, id): """ Delete the data for record with 'id' """ self._migrateStorage() del self._inputStorage[id] self._inputItems -= 1 self._length.change(-1) self.REQUEST.RESPONSE.redirect(self.absolute_url() + '/view') def _addDataRow(self, value): self._migrateStorage() if isinstance(self._inputStorage, IOBTree): # 32-bit IOBTree; use a key which is more likely to conflict # but which won't overflow the key's bits id = self._inputItems self._inputItems += 1 else: # 64-bit LOBTree id = int(time.time() * 1000) while id in self._inputStorage: # avoid collisions during testing id += 1 self._inputStorage[id] = value self._length.change(1) security.declareProtected(ModifyPortalContent, 'addDataRow') def addDataRow(self, value): # """ a wrapper for the _addDataRow method """ self._addDataRow(value) security.declarePrivate('onSuccess') def onSuccess(self, fields, REQUEST=None, loopstop=False): # """ # saves data. # """ if LP_SAVE_TO_CANONICAL and not loopstop: # LinguaPlone functionality: # check to see if we're in a translated # form folder, but not the canonical version. parent = self.aq_parent if safe_hasattr(parent, 'isTranslation') and \ parent.isTranslation() and not parent.isCanonical(): # look in the canonical version to see if there is # a matching (by id) save-data adapter. # If so, call its onSuccess method cf = parent.getCanonical() target = cf.get(self.getId()) if target is not None and target.meta_type == 'FormSaveDataAdapter': target.onSuccess(fields, REQUEST, loopstop=True) return from ZPublisher.HTTPRequest import FileUpload data = [] for f in fields: showFields = getattr(self, 'showFields', []) if showFields and f.id not in showFields: continue if f.isFileField(): file = REQUEST.form.get('%s_file' % f.fgField.getName()) if isinstance(file, FileUpload) and file.filename != '': file.seek(0) fdata = file.read() filename = file.filename mimetype, enc = guess_content_type(filename, fdata, None) if mimetype.find('text/') >= 0: # convert to native eols fdata = fdata.replace('\x0d\x0a', '\n').replace('\x0a', '\n').replace('\x0d', '\n') data.append('%s:%s:%s:%s' % (filename, mimetype, enc, fdata)) else: data.append('%s:%s:%s:Binary upload discarded' % (filename, mimetype, enc)) else: data.append('NO UPLOAD') elif not f.isLabel(): val = REQUEST.form.get(f.fgField.getName(), '') if not type(val) in StringTypes: # Zope has marshalled the field into # something other than a string val = str(val) data.append(val) if self.ExtraData: for f in self.ExtraData: if f == 'dt': data.append(str(DateTime())) else: data.append(getattr(REQUEST, f, '')) self._addDataRow(data) security.declareProtected(ModifyPortalContent, 'allFieldDisplayList') def allFieldDisplayList(self): # """ returns a DisplayList of all fields """ return self.fgFieldsDisplayList() security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'getColumnNames') def getColumnNames(self): # """Returns a list of column names""" showFields = getattr(self, 'showFields', []) names = [field.getName() for field in self.fgFields(displayOnly=True) if not showFields or field.getName() in showFields] for f in self.ExtraData: names.append(f) return names security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'getColumnTitles') def getColumnTitles(self): # """Returns a list of column titles""" names = [field.widget.label for field in self.fgFields(displayOnly=True)] for f in self.ExtraData: names.append(self.vocabExtraDataDL().getValue(f, '')) return names def _cleanInputForTSV(self, value): # make data safe to store in tab-delimited format return str(value).replace('\x0d\x0a', r'\n').replace('\x0a', r'\n').replace('\x0d', r'\n').replace('\t', r'\t') security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'download_tsv') def download_tsv(self, REQUEST=None, RESPONSE=None): # """Download the saved data # """ filename = self.id if filename.find('.') < 0: filename = '%s.tsv' % filename header_value = contentDispositionHeader('attachment', self.getCharset(), filename=filename) RESPONSE.setHeader("Content-Disposition", header_value) RESPONSE.setHeader("Content-Type", 'text/tab-separated-values;charset=%s' % self.getCharset()) if getattr(self, 'UseColumnNames', False): res = "%s\n" % '\t'.join(self.getColumnNames()) if isinstance(res, unicode): res = res.encode(self.getCharset()) else: res = '' for row in self.getSavedFormInput(): res = '%s%s\n' % (res, '\t'.join([self._cleanInputForTSV(col) for col in row])) return res security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'download_csv') def download_csv(self, REQUEST=None, RESPONSE=None): # """Download the saved data # """ filename = self.id if filename.find('.') < 0: filename = '%s.csv' % filename header_value = contentDispositionHeader('attachment', self.getCharset(), filename=filename) RESPONSE.setHeader("Content-Disposition", header_value) RESPONSE.setHeader("Content-Type", 'text/comma-separated-values;charset=%s' % self.getCharset()) if getattr(self, 'UseColumnNames', False): delimiter = self.csvDelimiter() res = "%s\n" % delimiter.join(self.getColumnNames()) if isinstance(res, unicode): res = res.encode(self.getCharset()) else: res = '' return '%s%s' % (res, self.getSavedFormInputForEdit()) security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'download') def download(self, REQUEST=None, RESPONSE=None): """Download the saved data """ format = getattr(self, 'DownloadFormat', 'tsv') if format == 'tsv': return self.download_tsv(REQUEST, RESPONSE) else: assert format == 'csv', 'Unknown download format' return self.download_csv(REQUEST, RESPONSE) security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'rowAsColDict') def rowAsColDict(self, row, cols): # """ Where row is a data sequence and cols is a column name sequence, # returns a dict of colname:column. This is a convenience method # used in the record view. # """ colcount = len(cols) rdict = {} for i in range(0, len(row)): if i < colcount: rdict[cols[i]] = row[i] else: rdict['column-%s' % i] = row[i] return rdict security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'inputAsDictionaries') def inputAsDictionaries(self): # """returns saved data as an iterable of dictionaries # """ cols = self.getColumnNames() for row in self.getSavedFormInput(): yield self.rowAsColDict(row, cols) # alias for old mis-naming security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'InputAsDictionaries') InputAsDictionaries = inputAsDictionaries security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'formatMIME') def formatMIME(self): # """MIME format selected for download # """ format = getattr(self, 'DownloadFormat', 'tsv') if format == 'tsv': return 'text/tab-separated-values' else: assert format == 'csv', 'Unknown download format' return 'text/comma-separated-values' security.declarePrivate('csvDelimiter') def csvDelimiter(self): # """Delimiter character for CSV downloads # """ fgt = getToolByName(self, 'formgen_tool') return fgt.getCSVDelimiter() security.declareProtected(DOWNLOAD_SAVED_PERMISSION, 'itemsSaved') def itemsSaved(self): # """Download the saved data # """ if base_hasattr(self, '_length'): return self._length() elif base_hasattr(self, '_inputItems'): return self._inputItems else: return len(self.SavedFormInput) def vocabExtraDataDL(self): # """ returns vocabulary for extra data """ return DisplayList(( ('dt', self.translate(msgid='vocabulary_postingdt_text', domain='ploneformgen', default='Posting Date/Time') ), ('HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR',), ('REMOTE_ADDR', 'REMOTE_ADDR',), ('HTTP_USER_AGENT', 'HTTP_USER_AGENT',), )) def vocabFormatDL(self): # """ returns vocabulary for format """ return DisplayList(( ('tsv', self.translate(msgid='vocabulary_tsv_text', domain='ploneformgen', default='Tab-Separated Values') ), ('csv', self.translate(msgid='vocabulary_csv_text', domain='ploneformgen', default='Comma-Separated Values') ), )) registerATCT(FormSaveDataAdapter, PROJECTNAME)