diff --git a/cco/work/README.rst b/cco/work/README.rst new file mode 100644 index 0000000..b19ef9c --- /dev/null +++ b/cco/work/README.rst @@ -0,0 +1,97 @@ + +cco.work - cyberconcepts.org: project and task management stuff +=============================================================== + + >>> from zope.publisher.browser import TestRequest + >>> from logging import getLogger + >>> log = getLogger('cco.work') + + >>> from loops.setup import addAndConfigureObject, addObject + >>> from loops.concept import Concept + >>> from loops.common import adapted, baseObject + + >>> concepts = loopsRoot['concepts'] + >>> len(list(concepts.keys())) + 14 + + >>> project = concepts['project'] + >>> task = concepts['task'] + + >>> from loops.browser.node import NodeView + >>> home = loopsRoot['views']['home'] + >>> homeView = NodeView(home, TestRequest()) + + +Projects +-------- + +### Basic operations ### + +We start with creating a project and assigning an estimated effort to it. + + >>> from loops.concept import Concept + >>> from loops.setup import addAndConfigureObject + + >>> proj01 = adapted(addAndConfigureObject(concepts, Concept, 'project01', + ... title=u'Project #1', conceptType=project)) + +When counting all assoctioated tasks we get just 1, the project itself. + + >>> len(proj01.getAllTasks()) + 1 + +We now add a task to the project so that we now count two tasks +(including the project itself). + + >>> task01 = adapted(addAndConfigureObject(concepts, Concept, 'task01', + ... title=u'Task #1', conceptType=task)) + >>> baseObject(task01).assignParent(baseObject(proj01)) + >>> len(proj01.getAllTasks()) + 2 + +The actual effort for the project is 0.0 because there aren't any work items +assigned to any of the tasks. + + >>> proj01.actualEffort + 0.0 + +u'0:00' + +So if we add work items the corresponding efforts are summed up. + + >>> task01.addWorkItem('4711', 'work', effort=15 * 3600) + >>> proj01.actualEffort + 54000.0 + +u'15:00' + + >>> task02 = adapted(addAndConfigureObject(concepts, Concept, 'task02', + ... title=u'Task #2', conceptType=task)) + >>> baseObject(task02).assignParent(baseObject(proj01)) + >>> task02.addWorkItem('4711', 'work', effort=8 * 3600) + >>> proj01.actualEffort + 82800.0 + +u'23:00' + +The estimated and charged effords for tasks may be stored in +corresponding fields. Their sums are shown in the enclosing project. + + >>> task01.estimatedEffort = 100 + >>> task02.estimatedEffort = 15 + + >>> proj01.estimatedEffort + 115.0 + +Reporting +--------- + +### Tasks overview ### + + >>> from cco.work.browser import TasksOverview + >>> view = TasksOverview(proj01, TestRequest()) + + >>> ri = view.reportInstance + >>> ri + + diff --git a/cco/work/__init__.py b/cco/work/__init__.py new file mode 100644 index 0000000..735fddd --- /dev/null +++ b/cco/work/__init__.py @@ -0,0 +1,2 @@ +""" package cco.work +""" diff --git a/cco/work/browser.py b/cco/work/browser.py new file mode 100644 index 0000000..c59b818 --- /dev/null +++ b/cco/work/browser.py @@ -0,0 +1,28 @@ +# +# Copyright (c) 2017 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 class(es) for the cco.work package. +""" + +from loops.expert.browser.report import ReportConceptView + + +class TasksOverview(ReportConceptView): + + reportName = 'tasks_overview' diff --git a/cco/work/configure.zcml b/cco/work/configure.zcml new file mode 100644 index 0000000..df256c1 --- /dev/null +++ b/cco/work/configure.zcml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cco/work/interfaces.py b/cco/work/interfaces.py new file mode 100644 index 0000000..8ec3e18 --- /dev/null +++ b/cco/work/interfaces.py @@ -0,0 +1,95 @@ +# +# Copyright (c) 2017 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 +# + +""" +Interfaces for organizational stuff like persons and addresses. +""" + +from zope.i18nmessageid import MessageFactory +from zope.interface import Interface, Attribute +from zope import interface, component, schema + +from cybertools.composer.schema.field import FieldInstance +from cybertools.composer.schema.interfaces import FieldType +from cybertools.organize.interfaces import ITask +from loops.interfaces import ILoopsAdapter, IConceptSchema + + +_ = MessageFactory('cco.work') + + + +class Duration(schema.Int): + + __typeInfo__ = ('duration', + FieldType('textline', + u'A field representing a duration in time.', + instanceName='duration')) + + +class DurationFieldInstance(FieldInstance): + + def display(self, value): + if value is None: + return '' + if isinstance(value, basestring): + value = value.replace(',', '.') + try: + value = float(value) + except ValueError: + value = 0.0 + return u'%02i:%02i' % divmod(value * self.factor / 60.0, 60) + + @property + def factor(self): + return getattr(self.context.baseField, 'factor', 1) + + +# project and task management + +class IProject(ILoopsAdapter): + + estimatedEffort = Duration( + title=_(u'label_estimatedEffort'), + description=_(u'desc_estimatedEffort'), + readonly=True,) + chargedEffort = Duration( + title=_(u'label_chargedEffort'), + description=_(u'desc_chargedEffort'), + readonly=True,) + actualEffort = Duration( + title=_(u'label_actualEffort'), + description=_(u'desc_actualEffort'), + readonly=True) + + estimatedEffort.factor = chargedEffort.factor = 3600 + + +class ITask(IConceptSchema, ITask, IProject): + + estimatedEffort = Duration( + title=_(u'label_estimatedEffort'), + description=_(u'desc_estimatedEffort'), + required=False,) + chargedEffort = Duration( + title=_(u'label_chargedEffort'), + description=_(u'desc_chargedEffort'), + required=False,) + + estimatedEffort.factor = chargedEffort.factor = 3600 + diff --git a/cco/work/locales/cco.work.pot b/cco/work/locales/cco.work.pot new file mode 100644 index 0000000..0722879 --- /dev/null +++ b/cco/work/locales/cco.work.pot @@ -0,0 +1,42 @@ + +"Project-Id-Version: 1.0\n" +"POT-Creation-Date: 2017-10-23 12:00 CET\n" +"PO-Revision-Date: 2017-12-07 12:00 CET\n" +"Last-Translator: Helmut Merz \n" +"Language-Team: cyberconcepts.org team \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: sublime_text\n" + +# forms + +msgid "label_estimatedEffort" +msgstr "Estimated Effort (hours)" + +msgid "desc_estimatedEffort" +msgstr "The estimated effort in hours for completing this project." + +msgid "label_chargedEffort" +msgstr "Charged Effort (hours)" + +msgid "desc_chargedEffort" +msgstr "The effort in hours for completing this project as charged to the customer." + +msgid "label_actualEffort" +msgstr "Actual Effort (hours)" + +msgid "desc_actualEffort" +msgstr "The actual effort in hours." + +# reports + +msgid "colheader_estimatedEffort" +msgstr "Estimated Effort" + +msgid "colheader_chargedEffort" +msgstr "Charged Effort" + +msgid "colheader_actualEffort" +msgstr "Actual Effort" + diff --git a/cco/work/locales/de/LC_MESSAGES/cco.work.mo b/cco/work/locales/de/LC_MESSAGES/cco.work.mo new file mode 100644 index 0000000..75bd200 Binary files /dev/null and b/cco/work/locales/de/LC_MESSAGES/cco.work.mo differ diff --git a/cco/work/locales/de/LC_MESSAGES/cco.work.po b/cco/work/locales/de/LC_MESSAGES/cco.work.po new file mode 100644 index 0000000..716503e --- /dev/null +++ b/cco/work/locales/de/LC_MESSAGES/cco.work.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" + +"Project-Id-Version: 1.0\n" +"POT-Creation-Date: 2017-10-23 12:00 CET\n" +"PO-Revision-Date: 2017-11-14 12:00 CET\n" +"Last-Translator: Helmut Merz \n" +"Language-Team: cyberconcepts.org team \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: sublime_text\n" + +# forms + +msgid "label_estimatedEffort" +msgstr "Geschätzter Aufwand (Stunden)" + +msgid "desc_estimatedEffort" +msgstr "Der geschätzte Aufwand für das Projekt in Stunden." + +msgid "label_chargedEffort" +msgstr "Aufwand lt. Rechnung (Stunden)" + +msgid "desc_chargedEffort" +msgstr "Der dem Kunden in Rechnung gestellte Aufwand in Stunden." + +msgid "label_actualEffort" +msgstr "Tatsächlicher Aufwand (Stunden)" + +msgid "desc_actualEffort" +msgstr "Der tatsächliche Aufwand in Stunden für das Projekt." + +# reports + +msgid "colheader_task" +msgstr "Projekt/Aufgabe" + +msgid "colheader_estimatedEffort" +msgstr "Geschätzter Aufwand" + +msgid "colheader_chargedEffort" +msgstr "Aufwand lt. Rechnung" + +msgid "colheader_actualEffort" +msgstr "Tatsächlicher Aufwand" + diff --git a/cco/work/locales/en/LC_MESSAGES/cco.work.mo b/cco/work/locales/en/LC_MESSAGES/cco.work.mo new file mode 100644 index 0000000..0e07c19 Binary files /dev/null and b/cco/work/locales/en/LC_MESSAGES/cco.work.mo differ diff --git a/cco/work/locales/en/LC_MESSAGES/cco.work.po b/cco/work/locales/en/LC_MESSAGES/cco.work.po new file mode 100644 index 0000000..f90d725 --- /dev/null +++ b/cco/work/locales/en/LC_MESSAGES/cco.work.po @@ -0,0 +1,47 @@ +msgid "" +msgstr "" + +"Project-Id-Version: 1.0\n" +"POT-Creation-Date: 2017-10-23 12:00 CET\n" +"PO-Revision-Date: 2017-12-07 12:00 CET\n" +"Last-Translator: Helmut Merz \n" +"Language-Team: cyberconcepts.org team \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: sublime_text\n" + +# forms + +msgid "label_estimatedEffort" +msgstr "Estimated Effort (hours)" + +msgid "desc_estimatedEffort" +msgstr "The estimated effort in hours for completing this project." + +msgid "label_chargedEffort" +msgstr "Charged Effort (hours)" + +msgid "desc_quotedEffort" +msgstr "The effort in hours for completing this project as given in a quote or offering." + +msgid "label_actualEffort" +msgstr "Actual Effort (hours)" + +msgid "desc_actualEffort" +msgstr "The actual effort in hours." + +# reports + +msgid "colheader_task" +msgstr "Project/Task" + +msgid "colheader_estimatedEffort" +msgstr "Estimated Effort" + +msgid "colheader_chargedEffort" +msgstr "Charged Effort" + +msgid "colheader_actualEffort" +msgstr "Actual Effort" + diff --git a/cco/work/report.py b/cco/work/report.py new file mode 100644 index 0000000..76cf2a8 --- /dev/null +++ b/cco/work/report.py @@ -0,0 +1,60 @@ +# +# Copyright (c) 2017 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 class(es) for the cco.work package. +""" + +from cybertools.util.jeep import Jeep +from loops.common import adapted, baseObject +from loops.expert.field import Field, DecimalField, TargetField, UrlField +from loops.expert.report import ReportInstance +from loops.organize.work.report import DurationField +from cco.work.interfaces import IProject, ITask, _ + + +task = UrlField('title', _(u'colheader_task'), + executionSteps=['sort', 'output']) +estimatedEffort = DurationField( + 'estimatedEffort', _(u'colheader_estimatedEffort'), + factor = 3600, + executionSteps=['output', 'totals']) +chargedEffort = DurationField('chargedEffort', _(u'colheader_chargedEffort'), + factor = 3600, + executionSteps=['output', 'totals']) +actualEffort = DurationField('actualEffort', _(u'colheader_actualEffort'), + executionSteps=['output', 'totals']) + + +class TasksOverview(ReportInstance): + + type = 'cco.work.tasks_overview' + label = u'Tasks Overview' + + fields = Jeep((task, estimatedEffort, chargedEffort, actualEffort)) + #userSettings = (dayFrom, dayTo, activity) + defaultOutputFields = fields + defaultSortCriteria = (task,) + + def selectObjects(self, parts): + result = [] + for c in baseObject(self.view.adapted).getChildren(): + obj = adapted(c) + if IProject.providedBy(obj) or ITask.providedBy(obj): + result.append(obj) + return result diff --git a/cco/work/task.py b/cco/work/task.py new file mode 100644 index 0000000..985a867 --- /dev/null +++ b/cco/work/task.py @@ -0,0 +1,115 @@ +# cco.work.task + +""" Implementation of cco.work concepts. +""" + +from zope.cachedescriptors.property import Lazy +from zope.interface import implementer + +from cybertools.organize.interfaces import IWorkItems +from loops.common import AdapterBase, adapted, baseObject +#from loops.organize.task import Task +from loops.type import TypeInterfaceSourceList +from loops import util +from cco.work.interfaces import IProject, ITask + +#from cco.storage.loops import common + + +TypeInterfaceSourceList.typeInterfaces += (IProject, ITask) + + +#class TaskBase(common.AdapterBase): +class TaskBase(AdapterBase): + + _contextAttributes = list(ITask) + + #start = end = None + + defaultStates = ['done', 'done_x', 'finished', 'finished_x'] + + @property + def actualEffort(self): + result = 0.0 + for t in self.getAllTasks(): + for wi in t.getWorkItems(): + result += wi.effort + return result + + def getSubTasks(self): + if self.__is_dummy__: + return [] + result = [] + for c in baseObject(self).getChildren(): + obj = adapted(c) + if IProject.providedBy(obj) or ITask.providedBy(obj): + result.append(obj) + return result + + def getAllTasks(self): + if self.__is_dummy__: + return [] + result = set([self]) + for t1 in self.getSubTasks(): + if t1 not in result: + for t2 in t1.getAllTasks(): + if t2 not in result: + result.add(t2) + return result + + @Lazy + def workItems(self): + rm = self.getLoopsRoot().getRecordManager() + return IWorkItems(rm['work']) + + def addWorkItem(self, party, action='plan', **kw): + wi = self.workItems.add(util.getUidForObject(baseObject(self)), party) + wi.doAction(action, party, **kw) + + def getWorkItems(self, crit={}): + kw = dict(task=util.getUidForObject(baseObject(self)), + state=crit.get('states') or self.defaultStates) + return self.workItems.query(**kw) + + +@implementer(IProject) +class Project(TaskBase): + """ Adapter for concepts of a project type + with additional fields for planning/controlling. + """ + + _adapterAttributes = AdapterBase._adapterAttributes + ( + 'estimatedEffort', 'chargedEffort', 'actualEffort',) + _contextAttributes = list(IProject) + + @property + def estimatedEffort(self): + return sum(self.tofloat(t.estimatedEffort or 0.0) + for t in self.getSubTasks()) + + @property + def chargedEffort(self): + return sum(self.tofloat(t.chargedEffort or 0.0) + for t in self.getSubTasks()) + + @staticmethod + def tofloat(v): + if isinstance(v, str): + v = v.replace(',', '.') + try: + return float(v) + except ValueError: + return 0.0 + + +@implementer(ITask) +class Task(TaskBase): + """ Adapter for concepts of a task type + with additional fields for planning/controlling. + """ + + #estimatedEffort = chargedEffort = 0.0 + + _adapterAttributes = AdapterBase._adapterAttributes + ('actualEffort',) + _contextAttributes = list(ITask) + diff --git a/cco/work/tests.py b/cco/work/tests.py new file mode 100644 index 0000000..332706a --- /dev/null +++ b/cco/work/tests.py @@ -0,0 +1,95 @@ +#! /usr/bin/python + +""" +Tests for the 'cco.work' package. +""" + +import os +import unittest, doctest +from zope import component +from zope.app.testing.setup import placefulSetUp, placefulTearDown + +from cybertools.organize.work import workItemStates +from cybertools.tracking.btree import TrackingStorage +from loops.concept import Concept +from loops.expert.report import IReport, IReportInstance, Report +from loops.organize.work.base import WorkItem, WorkItems +from loops.setup import addAndConfigureObject +from loops.tests.setup import TestSite +from cco.work.interfaces import IProject, ITask +from cco.work.report import TasksOverview +from cco.work.task import Project, Task + + +def setupComponents(loopsRoot): + component.provideAdapter(WorkItems) + component.provideUtility(workItemStates(), name='organize.workItemStates') + component.provideAdapter(Project) + component.provideAdapter(Report, provides=IReport) + component.provideAdapter(Task) + component.provideAdapter(TasksOverview, provides=IReportInstance, + name='cco.work.tasks_overview') + + +def setUp(self): + site = placefulSetUp(True) + t = TestSite(site) + concepts, resources, views = t.setup() + loopsRoot = site['loops'] + self.globs['loopsRoot'] = loopsRoot + setupComponents(loopsRoot) + records = loopsRoot.getRecordManager() + if 'work' not in records: + records['work'] = TrackingStorage(trackFactory=WorkItem) + setupObjects(concepts) + +def setupObjects(concepts): + addAndConfigureObject(concepts, Concept, 'project', + title='Project', conceptType=concepts['type'], + typeInterface=IProject) + addAndConfigureObject(concepts, Concept, 'task', + title='Task', conceptType=concepts['type'], + typeInterface=ITask) + addAndConfigureObject(concepts, Concept, 'report', + title='Task', conceptType=concepts['type'], + typeInterface=IReport) + addAndConfigureObject(concepts, Concept, 'tasks_overview', + title='Tasks Overview', conceptType=concepts['report'], + reportType='cco.work.tasks_overview') + + +def tearDown(self): + placefulTearDown() + + +class StorageTest(unittest.TestCase): + "Basic tests." + + def setUp(self): + site = placefulSetUp(True) + t = TestSite(site) + self.concepts, resources, views = t.setup() + loopsRoot = site['loops'] + setupComponents(loopsRoot) + setupObjects(self.concepts) + + def tearDown(self): + placefulTearDown() + + def testStorage(self): + self.setUp() + #print('***', self.concepts) + self.tearDown() + + +def test_suite(): + flags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS + loader = unittest.TestLoader() + return unittest.TestSuite(( + doctest.DocFileSuite('README.rst', optionflags=flags, + setUp=setUp, tearDown=tearDown), + loader.loadTestsFromTestCase(StorageTest), + )) + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite')