Items, Extensions and DashboardEntries

Items

At the core of the Chandler data model is the Item class:

>>> from chandler.core import *
>>> item = Item()

Every item has a title:

>>> item.title
u''

and, of course, you may supply your own title at initialization time:

>>> Item(title=u'Hello, world!').title
u'Hello, world!'

or after initialization:

>>> item.title = u'No!'
>>> item.title
u'No!'

Items also automatically have a created timestamp cell. To test it, we’ll pretend it’s 10AM.

>>> from chandler.time_services import *
>>> from peak.events.activity import Time
>>> from peak.util import plugins
>>> from datetime import datetime
>>> setNow(datetime(2008, 10, 1, 10, tzinfo=TimeZone.pacific))
>>> ten_am = nowTimestamp()
>>> item = Item()

To make sure the created cell is immediately set, we’ll advance time by an hour before checking its value.

>>> Time.advance(3600.0)
>>> item.created
1222880400.0
>>> item.created == ten_am
True

Collections

A Collection is a thin wrapper around items, a Many-valued attribute that contains Item instances.

>>> houses = Collection(title="Houses")
>>> houses
<Collection: Houses>
>>> houses.items
TupleBackedSet([])

You can add Items to a collection via and remove() methods, which affect Item.collections.

The inverse of items is Item.collections:

>>> item.collections
TupleBackedSet([])

So, if we use the add() method to add an item to our collection:

>>> houses.add(item)
>>> item in houses.items
True

then our collection shows up in the inverse:

>>> item.collections
TupleBackedSet([<Collection: Houses>])

In the same way, when you remove the item from the Collection, the collection is removed from the item’s Item.collections:

>>> houses.remove(item)
>>> item in houses.items
False
>>> item.collections
TupleBackedSet([])

More generally, any object is considered a collection if it has:

  • an items attribute whose value is a Trellis Set containing Items
  • a readable title cell
  • add() and remove() methods which accept an Item and change items appropriately

XXX What should add and remove do for read-only collections? Raise an exception?

Filtered Subsets

In some cases, you have a trellis.Set of objects, and want to create a subset based on some function applied to the members of the set. (For example, Chandler needs to do this to filter the items in Sidebar collections). FilteredSubset is a utility class that models this situation.

To create one, you need to supply input, the trellis.Set you want to filter, and predicate, a function that says whether objects are in the subset or not.

Let’s create a simple example, using sets of integers:

>>> import peak.events.trellis as trellis
>>> input = trellis.Set(xrange(10))

Imagine we’re interested in viewing only the even elements of input. Then a good predicate will be:

>>> def is_even(i):
...     return i%2 == 0

and our FilteredSubset will just be:

>>> filtered = FilteredSubset(input=input, predicate=is_even)

To show how changes are propagated, we’ll create a Performer cell to observe the contents of filtered, as well as its added and trellis.Set.removed cells, inherited from Set:

>>> def observe():
...     print "contents:", sorted(filtered)
...     for i in sorted(filtered.added): print "    added: %s" % (i,)
...     for i in sorted(filtered.removed): print "    removed: %s" % (i,)
>>> c = trellis.Performer(observe)
contents: [0, 2, 4, 6, 8]

Removing an item from the base set that isn’t the subset doesn’t cause a change to be propagated:

>>> input.remove(9)

Removing a matching member of the base set does cause a notification, of course:

>>> input.remove(2)
contents: [0, 4, 6, 8]
    removed: 2
contents: [0, 4, 6, 8]

If objects are added to the base set, and some of them match predicate, then we’ll get added notifications for those:

>>> input.union_update(set([10, 11, 12]))
contents: [0, 4, 6, 8, 10, 12]
    added: 10
    added: 12
contents: [0, 4, 6, 8, 10, 12]

It’s OK to change predicate in mid-stream, so to speak:

>>> filtered.predicate = lambda i: i>7
contents: [8, 10, 11, 12]
    added: 11
    removed: 0
    removed: 4
    removed: 6
contents: [8, 10, 11, 12]

Finally, the same goes for input:

>>> filtered.input = trellis.Set([6, 7, 8, 9])
contents: [8, 9]
    added: 9
    removed: 10
    removed: 11
    removed: 12
contents: [8, 9]

“Aggregated” Sets

FilteredSubset is actually a subclass of a Trellis Set-like class, AggregatedSet. This deals with the following general situation:

  • You have a Set of objects, called the input.
  • You have a function, get_values, that returns zero or more values for each object in the input.
  • You would like to know the Set of all values of get_values as you iterate over all objects in input.

Chandler encounters the above situation in several cases. For example, the Dashboard (List) view needs to take a Set of Items, and compute all the corresponding DashboardEntry objects (and track changes via the usual added and removed cells). Another case is to take one or more Collection objects, and compute all the Items they contain.

For demonstration purposes, let’s create a simple Component subclass:

>>> class Cake(trellis.Component):
...    ingredients = trellis.make(tuple, writable=True)

