add cco.work to loops-ext, + minor Python3 fixes

This commit is contained in:
Helmut Merz 2024-09-30 14:54:20 +02:00
parent 55af2b5f75
commit 812bd28bc2
13 changed files with 685 additions and 0 deletions

97
cco/work/README.rst Normal file
View file

@ -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
<cco.work.report.TasksOverview object ...>

2
cco/work/__init__.py Normal file
View file

@ -0,0 +1,2 @@
""" package cco.work
"""

28
cco/work/browser.py Normal file
View file

@ -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'

57
cco/work/configure.zcml Normal file
View file

@ -0,0 +1,57 @@
<configure
xmlns:zope="http://namespaces.zope.org/zope"
xmlns:browser="http://namespaces.zope.org/browser"
xmlns:i18n="http://namespaces.zope.org/i18n"
i18n_domain="cco.work">
<i18n:registerTranslations directory="locales" />
<!-- concept adapter classes -->
<zope:adapter factory="cco.work.task.Project" trusted="True" />
<zope:class class="cco.work.task.Project">
<require permission="zope.View"
interface="cco.work.interfaces.IProject" />
<require permission="zope.ManageContent"
set_schema="cco.work.interfaces.IProject" />
</zope:class>
<zope:adapter factory="cco.work.task.Task" trusted="True" />
<zope:class class="cco.work.task.Task">
<require permission="zope.View"
interface="cco.work.interfaces.ITask" />
<require permission="zope.ManageContent"
set_schema="cco.work.interfaces.ITask" />
</zope:class>
<!-- application views -->
<zope:adapter
name="cco.work.tasks_overview.html"
for="loops.interfaces.IConcept
zope.publisher.interfaces.browser.IBrowserRequest"
provides="zope.interface.Interface"
factory="cco.work.browser.TasksOverview"
permission="loops.ViewRestricted" />
<!-- field instances -->
<zope:adapter
name="duration"
factory="cco.work.interfaces.DurationFieldInstance" />
<!-- report instances -->
<zope:adapter
name="cco.work.tasks_overview"
factory="cco.work.report.TasksOverview"
provides="loops.expert.report.IReportInstance"
trusted="True" />
<zope:class class="cco.work.report.TasksOverview">
<require permission="zope.View"
interface="loops.expert.report.IReportInstance" />
<require permission="zope.ManageContent"
set_schema="loops.expert.report.IReportInstance" />
</zope:class>
</configure>

95
cco/work/interfaces.py Normal file
View file

@ -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

View file

@ -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 <helmutm@cy55.de>\n"
"Language-Team: cyberconcepts.org team <team@cyberconcepts.org>\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"

Binary file not shown.

View file

@ -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 <helmutm@cy55.de>\n"
"Language-Team: cyberconcepts.org team <team@cyberconcepts.org>\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"

Binary file not shown.

View file

@ -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 <helmutm@cy55.de>\n"
"Language-Team: cyberconcepts.org team <team@cyberconcepts.org>\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"

60
cco/work/report.py Normal file
View file

@ -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

115
cco/work/task.py Normal file
View file

@ -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)

95
cco/work/tests.py Normal file
View file

@ -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')