diff --git a/README.txt b/README.txt index 4068bfd..4781468 100755 --- a/README.txt +++ b/README.txt @@ -913,6 +913,12 @@ relates ISO country codes with the full name of the country. >>> sorted(adapted(concepts['countries']).data.items()) [('at', ['Austria']), ('de', ['Germany'])] + >>> countries.dataAsRecords() + [{'value': 'Austria', 'key': 'at'}, {'value': 'Germany', 'key': 'de'}] + + >>> countries.getRowsByValue('value', 'Germany') + [{'value': 'Germany', 'key': 'de'}] + Caching ======= diff --git a/browser/action.py b/browser/action.py index 4ac0b03..b51e40a 100644 --- a/browser/action.py +++ b/browser/action.py @@ -92,6 +92,8 @@ class DialogAction(Action): urlParams['fixed_type'] = 'yes' if self.viewTitle: urlParams['view_title'] = self.viewTitle + #for k, v in self.page.sortInfo.items(): + # urlParams['sortinfo_' + k] = v['fparam'] urlParams.update(self.addParams) if self.target is not None: url = self.page.getUrlForTarget(self.target) diff --git a/browser/common.py b/browser/common.py index 9329f21..a6bd733 100755 --- a/browser/common.py +++ b/browser/common.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,7 +22,7 @@ Common base class for loops browser view classes. from cgi import parse_qs, parse_qsl #import mimetypes # use more specific assignments from cybertools.text -from datetime import datetime +from datetime import date, datetime import re from time import strptime from urllib import urlencode @@ -61,17 +61,21 @@ from cybertools.stateful.interfaces import IStateful from cybertools.text import mimetypes from cybertools.typology.interfaces import IType, ITypeManager from cybertools.util.date import toLocalTime +from cybertools.util.format import formatDate from cybertools.util.jeep import Jeep from loops.browser.util import normalizeForUrl from loops.common import adapted, baseObject from loops.config.base import DummyOptions from loops.i18n.browser import I18NView from loops.interfaces import IResource, IView, INode, ITypeConcept +from loops.organize.personal import favorite +from loops.organize.party import getPersonForUser from loops.organize.tracking import access from loops.organize.util import getRolesForPrincipal from loops.resource import Resource from loops.security.common import checkPermission from loops.security.common import canAccessObject, canListObject, canWriteObject +from loops.security.common import canEditRestricted from loops.type import ITypeConcept, LoopsTypeInfo from loops import util from loops.util import _, saveRequest @@ -132,7 +136,58 @@ class EditForm(form.EditForm): return parentUrl + '/contents.html' -class BaseView(GenericView, I18NView): +class SortableMixin(object): + + @Lazy + def sortInfo(self): + result = {} + for k, v in self.request.form.items(): + if k.startswith('sortinfo_'): + tableName = k[len('sortinfo_'):] + if ',' in v: + fn, dir = v.split(',') + else: + fn = v + dir = 'asc' + result[tableName] = dict( + colName=fn, ascending=(dir=='asc'), fparam=v) + result = favorite.updateSortInfo(getPersonForUser( + self.context, self.request), self.target, result) + return result + + def isSortableColumn(self, tableName, colName): + return False # overwrite in subclass + + def getSortUrl(self, tableName, colName): + url = str(self.request.URL) + paramChar = '?' in url and '&' or '?' + si = self.sortInfo.get(tableName) + if si is not None and si.get('colName') == colName: + dir = si['ascending'] and 'desc' or 'asc' + else: + dir = 'asc' + return '%s%ssortinfo_%s=%s,%s' % (url, paramChar, tableName, colName, dir) + + def getSortParams(self, tableName): + url = str(self.request.URL) + paramChar = '?' in url and '&' or '?' + si = self.sortInfo.get(tableName) + if si is not None: + colName = si['colName'] + dir = si['ascending'] and 'asc' or 'desc' + return '%ssortinfo_%s=%s,%s' % (paramChar, tableName, colName, dir) + return '' + + def getSortImage(self, tableName, colName): + si = self.sortInfo.get(tableName) + if si is not None and si.get('colName') == colName: + if si['ascending']: + return '/@@/cybertools.icons/arrowdown.gif' + else: + return '/@@/cybertools.icons/arrowup.gif' + + +class BaseView(GenericView, I18NView, SortableMixin): actions = {} portlet_actions = [] @@ -153,6 +208,10 @@ class BaseView(GenericView, I18NView): pass saveRequest(request) + def todayFormatted(self): + return formatDate(date.today(), 'date', 'short', + self.languageInfo.language) + def checkPermissions(self): return canAccessObject(self.context) @@ -204,6 +263,16 @@ class BaseView(GenericView, I18NView): result.append(view) return result + @Lazy + def urlParamString(self): + return self.getUrlParamString() + + def getUrlParamString(self): + qs = self.request.get('QUERY_STRING') + if qs: + return '?' + qs + return '' + @Lazy def principalId(self): principal = self.request.principal @@ -337,6 +406,10 @@ class BaseView(GenericView, I18NView): def isPartOfPredicate(self): return self.conceptManager.get('ispartof') + @Lazy + def queryTargetPredicate(self): + return self.conceptManager.get('querytarget') + @Lazy def memberPredicate(self): return self.conceptManager.get('ismember') @@ -732,6 +805,8 @@ class BaseView(GenericView, I18NView): return result def checkState(self): + if checkPermission('loops.ManageSite', self.context): + return True if not self.allStates: return True for stf in self.allStates: @@ -806,6 +881,10 @@ class BaseView(GenericView, I18NView): def canAccessRestricted(self): return checkPermission('loops.ViewRestricted', self.context) + @Lazy + def canEditRestricted(self): + return canEditRestricted(self.context) + def openEditWindow(self, viewName='edit.html'): if self.editable: if checkPermission('loops.ManageSite', self.context): @@ -928,6 +1007,12 @@ class BaseView(GenericView, I18NView): jsCall = 'dojo.require("dojox.image.Lightbox");' self.controller.macros.register('js-execute', jsCall, jsCall=jsCall) + def registerDojoComboBox(self): + self.registerDojo() + jsCall = ('dojo.require("dijit.form.ComboBox");') + self.controller.macros.register('js-execute', + 'dojo.require.ComboBox', jsCall=jsCall) + def registerDojoFormAll(self): self.registerDojo() self.registerDojoEditor() diff --git a/browser/concept.py b/browser/concept.py index 32b63fc..9e54655 100644 --- a/browser/concept.py +++ b/browser/concept.py @@ -282,8 +282,17 @@ class ConceptView(BaseView): def breadcrumbsTitle(self): return self.title + @Lazy + def showInBreadcrumbs(self): + return (self.options('show_in_breadcrumbs') or + self.typeOptions('show_in_breadcrumbs')) + @Lazy def breadcrumbsParent(self): + for p in self.context.getParents([self.defaultPredicate]): + view = self.nodeView.getViewForTarget(p) + if view.showInBreadcrumbs: + return view return None def getData(self, omit=('title', 'description')): @@ -449,7 +458,7 @@ class ConceptView(BaseView): if r.order != pos: r.order = pos - def getResources(self): + def getResources(self, relView=None, sort='default'): form = self.request.form #if form.get('loops.viewName') == 'index.html' and self.editable: if self.editable: @@ -458,13 +467,17 @@ class ConceptView(BaseView): tokens = form.get('resources_tokens') if tokens: self.reorderResources(tokens) - from loops.browser.resource import ResourceRelationView + if relView is None: + from loops.browser.resource import ResourceRelationView + relView = ResourceRelationView from loops.organize.personal.browser.filter import FilterView fv = FilterView(self.context, self.request) - rels = self.context.getResourceRelations() + rels = self.context.getResourceRelations(sort=sort) for r in rels: if fv.check(r.first): - yield ResourceRelationView(r, self.request, contextIsSecond=True) + view = relView(r, self.request, contextIsSecond=True) + if view.checkState(): + yield view def resources(self): return self.getResources() diff --git a/browser/concept_macros.pt b/browser/concept_macros.pt index 7bbea5a..7d61dbb 100644 --- a/browser/concept_macros.pt +++ b/browser/concept_macros.pt @@ -51,7 +51,7 @@