The idea is that we are going to specify a couple of Cakes, and track the aggregate of all the ingredients we need.

>>> chocolate = Cake(ingredients=("flour", "butter", "sugar", "chocolate"))
>>> carrot = Cake(ingredients=("flour", "butter", "sugar", "carrots", "fertilizer"))

(Naturally, this example is a little contrived: In the real world, it is highly likely we would be interested in details like how much of each ingredient we need. Of course, in the real world, the last-mentioned carrot cake ingredient would be too agricultural for most people’s tastes!)

OK, time to make a Set of cakes:

>>> cakes = trellis.Set([chocolate, carrot])

and from these we’ll aggregate all ingredients:

>>> all_ingredients = AggregatedSet(input=cakes,
...                               get_values=lambda cake: cake.ingredients)

Let’s check that we do indeed get the correct aggregation of all ingredients (we use sorted so that this doctest doesn’t depend on the enumeration order of the set):

>>> sorted(all_ingredients)
['butter', 'carrots', 'chocolate', 'fertilizer', 'flour', 'sugar']

Note that a AggregatedSet is computed, so you can’t directly modify its values:

>>> all_ingredients.add('cod liver oil')
...
AttributeError: 'AggregatedSet' object has no attribute 'add'

Let’s do our usual trick of using a Performer cell to note what changes are being made to all_ingredients:

>>> def observe_ingredients():
...     if all_ingredients.added or all_ingredients.removed:
...         print "added:", " ".join(sorted(all_ingredients.added))
...         print "removed:", " ".join(sorted(all_ingredients.removed))
...         print "new contents:", " ".join(sorted(all_ingredients))
>>> observer = trellis.Performer(observe_ingredients)

Adding a splash of vanilla does change the aggregate all_ingredients as we expect:

>>> chocolate.ingredients += ('vanilla',)
added: vanilla
removed:
new contents: butter carrots chocolate fertilizer flour sugar vanilla

Adding the ever-popular fertilizer to our chocolate cake has no affect on our aggregate ingredients:

>>> chocolate.ingredients += ("fertilizer",)

Similarly, removing sugar from the chocolate cake does nothing:

>>> chocolate.ingredients = ("flour", "butter", "chocolate", "vanilla")

But once it’s gone from the carrot cake, it’s gone:

>>> carrot.ingredients = ("flour", "butter", "carrots", "fertilizer")
added:
removed: sugar
new contents: butter carrots chocolate fertilizer flour vanilla

Adding a new Cake can result in changes:

>>> cakes.add(Cake(ingredients=["flour", "walnuts", "spam"]))
added: spam walnuts
removed:
new contents: butter carrots chocolate fertilizer flour spam vanilla walnuts

and finally, removing one or more Cakes will remove their ingredients if they’re not used by any of the remaining Cakes:

>>> cakes.difference_update((chocolate, carrot))
added:
removed: butter carrots chocolate fertilizer vanilla
new contents: flour spam walnuts

Extensions

Of course, a titled object is not particularly interesting. Items and Collections support having sets of extensions installed. These extensions can be used to customize object attributes and behaviour. In fact, to make it possible for different plugins to extend Item co-operatively, subclassing Item is not recommended.

An item starts out with no extensions installed:

>>> list(item.extensions)
[]

Note that once you have an Extension, you can’t change its item.

>>> e = Extension(item)
>>> e.item = Item()
...
AttributeError: Constants can't be changed
>>> e.item is item
True

Extension objects, which use the peak.util.addons library, are uniqued:

>>> Extension(item) is e
True

Merely instantiating an Extension on an Item does not add it to the item’s set of chandler.core.Item.extensions. So, in our current example:

>>> e in item.extensions
False

However, if we call the add() method, then our Extension does get added:

>>> e.add()
<chandler.core.Extension object at 0x...>
>>> e in item.extensions
True

Note that the Extension object itself is returned from add(); this is for convenience in setting up and installing extensions.

You can’t add an extension that has already been added:

>>> e.add()
...
ValueError: Extension <class 'chandler.core.Extension'> has already been added

However, you can use the remove() method to remove an existing extension:

>>> e.remove()
>>> e in item.extensions
False

As you might expect from the add() case above, it is an error to try to remove() an extension that isn’t there:

>>> e.remove()
...
ValueError: Extension <class 'chandler.core.Extension'> is not present

Entities

Collection and Item have a common base class, Entity. Any Entity can be extended:

>>> entity = Entity()
>>> len(entity.extensions)
0
>>> isinstance(houses, Entity)
True
>>> extended_collection = Extension(houses).add()
>>> Extension.installed_on(houses)
True

Subclassing Extension

When you extend Item, you typically want to add attributes or methods. The way to do this is to subclass Extension:

>>> import peak.events.trellis as trellis
>>> class MyExtension(Extension):
...     can_this_be_true = trellis.attr(False)
>>> my_ext = MyExtension(item)

