diff --git a/tracking/README.txt b/tracking/README.txt new file mode 100644 index 0000000..01fd4f4 --- /dev/null +++ b/tracking/README.txt @@ -0,0 +1,39 @@ +==================================== +User tracking in the loops framework +==================================== + + ($Id$) + + >>> from cybertools.tracking.btree import TrackingStorage + + >>> tracks = TrackingStorage() + >>> runId = tracks.startRun('a001') + >>> tracks.saveUserTrack('a001', runId, 'u1', {'somekey': 'somevalue'}) + '0000001' + >>> t1 = tracks.getUserTrack('a001', runId, 'u1') + >>> t1.data + {'somekey': 'somevalue'} + >>> tracks.getUserNames('a001') + ['u1'] + >>> tracks.getUserNames('a002') + [] + >>> [str(id) for id in tracks.getTaskIds()] + ['a001'] + + >>> tracks.query(taskId='a001') + [] + + >>> tracks.saveUserTrack('a002', 0, 'u1', {'somekey': 'anothervalue'}) + '0000002' + >>> result = tracks.query(userName='u1') + >>> len(result) + 2 + +The tracks of a tracking store may be reindexed: + + >>> tracks.reindexTracks() + + +Fin de partie +============= + diff --git a/tracking/__init__.py b/tracking/__init__.py new file mode 100644 index 0000000..4bc90fb --- /dev/null +++ b/tracking/__init__.py @@ -0,0 +1,4 @@ +""" +$Id$ +""" + diff --git a/tracking/btree.py b/tracking/btree.py new file mode 100644 index 0000000..68992af --- /dev/null +++ b/tracking/btree.py @@ -0,0 +1,148 @@ +# +# Copyright (c) 2005 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 +# + +""" +BTree-based implementation of user tracking. + +$Id$ +""" + +import time + +from zope.interface import implements +from zope.app.container.btree import BTreeContainer +from zope.index.field import FieldIndex + +from persistent import Persistent +from BTrees import OOBTree +from BTrees.IFBTree import intersection + +from interfaces import ITrackingStorage, ITrack + + +class TrackingStorage(BTreeContainer): + + implements(ITrackingStorage) + + trackNum = runId = 0 + + indexAttributes = ('taskId', 'runId', 'userName', 'timeStamp') + + def __init__(self, *args, **kw): + super(TrackingStorage, self).__init__(*args, **kw) + self.indexes = OOBTree.OOBTree() + for idx in self.indexAttributes: + self.indexes[idx] = FieldIndex() + self.currentRuns = OOBTree.OOBTree() + self.taskUsers = OOBTree.OOBTree() + + def idFromNum(self, num): + return '%07i' % (num) + + def startRun(self, taskId): + self.runId += 1 + self.currentRuns[taskId] = self.runId + return self.runId + + def stopRun(self, taskId): + if taskId in self.currentRuns: + del self.currentRuns[taskId] + + def saveUserTrack(self, taskId, runId, userName, data): + if not runId: + runId = self.currentRuns.get(taskId) or self.startRun(taskId) + self.trackNum += 1 + trackNum = self.trackNum + trackId = self.idFromNum(trackNum) + timeStamp = int(time.time()) + track = Track(taskId, runId, userName, timeStamp, data) + self[trackId] = track + self.indexTrack(trackNum, track) + return trackId + + def indexTrack(self, trackNum, track): + md = track.metadata + for attr in self.indexAttributes: + self.indexes[attr].index_doc(trackNum, md[attr]) + taskId = md['taskId'] + userName = md['userName'] + if taskId not in self.taskUsers: + self.taskUsers[taskId] = OOBTree.OOTreeSet() + self.taskUsers[taskId].update([userName]) + + def reindexTracks(self): + for trackId in self: + trackNum = int(trackId) + self.indexTrack(trackNum, self[trackId]) + + def getUserTrack(self, taskId, runId, userName): + if not runId: + runId = self.currentRuns.get(taskId) + tracks = self.query(taskId=taskId, runId=runId, userName=userName) + return tracks and tracks[0] or None + + def query(self, **kw): + result = None + for idx in kw: + value = kw[idx] + if idx in self.indexAttributes: + result = self.intersect(result, self.indexes[idx].apply((value, value))) + elif idx == 'timeFrom': + result = self.intersect(result, + self.indexes['timeStamp'].apply((value, None))) + elif idx == 'timeTo': + result = self.intersect(result, + self.indexes['timeStamp'].apply((None, value))) + return result and [self[self.idFromNum(r)] for r in result] or set() + + def intersect(self, r1, r2): + return r1 and intersection(r1, r2) or r2 + + def getUserNames(self, taskId): + return sorted(self.taskUsers.get(taskId, [])) + + def getTaskIds(self): + return self.taskUsers.keys() + + +class Track(Persistent): + + implements(ITrack) + + metadata_attributes = ('taskId', 'runId', 'userName', 'timeStamp') + + @property + def metadata(self): + return dict((attr, getattr(self, attr)) for attr in self.metadata_attributes) + + def __init__(self, taskId, runId, userName, timeStamp, data={}): + self.taskId = taskId + self.runId = runId + self.userName = userName + self.timeStamp = timeStamp + self.data = data + + def __repr__(self): + md = self.metadata + md['timeStamp'] = timeStamp2ISO(md['timeStamp']) + return '' % (`[md[a] for a in self.metadata_attributes]`, + `self.data`) + +def timeStamp2ISO(ts): + return time.strftime('%Y-%m-%d %H:%M', time.gmtime(ts)) + diff --git a/tracking/interfaces.py b/tracking/interfaces.py new file mode 100644 index 0000000..604c20f --- /dev/null +++ b/tracking/interfaces.py @@ -0,0 +1,78 @@ +# +# Copyright (c) 2005 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 +# + +""" +loops tracking interface definitions. + +$Id$ +""" + +from zope.interface import Interface, Attribute +from zope import schema + + +# user interaction tracking + +class ITrack(Interface): + """ Result data from the interactions of a user with an task. + """ + + data = Attribute('The data for this track, typically a mapping') + metadata = Attribute('A mapping with the track\'s metadata') + + +class ITrackingStorage(Interface): + """ A utility for storing user tracks. + """ + + def startRun(taskId): + """ Creates a new run for the task given and return its id. + """ + + def stopRun(taskId): + """ Remove the current run entry for the task given. + """ + + def saveUserTrack(taskId, runId, userName, data): + """ Save the data given (typically a mapping object) to the user track + corresponding to the user name, task id, and run id given. + """ + + def query(**criteria): + """ Search for tracks. Possible criteria are: taskId, runId, + userName, timeFrom, timeTo. + """ + + def getUserTrack(taskId, runId, userName): + """ Return the user track data corresponding to the user name and + task id given. If no run id is given use the current one. + """ + + def getUserNames(taskId): + """ Return all user names (user ids) that have tracks for the + task given. + """ + + def getTaskIds(): + """ Return all ids of the tasks for which there are any tracks. + """ + + def reindexTracks(): + """ Reindexes all tracks - in case of trouble... + """ + diff --git a/tracking/tests.py b/tracking/tests.py new file mode 100755 index 0000000..d0a6bb1 --- /dev/null +++ b/tracking/tests.py @@ -0,0 +1,22 @@ +# $Id$ + +import unittest, doctest +from zope.testing.doctestunit import DocFileSuite + + +class Test(unittest.TestCase): + "Basic tests for the loops.track package." + + def testBasics(self): + pass + + +def test_suite(): + flags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS + return unittest.TestSuite(( + unittest.makeSuite(Test), + DocFileSuite('README.txt', optionflags=flags), + )) + +if __name__ == '__main__': + unittest.main(defaultTest='test_suite')