Title + + + + + + + + diff --git a/browser/configure.zcml b/browser/configure.zcml index 311b87b..30135df 100644 --- a/browser/configure.zcml +++ b/browser/configure.zcml @@ -561,6 +561,14 @@ factory="loops.browser.concept.TabbedPage" permission="zope.View" /> + + + + + ondblclick python:target.openEditWindow('configure.html')"> The body @@ -41,17 +41,22 @@ - +
+ ondblclick python:item.openEditWindow('configure.html')"> Node Body
- -
- +
+
+
+
+
diff --git a/browser/resource.py b/browser/resource.py index 7d35c0c..2466f71 100644 --- a/browser/resource.py +++ b/browser/resource.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -20,6 +20,7 @@ View class for resource objects. """ +import os.path import urllib from zope.cachedescriptors.property import Lazy from zope import component @@ -47,7 +48,7 @@ from loops.browser.common import EditForm, BaseView from loops.browser.concept import BaseRelationView, ConceptRelationView from loops.browser.concept import ConceptConfigureView from loops.browser.node import NodeView, node_macros -from loops.common import adapted, NameChooser, normalizeName +from loops.common import adapted, baseObject, NameChooser, normalizeName from loops.interfaces import IBaseResource, IDocument, ITextDocument from loops.interfaces import IMediaAsset as legacy_IMediaAsset from loops.interfaces import ITypeConcept @@ -216,6 +217,16 @@ class ResourceView(BaseView): if filename is None: filename = (adapted(self.context).localFilename or getName(self.context)) + if self.typeOptions('use_title_for_download_filename'): + base, ext = os.path.splitext(filename) + filename = context.title + vr = IVersionable(baseObject(context)) + if len(vr.versions) > 0: + filename = vr.generateName(filename, ext, vr.versionId) + else: + if not filename.endswith(ext): + filename += ext + filename = filename.encode('UTF-8') if self.typeOptions('no_normalize_download_filename'): filename = '"%s"' % filename else: diff --git a/browser/resource_macros.pt b/browser/resource_macros.pt index 8a70e4f..a273dd7 100644 --- a/browser/resource_macros.pt +++ b/browser/resource_macros.pt @@ -96,6 +96,7 @@
+
diff --git a/browser/skin/lobo/lobo.css b/browser/skin/lobo/lobo.css index 30fdd1a..3281a0d 100644 --- a/browser/skin/lobo/lobo.css +++ b/browser/skin/lobo/lobo.css @@ -20,6 +20,7 @@ body { #portlets { margin-top: 1em; + background-color: #ffffff; } ul.view-modes { @@ -108,6 +109,14 @@ thead th { background: none; } +/* printing */ + +@media print { + .noprint { + display: none; + } +} + /* class-specific */ .breadcrumbs td { diff --git a/browser/skin/lobo/print.css b/browser/skin/lobo/print.css index 19dd977..7ea21f3 100644 --- a/browser/skin/lobo/print.css +++ b/browser/skin/lobo/print.css @@ -11,8 +11,14 @@ body { display: none; } +.container { + width: auto; + margin: 0; + border: 0; +} + #content { -/* width: 100%; */ - width: 80%; + width: auto; color: Black; } + diff --git a/common.py b/common.py index 0a8b9c1..48033fa 100644 --- a/common.py +++ b/common.py @@ -231,17 +231,19 @@ class NameChooser(BaseNameChooser): return name def generateNameFromTitle(self, obj): - title = obj.title - if len(title) > 15: - words = title.split() - if len(words) > 1: - title = '_'.join((words[0], words[-1])) - return self.normalizeName(title) + return generateNameFromTitle(obj.title) def normalizeName(self, baseName): return normalizeName(baseName) +def generateNameFromTitle(title): + if len(title) > 15: + words = title.split() + if len(words) > 1: + title = '_'.join((words[0], words[-1])) + return normalizeName(title) + def normalizeName(baseName): specialCharacters = { '\xc4': 'Ae', '\xe4': 'ae', '\xd6': 'Oe', '\xf6': 'oe', diff --git a/compound/book/browser.py b/compound/book/browser.py index fb6fcf6..fb43ab2 100644 --- a/compound/book/browser.py +++ b/compound/book/browser.py @@ -107,6 +107,7 @@ class Base(object): @Lazy def textResources(self): self.images = [[]] + self.otherResources = [] result = [] idx = 0 for rv in self.getResources(): @@ -115,7 +116,7 @@ class Base(object): idx += 1 result.append(rv) self.images.append([]) - else: + elif rv.context.contentType.startswith('image/'): self.registerDojoLightbox() url = self.nodeView.getUrlForTarget(rv.context) src = '%s/mediaasset.html?v=small' % url @@ -123,6 +124,8 @@ class Base(object): img = dict(src=src, fullImageUrl=fullSrc, title=rv.title, description=rv.description, url=url, object=rv) self.images[idx].append(img) + else: + self.otherResources.append(rv) return result def getDocumentTypeForResource(self, r): diff --git a/compound/book/view_macros.pt b/compound/book/view_macros.pt index c85a69c..1fcf7b1 100644 --- a/compound/book/view_macros.pt +++ b/compound/book/view_macros.pt @@ -130,7 +130,8 @@ + textResources textResources|item/textResources; + resources item/otherResources">

Children

@@ -151,8 +152,18 @@ more...

+
+ + +
+
diff --git a/expert/README.txt b/expert/README.txt index d3fd786..35fe082 100644 --- a/expert/README.txt +++ b/expert/README.txt @@ -27,7 +27,7 @@ configuration): >>> concepts, resources, views = t.setup() >>> len(concepts) + len(resources) - 36 + 38 >>> loopsRoot = site['loops'] @@ -47,11 +47,11 @@ Type- and text-based queries >>> from loops.expert import query >>> qu = query.Title('ty*') >>> list(qu.apply()) - [0, 2, 65] + [0, 2, 70] >>> qu = query.Type('loops:*') >>> len(list(qu.apply())) - 36 + 38 >>> qu = query.Type('loops:concept:predicate') >>> len(list(qu.apply())) diff --git a/expert/browser/configure.zcml b/expert/browser/configure.zcml index 5dadb3a..95244a8 100644 --- a/expert/browser/configure.zcml +++ b/expert/browser/configure.zcml @@ -91,4 +91,18 @@ factory="loops.expert.browser.report.ResultsConceptView" permission="zope.View" /> + + + + diff --git a/expert/browser/export.py b/expert/browser/export.py new file mode 100644 index 0000000..41870d4 --- /dev/null +++ b/expert/browser/export.py @@ -0,0 +1,104 @@ +# +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +""" +View classes for export of report results. +""" + +import csv +from cStringIO import StringIO +from zope.cachedescriptors.property import Lazy +from zope.i18n import translate + +from loops.common import normalizeName +from loops.expert.browser.report import ResultsConceptView +from loops.interfaces import ILoopsObject +from loops.util import _ + + +class ResultsConceptCSVExport(ResultsConceptView): + + isToplevel = True + reportMode = 'export' + + delimiter = ';' + #encoding = 'UTF-8' + #encoding = 'ISO8859-15' + #encoding = 'CP852' + + @Lazy + def encoding(self): + enc = self.globalOptions('csv_encoding') + if enc: + return enc[0] + return 'UTF-8' + + def getFileName(self): + return normalizeName(self.context.title) + + def getColumnTitle(self, field): + lang = self.languageInfo.language + return translate(_(field.title), target_language=lang) + + def __call__(self): + fields = self.displayedColumns + fieldNames = [f.name for f in fields] + output = StringIO() + writer = csv.DictWriter(output, fieldNames, delimiter=self.delimiter) + output.write(self.delimiter.join( + [self.getColumnTitle(f) for f in fields]) + '\n') + results = self.reportInstance.getResults() + for row in results: + data = {} + for f in fields: + value = f.getValue(row) + if ILoopsObject.providedBy(value): + value = value.title + value = encode(value, self.encoding) + data[f.name] = value + writer.writerow(data) + text = output.getvalue() + self.setDownloadHeader(text) + return text + + def setDownloadHeader(self, text): + response = self.request.response + response.setHeader('Content-Disposition', + 'attachment; filename=%s.csv' % + self.getFileName()) + response.setHeader('Cache-Control', '') + response.setHeader('Pragma', '') + response.setHeader('Content-Type', 'text/csv') + response.setHeader('Content-Length', len(text)) + + +def encode(text, encoding): + if not isinstance(text, unicode): + return text + try: + return text.encode(encoding) + except UnicodeEncodeError: + result = [] + for c in text: + try: + result.append(c.encode(encoding)) + except UnicodeEncodeError: + result.append('?') + return ''.join(result) + return '???' + diff --git a/expert/browser/report.pt b/expert/browser/report.pt index c56447d..95ea185 100755 --- a/expert/browser/report.pt +++ b/expert/browser/report.pt @@ -3,7 +3,8 @@
@@ -37,6 +38,7 @@

+ +
+ + +
+ +
diff --git a/expert/browser/report.py b/expert/browser/report.py index 940a89b..c9f44c9 100755 --- a/expert/browser/report.py +++ b/expert/browser/report.py @@ -46,6 +46,8 @@ class ReportView(ConceptView): """ A view for defining (editing) a report. """ + resultsRenderer = None # to be defined by subclass + @Lazy def report_macros(self): return self.controller.mergeTemplateMacros('report', report_template) @@ -59,6 +61,25 @@ class ReportView(ConceptView): def dynamicParams(self): return self.request.form + @Lazy + def report(self): + return self.adapted + + @Lazy + def reportInstance(self): + instance = component.getAdapter(self.report, IReportInstance, + name=self.report.reportType) + instance.view = self + return instance + + @Lazy + def queryFields(self): + ri = self.reportInstance + qf = ri.getAllQueryFields() + if ri.userSettings: + return [f for f in qf if f in ri.userSettings] + return qf + class ResultsView(NodeView): @@ -107,13 +128,6 @@ class ResultsView(NodeView): def report(self): return adapted(self.virtualTargetObject) - @Lazy - def reportInstance(self): - instance = component.getAdapter(self.report, IReportInstance, - name=self.report.reportType) - instance.view = self - return instance - #@Lazy def results(self): return self.reportInstance.getResults(self.params) @@ -193,6 +207,13 @@ class ResultsConceptView(ConceptView): ri = component.getAdapter(self.report, IReportInstance, name=reportType) ri.view = self + if not ri.sortCriteria: + si = self.sortInfo.get('results') + if si is not None: + fnames = (si['colName'],) + ri.sortCriteria = [f for f in ri.getSortFields() + if f.name in fnames] + ri.sortDescending = not si['ascending'] return ri def results(self): @@ -207,6 +228,31 @@ class ResultsConceptView(ConceptView): def getColumnRenderer(self, col): return self.result_macros[col.renderer] + @Lazy + def downloadLink(self, format='csv'): + opt = self.options('download_' + format) + if not opt: + opt = self.typeOptions('download_' + format) + if opt: + return opt[0] + + def isSortableColumn(self, tableName, colName): + if tableName == 'results': + if colName in [f.name for f in self.reportInstance.getSortFields()]: + return True + return False + + +class EmbeddedResultsConceptView(ResultsConceptView): + + @Lazy + def macro(self): + return self.result_macros['embedded_content'] + + @Lazy + def title(self): + return self.report.title + class ReportConceptView(ResultsConceptView, ReportView): """ View on a concept using a report. diff --git a/expert/browser/results.pt b/expert/browser/results.pt index 55f3c29..11ed1f7 100644 --- a/expert/browser/results.pt +++ b/expert/browser/results.pt @@ -25,29 +25,61 @@
-
+
+
+ +
+
+
+ + +
+
+ + +
+
- - - + + - +
+ + + + + +
- -
+ +
- - + +
@@ -78,6 +110,17 @@ + + + + + + + + + >> request = TestRequest(form=form) >>> view = Search(page, request) >>> view.listConcepts() - u"{identifier: 'id', items: [{label: 'Zope (Thema)', name: 'Zope', id: '101'}, {label: 'Zope 2 (Thema)', name: 'Zope 2', id: '103'}, {label: 'Zope 3 (Thema)', name: 'Zope 3', id: '105'}]}" + u"{identifier: 'id', items: [{label: 'Zope (Thema)', name: 'Zope', id: '...'}, {label: 'Zope 2 (Thema)', name: 'Zope 2', id: '...'}, {label: 'Zope 3 (Thema)', name: 'Zope 3', id: '...'}]}" Preset Concept Types on Search Forms ------------------------------------ @@ -219,13 +219,13 @@ and thus include the customer type in the preset search types. >>> searchView.conceptsForType('loops:concept:customer') [{'token': 'none', 'title': u'not selected'}, - {'token': '74', 'title': u'Customer 1'}, - {'token': '76', 'title': u'Customer 2'}, - {'token': '78', 'title': u'Customer 3'}] + {'token': '...', 'title': u'Customer 1'}, + {'token': '...', 'title': u'Customer 2'}, + {'token': '...', 'title': u'Customer 3'}] Let's use this new search option for querying: - >>> form = {'search.4.text_selected': u'74'} + >>> form = {'search.4.text_selected': u'75'} >>> resultsView = SearchResults(page, TestRequest(form=form)) >>> results = list(resultsView.results) >>> results[0].title diff --git a/external/README.txt b/external/README.txt index bcc1cf5..8f62480 100644 --- a/external/README.txt +++ b/external/README.txt @@ -17,7 +17,7 @@ Let's set up a loops site with basic and example concepts and resources. >>> concepts, resources, views = t.setup() >>> loopsRoot = site['loops'] >>> len(concepts), len(resources), len(views) - (33, 3, 1) + (35, 3, 1) Importing loops Objects @@ -44,7 +44,7 @@ Creating the corresponding objects >>> loader = Loader(loopsRoot) >>> loader.load(elements) >>> len(concepts), len(resources), len(views) - (34, 3, 1) + (36, 3, 1) >>> from loops.common import adapted >>> adMyquery = adapted(concepts['myquery']) @@ -131,7 +131,7 @@ Extracting elements >>> extractor = Extractor(loopsRoot, os.path.join(dataDirectory, 'export')) >>> elements = list(extractor.extract()) >>> len(elements) - 66 + 69 Writing object information to the external storage -------------------------------------------------- diff --git a/integrator/interfaces.py b/integrator/interfaces.py index 4d2a4bd..afde0de 100644 --- a/integrator/interfaces.py +++ b/integrator/interfaces.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2011 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ """ Integrator interfaces. - -$Id$ """ from zope.interface import Interface, Attribute @@ -133,3 +131,5 @@ class IOfficeFile(IExternalFile): It provides access to the document content and properties. """ + documentPropertiesAccessible = Attribute( + 'Are document properties accessible?') diff --git a/integrator/office/base.py b/integrator/office/base.py index 542aa0a..6271d9f 100644 --- a/integrator/office/base.py +++ b/integrator/office/base.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -26,7 +26,7 @@ from lxml import etree import os import shutil from time import strptime -from zipfile import ZipFile +from zipfile import ZipFile, BadZipfile from zope.cachedescriptors.property import Lazy from zope import component from zope.component import adapts @@ -52,12 +52,22 @@ class OfficeFile(ExternalFileAdapter): implements(IOfficeFile) + _adapterAttributes = (ExternalFileAdapter._adapterAttributes + + ('documentPropertiesAccessible',)) + propertyMap = {u'Revision:': 'version'} propFileName = 'docProps/custom.xml' corePropFileName = 'docProps/core.xml' fileExtensions = ('.docm', '.docx', 'dotm', 'dotx', 'pptx', 'potx', 'ppsx', '.xlsm', '.xlsx', '.xltm', '.xltx') + def getDocumentPropertiesAccessible(self): + return getattr(self.context, '_documentPropertiesAccessible', True) + def setDocumentPropertiesAccessible(self, value): + self.context._documentPropertiesAccessible = value + documentPropertiesAccessible = property( + getDocumentPropertiesAccessible, setDocumentPropertiesAccessible) + @Lazy def logger(self): return getLogger('loops.integrator.office.base.OfficeFile') @@ -79,14 +89,19 @@ class OfficeFile(ExternalFileAdapter): def docPropertyDom(self): fn = self.docFilename result = dict(core=[], custom=[]) + if not os.path.exists(fn): + # may happen before file has been created + return result root, ext = os.path.splitext(fn) if not ext.lower() in self.fileExtensions: return result try: zf = ZipFile(fn, 'r') - except IOError, e: + self.documentPropertiesAccessible = True + except (IOError, BadZipfile), e: from logging import getLogger self.logger.warn(e) + self.documentPropertiesAccessible = False return result if self.corePropFileName not in zf.namelist(): self.logger.warn('Core properties not found in file %s.' % @@ -123,6 +138,8 @@ class OfficeFile(ExternalFileAdapter): attributes = {} # get dc:description from core.xml desc = self.getCoreProperty('description') + if not self.documentPropertiesAccessible: + return if desc is not None: attributes['comments'] = desc dom = self.docPropertyDom['custom'] diff --git a/knowledge/browser.py b/knowledge/browser.py index 6126661..8eb8645 100644 --- a/knowledge/browser.py +++ b/knowledge/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -30,8 +30,13 @@ from cybertools.typology.interfaces import IType from loops.browser.action import DialogAction from loops.browser.common import BaseView from loops.browser.concept import ConceptView +from loops.common import adapted from loops.knowledge.interfaces import IPerson, ITask from loops.organize.party import getPersonForUser +from loops.organize.personal import favorite +from loops.organize.personal.interfaces import IFavorites +from loops.security.common import checkPermission +from loops import util from loops.util import _ @@ -69,6 +74,63 @@ actions.register('createQualification', 'portlet', DialogAction, ) +class InstitutionMixin(object): + + knowledge_macros = knowledge_macros + + adminMaySelectAllInstitutions = True + + @Lazy + def institutionType(self): + return self.conceptManager['institution'] + + @Lazy + def institutions(self): + if self.adminMaySelectAllInstitutions: + if checkPermission('loops.ManageWorkspaces', self.context): + return self.getAllInstitutions() + result = [] + p = getPersonForUser(self.context, self.request) + if p is None: + return result + for parent in p.getParents( + [self.memberPredicate, self.masterPredicate]): + if parent.conceptType == self.institutionType: + result.append(dict( + object=adapted(parent), + title=parent.title, + uid=util.getUidForObject(parent))) + return result + + def getAllInstitutions(self): + insts = self.institutionType.getChildren([self.typePredicate]) + return [dict(object=adapted(inst), + title=inst.title, + uid=util.getUidForObject(inst)) for inst in insts] + + def setInstitution(self, uid): + inst = util.getObjectForUid(uid) + person = getPersonForUser(self.context, self.request) + favorite.setInstitution(person, inst) + self.institution = inst + return True + + def getSavedInstitution(self): + person = getPersonForUser(self.context, self.request) + favorites = IFavorites(self.loopsRoot.getRecordManager()['favorites']) + for inst in favorites.list(person, type='institution'): + return adapted(util.getObjectForUid(inst)) + + @Lazy + def institution(self): + saved = self.getSavedInstitution() + for inst in self.institutions: + if inst['object'] == saved: + return inst['object'] + if self.institutions: + return self.institutions[0]['object'] + + class MyKnowledge(ConceptView): template = template diff --git a/knowledge/data/knowledge_de.dmp b/knowledge/data/knowledge_de.dmp index d1a0e36..64fdaf4 100644 --- a/knowledge/data/knowledge_de.dmp +++ b/knowledge/data/knowledge_de.dmp @@ -1,9 +1,11 @@ -type(u'competence', u'Kompetenz', viewName=u'', +type(u'competence', u'Qualifikation', viewName=u'', typeInterface=u'loops.knowledge.qualification.interfaces.ICompetence', options=u'action.portlet:create_subtype,edit_concept') type(u'person', u'Person', viewName=u'', typeInterface=u'loops.knowledge.interfaces.IPerson', options=u'action.portlet:createQualification,editPerson') +type(u'report', u'Report', viewName=u'', + typeInterface='loops.expert.report.IReport') type(u'task', u'Aufgabe', viewName=u'', typeInterface=u'loops.knowledge.interfaces.ITask', options=u'action.portlet:createTask,editTask') @@ -26,6 +28,10 @@ concept(u'requires', u'requires', u'predicate') concept(u'issubtype', u'is Subtype', u'predicate', options=u'hide_children', predicateInterface='loops.interfaces.IIsSubtype') +# reports +concept(u'qualification_overview', u'Qualification Overview', u'report', + reportType=u'qualification_overview') + # structure child(u'general', u'competence', u'standard') child(u'general', u'depends', u'standard') @@ -38,6 +44,7 @@ child(u'general', u'topic', u'standard') #child(u'general', u'training', u'standard') child(u'system', u'issubtype', u'standard') +child(u'system', u'report', u'standard') child(u'competence', u'competence', u'issubtype') #child(u'competence', u'training', u'issubtype', usePredicate=u'provides') diff --git a/knowledge/data/knowledge_update_de.dmp b/knowledge/data/knowledge_update_de.dmp index 403589f..06d7f29 100644 --- a/knowledge/data/knowledge_update_de.dmp +++ b/knowledge/data/knowledge_update_de.dmp @@ -1,6 +1,14 @@ -type(u'competence', u'Kompetenz', viewName=u'', +type(u'competence', u'Qualifikation', viewName=u'', typeInterface=u'loops.knowledge.qualification.interfaces.ICompetence', options=u'action.portlet:create_subtype,edit_concept') +type(u'ipskill', u'Kompetenz', viewName=u'', + options=u'action.portlet:edit_concept') +type(u'ipskillsrequired', u'Soll-Profil', viewName=u'', + options=u'action.portlet:edit_concept') +type(u'jobposition', u'Stelle', viewName=u'', + options=u'action.portlet:edit_concept') +type(u'report', u'Report', viewName=u'', + typeInterface='loops.expert.report.IReport') # type(u'person', u'Person', viewName=u'', # typeInterface=u'loops.knowledge.interfaces.IPerson', # options=u'action.portlet:editPerson') @@ -26,9 +34,16 @@ concept(u'requires', u'requires', u'predicate') concept(u'issubtype', u'is Subtype', u'predicate', options=u'hide_children', predicateInterface='loops.interfaces.IIsSubtype') +# reports +concept(u'qualification_overview', u'Qualification Overview', u'report', + reportType=u'qualification_overview') + # structure child(u'general', u'competence', u'standard') child(u'general', u'depends', u'standard') +child(u'general', u'ipskill', u'standard') +child(u'general', u'ipskillsrequired', u'standard') +child(u'general', u'jobposition', u'standard') child(u'general', u'knows', u'standard') #child(u'general', u'person', u'standard') child(u'general', u'provides', u'standard') @@ -38,6 +53,7 @@ child(u'general', u'requires', u'standard') #child(u'general', u'training', u'standard') child(u'system', u'issubtype', u'standard') +child(u'system', u'report', u'standard') child(u'competence', u'competence', u'issubtype') #child(u'competence', u'training', u'issubtype', usePredicate=u'provides') diff --git a/knowledge/glossary/configure.zcml b/knowledge/glossary/configure.zcml index 6919ed3..a8924c8 100755 --- a/knowledge/glossary/configure.zcml +++ b/knowledge/glossary/configure.zcml @@ -1,5 +1,3 @@ - - diff --git a/knowledge/knowledge_macros.pt b/knowledge/knowledge_macros.pt index f775b71..133eb2a 100644 --- a/knowledge/knowledge_macros.pt +++ b/knowledge/knowledge_macros.pt @@ -1,6 +1,27 @@ + +
+
+ Organisation/Team: + + + +
+ +
+ +
diff --git a/knowledge/qualification/browser.py b/knowledge/qualification/browser.py index 29b53d6..4a98f70 100644 --- a/knowledge/qualification/browser.py +++ b/knowledge/qualification/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,19 +18,25 @@ """ Definition of view classes and other browser related stuff for the -loops.knowledge package. +loops.knowledge.qualification package. """ from zope import interface, component from zope.app.pagetemplate import ViewPageTemplateFile from zope.cachedescriptors.property import Lazy +from loops.browser.concept import ConceptView +from loops.expert.browser.export import ResultsConceptCSVExport from loops.expert.browser.report import ResultsConceptView -from loops.knowledge.browser import template, knowledge_macros -from loops.knowledge.qualification.base import QualificationRecord +from loops.organize.party import getPersonForUser +from loops.util import _ -class PersonQualificationView(ResultsConceptView): +class Qualifications(ResultsConceptView): - pass + # obsolete because we can directly use ResultsConceptView + + #reportName = 'qualification_overview' + + pass # report assigned to query via hasReport relation diff --git a/knowledge/qualification/configure.zcml b/knowledge/qualification/configure.zcml index dea246f..e4f02c9 100644 --- a/knowledge/qualification/configure.zcml +++ b/knowledge/qualification/configure.zcml @@ -15,4 +15,50 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/knowledge/qualification/report.py b/knowledge/qualification/report.py new file mode 100644 index 0000000..0bbc0b0 --- /dev/null +++ b/knowledge/qualification/report.py @@ -0,0 +1,133 @@ +# +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +""" +Qualification management report definitions. +""" + +from zope.cachedescriptors.property import Lazy + +from cybertools.composer.report.base import LeafQueryCriteria, CompoundQueryCriteria +from cybertools.util.jeep import Jeep +from loops.expert.report import ReportInstance +from loops.organize.work.report import WorkRow +from loops.organize.work.report import deadline, day, task, party, state +from loops.organize.work.report import dayStart, dayEnd +from loops.organize.work.report import workTitle, workDescription +from loops.organize.work.report import partyState +from loops import util + + +class QualificationOverview(ReportInstance): + + type = 'qualification_overview' + label = u'Qualification Overview' + + rowFactory = WorkRow + + fields = Jeep((task, party, workTitle, dayStart, dayEnd, state, + partyState,)) # +deadline? + + defaultOutputFields = Jeep(list(fields)[:-1]) + defaultSortCriteria = (party, task,) + + def getOptions(self, option): + return self.view.options(option) + + @Lazy + def states(self): + return self.getOptions('report_select_state' or ('planned',)) + + @property + def queryCriteria(self): + crit = self.context.queryCriteria or [] + f = self.fields.partyState + crit.append( + LeafQueryCriteria(f.name, f.operator, 'active', f)) + return CompoundQueryCriteria(crit) + + def selectObjects(self, parts): + result = [] + workItems = self.recordManager['work'] + pred = self.conceptManager['querytarget'] + types = self.view.context.getChildren([pred]) + for t in types: + for c in t.getChildren([self.view.typePredicate]): + uid = util.getUidForObject(c) + for wi in workItems.query(taskId=uid, state=self.states): + result.append(wi) + return result + + +class Qualifications(QualificationOverview): + + type = 'qualifications' + label = u'Qualifications' + + taskTypeNames = ('competence',) + + def getOptions(self, option): + return self.view.typeOptions(option) + + def selectObjects(self, parts): + result = [] + workItems = self.recordManager['work'] + target = self.view.context + tasks = [target] + self.getAllSubtasks(target) + for t in tasks: + uid = util.getUidForObject(t) + for wi in workItems.query(taskId=uid, state=self.states): + result.append(wi) + return result + + def getAllSubtasks(self, concept): + result = [] + for c in concept.getChildren([self.view.defaultPredicate]): + if c.conceptType in self.taskTypes: + result.append(c) + result.extend(self.getAllSubtasks(c)) + return result + + @Lazy + def taskTypes(self): + return [c for c in [self.conceptManager.get(name) + for name in self.taskTypeNames] + if c is not None] + + +class PersonQualifications(QualificationOverview): + + type = 'person_qualifications' + label = u'Qualifications for Person' + + defaultSortCriteria = (task,) + + def getOptions(self, option): + return self.view.typeOptions(option) + + @property + def queryCriteria(self): + crit = self.context.queryCriteria or [] + return CompoundQueryCriteria(crit) + + def selectObjects(self, parts): + workItems = self.recordManager['work'] + person = self.view.context + uid = util.getUidForObject(person) + return workItems.query(userName=uid, state=self.states) + diff --git a/knowledge/survey/browser.py b/knowledge/survey/browser.py index d9075d6..48a341f 100644 --- a/knowledge/survey/browser.py +++ b/knowledge/survey/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -23,6 +23,7 @@ surveys and self-assessments. import csv from cStringIO import StringIO +import math from zope.app.pagetemplate import ViewPageTemplateFile from zope.cachedescriptors.property import Lazy from zope.i18n import translate @@ -31,61 +32,305 @@ from cybertools.knowledge.survey.questionnaire import Response from cybertools.util.date import formatTimeStamp from loops.browser.concept import ConceptView from loops.browser.node import NodeView -from loops.common import adapted +from loops.common import adapted, baseObject +from loops.knowledge.browser import InstitutionMixin from loops.knowledge.survey.response import Responses from loops.organize.party import getPersonForUser +from loops.security.common import checkPermission from loops.util import getObjectForUid from loops.util import _ template = ViewPageTemplateFile('view_macros.pt') -class SurveyView(ConceptView): +class SurveyView(InstitutionMixin, ConceptView): data = None - errors = None + errors = message = None + batchSize = 12 + teamData = None + + template = template + + adminMaySelectAllInstitutions = False @Lazy def macro(self): self.registerDojo() return template.macros['survey'] + @Lazy + def title(self): + title = self.context.title + personId = self.request.form.get('person') + if personId: + person = adapted(getObjectForUid(personId)) + if person is not None: + return '%s: %s' % (title, person.title) + return title + @Lazy def tabview(self): if self.editable: return 'index.html' - def results(self): + def getUrlParamString(self): + qs = super(SurveyView, self).getUrlParamString() + if qs.startswith('?report='): + return '' + return qs + + @Lazy + def report(self): + return self.request.form.get('report') + + @Lazy + def questionnaireType(self): + return self.adapted.questionnaireType + + def teamReports(self): + if self.adapted.teamBasedEvaluation: + if checkPermission('loops.ViewRestricted', self.context): + return [dict(name='standard', label='label_survey_report_standard'), + dict(name='questions', + label='label_survey_report_questions')] + + def update(self): + instUid = self.request.form.get('select_institution') + if instUid: + return self.setInstitution(instUid) + + @Lazy + def groups(self): result = [] - response = None + if self.questionnaireType == 'pref_selection': + groups = [g.questions for g in self.adapted.questionGroups] + questions = [] + for idxg, g in enumerate(groups): + qus = [] + for idxq, qu in enumerate(g): + questions.append((idxg + 3 * idxq, idxg, qu)) + questions.sort() + questions = [item[2] for item in questions] + size = len(questions) + for idx in range(0, size, 3): + result.append(dict(title=u'Question', infoText=None, + questions=questions[idx:idx+3])) + return [g for g in result if len(g['questions']) == 3] + if self.adapted.noGrouping: + questions = list(self.adapted.questions) + questions.sort(key=lambda x: x.title) + size = len(questions) + bs = self.batchSize + for idx in range(0, size, bs): + result.append(dict(title=u'Question', infoText=None, + questions=questions[idx:idx+bs])) + else: + for group in self.adapted.questionGroups: + result.append(dict(title=group.title, + infoText=self.getInfoText(group), + questions=group.questions)) + return result + + @Lazy + def answerOptions(self): + opts = self.adapted.answerOptions + if not opts: + opts = [ + dict(value='none', label=u'No answer', + description=u'survey_value_none'), + dict(value=3, label=u'Fully applies', + description=u'survey_value_3'), + dict(value=2, label=u'', description=u'survey_value_2'), + dict(value=1, label=u'', description=u'survey_value_1'), + dict(value=0, label=u'Does not apply', + description=u'survey_value_0'),] + return opts + + @Lazy + def showFeedbackText(self): + sft = self.adapted.showFeedbackText + return sft is None and True or sft + + @Lazy + def feedbackColumns(self): + cols = self.adapted.feedbackColumns + if not cols: + cols = [ + dict(name='text', label=u'Response'), + dict(name='score', label=u'Score')] + if self.report == 'standard': + cols = [c for c in cols if c['name'] in self.teamColumns] + return cols + + teamColumns = ['category', 'average', 'stddev', 'teamRank', 'text'] + + @Lazy + def showTeamResults(self): + for c in self.feedbackColumns: + if c['name'] in ('average', 'teamRank'): + return True + return False + + def getTeamData(self, respManager): + result = [] + pred = [self.conceptManager.get('ismember'), + self.conceptManager.get('ismaster')] + if None in pred: + return result + inst = self.institution + instUid = self.getUidForObject(inst) + if inst: + for c in inst.getChildren(pred): + uid = self.getUidForObject(c) + data = respManager.load(uid, instUid) + if data: + resp = Response(self.adapted, None) + for qu in self.adapted.questions: + if qu.questionType in (None, 'value_selection'): + if qu.uid in data: + value = data[qu.uid] + if isinstance(value, int) or value.isdigit(): + resp.values[qu] = int(value) + else: + resp.texts[qu] = data.get(qu.uid) or u'' + qgAvailable = True + for qg in self.adapted.questionGroups: + if qg.uid in data: + resp.values[qg] = data[qg.uid] + else: + qgAvailable = False + if not qgAvailable: + values = resp.getGroupedResult() + for v in values: + resp.values[v['group']] = v['score'] + result.append(resp) + return result + + def results(self): + if self.report: + return self.teamResults(self.report) form = self.request.form - if 'submit' in form: - self.data = {} - response = Response(self.adapted, None) - for key, value in form.items(): - if key.startswith('question_'): + action = None + for k in ('submit', 'save'): + if k in form: + action = k + break + if action is None: + return [] + respManager = Responses(self.context) + respManager.personId = (self.request.form.get('person') or + respManager.getPersonId()) + if self.adapted.teamBasedEvaluation and self.institution: + respManager.institutionId = self.getUidForObject( + baseObject(self.institution)) + if self.adapted.questionnaireType == 'pref_selection': + return self.prefsResults(respManager, form, action) + data = {} + response = Response(self.adapted, None) + for key, value in form.items(): + if key.startswith('question_'): + if value != 'none': uid = key[len('question_'):] question = adapted(self.getObjectForUid(uid)) - if value != 'none': + if value.isdigit(): value = int(value) - self.data[uid] = value - response.values[question] = value - Responses(self.context).save(self.data) - self.errors = self.check(response) - if self.errors: - return [] - if response is not None: - result = response.getGroupedResult() - return [dict(category=r[0].title, text=r[1].text, - score=int(round(r[2] * 100))) - for r in result] + data[uid] = value + response.values[question] = value + values = response.getGroupedResult() + for v in values: + data[self.getUidForObject(v['group'])] = v['score'] + respManager.save(data) + if action == 'save': + self.message = u'Your data have been saved.' + return [] + self.data = data + self.errors = self.check(response) + if self.errors: + return [] + result = [dict(category=r['group'].title, text=r['feedback'].text, + score=int(round(r['score'] * 100)), rank=r['rank']) + for r in values] + if self.showTeamResults: + self.teamData = self.getTeamData(respManager) + groups = [r['group'] for r in values] + teamValues = response.getTeamResult(groups, self.teamData) + for idx, r in enumerate(teamValues): + result[idx]['average'] = int(round(r['average'] * 100)) + result[idx]['teamRank'] = r['rank'] + return result + + def teamResults(self, report): + result = [] + respManager = Responses(self.context) + self.teamData = self.getTeamData(respManager) + response = Response(self.adapted, None) + groups = self.adapted.questionGroups + teamValues = response.getTeamResult(groups, self.teamData) + for idx, r in enumerate(teamValues): + group = r['group'] + item = dict(category=group.title, + average=int(round(r['average'] * 100)), + teamRank=r['rank']) + if group.feedbackItems: + wScore = r['average'] * len(group.feedbackItems) - 0.00001 + item['text'] = group.feedbackItems[int(wScore)].text + result.append(item) + return result + + def getTeamResultsForQuestion(self, question, questionnaire): + result = dict(average=0.0, stddev=0.0) + if self.teamData is None: + respManager = Responses(self.context) + self.teamData = self.getTeamData(respManager) + answerRange = question.answerRange or questionnaire.defaultAnswerRange + values = [r.values.get(question) for r in self.teamData] + values = [v for v in values if v is not None] + if values: + average = float(sum(values)) / len(values) + if question.revertAnswerOptions: + average = answerRange - average - 1 + devs = [(average - v) for v in values] + stddev = math.sqrt(sum(d * d for d in devs) / len(values)) + average = average * 100 / (answerRange - 1) + stddev = stddev * 100 / (answerRange - 1) + result['average'] = int(round(average)) + result['stddev'] = int(round(stddev)) + texts = [r.texts.get(question) for r in self.teamData] + result['texts'] = '
'.join([unicode(t) for t in texts if t]) + return result + + def prefsResults(self, respManager, form, action): + result = [] + data = {} + for key, value in form.items(): + if key.startswith('group_') and value: + data[value] = 1 + respManager.save(data) + if action == 'save': + self.message = u'Your data have been saved.' + return [] + self.data = data + #self.errors = self.check(response) + if self.errors: + return [] + for group in self.adapted.questionGroups: + score = 0 + for qu in group.questions: + value = data.get(qu.uid) or 0 + if qu.revertAnswerOptions: + value = -value + score += value + result.append(dict(category=group.title, score=score)) + return result def check(self, response): errors = [] values = response.values for qu in self.adapted.questions: if qu.required and qu not in values: - errors.append('Please answer the obligatory questions.') + errors.append(dict(uid=qu.uid, + text='Please answer the obligatory questions.')) break qugroups = {} for qugroup in self.adapted.questionGroups: @@ -97,7 +342,12 @@ class SurveyView(ConceptView): if minAnswers in (u'', None): minAnswers = len(qugroup.questions) if count < minAnswers: - errors.append('Please answer the minimum number of questions.') + if self.adapted.noGrouping: + errors.append(dict(uid=qugroup.uid, + text='Please answer the highlighted questions.')) + else: + errors.append(dict(uid=qugroup.uid, + text='Please answer the minimum number of questions.')) break return errors @@ -106,7 +356,8 @@ class SurveyView(ConceptView): text = qugroup.description info = None if qugroup.minAnswers in (u'', None): - info = translate(_(u'Please answer all questions.'), target_language=lang) + info = translate(_(u'Please answer all questions.'), + target_language=lang) elif qugroup.minAnswers > 0: info = translate(_(u'Please answer at least $minAnswers questions.', mapping=dict(minAnswers=qugroup.minAnswers)), @@ -115,16 +366,46 @@ class SurveyView(ConceptView): text = u'%s
(%s)' % (text, info) return text + def loadData(self): + if self.data is None: + respManager = Responses(self.context) + respManager.personId = (self.request.form.get('person') or + respManager.getPersonId()) + if self.adapted.teamBasedEvaluation and self.institution: + respManager.institutionId = self.getUidForObject( + baseObject(self.institution)) + self.data = respManager.load() + def getValues(self, question): setting = None - if self.data is None: - self.data = Responses(self.context).load() + self.loadData() if self.data: setting = self.data.get(question.uid) - noAnswer = [dict(value='none', checked=(setting == None), - radio=(not question.required))] - return noAnswer + [dict(value=i, checked=(setting == i), radio=True) - for i in reversed(range(question.answerRange))] + if setting is None: + setting = 'none' + setting = str(setting) + result = [] + for opt in self.answerOptions: + value = str(opt['value']) + result.append(dict(value=value, checked=(setting == value), + title=opt.get('description') or u'')) + return result + + def getTextValue(self, question): + self.loadData() + if self.data: + return self.data.get(question.uid) + + def getPrefsValue(self, question): + self.loadData() + if self.data: + return self.data.get(question.uid) + + def getCssClass(self, question): + cls = '' + if self.errors and self.data.get(question.uid) is None: + cls = 'error ' + return cls + 'vpad' class SurveyCsvExport(NodeView): @@ -132,36 +413,43 @@ class SurveyCsvExport(NodeView): encoding = 'ISO8859-15' def encode(self, text): - text.encode(self.encoding) + return text.encode(self.encoding) @Lazy def questions(self): result = [] - for idx1, qug in enumerate(adapted(self.virtualTargetObject).questionGroups): + for idx1, qug in enumerate( + adapted(self.virtualTargetObject).questionGroups): for idx2, qu in enumerate(qug.questions): result.append((idx1, idx2, qug, qu)) return result @Lazy def columns(self): - infoCols = ['Name', 'Timestamp'] + infoCols = ['Institution', 'Name', 'Timestamp'] dataCols = ['%02i-%02i' % (item[0], item[1]) for item in self.questions] return infoCols + dataCols def getRows(self): + memberPred = self.conceptManager.get('ismember') for tr in Responses(self.virtualTargetObject).getAllTracks(): p = adapted(getObjectForUid(tr.userName)) - name = p and p.title or u'???' + name = self.encode(p and p.title or u'???') + inst = u'' + if memberPred is not None: + for i in baseObject(p).getParents([memberPred]): + inst = self.encode(i.title) + break ts = formatTimeStamp(tr.timeStamp) cells = [tr.data.get(qu.uid, -1) for (idx1, idx2, qug, qu) in self.questions] - yield [name, ts] + cells + yield [inst, name, ts] + cells def __call__(self): f = StringIO() writer = csv.writer(f, delimiter=',') writer.writerow(self.columns) - for row in self.getRows(): + for row in sorted(self.getRows()): writer.writerow(row) text = f.getvalue() self.setDownloadHeader(text) diff --git a/knowledge/survey/interfaces.py b/knowledge/survey/interfaces.py index e2adffc..500c60f 100644 --- a/knowledge/survey/interfaces.py +++ b/knowledge/survey/interfaces.py @@ -23,21 +23,78 @@ Interfaces for surveys used in knowledge management. from zope.interface import Interface, Attribute from zope import interface, component, schema +from cybertools.composer.schema.grid.interfaces import Records from cybertools.knowledge.survey import interfaces from loops.interfaces import IConceptSchema, ILoopsAdapter -from loops.util import _ +from loops.util import _, KeywordVocabulary class IQuestionnaire(IConceptSchema, interfaces.IQuestionnaire): """ A collection of questions for setting up a survey. """ + questionnaireHeader = schema.Text( + title=_(u'Questionnaire Header'), + description=_(u'Text that will appear at the top of the questionnaire.'), + default=u'', + missing_value=u'', + required=False) + + questionnaireType = schema.Choice( + title=_(u'Questionnaire Type'), + description=_(u'Select the type of the questionnaire.'), + source=KeywordVocabulary(( + ('standard', _(u'Standard Questionnaire')), + ('pref_selection', _(u'Preference Selection')), + )), + default='standard', + required=True) + defaultAnswerRange = schema.Int( title=_(u'Answer Range'), description=_(u'Number of items (answer options) to select from.'), default=4, required=True) + answerOptions = Records( + title=_(u'Answer Options'), + description=_(u'Values to select from with corresponding column ' + u'labels and descriptions. There should be at ' + u'least answer range items with numeric values.'), + default=[], + required=False) + + answerOptions.column_types = [ + schema.Text(__name__='value', title=u'Value',), + schema.Text(__name__='label', title=u'Label'), + schema.Text(__name__='description', title=u'Description'), + schema.Text(__name__='colspan', title=u'ColSpan'), + schema.Text(__name__='cssclass', title=u'CSS Class'),] + + noGrouping = schema.Bool( + title=_(u'No Grouping of Questions'), + description=_(u'The questions should be presented in a linear manner, ' + u'not grouped by categories or question groups.'), + default=False, + required=False) + + teamBasedEvaluation = schema.Bool( + title=_(u'Team-based Evaluation'), + description=_(u'.'), + default=False, + required=False) + + feedbackColumns = Records( + title=_(u'Feedback Columns'), + description=_(u'Column definitions for the results table ' + u'on the feedback page.'), + default=[], + required=False) + + feedbackColumns.column_types = [ + schema.Text(__name__='name', title=u'Column Name',), + schema.Text(__name__='label', title=u'Column Label'),] + feedbackHeader = schema.Text( title=_(u'Feedback Header'), description=_(u'Text that will appear at the top of the feedback page.'), @@ -69,6 +126,16 @@ class IQuestion(IConceptSchema, interfaces.IQuestion): """ A single question within a questionnaire. """ + questionType = schema.Choice( + title=_(u'Question Type'), + description=_(u'Select the type of the question.'), + source=KeywordVocabulary(( + ('value_selection', _(u'Value Selection')), + ('text', _(u'Text')), + )), + default='value_selection', + required=True) + required = schema.Bool( title=_(u'Required'), description=_(u'Question must be answered.'), diff --git a/knowledge/survey/response.py b/knowledge/survey/response.py index 3841c4f..8ba293c 100644 --- a/knowledge/survey/response.py +++ b/knowledge/survey/response.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -34,18 +34,32 @@ class Responses(BaseRecordManager): implements(IResponses) storageName = 'survey_responses' + personId = None + institutionId = None def __init__(self, context): self.context = context def save(self, data): if self.personId: - self.storage.saveUserTrack(self.uid, 0, self.personId, data, + id = self.personId + if self.institutionId: + id += '.' + self.institutionId + self.storage.saveUserTrack(self.uid, 0, id, data, update=True, overwrite=True) - def load(self): - if self.personId: - tracks = self.storage.getUserTracks(self.uid, 0, self.personId) + def load(self, personId=None, institutionId=None): + if personId is None: + personId = self.personId + if institutionId is None: + institutionId = self.institutionId + if personId: + id = personId + if institutionId: + id += '.' + institutionId + tracks = self.storage.getUserTracks(self.uid, 0, id) + if not tracks: # then try without institution + tracks = self.storage.getUserTracks(self.uid, 0, personId) if tracks: return tracks[0].data return {} diff --git a/knowledge/survey/view_macros.pt b/knowledge/survey/view_macros.pt index 9e7593c..6a0b7ac 100644 --- a/knowledge/survey/view_macros.pt +++ b/knowledge/survey/view_macros.pt @@ -4,96 +4,273 @@ + questType item/questionnaireType; + questMacro python: + 'quest_' + (questType or 'standard'); + report request/report|nothing; + reportMacro python: + 'report_' + (report or 'standard'); + errors item/errors; + message item/message; + dummy item/update"> - - -
-

Feedback

-
- - - - - - - - -
CategoryResponse%
- - -
-
-
+ tal:content="structure python: + item.renderText(header, 'text/restructured')" /> + + +
+
+ +
+ + + + + + + +
+ Show Report:   + +

+

Questionnaire

+ tal:content="error/text" />
+
- - + + + + + - - - - + - - - - + + + + + +
 
  
- + +
No answerFully appliesDoes not apply
- - - *** - -
+ + i18n:attributes="value; onclick" + onclick="if (confirm('Do you really want to reset all response data?')) setRadioButtons('none'); return false" />
-
+
+ + + +

Questionnaire

+
+
+ +
+
+
+
+ + + + + + + + + + +
  
+ + +
+ + + +
+ + + + + + + + + + + + + + + + +
+ + + + + + + + +

Feedback

+
+ + + + + + +
Category +
+ + + +
+

+ Team Size: +
  +

+
+
+ + + + +

+
+ + + + + + + + + + + + + + + + + + + + +
  
Average
+
+
+
+
+

+ Team Size: +
  +

+ +
diff --git a/knowledge/tests.py b/knowledge/tests.py index 8340a79..11f8ec4 100755 --- a/knowledge/tests.py +++ b/knowledge/tests.py @@ -7,6 +7,7 @@ from zope import component from zope.interface.verify import verifyClass from zope.testing.doctestunit import DocFileSuite +from loops.expert.report import IReport, Report from loops.knowledge.qualification.base import Competence from loops.knowledge.survey.base import Questionnaire, Question, FeedbackItem from loops.knowledge.survey.interfaces import IQuestionnaire, IQuestion, \ @@ -19,6 +20,7 @@ importPath = os.path.join(os.path.dirname(__file__), 'data') def importData(loopsRoot): + component.provideAdapter(Report, provides=IReport) baseImportData(loopsRoot, importPath, 'knowledge_de.dmp') def importSurvey(loopsRoot): diff --git a/locales/de/LC_MESSAGES/loops.mo b/locales/de/LC_MESSAGES/loops.mo index 0a244f1..ed80ed5 100644 Binary files a/locales/de/LC_MESSAGES/loops.mo and b/locales/de/LC_MESSAGES/loops.mo differ diff --git a/locales/de/LC_MESSAGES/loops.po b/locales/de/LC_MESSAGES/loops.po index afe516f..4c82808 100644 --- a/locales/de/LC_MESSAGES/loops.po +++ b/locales/de/LC_MESSAGES/loops.po @@ -1,9 +1,9 @@ msgid "" msgstr "" -"Project-Id-Version: 0.13.0\n" +"Project-Id-Version: 0.13.1\n" "POT-Creation-Date: 2007-05-22 12:00 CET\n" -"PO-Revision-Date: 2013-07-15 12:00 CET\n" +"PO-Revision-Date: 2015-06-06 12:00 CET\n" "Last-Translator: Helmut Merz \n" "Language-Team: loops developers \n" "MIME-Version: 1.0\n" @@ -89,6 +89,14 @@ msgstr "Thema ändern" msgid "Please correct the indicated errors." msgstr "Bitte berichtigen Sie die angezeigten Fehler." +msgid "tooltip_sort_column" +msgstr "Nach dieser Spalte sortieren" + +# expert (reporting) + +msgid "Download Data" +msgstr "Download als Excel-Datei" + # blog msgid "Edit Blog Post..." @@ -178,6 +186,30 @@ msgstr "Glossareintrag anlegen." msgid "Answer Range" msgstr "Abstufung Bewertungen" +msgid "Answer Options" +msgstr "Antwortmöglichkeiten" + +msgid "Values to select from with corresponding column labels and descriptions. There should be at least answer range items with numeric values." +msgstr "Auszuwählende Werte mit zugehörigen Spaltenüberschriften und Beschreibungen. Es sollte mindestens so viele Einträge mit numerischen Werten geben wie durch das Feld 'Abstufung Bewertungen' vorgegeben." + +msgid "No Grouping of Questions" +msgstr "Keine Gruppierung der Fragen" + +msgid "The questions should be presented in a linear manner, not grouped by categories or question groups." +msgstr "Die Fragen sollen in linearer Reihenfolge ausgegeben und nicht nach Fragengruppen bzw. Kategorien gruppiert werden." + +msgid "Questionnaire Header" +msgstr "Infotext zum Fragebogen" + +msgid "Text that will appear at the top of the questionnaire." +msgstr "Text, der vor dem Fragebogen erscheinen soll" + +msgid "Feedback Header" +msgstr "Infotext zur Auswertung" + +msgid "Text that will appear at the top of the feedback page." +msgstr "Text, der oben auf der Auswertungsseite erscheinen soll." + msgid "Feedback Footer" msgstr "Auswertungs-Hinweis" @@ -193,6 +225,15 @@ msgstr "Mindestanzahl an Antworten" msgid "Minumum number of questions that have to be answered. Empty means all questions have to be answered." msgstr "Anzahl der Fragen, die mindestens beantwortet werden müssen. Keine Angabe: Es müssen alle Fragen beantwortet werden." +msgid "Question Type" +msgstr "Fragentyp" + +msgid "Select the type of the question." +msgstr "Bitte den Typ der Frage auswählen." + +msgid "Value Selection" +msgstr "Auswahl Bewertung" + msgid "Required" msgstr "Pflichtfrage" @@ -205,6 +246,9 @@ msgstr "Negative Polarität" msgid "Value inversion: High selection means low value." msgstr "Invertierung der Bewertung: Hohe gewählte Stufe bedeutet niedriger Wert." +msgid "Question" +msgstr "Frage" + msgid "Questionnaire" msgstr "Fragebogen" @@ -241,15 +285,30 @@ msgstr "Trifft eher zu" msgid "survey_value_3" msgstr "Trifft für unser Unternehmen voll und ganz zu" +msgid "label_survey_show_report" +msgstr "Auswertung anzeigen" + +msgid "label_survey_report_standard" +msgstr "Standard-Auswertung" + +msgid "label_survey_report_questions" +msgstr "Einzelfragen-Auswertung" + msgid "Evaluate Questionnaire" msgstr "Fragebogen auswerten" +msgid "Save Data" +msgstr "Daten speichern" + msgid "Reset Responses Entered" msgstr "Eingaben zurücksetzen" msgid "Back to Questionnaire" msgstr "Zurück zum Fragebogen" +msgid "Your data have been saved." +msgstr "Ihre Daten wurden gespeichert." + msgid "Please answer at least $minAnswers questions." msgstr "Bitte beantworten Sie mindestens $minAnswers Fragen." @@ -262,10 +321,37 @@ msgstr "Bitte beantworten Sie die Pflichtfragen." msgid "Please answer the minimum number of questions." msgstr "Bitte beantworten Sie die angegebene Mindestanzahl an Fragen je Fragengruppe." +msgid "Please answer the highlighted questions." +msgstr "Bitte beantworten Sie die markierten Fragen." + msgid "Obligatory question, must be answered" msgstr "Pflichtfrage, muss beantwortet werden" -# competence (qualification) +msgid "Score" +msgstr "Ergebnis %" + +msgid "Team Score" +msgstr "Durchschnitt Team %" + +msgid "Rank" +msgstr "Rang" + +msgid "Team Rank" +msgstr "Rang Team" + +msgid "Average" +msgstr "Durchschnitt" + +msgid "Deviation" +msgstr "Abweichung" + +msgid "Team Size" +msgstr "Anzahl der vom Team ausgefüllten Fragebögen" + +msgid "if (confirm('Do you really want to reset all response data?')) setRadioButtons('none'); return false" +msgstr "if (confirm('Wollen Sie wirklich alle eingegebenen Daten zurücksetzen?')) setRadioButtons('none'); return false" + +# compentence and qualification management msgid "Validity Period (Months)" msgstr "Gültigkeitszeitraum (Monate)" @@ -471,6 +557,8 @@ msgstr "Wer?" msgid "When?" msgstr "Wann?" +# personal stuff + msgid "Favorites" msgstr "Lesezeichen" @@ -495,6 +583,8 @@ msgstr "Anmelden" msgid "Presence" msgstr "Anwesenheit" +# general + msgid "Actions" msgstr "Aktionen" @@ -819,6 +909,12 @@ msgstr "Beginn" msgid "End date" msgstr "Ende" +msgid "Start Day" +msgstr "Beginn" + +msgid "End Day" +msgstr "Ende" + msgid "Knowledge" msgstr "Kompetenzen" @@ -891,6 +987,9 @@ msgstr "Kommentare" msgid "Add Comment" msgstr "Kommentar hinzufügen" +msgid "Email Address" +msgstr "E-Mail-Adresse" + msgid "Subject" msgstr "Thema" @@ -965,6 +1064,21 @@ msgstr "Kalender" msgid "Work Items" msgstr "Aktivitäten" +msgid "Work Item Type" +msgstr "Art der Aktivität" + +msgid "Unit of Work" +msgstr "Standard-Aktivität" + +msgid "Scheduled Event" +msgstr "Termin" + +msgid "Deadline" +msgstr "Deadline" + +msgid "Check-up" +msgstr "Überprüfung" + msgid "Work Items for $title" msgstr "Aktivitäten für $title" @@ -995,6 +1109,12 @@ msgstr "Dauer/Aufwand" msgid "Duration / Effort (hh:mm)" msgstr "Dauer / Aufwand (hh:mm)" +msgid "Priority" +msgstr "Priorität" + +msgid "Activity" +msgstr "Leistungsart" + msgid "Action" msgstr "Aktion" @@ -1069,6 +1189,9 @@ msgstr "Bemerkung" msgid "desc_transition_comments" msgstr "Notizen zum Statusübergang." +msgid "contact_states" +msgstr "Kontaktstatus" + # state names msgid "accepted" @@ -1137,6 +1260,12 @@ msgstr "unklassifiziert" msgid "verified" msgstr "verifiziert" +msgid "prospective" +msgstr "künftig" + +msgid "inactive" +msgstr "inaktiv" + # transitions msgid "accept" @@ -1211,6 +1340,15 @@ msgstr "verifizieren" msgid "work" msgstr "bearbeiten" +msgid "activate" +msgstr "aktivieren" + +msgid "inactivate" +msgstr "inaktiv setzen" + +msgid "reset" +msgstr "zurücksetzen" + # calendar msgid "Monday" diff --git a/organize/README.txt b/organize/README.txt index 103973c..7dd3cd1 100644 --- a/organize/README.txt +++ b/organize/README.txt @@ -228,6 +228,23 @@ We need a principal for testing the login stuff: >>> pwcView.update() False +Reset Password +-------------- + +Invalidates the user account by generating a new password. A mail ist sent to +the email address of the person with a link for re-activating the account +and enter a new password. + + >>> data = {'loginName': u'dummy', + ... 'action': 'update'} + + >>> request = TestRequest(form=data) + + >>> from loops.organize.browser.member import PasswordReset + >>> pwrView = PasswordReset(menu, request) + >>> pwrView.update() + True + Pure Person-based Authentication ================================ @@ -410,7 +427,7 @@ Send Email to Members >>> form.subject u"loops Notification from '$site'" >>> form.mailBody - u'\n\nEvent #1\nhttp://127.0.0.1/loops/views/menu/.113\n\n' + u'\n\nEvent #1\nhttp://127.0.0.1/loops/views/menu/.118\n\n' Show Presence of Other Users diff --git a/organize/browser/configure.zcml b/organize/browser/configure.zcml index e351efa..3aa0336 100644 --- a/organize/browser/configure.zcml +++ b/organize/browser/configure.zcml @@ -45,6 +45,12 @@ class="loops.organize.browser.member.PasswordChange" permission="zope.View" /> + + + + + + diff --git a/organize/browser/member.py b/organize/browser/member.py index 7cd4948..7aabe80 100644 --- a/organize/browser/member.py +++ b/organize/browser/member.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -25,6 +25,7 @@ from datetime import datetime from email.MIMEText import MIMEText from zope import interface, component from zope.app.authentication.principalfolder import InternalPrincipal +from zope.app.authentication.principalfolder import PrincipalInfo from zope.app.form.browser.textwidgets import PasswordWidget as BasePasswordWidget from zope.app.pagetemplate import ViewPageTemplateFile from zope.app.principalannotation import annotations @@ -47,9 +48,11 @@ from loops.browser.node import NodeView from loops.common import adapted from loops.concept import Concept from loops.organize.interfaces import ANNOTATION_KEY, IMemberRegistrationManager -from loops.organize.interfaces import IMemberRegistration, IPasswordChange +from loops.organize.interfaces import IMemberRegistration, IPasswordEntry +from loops.organize.interfaces import IPasswordChange, IPasswordReset from loops.organize.party import getPersonForUser, Person from loops.organize.util import getInternalPrincipal, getPrincipalForUserId +from loops.organize.util import getPrincipalFolder import loops.browser.util from loops.util import _ @@ -86,7 +89,8 @@ class BaseMemberRegistration(NodeView): template = form_macros formErrors = dict( - confirm_nomatch=FormError(_(u'Password and password confirmation do not match.')), + confirm_nomatch=FormError(_(u'Password and password confirmation ' + u'do not match.')), duplicate_loginname=FormError(_('Login name already taken.')), ) @@ -187,7 +191,9 @@ class MemberRegistration(BaseMemberRegistration, CreateForm): result = regMan.register(login, pw, form.get('lastName'), form.get('firstName'), email=form.get('email'), - phoneNumbers=[x for x in phoneNumbers if x]) + phoneNumbers=[x for x in phoneNumbers if x], + salutation=form.get('salutation'), + academicTitle=form.get('academicTitle')) if isinstance(result, dict): fi = formState.fieldInstances[result['fieldName']] fi.setError(result['error'], self.formErrors) @@ -210,6 +216,8 @@ class SecureMemberRegistration(BaseMemberRegistration, CreateForm): @Lazy def schema(self): schema = super(SecureMemberRegistration, self).schema + schema.fields.remove('salutation') + schema.fields.remove('academicTitle') schema.fields.remove('birthDate') schema.fields.remove('password') schema.fields.remove('passwordConfirm') @@ -366,7 +374,8 @@ class PasswordChange(NodeView, Form): message = _(u'Your password has been changed.') formErrors = dict( - confirm_nomatch=FormError(_(u'Password and password confirmation do not match.')), + confirm_nomatch=FormError(_(u'Password and password confirmation ' + u'do not match.')), wrong_oldpw=FormError(_(u'Your old password was not entered correctly.')), ) @@ -422,3 +431,84 @@ class PasswordChange(NodeView, Form): formState.severity = max(formState.severity, fi.severity) return formState + +class PasswordReset(PasswordChange): + + interface = IPasswordReset + message = _(u'Password Reset: You will receive an email with ' + u'a link to change your password.') + + formErrors = dict( + confirm_notfound=FormError(_(u'Invalid user account.')), + ) + + label = label_submit = _(u'Reset Password') + + @Lazy + def data(self): + data = dict(loginName=u'') + return data + + def update(self): + form = self.request.form + if not form.get('action'): + return True + formState = self.formState = self.validate(form) + if formState.severity > 0: + return True + loginName = form.get('loginName') + person = principal = None + regMan = IMemberRegistrationManager(self.context.getLoopsRoot()) + authenticator = regMan.getPrincipalFolderFromOption() + if authenticator is not None: + userId = authenticator.prefix + loginName + principal = getPrincipalForUserId(userId) + if principal is not None: + person = getPersonForUser(self.context, principal=principal) + if person is None: + fi = formState.fieldInstances['loginName'] + fi.setError('confirm_notfound', self.formErrors) + formState.severity = max(formState.severity, fi.severity) + return True + person = adapted(person) + pa = self.getPrincipalAnnotation(principal) + pa['id'] = generateName() + pa['timestamp'] = datetime.utcnow() + self.notifyEmail(loginName, person.email, pa['id']) + url = '%s?messsage=%s' % (self.url, self.message) + self.request.response.redirect(url) + return False + + def getPrincipalAnnotation(self, principal): + return annotations(principal).get(ANNOTATION_KEY, None) + + def notifyEmail(self, userid, recipient, id): + baseUrl = absoluteURL(self.context.getMenu(), self.request) + url = u'%s/selfservice_confirmation.html?login=%s&id=%s' % ( + baseUrl, userid, id,) + recipients = [recipient] + subject = _(u'password_reset_mail_subject') + message = _(u'password_reset_mail_text') + u':\n\n' + message = (message + url).encode('UTF-8') + senderInfo = self.globalOptions('email.sender') + sender = senderInfo and senderInfo[0] or 'info@loops.cy55.de' + sender = sender.encode('UTF-8') + msg = MIMEText(message, 'plain', 'utf-8') + msg['Subject'] = subject.encode('UTF-8') + msg['From'] = sender + msg['To'] = ', '.join(recipients) + mailhost = component.getUtility(IMailDelivery, 'Mail') + mailhost.send(sender, recipients, msg.as_string()) + + +class FixPersonRoles(object): + + def __call__(self): + concepts = self.context['concepts'] + for p in concepts['person'].getChildren([concepts['hasType']]): + person = adapted(p) + userId = person.userId + print '***', userId + person.userId = userId + return 'blubb' + diff --git a/organize/comment/README.txt b/organize/comment/README.txt index f3a1df9..060571e 100644 --- a/organize/comment/README.txt +++ b/organize/comment/README.txt @@ -45,6 +45,9 @@ to assign comments to this document. >>> home = views['home'] >>> home.target = resources['d001.txt'] + >>> from loops.organize.comment.base import commentStates + >>> component.provideUtility(commentStates(), name='organize.commentStates') + Creating comments ----------------- @@ -75,6 +78,12 @@ Viewing comments ('My comment', u'... ...', u'john') +Reporting +========= + + >>> from loops.organize.comment.report import CommentsOverview + + Fin de partie ============= diff --git a/organize/comment/base.py b/organize/comment/base.py index 3ad956e..d68c81a 100644 --- a/organize/comment/base.py +++ b/organize/comment/base.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2008 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,24 +18,55 @@ """ Base classes for comments/discussions. - -$Id$ """ from zope.component import adapts -from zope.interface import implements +from zope.interface import implementer, implements +from zope.traversing.api import getParent +from cybertools.stateful.definition import StatesDefinition +from cybertools.stateful.definition import State, Transition +from cybertools.stateful.interfaces import IStatesDefinition from cybertools.tracking.btree import Track from cybertools.tracking.interfaces import ITrackingStorage -from cybertools.tracking.comment.interfaces import IComment +from loops.organize.comment.interfaces import IComment +from loops.organize.stateful.base import Stateful from loops import util -class Comment(Track): +@implementer(IStatesDefinition) +def commentStates(): + return StatesDefinition('commentStates', + State('new', 'new', ('accept', 'reject'), color='red'), + State('public', 'public', ('retract', 'reject'), color='green'), + State('rejected', 'rejected', ('accept',), color='grey'), + Transition('accept', 'accept', 'public'), + Transition('reject', 'reject', 'rejected'), + Transition('retract', 'retract', 'new'), + initialState='new') + + +class Comment(Stateful, Track): implements(IComment) + metadata_attributes = Track.metadata_attributes + ('state',) + index_attributes = metadata_attributes typeName = 'Comment' + typeInterface = IComment + statesDefinition = 'organize.commentStates' contentType = 'text/restructured' + def __init__(self, taskId, runId, userName, data): + super(Comment, self).__init__(taskId, runId, userName, data) + self.state = self.getState() # make initial state persistent + + @property + def title(self): + return self.data['subject'] + + def doTransition(self, action): + super(Comment, self).doTransition(action) + getParent(self).indexTrack(None, self, 'state') + diff --git a/organize/comment/browser.py b/organize/comment/browser.py index 2179c76..f7efe0d 100644 --- a/organize/comment/browser.py +++ b/organize/comment/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2012 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -23,15 +23,17 @@ Definition of view classes and other browser related stuff for comments. from zope import interface, component from zope.app.pagetemplate import ViewPageTemplateFile from zope.cachedescriptors.property import Lazy +from zope.security import checkPermission from cybertools.browser.action import actions from cybertools.tracking.btree import TrackingStorage -from loops.browser.action import DialogAction +from loops.browser.action import Action, DialogAction from loops.browser.common import BaseView from loops.browser.form import ObjectForm, EditObject from loops.browser.node import NodeView from loops.organize.comment.base import Comment from loops.organize.party import getPersonForUser +from loops.organize.stateful.browser import StateAction from loops.organize.tracking.report import TrackDetails from loops.security.common import canAccessObject from loops.setup import addObject @@ -50,10 +52,17 @@ class CommentsView(NodeView): @Lazy def allowed(self): - if self.isAnonymous: + if self.virtualTargetObject is None: return False - return (self.virtualTargetObject is not None and - self.globalOptions('organize.allowComments')) + opts = (self.globalOptions('organize.allowComments') or + self.typeOptions('organize.allowComments')) + if not opts: + return False + if opts is True: + opts = [] + if self.isAnonymous and not 'all' in opts: + return False + return True @Lazy def addUrl(self): @@ -76,9 +85,47 @@ class CommentsView(NodeView): result.append(CommentDetails(self, tr)) return result + def getActionsFor(self, comment): + if not self.globalOptions('organize.showCommentState'): + return [] + if not checkPermission('loops.ViewRestricted', self.context): + return [] + trackUid = util.getUidForObject(comment.track) + url = '%s/.%s/change_state.html' % ( + self.page.virtualTargetUrl, trackUid) + onClick = ("objectDialog('change_state', " + "'%s?dialog=change_state" + "&target_uid=%s'); return false;" % (url, trackUid)) + stateAct = StateAction(self, + definition='organize.commentStates', + stateful=comment.track, + url=url, + onClick=onClick) + actions = [stateAct] + if not checkPermission('loops.EditRestricted', self.context): + return actions + baseUrl = self.page.virtualTargetUrl + url = '%s/delete_object?uid=%s' % (baseUrl, trackUid) + onClick = _("return confirm('Do you really want to delete this object?')") + delAct = Action(self, + url=url, + description=_('Delete Comment'), + icon='cybertools.icons/delete.png', + cssClass='icon-action', + onClick=onClick) + actions.append(delAct) + return actions + class CommentDetails(TrackDetails): + @Lazy + def poster(self): + name = self.track.data.get('name') + if name: + return name + return self.user['title'] + @Lazy def subject(self): return self.track.data['subject'] @@ -108,6 +155,8 @@ class CreateComment(EditObject): @Lazy def personId(self): + if self.view.isAnonymous: + return self.request.form.get('email') p = getPersonForUser(self.context, self.request) if p is not None: return util.getUidForObject(p) @@ -129,8 +178,11 @@ class CreateComment(EditObject): if ts is None: ts = addObject(rm, TrackingStorage, 'comments', trackFactory=Comment) uid = util.getUidForObject(self.object) - ts.saveUserTrack(uid, 0, self.personId, dict( - subject=subject, text=text)) + data = dict(subject=subject, text=text) + for k in ('name', 'email'): + if k in form: + data[k] = form[k] + ts.saveUserTrack(uid, 0, self.personId, data) url = self.view.virtualTargetUrl + '?version=this' self.request.response.redirect(url) return False diff --git a/organize/comment/comment_macros.pt b/organize/comment/comment_macros.pt index c512233..37cec4b 100644 --- a/organize/comment/comment_macros.pt +++ b/organize/comment/comment_macros.pt @@ -14,10 +14,17 @@
+
+ + + +

Subject

- John, + John, 2007-03-30

Add Comment
+ + +
+ +
+
+ + + + + + + + + + + + diff --git a/organize/comment/interfaces.py b/organize/comment/interfaces.py new file mode 100644 index 0000000..687e8c4 --- /dev/null +++ b/organize/comment/interfaces.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +""" +Interface definitions for comments - discussions - forums. +""" + +from zope.interface import Interface, Attribute +from zope import schema + +from cybertools.tracking.comment.interfaces import IComment diff --git a/organize/comment/loops_comment_de.dmp b/organize/comment/loops_comment_de.dmp new file mode 100644 index 0000000..85fc571 --- /dev/null +++ b/organize/comment/loops_comment_de.dmp @@ -0,0 +1,6 @@ +type(u'report', u'Report', options=u'', + typeInterface='loops.expert.report.IReport', viewName=u'') +concept(u'comments_overview', u'\xdcbersicht Kommentare', u'report', + reportType=u'comments_overview') +concept(u'comments', u'Kommentare', u'query', options=u'', + viewName=u'list_comments.html') diff --git a/organize/comment/report.py b/organize/comment/report.py new file mode 100644 index 0000000..99c451e --- /dev/null +++ b/organize/comment/report.py @@ -0,0 +1,75 @@ +# +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +""" +Report views and definitions for comments listings and similar stuff. +""" + +from cybertools.util.jeep import Jeep +from loops.expert.browser.report import ReportConceptView +from loops.expert.field import Field, StateField, TargetField +from loops.expert.field import TrackDateField +from loops.expert.report import ReportInstance, TrackRow + + +class CommentsOverview(ReportConceptView): + + reportName = 'comments_overview' + + +timeStamp = TrackDateField('timeStamp', u'Timestamp', + description=u'The date and time the comment was posted.', + part='dateTime', descending=True, + executionSteps=['sort', 'output']) +target = TargetField('taskId', u'Target', + description=u'The resource or concept the comment was posted at.', + executionSteps=['output']) +name = Field('name', u'Name', + description=u'The name addres of the poster.', + executionSteps=['output']) +email = Field('email', u'E-Mail Address', + description=u'The email addres of the poster.', + executionSteps=['output']) +subject = Field('subject', u'Subject', + description=u'The subject of the comment.', + executionSteps=['output']) +state = StateField('state', u'State', + description=u'The state of the comment.', + cssClass='center', + statesDefinition='organize.commentStates', + executionSteps=['query', 'sort', 'output']) + + +class CommentsRow(TrackRow): + + pass + + +class CommentsReportInstance(ReportInstance): + + type = "comments_overview" + label = u'Comments Overview' + + rowFactory = CommentsRow + + fields = Jeep((timeStamp, target, name, email, subject, state)) + defaultOutputFields = fields + defaultSortCriteria = (state, timeStamp) + + def selectObjects(self, parts): + return self.recordManager['comments'].values() diff --git a/organize/interfaces.py b/organize/interfaces.py index 3581128..3fabfd9 100644 --- a/organize/interfaces.py +++ b/organize/interfaces.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -82,7 +82,7 @@ class LoginName(schema.TextLine): super(LoginName, self)._validate(userId) if userId in getPrincipalFolder(self.context): raiseValidationError( - _(u'There is alread a user with ID $userId.', + _(u'There is already a user with ID $userId.', mapping=dict(userId=userId))) @@ -124,6 +124,14 @@ class IPasswordChange(IPasswordEntry): required=True,) +class IPasswordReset(Interface): + + loginName = schema.TextLine(title=_(u'User ID'), + description=_(u'Your login name.'), + required=True,) + loginName.nostore = True + + class IMemberRegistration(IBasePerson, IPasswordEntry): """ Schema for registering a new member (user + person). """ diff --git a/organize/party.py b/organize/party.py index 1d87a7f..64247b3 100644 --- a/organize/party.py +++ b/organize/party.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -57,6 +57,8 @@ PredicateInterfaceSourceList.predicateInterfaces += (IHasRole,) def getPersonForUser(context, request=None, principal=None): + if context is None: + return None if principal is None: if request is None: principal = getCurrentPrincipal() @@ -95,9 +97,11 @@ class Person(AdapterBase, BasePerson): return person = getPersonForUser(self.context, principal=principal) if person is not None and person != self.context: - raise ValueError( - 'Error when creating user %s: There is already a person (%s) assigned to user %s.' - % (getName(self.context), getName(person), userId)) + name = getName(person) + if name: + raise ValueError( + 'There is already a person (%s) assigned to user %s.' + % (getName(person), userId)) pa = annotations(principal) loopsId = util.getUidForObject(self.context.getLoopsRoot()) ann = pa.get(ANNOTATION_KEY) diff --git a/organize/personal/README.txt b/organize/personal/README.txt index ed19349..d058355 100644 --- a/organize/personal/README.txt +++ b/organize/personal/README.txt @@ -75,7 +75,7 @@ So we are now ready to query the favorites. >>> favs = list(favorites.query(userName=johnCId)) >>> favs - [] + [] >>> list(favAdapted.list(johnC)) ['27'] diff --git a/organize/personal/favorite.py b/organize/personal/favorite.py index 96a8b16..ef97974 100644 --- a/organize/personal/favorite.py +++ b/organize/personal/favorite.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2010 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ """ Base classes for a notification framework. - -$Id$ """ from zope.component import adapts @@ -39,17 +37,20 @@ class Favorites(object): def __init__(self, context): self.context = context - def list(self, person, sortKey=None): - for item in self.listTracks(person, sortKey): + def list(self, person, sortKey=None, type='favorite'): + for item in self.listTracks(person, sortKey, type): yield item.taskId - def listTracks(self, person, sortKey=None): + def listTracks(self, person, sortKey=None, type='favorite'): if person is None: return personUid = util.getUidForObject(person) if sortKey is None: sortKey = lambda x: -x.timeStamp for item in sorted(self.context.query(userName=personUid), key=sortKey): + if type is not None: + if item.type != type: + continue yield item def add(self, obj, person, data=None): @@ -57,21 +58,23 @@ class Favorites(object): return False uid = util.getUidForObject(obj) personUid = util.getUidForObject(person) - if self.context.query(userName=personUid, taskId=uid): - return False if data is None: - data = {} + data = {'type': 'favorite'} + for track in self.context.query(userName=personUid, taskId=uid): + if track.type == data['type']: # already present + return False return self.context.saveUserTrack(uid, 0, personUid, data) - def remove(self, obj, person): + def remove(self, obj, person, type='favorite'): if None in (obj, person): return False uid = util.getUidForObject(obj) personUid = util.getUidForObject(person) changed = False - for t in self.context.query(userName=personUid, taskId=uid): - changed = True - self.context.removeTrack(t) + for track in self.context.query(userName=personUid, taskId=uid): + if track.type == type: + changed = True + self.context.removeTrack(track) return changed @@ -81,3 +84,46 @@ class Favorite(Track): typeName = 'Favorite' + @property + def type(self): + return self.data.get('type') or 'favorite' + + +def updateSortInfo(person, task, data): + if person is not None: + favorites = task.getLoopsRoot().getRecordManager().get('favorites') + if favorites is None: + return data + personUid = util.getUidForObject(person) + taskUid = util.getUidForObject(task) + for fav in favorites.query(userName=personUid, taskId=taskUid): + if fav.data.get('type') == 'sort': + fdata = fav.data['sortInfo'] + if not data: + data = fdata + else: + if data != fdata: + newData = fav.data + newData['sortInfo'] = data + fav.data = newData + break + else: + if data: + Favorites(favorites).add(task, person, + dict(type='sort', sortInfo=data)) + return data + + +def setInstitution(person, inst): + if person is not None: + favorites = inst.getLoopsRoot().getRecordManager().get('favorites') + if favorites is None: + return + personUid = util.getUidForObject(person) + taskUid = util.getUidForObject(inst) + for fav in favorites.query(userName=personUid): + if fav.type == 'institution': + fav.taskId = taskUid + favorites.indexTrack(None, fav, 'taskId') + else: + Favorites(favorites).add(inst, person, dict(type='institution')) diff --git a/organize/stateful/README.txt b/organize/stateful/README.txt index 11182e4..be98378 100644 --- a/organize/stateful/README.txt +++ b/organize/stateful/README.txt @@ -187,6 +187,12 @@ Task States >>> from loops.organize.stateful.task import taskStates, publishableTask +Contact States +=========== + + >>> from loops.organize.stateful.contact import contactStates + + Fin de partie ============= diff --git a/organize/stateful/browser.py b/organize/stateful/browser.py index 689f1b9..fb1545a 100644 --- a/organize/stateful/browser.py +++ b/organize/stateful/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,6 +37,7 @@ from loops.common import adapted from loops.expert.query import And, Or, State, Type, getObjects from loops.expert.browser.search import search_template from loops.security.common import checkPermission +from loops import util from loops.util import _ @@ -45,7 +46,8 @@ template = ViewPageTemplateFile('view_macros.pt') statefulActions = ('classification_quality', 'simple_publishing', 'task_states', - 'publishable_task',) + 'publishable_task', + 'contact_states',) def registerStatesPortlet(controller, view, statesDefs, @@ -65,6 +67,7 @@ class StateAction(Action): url = None definition = None msgFactory = _ + cssClass = 'icon-action' @Lazy def stateful(self): @@ -106,8 +109,10 @@ class ChangeStateBase(object): @Lazy def stateful(self): - return component.getAdapter(self.view.virtualTargetObject, IStateful, - name=self.definition) + target = self.view.virtualTargetObject + if IStateful.providedBy(target): + return target + return component.getAdapter(target, IStateful, name=self.definition) @Lazy def definition(self): @@ -119,7 +124,8 @@ class ChangeStateBase(object): @Lazy def transition(self): - return self.stateful.getStatesDefinition().transitions[self.action] + if self.action: + return self.stateful.getStatesDefinition().transitions[self.action] @Lazy def stateObject(self): @@ -152,9 +158,17 @@ class ChangeStateForm(ChangeStateBase, ObjectForm): class ChangeState(ChangeStateBase, EditObject): + @Lazy + def stateful(self): + target = self.target + if IStateful.providedBy(target): + return target + return component.getAdapter(target, IStateful, name=self.definition) + def update(self): - self.stateful.doTransition(self.action) formData = self.request.form + if 'target_uid' in formData: + self.target = util.getObjectForUid(formData['target_uid']) # store data in target object (unless field.nostore) self.object = self.target formState = self.instance.applyTemplate(data=formData) @@ -169,8 +183,9 @@ class ChangeState(ChangeStateBase, EditObject): fi = formState.fieldInstances[name] rawValue = fi.getRawValue(formData, name, u'') trackData[name] = fi.unmarshall(rawValue) - notify(ObjectModifiedEvent(self.view.virtualTargetObject, trackData)) - self.request.response.redirect(self.request.getURL()) + self.stateful.doTransition(self.action) + notify(ObjectModifiedEvent(self.target, trackData)) + #self.request.response.redirect(self.request.getURL()) return True diff --git a/organize/stateful/configure.zcml b/organize/stateful/configure.zcml index 30ad487..1c6ebae 100644 --- a/organize/stateful/configure.zcml +++ b/organize/stateful/configure.zcml @@ -77,6 +77,20 @@ set_schema="cybertools.stateful.interfaces.IStateful" /> + + + + + + + + - Transition: - + + + + + + + +
- +
-
+
+ Task @@ -69,10 +70,13 @@ + workItemType view/workItemType; + dummy view/setupView"> +
Add Work Item
@@ -91,8 +95,17 @@ tal:attributes="value python:workItemTypes[0].name" /> -
- + +
@@ -107,12 +120,21 @@ + -
-
- -
-
-
-
- -
- - - -
-
-
- -
- / -
+
+ +
+ +
+
+
diff --git a/organize/tracking/browser.py b/organize/tracking/browser.py index aa3e588..d7439bc 100644 --- a/organize/tracking/browser.py +++ b/organize/tracking/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,9 +21,11 @@ View classes for tracks. """ from zope import component +from zope.app.pagetemplate import ViewPageTemplateFile from zope.app.security.interfaces import IAuthentication, PrincipalLookupError from zope.cachedescriptors.property import Lazy from zope.app.pagetemplate import ViewPageTemplateFile +from zope.security.proxy import removeSecurityProxy from zope.traversing.browser import absoluteURL from zope.traversing.api import getName @@ -34,6 +36,8 @@ from loops.browser.form import ObjectForm, EditObject from loops.organize.party import getPersonForUser from loops import util +track_edit_template = ViewPageTemplateFile('edit_track.pt') + class BaseTrackView(TrackView): @@ -62,7 +66,15 @@ class BaseTrackView(TrackView): obj = util.getObjectForUid(uid) if obj is not None: return obj - return uid + result = [] + for id in uid.split('.'): + if id.isdigit(): + obj = util.getObjectForUid(id) + if obj is not None: + result.append(obj.title) + continue + result.append(id) + return ' / '.join(result) @Lazy def authentication(self): @@ -102,6 +114,29 @@ class BaseTrackView(TrackView): return self.request.principal.id +class EditForm(BaseTrackView): + + template = track_edit_template + + def update(self): + form = self.request.form + if not form.get('form_submitted'): + return True + data = {} + for row in form.get('data') or []: + key = row['key'] + if not key: + continue + value = row['value'] + # TODO: unmarshall value if necessary + data[key] = value + context = removeSecurityProxy(self.context) + context.data = data + return True + + +# specialized views + class ChangeView(BaseTrackView): pass diff --git a/organize/tracking/configure.zcml b/organize/tracking/configure.zcml index 6c81981..b1b0867 100644 --- a/organize/tracking/configure.zcml +++ b/organize/tracking/configure.zcml @@ -61,7 +61,7 @@ for="cybertools.tracking.interfaces.ITrackingStorage" name="index.html" class="cybertools.tracking.browser.TrackingStorageView" - permission="zope.View" /> + permission="loops.ManageSite" /> + permission="loops.ManageSite" /> + permission="loops.ManageSite" /> + + + permission="loops.ManageSite" /> + + + +
+
+ +