As usual, your subclass does not start off in the extensions for the given Item:

>>> my_ext in item.extensions
False

This is true even if the superclass has been added:

>>> Extension(item).add() in item.extensions
True
>>> my_ext in item.extensions
False

There is also a convenience class method for testing if a given extension class has been installed on an Item. So, the last two tests of item.extensions can be rephrased as:

>>> Extension.installed_on(item)
True
>>> MyExtension.installed_on(item)
False

The installed_on method also works on an arbitrary Extension object:

>>> another_extension = MyExtension(Item()).add()
>>> MyExtension.installed_on(another_extension)
True
>>> Extension.installed_on(another_extension)
False

Initializing Attributes When Adding an Extension

The add() method takes keywords, so you can set up attributes when you add extensions to an item:

>>> new_my_ext = MyExtension(Item()).add(can_this_be_true=True)
>>> new_my_ext.can_this_be_true
True

The DashboardEntry Class

So, we now have a basic concept of an Item, as titled, creation date-stamped, extensible object. In Chandler 1.0, such objects could be displayed in a list view, the “All”, or “Dashboard”, view.

One of the issues we ran into in the past was the fact that individual Items ended up corresponding to multiple entries in the Chandler Dashboard. So, for example, if you had a simple recurring event (i.e. no modifications), the Dashboard could end up having to show multiple occurrences of this event, corresponding to NOW, LATER and/or DONE triage status.

This complexity would be increased if you wanted to model other relationships between the Items. Example design concepts are:

  • Clustering: Here, the user views multiple, edited versions of an Item in separate rows in the Dashboard, grouped in some kind of hierarchy.
  • Sub-tasks: Here, you could imagine an extension that pulls sub-entries out from a bulleted list in a Note, and makes separately triaged “Items” from them.

There are others, of course, but ideas like this led us to separate out the notion of DashboardEntry (row in Dashboard table) from Item (unit of user information).

A DashboardEntry is a simple data-bearing object. To create one, you need to pass in an Item, which cannot be None:

>>> from chandler.core import DashboardEntry
>>> entry = DashboardEntry(None)
...
TypeError: DashboardEntry's subject_item must be an Item
>>> entry = DashboardEntry(item)

Note that DashboardEntry is not an AddOn; it is possible to create multiple entries for a given Item:

>>> entry == DashboardEntry(item)
False

Let’s have a look at some DashboardEntry fields. The when field is supposed to indicate a time of interest to the user. By default, it has the same value as the created field of its subject_item:

>>> entry.when == item.created
True

Similarly, what is a summary of the entry, and defaults to the subject’s title:

>>> entry.what == item.title
True
>>> item.title = u"Run around aimlessly"
>>> entry.what
u'Run around aimlessly'

As with other Trellis attributes, you can override these values at initialization time, or later.

>>> another_entry = DashboardEntry(item, what=u'Bake a cake')
>>> another_entry.what
u'Bake a cake'

An Item tracks any entries created on its behalf via the dashboard_entries attribute. By default, this starts out with a single entry corresponding to the item:

>>> new_item = Item(title=u'Hello')
>>> list(new_item.dashboard_entries)
[<chandler.core.DashboardEntry object at ...>]

XXX dashboard_entries doesn’t behave like a “real” bi-ref; i.e. automatically update when you create a new DashboardEntry for an item.

XXX But in the case of recurrence, a master’s entries might have
subject_item be something other than the master, so perhaps it really shouldn’t be a bi-ref?

Customizing creation of DashboardEntries

For now, if you want to create multiple DashboardEntry instances per Item, there are a couple of routes you can take. One is to utilize the chandler.domain.item_addon hook below, assuming you want entries for any created item. Alternatively, if your functionality lives in a Extension, a good route is to override :meth:Extension.add()`, and do your customization there.

Extending All Items and DashboardEntries

Most applications will need additional fields associated with items and dashboard entries. To achieve this, either can be extended with an AddOn.

Adding attributes to all Items

Extensions are optional. Some items will have a given Extension added, others won’t.

If an addition to Item should apply to all items, instead of using an Extension, create an AddOn class. The class can be automatically added to items when they’re initialized by registering the class using the chandler.domain.item_addon hook.

>>> addon_hook = plugins.Hook('chandler.domain.item_addon')
>>> def item_initialized(item):
...     print "Item initialized!"
>>> addon_hook.register(item_initialized)
>>> new_item = Item()
Item initialized!

Adding cells to all DashboardEntries

Similar to Item add-ons, DashboardEntry add-ons can be created using the ref:chandler.domain.dashboard_entry_addon <entry-addon-hook-central> hook.

>>> entry_hook = plugins.Hook('chandler.domain.dashboard_entry_addon')
>>> def entry_initialized(item):
...     print "Entry initialized!"
>>> entry_hook.register(entry_initialized)
>>> new_item.dashboard_entries
Entry initialized!
Set([<chandler.core.DashboardEntry object at ...>])