Edit Track

+
+ + + + + + + + + + + + + + + + +
Task:
Run:
User:
Timestamp:
:
+
+

Data

+
+ + + + + + + + + + + + + +
KeyValue
+ +
+ +
+
+
+ +
+
+
+
+
+ + + + diff --git a/organize/tracking/report.py b/organize/tracking/report.py index db04490..d1ee35e 100644 --- a/organize/tracking/report.py +++ b/organize/tracking/report.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2008 Helmut Merz helmutm@cy55.de +# Copyright (c) 2014 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -264,6 +264,9 @@ class TrackDetails(BaseView): @Lazy def objectData(self): obj = self.object + if obj is None: + return dict(object=None, title='-', type='-', url='', + version=None, canAccess=False) node = self.view.nodeView url = node is not None and node.getUrlForTarget(obj) or '' view = self.view.getViewForObject(obj) diff --git a/organize/work/README.txt b/organize/work/README.txt index bc9b5d4..5dd505a 100644 --- a/organize/work/README.txt +++ b/organize/work/README.txt @@ -229,11 +229,23 @@ The user interface is a ReportConceptView subclass that is directly associated w 08/12/28 19:00 20:15 {'url': '.../home/.36', 'title': u'loops Development'} {'url': '.../home/.33', 'title': u'john'} 01:15 00:15 - {'icon': 'cybertools.icons/ledgreen.png', 'title': u'finished'} + {'actions': [...]} >>> results.totals.data {'effort': 900} +Export of work data +------------------- + + >>> from loops.organize.work.report import WorkStatementCSVExport + >>> reportView = WorkStatementCSVExport(task01, TestRequest()) + >>> reportView.nodeView = nodeView + + >>> output = reportView() + >>> print output + Day;Start;End;Task;Party;Title;Duration;Effort;State + 08/12/28;19:00;20:15;loops Development;john;;1.25;0.25;finished + Meeting Minutes =============== diff --git a/organize/work/browser.py b/organize/work/browser.py index 8975aba..ef5686d 100644 --- a/organize/work/browser.py +++ b/organize/work/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2012 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,12 +22,14 @@ View class(es) for work items. from datetime import date import time +from urllib import urlencode from zope import component from zope.app.security.interfaces import IAuthentication, PrincipalLookupError from zope.app.pagetemplate import ViewPageTemplateFile from zope.cachedescriptors.property import Lazy from zope.event import notify from zope.lifecycleevent import ObjectModifiedEvent +from zope.security.proxy import removeSecurityProxy from zope.traversing.browser import absoluteURL from zope.traversing.api import getName, getParent @@ -43,6 +45,7 @@ from loops.browser.concept import ConceptView from loops.browser.form import ObjectForm, EditObject from loops.browser.node import NodeView from loops.common import adapted +from loops.interfaces import IConcept from loops.organize.interfaces import IPerson from loops.organize.party import getPersonForUser from loops.organize.stateful.browser import StateAction @@ -84,6 +87,14 @@ class WorkItemDetails(TrackDetails): def deadline(self): return self.formatTimeStamp(self.track.deadline, 'date') + @Lazy + def deadlineTime(self): + return self.formatTimeStamp(self.track.deadline, 'time') + + @Lazy + def deadlineWithTime(self): + return self.globalOptions('organize.work.deadline_with_time') + @Lazy def start(self): result = self.formatTimeStamp(self.track.start, 'time') @@ -106,6 +117,11 @@ class WorkItemDetails(TrackDetails): def startDay(self): return self.formatTimeStamp(self.track.timeStamp, 'date') + @Lazy + def endDay(self): + endDay = self.formatTimeStamp(self.track.end, 'date') + return endDay != self.startDay and endDay or '' + @Lazy def created(self): return self.formatTimeStamp(self.track.created, 'dateTime') @@ -151,8 +167,8 @@ class WorkItemDetails(TrackDetails): target=self.object, addParams=dict(id=self.track.__name__)) actions = [info, WorkItemStateAction(self)] - if self.isLastInRun and self.allowedToEditWorkItem: - #if self.allowedToEditWorkItem: + #if self.isLastInRun and self.allowedToEditWorkItem: + if self.allowedToEditWorkItem: self.view.registerDojoDateWidget() self.view.registerDojoNumberWidget() self.view.registerDojoTextarea() @@ -169,12 +185,11 @@ class WorkItemDetails(TrackDetails): @Lazy def allowedToEditWorkItem(self): - # if not canAccessObject(self.object.task): - # return False - if checkPermission('loops.ManageSite', self.object): - # or hasRole('loops.Master', self.object): + #if checkPermission('loops.ManageSite', self.object): + if (self.object is None and + checkPermission('zope.ManageContent', self.view.node)): return True - if self.track.data.get('creator') == self.personId: + if checkPermission('zope.ManageContent', self.object): return True return self.user['object'] == getPersonForUser(self.object, self.view.request) @@ -342,6 +357,10 @@ class PersonWorkItems(BaseWorkItemsView, ConceptView): class UserWorkItems(PersonWorkItems): + @Lazy + def title(self): + return self.adapted.title + def listWorkItems(self): criteria = self.getCriteria() p = getPersonForUser(self.context, self.request) @@ -361,6 +380,10 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): def checkPermissions(self): return canAccessObject(self.task or self.target) + def setupView(self): + self.setupController() + self.registerDojoComboBox() + @Lazy def macro(self): return self.template.macros['create_workitem'] @@ -385,6 +408,22 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): track.workItemType = types[0].name return track + @Lazy + def titleSelection(self): + result = [] + if self.title: + return result + dt = adapted(self.conceptManager.get('organize.work.texts')) + if dt is None or not dt.data: + return result + names = ([getName(self.target)] + + [getName(p.object) + for p in self.target.getAllParents(ignoreTypes=True)]) + for name, text in dt.data.values(): + if not name or name in names: + result.append(text) + return result + @Lazy def title(self): return self.track.title or u'' @@ -402,10 +441,11 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): task = self.task if task is None: task = self.target - options = IOptions(adapted(task.conceptType)) - typeNames = options.workitem_types - if typeNames: - return [workItemTypes[name] for name in typeNames] + if IConcept.providedBy(task): + options = IOptions(adapted(task.conceptType)) + typeNames = options.workitem_types + if typeNames: + return [workItemTypes[name] for name in typeNames] return workItemTypes @Lazy @@ -419,12 +459,27 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): return time.strftime('%Y-%m-%d', time.localtime(ts)) return '' + @Lazy + def deadlineTime(self): + ts = self.track.deadline# or getTimeStamp() + if ts: + return time.strftime('T%H:%M', time.localtime(ts)) + return '' + + @Lazy + def deadlineWithTime(self): + return self.globalOptions('organize.work.deadline_with_time') + @Lazy def defaultTimeStamp(self): if self.workItemType.prefillDate: return getTimeStamp() return None + @Lazy + def defaultDate(self): + return time.strftime('%Y-%m-%dT%H:%M', time.localtime(getTimeStamp())) + @Lazy def date(self): ts = self.track.start or self.defaultTimeStamp @@ -432,6 +487,13 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): return time.strftime('%Y-%m-%d', time.localtime(ts)) return '' + @Lazy + def endDate(self): + ts = self.track.end or self.defaultTimeStamp + if ts: + return time.strftime('%Y-%m-%d', time.localtime(ts)) + return '' + @Lazy def startTime(self): ts = self.track.start or self.defaultTimeStamp @@ -468,6 +530,8 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): task = self.task if task is None: task = self.target + if not IConcept.providedBy(task): + return [] options = IOptions(adapted(task.conceptType)) return options.hidden_workitem_actions or [] @@ -486,7 +550,10 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): return [dict(name=util.getUidForObject(p), title=p.title) for p in persons] - taskTypes = ['task', 'event', 'agendaitem'] + @Lazy + def taskTypes(self): + return (self.globalOptions('organize.work.task_types') or + ['task', 'event', 'agendaitem']) @Lazy def followUpTask(self): @@ -506,6 +573,22 @@ class CreateWorkItemForm(ObjectForm, BaseTrackView): return [dict(name=util.getUidForObject(t), title=t.title) for t in tasks] + @Lazy + def priorities(self): + if 'priority' in self.workItemType.fields: + prio = self.conceptManager.get('organize.work.priorities') + if prio: + return adapted(prio).dataAsRecords() + return [] + + @Lazy + def activities(self): + if 'activity' in self.workItemType.fields: + act = self.conceptManager.get('organize.work.activities') + if act: + return adapted(act).dataAsRecords() + return [] + @Lazy def duration(self): if self.state == 'running': @@ -564,13 +647,23 @@ class CreateWorkItem(EditObject, BaseTrackView): setValue('party') if action == 'move': setValue('task') - result['deadline'] = parseDate(form.get('deadline')) + #result['deadline'] = parseDate(form.get('deadline')) + deadline = form.get('deadline') + if deadline: + deadlineTime = (form.get('deadline_time', ''). + strip().replace('T', '') or '00:00:00') + result['deadline'] = parseDateTime('T'.join((deadline, deadlineTime))) + else: + result['deadline'] = None + result['priority'] = form.get('priority') + result['activity'] = form.get('activity') startDate = form.get('start_date', '').strip() + endDate = form.get('end_date', '').strip() or startDate startTime = form.get('start_time', '').strip().replace('T', '') or '00:00:00' endTime = form.get('end_time', '').strip().replace('T', '') or '00:00:00' if startDate: result['start'] = parseDateTime('T'.join((startDate, startTime))) - result['end'] = parseDateTime('T'.join((startDate, endTime))) + result['end'] = parseDateTime('T'.join((endDate, endTime))) result['duration'] = parseTime(form.get('duration')) result['effort'] = parseTime(form.get('effort')) return action, result @@ -589,6 +682,12 @@ class CreateWorkItem(EditObject, BaseTrackView): #notify(ObjectModifiedEvent(obj)) url = self.view.virtualTargetUrl #url = self.request.URL + # append sortinfo parameters: + #urlParams = {} + #for k, v in self.view.sortInfo.items(): + # urlParams['sortinfo_' + k] = v['fparam'] + #if urlParams: + # url = '%s?%s' % (url, urlencode(urlParams)) self.request.response.redirect(url) return False @@ -660,5 +759,31 @@ def formatTimeDelta(value): if not value: return u'' h, m = divmod(int(value) / 60, 60) + if h > 24: + #d, h = divmod(h / 24, 24) + #return u'%id %02i:%02i' % (d, h, m) + return str(int(round(h / 24.0))) return u'%02i:%02i' % (h, m) + +class FixCheckupWorkItems(object): + + def __call__(self): + context = removeSecurityProxy(self.context) + rm = context['records']['work'] + count = 0 + workItems = list(rm.values()) + for wi in workItems: + if wi.state in ('done',): + if wi.workItemType != 'checkup': + print '*** done, but not checkup', wi.__name__ + continue + wi.state = 'running' + wi.reindex('state') + if wi.end == wi.start: + del wi.data['end'] + count += 1 + msg = '*** checked: %i, updated: %i.' % (len(workItems), count) + print msg + return msg + diff --git a/organize/work/configure.zcml b/organize/work/configure.zcml index bbd83f7..28d8d54 100644 --- a/organize/work/configure.zcml +++ b/organize/work/configure.zcml @@ -97,10 +97,16 @@ + + + + + + 20:00
2:30 - Task John
+ + + + + + + +
+ +
+ + + +
+
+
+ +
+ + - +
+
+
+ +
+ +
+
+
+ +
+ / +
+
@@ -227,17 +296,23 @@ Task: - + Deadline: - + + + + Start - End: - - + + + Duration/Effort: diff --git a/security/common.py b/security/common.py index 13e3835..4a1371c 100644 --- a/security/common.py +++ b/security/common.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -36,6 +36,7 @@ from zope.securitypolicy.interfaces import IRolePermissionManager from zope.traversing.api import getName from zope.traversing.interfaces import IPhysicallyLocatable +from cybertools.meta.interfaces import IOptions from loops.common import adapted from loops.interfaces import ILoopsObject, IConcept from loops.interfaces import IAssignmentEvent, IDeassignmentEvent @@ -66,13 +67,39 @@ workspaceGroupsFolderName = 'gloops_ws' # checking and querying functions +def getOption(obj, option, checkType=True): + opts = component.queryAdapter(adapted(obj), IOptions) + if opts is not None: + opt = opts(option, None) + if opt is True: + return opt + if opt: + return opt[0] + if not checkType: + return None + typeMethod = getattr(obj, 'getType', None) + if typeMethod is not None: + opts = component.queryAdapter(adapted(typeMethod()), IOptions) + if opts is not None: + opt = opts(option, None) + if opt is True: + return opt + if opt: + return opt[0] + return None + def canAccessObject(obj): - return canAccess(obj, 'title') + if not canAccess(obj, 'title'): + return False + perm = getOption(obj, 'access_permission') + if not perm: + return True + return checkPermission(perm, obj) def canListObject(obj, noCheck=False): if noCheck: return True - return canAccess(obj, 'title') + return canAccessObject(obj) def canAccessRestricted(obj): return checkPermission('loops.ViewRestricted', obj) diff --git a/security/setter.py b/security/setter.py index 82ff200..b417a16 100644 --- a/security/setter.py +++ b/security/setter.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2013 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,6 +21,7 @@ Base classes for security setters, i.e. adapters that provide standardized methods for setting role permissions and other security-related stuff. """ +from logging import getLogger from zope.app.security.settings import Allow, Deny, Unset from zope import component from zope.component import adapts @@ -39,9 +40,12 @@ from loops.interfaces import IConceptSchema, IBaseResourceSchema, ILoopsAdapter from loops.organize.util import getPrincipalFolder, getGroupsFolder, getGroupId from loops.security.common import overrides, setRolePermission, setPrincipalRole from loops.security.common import allRolesExceptOwner, acquiringPredicateNames +from loops.security.common import getOption from loops.security.interfaces import ISecuritySetter from loops.versioning.interfaces import IVersionable +logger = getLogger('loops.security') + class BaseSecuritySetter(object): @@ -55,10 +59,18 @@ class BaseSecuritySetter(object): def baseObject(self): return baseObject(self.context) + @Lazy + def adapted(self): + return adapted(self.context) + @Lazy def conceptManager(self): return self.baseObject.getLoopsRoot().getConceptManager() + @Lazy + def options(self): + return IOptions(self.adapted) + @Lazy def typeOptions(self): type = self.baseObject.getType() @@ -133,11 +145,17 @@ class LoopsObjectSecuritySetter(BaseSecuritySetter): def acquireRolePermissions(self): settings = {} - for p in self.parents: - if p == self.baseObject: + #rpm = IRolePermissionMap(self.baseObject) + #for p, r, s in rpm.getRolesAndPermissions(): + # settings[(p, r)] = s + for parent in self.parents: + if parent == self.baseObject: continue - secProvider = p - wi = p.workspaceInformation + if getOption(parent, 'security.no_propagate_rolepermissions', + checkType=False): + continue + secProvider = parent + wi = parent.workspaceInformation if wi: if wi.propagateRolePermissions == 'none': continue @@ -147,6 +165,10 @@ class LoopsObjectSecuritySetter(BaseSecuritySetter): for p, r, s in rpm.getRolesAndPermissions(): current = settings.get((p, r)) if current is None or overrides(s, current): + if self.globalOptions('security.log_acquired_setting'): + logger.info('*** %s: %s, %s: current %s; new from %s: %s' % + (self.baseObject.__name__, p, r, current, + parent.__name__, s)) settings[(p, r)] = s self.setDefaultRolePermissions() self.setRolePermissions(settings) @@ -213,14 +235,20 @@ class ConceptSecuritySetter(LoopsObjectSecuritySetter): adapts(IConceptSchema) + @Lazy + def noPropagateRolePermissions(self): + return getOption(self.baseObject, 'security.no_propagate_rolepermissions', + checkType=False) + def setAcquiredSecurity(self, relation, revert=False, updated=None): if updated and relation.second in updated: return if relation.predicate not in self.acquiringPredicates: return setter = ISecuritySetter(adapted(relation.second)) - setter.setDefaultRolePermissions() - setter.acquireRolePermissions() + if not self.noPropagateRolePermissions: + setter.setDefaultRolePermissions() + setter.acquireRolePermissions() setter.acquirePrincipalRoles() #wi = baseObject(self.context).workspaceInformation #if wi and not wi.propagateParentSecurity: diff --git a/system/sync/README.txt b/system/sync/README.txt index 5ed477f..143c6a9 100644 --- a/system/sync/README.txt +++ b/system/sync/README.txt @@ -18,7 +18,7 @@ Let's set up a loops site with basic and example concepts and resources. >>> concepts, resources, views = t.setup() >>> loopsRoot = site['loops'] >>> len(concepts), len(resources), len(views) - (33, 3, 1) + (35, 3, 1) >>> from cybertools.tracking.btree import TrackingStorage >>> from loops.system.job import JobRecord diff --git a/table.py b/table.py index ece56e0..6f3b5f6 100644 --- a/table.py +++ b/table.py @@ -73,7 +73,10 @@ class DataTable(AdapterBase): _adapterAttributes = AdapterBase._adapterAttributes + ('columns', 'data') def getColumns(self): - return getattr(self.context, '_columns', ['key', 'value']) + cols = getattr(self.context, '_columns', None) + if not cols: + cols = getattr(baseObject(self.type), '_columns', None) + return cols or ['key', 'value'] def setColumns(self, value): self.context._columns = value columns = property(getColumns, setColumns) @@ -90,6 +93,21 @@ class DataTable(AdapterBase): self.context._data = OOBTree(data) data = property(getData, setData) + def dataAsRecords(self): + result = [] + for k, v in sorted(self.data.items()): + item = {} + for idx, c in enumerate(self.columns): + if idx == 0: + item[c] = k + else: + item[c] = v[idx-1] + result.append(item) + return result + + def getRowsByValue(self, column, value): + return [r for r in self.dataAsRecords() if r[column] == value] + TypeInterfaceSourceList.typeInterfaces += (IDataTable,) diff --git a/type.py b/type.py index eefaa38..4408acd 100644 --- a/type.py +++ b/type.py @@ -115,7 +115,7 @@ class LoopsType(BaseType): @Lazy def typeProvider(self): # TODO: unify this type attribute naming... - return self.context.resourceType + return getattr(self.context, 'resourceType', None) @Lazy def options(self): diff --git a/versioning/browser.py b/versioning/browser.py index bd80dd0..3021513 100644 --- a/versioning/browser.py +++ b/versioning/browser.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2007 Helmut Merz helmutm@cy55.de +# Copyright (c) 2015 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ """ View classes for versioning. - -$Id$ """ from zope import interface, component @@ -51,8 +49,11 @@ class ListVersions(BaseView): def versions(self): versionable = IVersionable(self.context) versions = versionable.versions + cls = getattr(self.controller, 'versionViewClass', None) for v in sorted(versions): - if isinstance(versions[v], Resource): + if cls is not None: + yield(cls(versions[v], self.request)) + elif isinstance(versions[v], Resource): from loops.browser.resource import ResourceView yield ResourceView(versions[v], self.request) else: diff --git a/xmlrpc/README.txt b/xmlrpc/README.txt index 3ea6557..b237258 100755 --- a/xmlrpc/README.txt +++ b/xmlrpc/README.txt @@ -35,7 +35,7 @@ ZCML setup): Let's look what setup has provided us with: >>> len(concepts) - 22 + 24 Now let's add a few more concepts: @@ -73,7 +73,7 @@ applied in an explicit assignment. >>> sorted(t['name'] for t in xrf.getConceptTypes()) [u'competence', u'customer', u'domain', u'file', u'note', u'person', - u'predicate', u'task', u'textdocument', u'topic', u'type'] + u'predicate', u'report', u'task', u'textdocument', u'topic', u'type'] >>> sorted(t['name'] for t in xrf.getPredicates()) [u'depends', u'issubtype', u'knows', u'ownedby', u'provides', u'requires', u'standard'] @@ -96,7 +96,7 @@ All methods that retrieve one object also returns its children and parents: u'hasType' >>> sorted(c['name'] for c in ch[0]['objects']) [u'competence', u'customer', u'domain', u'file', u'note', u'person', - u'predicate', u'task', u'textdocument', u'topic', u'type'] + u'predicate', u'report', u'task', u'textdocument', u'topic', u'type'] >>> pa = defaultPred['parents'] >>> len(pa) @@ -115,7 +115,7 @@ We can also retrieve children and parents explicitely: u'hasType' >>> sorted(c['name'] for c in ch[0]['objects']) [u'competence', u'customer', u'domain', u'file', u'note', u'person', - u'predicate', u'task', u'textdocument', u'topic', u'type'] + u'predicate', u'report', u'task', u'textdocument', u'topic', u'type'] >>> pa = xrf.getParents('5') >>> len(pa) @@ -174,14 +174,14 @@ Updating the concept map >>> topicId = xrf.getObjectByName('topic')['id'] >>> xrf.createConcept(topicId, u'zope2', u'Zope 2') - {'description': u'', 'title': u'Zope 2', 'type': '36', 'id': '72', + {'description': u'', 'title': u'Zope 2', 'type': '38', 'id': '77', 'name': u'zope2'} The name of the concept is checked by a name chooser; if the corresponding parameter is empty, the name will be generated from the title. >>> xrf.createConcept(topicId, u'', u'Python') - {'description': u'', 'title': u'Python', 'type': '36', 'id': '74', + {'description': u'', 'title': u'Python', 'type': '38', 'id': '79', 'name': u'python'} If we try to deassign a ``hasType`` relation nothing will happen; a