From ba407b35498a789dc2ca7e4ec1b34a75cf068906 Mon Sep 17 00:00:00 2001
From: "James E. Blair" <jeblair@redhat.com>
Date: Wed, 2 Nov 2016 20:00:35 -0700
Subject: [PATCH] Add beginning of support for boards/worklists

Change-Id: I3bd2a564c84cf999767d6ce185fc1279ab2d2de5
---
 boartty/app.py             |   4 +
 boartty/db.py              | 210 +++++++++++++++++++++++++-
 boartty/keymap.py          |   2 +
 boartty/sync.py            | 258 +++++++++++++++++++++++++++++++
 boartty/view/board.py      | 122 +++++++++++++++
 boartty/view/board_list.py | 301 +++++++++++++++++++++++++++++++++++++
 boartty/view/story.py      |   1 +
 7 files changed, 896 insertions(+), 2 deletions(-)
 create mode 100644 boartty/view/board.py
 create mode 100644 boartty/view/board_list.py

diff --git a/boartty/app.py b/boartty/app.py
index be713d4..ff31b44 100644
--- a/boartty/app.py
+++ b/boartty/app.py
@@ -45,6 +45,7 @@ from boartty import search
 from boartty import requestsexceptions
 from boartty.view import story_list as view_story_list
 from boartty.view import project_list as view_project_list
+from boartty.view import board_list as view_board_list
 from boartty.view import story as view_story
 import boartty.view
 import boartty.version
@@ -676,6 +677,9 @@ class App(object):
         elif keymap.TOP_SCREEN in commands:
             self.clearHistory()
             self.refresh(force=True)
+        elif keymap.BOARD_LIST in commands:
+            view = view_board_list.BoardListView(self)
+            self.changeScreen(view)
         elif keymap.HELP in commands:
             self.help()
         elif keymap.QUIT in commands:
diff --git a/boartty/db.py b/boartty/db.py
index 7bd1b6f..95b72e8 100644
--- a/boartty/db.py
+++ b/boartty/db.py
@@ -78,6 +78,63 @@ story_table = Table(
     Column('pending', Boolean, index=True, nullable=False),
     Column('pending_delete', Boolean, index=True, nullable=False),
 )
+board_table = Table(
+    'board', metadata,
+    Column('key', Integer, primary_key=True),
+    Column('id', Integer, index=True),
+    Column('user_key', Integer, ForeignKey("user.key"), index=True),
+    Column('hidden', Boolean, index=True, nullable=False, default=False),
+    Column('subscribed', Boolean, index=True, nullable=False, default=False),
+    Column('title', String(255), index=True),
+    Column('private', Boolean, nullable=False, default=False),
+    Column('description', Text),
+    Column('created', DateTime, index=True),
+    Column('updated', DateTime, index=True),
+    Column('last_seen', DateTime, index=True),
+    Column('pending', Boolean, index=True, nullable=False, default=False),
+    Column('pending_delete', Boolean, index=True, nullable=False, default=False),
+)
+lane_table = Table(
+    'lane', metadata,
+    Column('key', Integer, primary_key=True),
+    Column('id', Integer, index=True),
+    Column('board_key', Integer, ForeignKey("board.key"), index=True),
+    Column('worklist_key', Integer, ForeignKey("worklist.key"), index=True),
+    Column('position', Integer),
+    Column('created', DateTime, index=True),
+    Column('updated', DateTime, index=True),
+    Column('pending', Boolean, index=True, nullable=False, default=False),
+    Column('pending_delete', Boolean, index=True, nullable=False, default=False),
+)
+worklist_table = Table(
+    'worklist', metadata,
+    Column('key', Integer, primary_key=True),
+    Column('id', Integer, index=True),
+    Column('user_key', Integer, ForeignKey("user.key"), index=True),
+    Column('hidden', Boolean, index=True, nullable=False, default=False),
+    Column('subscribed', Boolean, index=True, nullable=False, default=False),
+    Column('title', String(255), index=True),
+    Column('private', Boolean, nullable=False, default=False),
+    Column('automatic', Boolean, nullable=False, default=False),
+    Column('created', DateTime, index=True),
+    Column('updated', DateTime, index=True),
+    Column('last_seen', DateTime, index=True),
+    Column('pending', Boolean, index=True, nullable=False, default=False),
+    Column('pending_delete', Boolean, index=True, nullable=False, default=False),
+)
+worklist_item_table = Table(
+    'worklist_item', metadata,
+    Column('key', Integer, primary_key=True),
+    Column('id', Integer, index=True),
+    Column('worklist_key', Integer, ForeignKey("worklist.key"), index=True),
+    Column('story_key', Integer, ForeignKey("story.key"), index=True),
+    Column('task_key', Integer, ForeignKey("task.key"), index=True),
+    Column('position', Integer),
+    Column('created', DateTime, index=True),
+    Column('updated', DateTime, index=True),
+    Column('pending', Boolean, index=True, nullable=False, default=False),
+    Column('pending_delete', Boolean, index=True, nullable=False, default=False),
+)
 tag_table = Table(
     'tag', metadata,
     Column('key', Integer, primary_key=True),
@@ -235,6 +292,10 @@ class Story(object):
         self.pending = pending
         self.pending_delete = False
 
+    def __repr__(self):
+        return '<Story key=%s id=%s title=%s>' % (
+            self.key, self.id, self.title)
+
     @property
     def creator_name(self):
         return format_name(self)
@@ -297,6 +358,10 @@ class Task(object):
         self.created = created
         self.project = project
 
+    def __repr__(self):
+        return '<Task key=%s id=%s title=%s, project=%s>' % (
+            self.key, self.id, self.title, self.project)
+
 class Event(object):
     def __init__(self, id=None, type=None, creator=None, created=None, info=None):
         self.id = id
@@ -333,6 +398,58 @@ class Comment(object):
         self.pending_delete = pending_delete
         self.draft = draft
 
+class Board(object):
+    def __init__(self, **kw):
+        for k, v in kw.items():
+            setattr(self, k, v)
+
+    def __repr__(self):
+        return '<Board key=%s id=%s title=%s>' % (
+            self.key, self.id, self.title)
+
+    def addLane(self, *args, **kw):
+        session = Session.object_session(self)
+        l = Lane(*args, **kw)
+        session.add(l)
+        session.flush()
+        l.board = self
+        return l
+
+class Lane(object):
+    def __init__(self, **kw):
+        for k, v in kw.items():
+            setattr(self, k, v)
+
+    def __repr__(self):
+        return '<Lane key=%s id=%s worklist=%s>' % (
+            self.key, self.id, self.worklist)
+
+class Worklist(object):
+    def __init__(self, **kw):
+        for k, v in kw.items():
+            setattr(self, k, v)
+
+    def __repr__(self):
+        return '<Worklist key=%s id=%s title=%s>' % (
+            self.key, self.id, self.title)
+
+    def addItem(self, *args, **kw):
+        session = Session.object_session(self)
+        i = WorklistItem(*args, **kw)
+        session.add(i)
+        session.flush()
+        i.worklist = self
+        return i
+
+class WorklistItem(object):
+    def __init__(self, **kw):
+        for k, v in kw.items():
+            setattr(self, k, v)
+
+    def __repr__(self):
+        return '<WorklistItem key=%s id=%s story=%s task=%s>' % (
+            self.key, self.id, self.story, self.task)
+
 class SyncQuery(object):
     def __init__(self, name):
         self.name = name
@@ -390,6 +507,25 @@ mapper(Event, event_table, properties=dict(
 mapper(Comment, comment_table, properties=dict(
     parent=relationship(Comment, remote_side=[comment_table.c.key],backref='children'),
 ))
+mapper(Board, board_table, properties=dict(
+    lanes=relationship(Lane,
+                       order_by=lane_table.c.position),
+    creator=relationship(User),
+))
+mapper(Lane, lane_table, properties=dict(
+    board=relationship(Board),
+    worklist=relationship(Worklist),
+))
+mapper(Worklist, worklist_table, properties=dict(
+    items=relationship(WorklistItem,
+                       order_by=worklist_item_table.c.position),
+    creator=relationship(User),
+))
+mapper(WorklistItem, worklist_item_table, properties=dict(
+    worklist=relationship(Worklist),
+    story=relationship(Story),
+    task=relationship(Task),
+))
 mapper(SyncQuery, sync_query_table)
 
 def match(expr, item):
@@ -407,8 +543,8 @@ class Database(object):
         self.dburi = dburi
         self.search = search
         self.engine = create_engine(self.dburi)
-        #metadata.create_all(self.engine)
-        self.migrate(app)
+        metadata.create_all(self.engine)
+        #self.migrate(app)
         # If we want the objects returned from query() to be usable
         # outside of the session, we need to expunge them from the session,
         # and since the DatabaseSession always calls commit() on the session
@@ -632,6 +768,64 @@ class DatabaseSession(object):
         except sqlalchemy.orm.exc.NoResultFound:
             return None
 
+    def getBoards(self, subscribed=False):
+        query = self.session().query(Board)
+        if subscribed:
+            query = query.filter_by(subscribed=subscribed)
+        return query.order_by(Board.title).all()
+
+    def getBoard(self, key):
+        query = self.session().query(Board).filter_by(key=key)
+        try:
+            return query.one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getBoardByID(self, id):
+        try:
+            return self.session().query(Board).filter_by(id=id).one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getLane(self, key):
+        query = self.session().query(Lane).filter_by(key=key)
+        try:
+            return query.one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getLaneByID(self, id):
+        try:
+            return self.session().query(Lane).filter_by(id=id).one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getWorklist(self, key):
+        query = self.session().query(Worklist).filter_by(key=key)
+        try:
+            return query.one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getWorklistByID(self, id):
+        try:
+            return self.session().query(Worklist).filter_by(id=id).one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getWorklistItem(self, key):
+        query = self.session().query(WorklistItem).filter_by(key=key)
+        try:
+            return query.one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
+    def getWorklistItemByID(self, id):
+        try:
+            return self.session().query(WorklistItem).filter_by(id=id).one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
     def createProject(self, *args, **kw):
         o = Project(*args, **kw)
         self.session().add(o)
@@ -644,6 +838,18 @@ class DatabaseSession(object):
         self.session().flush()
         return s
 
+    def createBoard(self, *args, **kw):
+        s = Board(*args, **kw)
+        self.session().add(s)
+        self.session().flush()
+        return s
+
+    def createWorklist(self, *args, **kw):
+        s = Worklist(*args, **kw)
+        self.session().add(s)
+        self.session().flush()
+        return s
+
     def createUser(self, *args, **kw):
         a = User(*args, **kw)
         self.session().add(a)
diff --git a/boartty/keymap.py b/boartty/keymap.py
index 5e58d87..d8923da 100644
--- a/boartty/keymap.py
+++ b/boartty/keymap.py
@@ -42,6 +42,7 @@ STORY_SEARCH = 'story search'
 REFINE_STORY_SEARCH = 'refine story search'
 LIST_HELD = 'list held stories'
 NEW_STORY = 'new story'
+BOARD_LIST = 'board list'
 # Story screen:
 TOGGLE_HIDDEN = 'toggle hidden'
 TOGGLE_STARRED = 'toggle starred'
@@ -94,6 +95,7 @@ DEFAULT_KEYMAP = {
 
     PREV_SCREEN: 'esc',
     TOP_SCREEN: 'meta home',
+    BOARD_LIST: 'f6',
     HELP: ['f1', '?'],
     QUIT: ['ctrl q'],
     STORY_SEARCH: 'ctrl o',
diff --git a/boartty/sync.py b/boartty/sync.py
index 5de4e17..32ac47d 100644
--- a/boartty/sync.py
+++ b/boartty/sync.py
@@ -144,8 +144,41 @@ class StoryUpdatedEvent(UpdateEvent):
 
     def __init__(self, story, status_changed=False):
         self.story_key = story.key
+        self.status_changed = status_changed
         self.updateRelatedProjects(story)
 
+class BoardAddedEvent(UpdateEvent):
+    def __repr__(self):
+        return '<BoardAddedEvent board_key:%s>' % (
+            self.board_key)
+
+    def __init__(self, board):
+        self.board_key = board.key
+
+class BoardUpdatedEvent(UpdateEvent):
+    def __repr__(self):
+        return '<BoardUpdatedEvent board_key:%s>' % (
+            self.board_key)
+
+    def __init__(self, board):
+        self.board_key = board.key
+
+class WorklistAddedEvent(UpdateEvent):
+    def __repr__(self):
+        return '<WorklistAddedEvent worklist_key:%s>' % (
+            self.worklist_key)
+
+    def __init__(self, worklist):
+        self.worklist_key = worklist.key
+
+class WorklistUpdatedEvent(UpdateEvent):
+    def __repr__(self):
+        return '<WorklistUpdatedEvent worklist_key:%s>' % (
+            self.worklist_key)
+
+    def __init__(self, worklist):
+        self.worklist_key = worklist.key
+
 def parseDateTime(dt):
     if dt is None:
         return None
@@ -550,6 +583,27 @@ class SyncStoryTask(Task):
                 self.results.append(StoryUpdatedEvent(story,
                                                       status_changed=status_changed))
 
+class SyncStoryByTaskTask(Task):
+    def __init__(self, task_id, priority=NORMAL_PRIORITY):
+        super(SyncStoryByTaskTask, self).__init__(priority)
+        self.task_id = task_id
+
+    def __repr__(self):
+        return '<SyncStoryByTaskTask %s>' % (self.task_id,)
+
+    def __eq__(self, other):
+        if (other.__class__ == self.__class__ and
+            other.task_id == self.task_id):
+            return True
+        return False
+
+    def run(self, sync):
+        app = sync.app
+        remote = sync.get('v1/tasks/%s' % (self.task_id,))
+
+        self.tasks.append(sync.submitTask(SyncStoryTask(
+            remote['story_id'], priority=self.priority)))
+
 class SetProjectUpdatedTask(Task):
     def __init__(self, project_key, updated, priority=NORMAL_PRIORITY):
         super(SetProjectUpdatedTask, self).__init__(priority)
@@ -572,6 +626,206 @@ class SetProjectUpdatedTask(Task):
             project = session.getProject(self.project_key)
             project.updated = self.updated
 
+class SyncBoardsTask(Task):
+    def __init__(self, priority=NORMAL_PRIORITY):
+        super(SyncBoardsTask, self).__init__(priority)
+
+    def __repr__(self):
+        return '<SyncBoardsTask>'
+
+    def __eq__(self, other):
+        if (other.__class__ == self.__class__):
+            return True
+        return False
+
+    #TODO: updated since, deleted
+    def run(self, sync):
+        app = sync.app
+        remote = sync.get('v1/boards')
+
+        for remote_board in remote:
+            t = SyncBoardTask(remote_board['id'], remote_board,
+                              priority=self.priority)
+            sync.submitTask(t)
+            self.tasks.append(t)
+
+class SyncWorklistsTask(Task):
+    def __init__(self, priority=NORMAL_PRIORITY):
+        super(SyncWorklistsTask, self).__init__(priority)
+
+    def __repr__(self):
+        return '<SyncWorklistsTask>'
+
+    def __eq__(self, other):
+        if (other.__class__ == self.__class__):
+            return True
+        return False
+
+    #TODO: updated since, deleted
+    def run(self, sync):
+        app = sync.app
+        remote = sync.get('v1/worklists')
+
+        for remote_worklist in remote:
+            t = SyncWorklistTask(remote_worklist['id'], remote_worklist,
+                                 priority=self.priority)
+            sync.submitTask(t)
+            self.tasks.append(t)
+
+class SyncBoardTask(Task):
+    def __init__(self, board_id, data=None, priority=NORMAL_PRIORITY):
+        super(SyncBoardTask, self).__init__(priority)
+        self.board_id = board_id
+        self.data = data
+
+    def __repr__(self):
+        return '<SyncBoardTask %s>' % (self.board_id,)
+
+    def __eq__(self, other):
+        if (other.__class__ == self.__class__ and
+            other.board_id == self.board_id and
+            other.data == self.data):
+            return True
+        return False
+
+    def updateLanes(self, sync, session, board, remote_lanes):
+        local_lane_ids = set([l.id for l in board.lanes])
+        remote_lane_ids = set()
+        for remote_lane in remote_lanes:
+            remote_lane_ids.add(remote_lane['id'])
+            if remote_lane['id'] not in local_lane_ids:
+                self.log.debug("Adding to board id %s lane %s" %
+                               (board.id, remote_lane,))
+                remote_created = parseDateTime(remote_lane['created_at'])
+                lane = board.addLane(id=remote_lane['id'],
+                                     position=remote_lane['position'],
+                                     created=remote_created)
+            else:
+                lane = session.getLane(remote_lane['id'])
+            lane.updated = parseDateTime(remote_lane['updated_at'])
+            t = SyncWorklistTask(remote_lane['worklist']['id'],
+                                 priority=self.priority)
+            t._run(sync, session, remote_lane['worklist'])
+            lane.worklist = session.getWorklistByID(remote_lane['worklist']['id'])
+        for local_lane in board.lanes[:]:
+            if local_lane.id not in remote_lane_ids:
+                session.delete(lane)
+
+    def run(self, sync):
+        app = sync.app
+        if self.data is None:
+            remote_board = sync.get('v1/boards/%s' % (self.board_id,))
+        else:
+            remote_board = self.data
+
+        with app.db.getSession() as session:
+            board = session.getBoardByID(remote_board['id'])
+            added = False
+            if not board:
+                board = session.createBoard(id=remote_board['id'])
+                sync.log.info("Created new board %s in local DB.", board.id)
+                added = True
+            board.title = remote_board['title']
+            board.description = remote_board['description']
+            board.updated = parseDateTime(remote_board['updated_at'])
+            board.creator = getUser(sync, session,
+                                    remote_board['creator_id'])
+            board.created = parseDateTime(remote_board['created_at'])
+
+            self.updateLanes(sync, session, board, remote_board['lanes'])
+
+            if added:
+                self.results.append(BoardAddedEvent(board))
+            else:
+                self.results.append(BoardUpdatedEvent(board))
+
+class SyncWorklistTask(Task):
+    def __init__(self, worklist_id, data=None, priority=NORMAL_PRIORITY):
+        super(SyncWorklistTask, self).__init__(priority)
+        self.worklist_id = worklist_id
+        self.data = data
+
+    def __repr__(self):
+        return '<SyncWorklistTask %s>' % (self.worklist_id,)
+
+    def __eq__(self, other):
+        if (other.__class__ == self.__class__ and
+            other.worklist_id == self.worklist_id and
+            other.data == self.data):
+            return True
+        return False
+
+    def updateItems(self, sync, session, worklist, remote_items):
+        local_item_ids = set([l.id for l in worklist.items])
+        remote_item_ids = set()
+        reenqueue = False
+        for remote_item in remote_items:
+            remote_item_ids.add(remote_item['id'])
+            if remote_item['id'] not in local_item_ids:
+                self.log.debug("Adding to worklist id %s item %s" %
+                               (worklist.id, remote_item,))
+                remote_created = parseDateTime(remote_item['created_at'])
+                self.log.debug("Create item %s", remote_item['id'])
+                item = worklist.addItem(id=remote_item['id'],
+                                        position=remote_item['list_position'],
+                                        created=remote_created)
+            else:
+                self.log.debug("Get item %s", remote_item['id'])
+                item = session.getWorklistItemByID(remote_item['id'])
+            self.log.debug("Using item %s", item)
+            item.updated = parseDateTime(remote_item['updated_at'])
+            if remote_item['item_type'] == 'story':
+                item.story = session.getStoryByID(remote_item['item_id'])
+                self.log.debug("Story %s", item.story)
+                if item.story is None:
+                    self.tasks.append(sync.submitTask(SyncStoryTask(
+                        remote_item['item_id'], priority=self.priority)))
+                    reenqueue = True
+            if remote_item['item_type'] == 'task':
+                item.task = session.getTaskByID(remote_item['item_id'])
+                self.log.debug("Task %s", item.task)
+                if item.task is None:
+                    self.tasks.append(sync.submitTask(SyncStoryByTaskTask(
+                        remote_item['item_id'], priority=self.priority)))
+                    reenqueue = True
+        if reenqueue:
+            self.tasks.append(sync.submitTask(SyncWorklistTask(
+                self.worklist_id, self.data, priority=self.priority)))
+
+        for local_item in worklist.items[:]:
+            if local_item.id not in remote_item_ids:
+                session.delete(item)
+
+    def run(self, sync):
+        app = sync.app
+        if self.data is None:
+            remote_worklist = sync.get('v1/worklists/%s' % (self.worklist_id,))
+        else:
+            remote_worklist = self.data
+
+        with app.db.getSession() as session:
+            return self._run(sync, session, remote_worklist)
+
+    def _run(self, sync, session, remote_worklist):
+        worklist = session.getWorklistByID(remote_worklist['id'])
+        added = False
+        if not worklist:
+            worklist = session.createWorklist(id=remote_worklist['id'])
+            sync.log.info("Created new worklist %s in local DB.", worklist.id)
+            added = True
+        worklist.title = remote_worklist['title']
+        worklist.updated = parseDateTime(remote_worklist['updated_at'])
+        worklist.creator = getUser(sync, session,
+                                remote_worklist['creator_id'])
+        worklist.created = parseDateTime(remote_worklist['created_at'])
+
+        self.updateItems(sync, session, worklist, remote_worklist['items'])
+
+        if added:
+            self.results.append(WorklistAddedEvent(worklist))
+        else:
+            self.results.append(WorklistUpdatedEvent(worklist))
+
 #storyboard
 class SyncQueriedChangesTask(Task):
     def __init__(self, query_name, query, priority=NORMAL_PRIORITY):
@@ -954,6 +1208,8 @@ class Sync(object):
             self.submitTask(SyncUserListTask(HIGH_PRIORITY))
             self.submitTask(SyncProjectSubscriptionsTask(NORMAL_PRIORITY))
             self.submitTask(SyncSubscribedProjectsTask(NORMAL_PRIORITY))
+            self.submitTask(SyncBoardsTask(NORMAL_PRIORITY))
+            self.submitTask(SyncWorklistsTask(NORMAL_PRIORITY))
             #self.submitTask(SyncSubscribedProjectBranchesTask(LOW_PRIORITY))
             #self.submitTask(SyncOutdatedChangesTask(LOW_PRIORITY))
             #self.submitTask(PruneDatabaseTask(self.app.config.expire_age, LOW_PRIORITY))
@@ -976,11 +1232,13 @@ class Sync(object):
                 self.log.exception('Exception in periodicSync')
 
     def submitTask(self, task):
+        self.log.debug("Enqueue %s", task)
         if not self.offline:
             if not self.queue.put(task, task.priority):
                 task.complete(False)
         else:
             task.complete(False)
+        return task
 
     def run(self, pipe):
         task = None
diff --git a/boartty/view/board.py b/boartty/view/board.py
new file mode 100644
index 0000000..1c9829a
--- /dev/null
+++ b/boartty/view/board.py
@@ -0,0 +1,122 @@
+# Copyright 2014 OpenStack Foundation
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import urwid
+
+from boartty import keymap
+from boartty import mywid
+from boartty import sync
+from boartty.view import mouse_scroll_decorator
+
+# +-----listbox---+
+# |table pile     |
+# |               |
+# |+------+-cols-+|
+# ||+----+|+----+||
+# |||    |||    |||
+# |||pile|||pile|||
+# |||    |||    |||
+# ||+----+|+----+||
+# |+------+------+|
+# +---------------+
+
+class BoardView(urwid.WidgetWrap, mywid.Searchable):
+    def getCommands(self):
+        return [
+            (keymap.REFRESH,
+             "Sync subscribed boards"),
+            (keymap.INTERACTIVE_SEARCH,
+             "Interactive search"),
+        ]
+
+    def help(self):
+        key = self.app.config.keymap.formatKeys
+        commands = self.getCommands()
+        return [(c[0], key(c[0]), c[1]) for c in commands]
+
+    def interested(self, event):
+        if not (isinstance(event, sync.BoardAddedEvent)
+                or
+                isinstance(event, sync.StoryAddedEvent)
+                or
+                (isinstance(event, sync.StoryUpdatedEvent) and
+                 event.status_changed)):
+            self.log.debug("Ignoring refresh board due to event %s" % (event,))
+            return False
+        self.log.debug("Refreshing board due to event %s" % (event,))
+        return True
+
+    def __init__(self, app, board_key):
+        super(BoardView, self).__init__(urwid.Pile([]))
+        self.log = logging.getLogger('boartty.view.board')
+        self.searchInit()
+        self.app = app
+        self.board_key = board_key
+
+        self.title_label = urwid.Text(u'', wrap='clip')
+        self.description_label = urwid.Text(u'', wrap='clip')
+        board_info = []
+        board_info_map={'story-data': 'focused-story-data'}
+        for l, v in [("Title", self.title_label),
+                     ("Description", self.description_label),
+                     ]:
+            row = urwid.Columns([(12, urwid.Text(('story-header', l), wrap='clip')), v])
+            board_info.append(row)
+        board_info = urwid.Pile(board_info)
+
+        self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
+        self._w.contents.append((self.app.header, ('pack', 1)))
+        self._w.contents.append((urwid.Divider(), ('pack', 1)))
+        self._w.contents.append((self.listbox, ('weight', 1)))
+        self._w.set_focus(2)
+
+        self.listbox.body.append(board_info)
+        self.listbox.body.append(urwid.Divider())
+        self.listbox_board_start = len(self.listbox.body)
+        self.refresh()
+
+    def refresh(self):
+        with self.app.db.getSession() as session:
+            board = session.getBoard(self.board_key)
+            self.log.debug("Display board %s", board)
+            self.title = board.title
+            self.app.status.update(title=self.title)
+            self.title_label.set_text(('story-data', board.title))
+            self.description_label.set_text(('story-data', board.description))
+            columns = []
+            for lane in board.lanes:
+                items = []
+                self.log.debug("Display lane %s", lane)
+                items.append(urwid.Text(lane.worklist.title))
+                for item in lane.worklist.items:
+                    self.log.debug("Display item %s", item)
+                    items.append(urwid.Text(item.story.title))
+                pile = urwid.Pile(items)
+                columns.append(pile)
+            columns = urwid.Columns(columns)
+            self.listbox.body.append(columns)
+
+    def handleCommands(self, commands):
+        if keymap.REFRESH in commands:
+            self.app.sync.submitTask(
+                sync.SyncBoardTask(self.board_key, sync.HIGH_PRIORITY))
+            self.app.status.update()
+            self.refresh()
+            return True
+        if keymap.INTERACTIVE_SEARCH in commands:
+            self.searchStart()
+            return True
+        return False
diff --git a/boartty/view/board_list.py b/boartty/view/board_list.py
new file mode 100644
index 0000000..c664988
--- /dev/null
+++ b/boartty/view/board_list.py
@@ -0,0 +1,301 @@
+# Copyright 2014 OpenStack Foundation
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+import urwid
+
+from boartty import keymap
+from boartty import mywid
+from boartty import sync
+from boartty.view import board as view_board
+from boartty.view import mouse_scroll_decorator
+
+ACTIVE_COL_WIDTH = 7
+
+class BoardRow(urwid.Button):
+    board_focus_map = {None: 'focused',
+                         'active-project': 'focused-active-project',
+                         'subscribed-project': 'focused-subscribed-project',
+                         'unsubscribed-project': 'focused-unsubscribed-project',
+                         'marked-project': 'focused-marked-project',
+    }
+
+    def selectable(self):
+        return True
+
+    def _setTitle(self, title, indent):
+        self.board_title = title
+        title = indent+title
+        if self.mark:
+            title = '%'+title
+        else:
+            title = ' '+title
+        self.title.set_text(title)
+
+    def __init__(self, app, board, topic, callback=None):
+        super(BoardRow, self).__init__('', on_press=callback,
+                                         user_data=(board.key, board.title))
+        self.app = app
+        self.mark = False
+        self._style = None
+        self.board_key = board.key
+        if topic:
+            self.topic_key = topic.key
+            self.indent = '  '
+        else:
+            self.topic_key = None
+            self.indent = ''
+        self.board_title = board.title
+        self.title = mywid.SearchableText('')
+        self._setTitle(board.title, self.indent)
+        self.title.set_wrap_mode('clip')
+        self.active_stories = urwid.Text(u'', align=urwid.RIGHT)
+        col = urwid.Columns([
+                self.title,
+                ('fixed', ACTIVE_COL_WIDTH, self.active_stories),
+                ])
+        self.row_style = urwid.AttrMap(col, '')
+        self._w = urwid.AttrMap(self.row_style, None, focus_map=self.board_focus_map)
+        self.update(board)
+
+    def search(self, search, attribute):
+        return self.title.search(search, attribute)
+
+    def update(self, board):
+        if board.subscribed:
+            style = 'subscribed-project'
+        else:
+            style = 'unsubscribed-project'
+        self._style = style
+        if self.mark:
+            style = 'marked-project'
+        self.row_style.set_attr_map({None: style})
+        #self.active_stories.set_text('%i ' % cache['active_stories'])
+
+    def toggleMark(self):
+        self.mark = not self.mark
+        if self.mark:
+            style = 'marked-project'
+        else:
+            style = self._style
+        self.row_style.set_attr_map({None: style})
+        self._setTitle(self.board_title, self.indent)
+
+class BoardListHeader(urwid.WidgetWrap):
+    def __init__(self):
+        cols = [urwid.Text(u' Board'),
+                (ACTIVE_COL_WIDTH, urwid.Text(u'Active'))]
+        super(BoardListHeader, self).__init__(urwid.Columns(cols))
+
+@mouse_scroll_decorator.ScrollByWheel
+class BoardListView(urwid.WidgetWrap, mywid.Searchable):
+    def getCommands(self):
+        return [
+            (keymap.TOGGLE_LIST_SUBSCRIBED,
+             "Toggle whether only subscribed boards or all boards are listed"),
+            (keymap.TOGGLE_LIST_ACTIVE,
+             "Toggle listing of boards with active changes"),
+            (keymap.TOGGLE_SUBSCRIBED,
+             "Toggle the subscription flag for the selected board"),
+            (keymap.REFRESH,
+             "Sync subscribed boards"),
+            (keymap.TOGGLE_MARK,
+             "Toggle the process mark for the selected board"),
+            (keymap.INTERACTIVE_SEARCH,
+             "Interactive search"),
+        ]
+
+    def help(self):
+        key = self.app.config.keymap.formatKeys
+        commands = self.getCommands()
+        return [(c[0], key(c[0]), c[1]) for c in commands]
+
+    def __init__(self, app):
+        super(BoardListView, self).__init__(urwid.Pile([]))
+        self.log = logging.getLogger('boartty.view.board_list')
+        self.searchInit()
+        self.app = app
+        self.active = True
+        self.subscribed = False #True
+        self.board_rows = {}
+        self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
+        self.header = BoardListHeader()
+        self.refresh()
+        self._w.contents.append((app.header, ('pack', 1)))
+        self._w.contents.append((urwid.Divider(),('pack', 1)))
+        self._w.contents.append((urwid.AttrWrap(self.header, 'table-header'), ('pack', 1)))
+        self._w.contents.append((self.listbox, ('weight', 1)))
+        self._w.set_focus(3)
+
+    def interested(self, event):
+        if not (isinstance(event, sync.BoardAddedEvent)
+                or
+                isinstance(event, sync.StoryAddedEvent)
+                or
+                (isinstance(event, sync.StoryUpdatedEvent) and
+                 event.status_changed)):
+            self.log.debug("Ignoring refresh board list due to event %s" % (event,))
+            return False
+        self.log.debug("Refreshing board list due to event %s" % (event,))
+        return True
+
+    def advance(self):
+        pos = self.listbox.focus_position
+        if pos < len(self.listbox.body)-1:
+            pos += 1
+            self.listbox.focus_position = pos
+
+    def _deleteRow(self, row):
+        if row in self.listbox.body:
+            self.listbox.body.remove(row)
+        if isinstance(row, BoardRow):
+            del self.board_rows[(row.topic_key, row.board_key)]
+        else:
+            del self.topic_rows[row.topic_key]
+
+    def _boardRow(self, i, board, topic):
+        # Ensure that the row at i is the given board.  If the row
+        # already exists somewhere in the list, delete all rows
+        # between i and the row and then update the row.  If the row
+        # does not exist, insert the row at position i.
+        topic_key = topic and topic.key or None
+        key = (topic_key, board.key)
+        row = self.board_rows.get(key)
+        while row:  # This is "if row: while True:".
+            if i >= len(self.listbox.body):
+                break
+            current_row = self.listbox.body[i]
+            if (isinstance(current_row, BoardRow) and
+                current_row.board_key == board.key):
+                break
+            self._deleteRow(current_row)
+        if not row:
+            row = BoardRow(self.app, board, topic, self.onSelect)
+            self.listbox.body.insert(i, row)
+            self.board_rows[key] = row
+        else:
+            row.update(board)
+        return i+1
+
+    def refresh(self):
+        if self.subscribed:
+            self.title = u'Subscribed boards'
+            self.short_title = self.title[:]
+            if self.active:
+                self.title += u' with active stories'
+        else:
+            self.title = u'All boards'
+            self.short_title = self.title[:]
+        self.app.status.update(title=self.title)
+        with self.app.db.getSession() as session:
+            i = 0
+            for board in session.getBoards(subscribed=self.subscribed):
+                #self.log.debug("board: %s" % board.name)
+                i = self._boardRow(i, board, None)
+        while i < len(self.listbox.body):
+            current_row = self.listbox.body[i]
+            self._deleteRow(current_row)
+
+    def toggleSubscribed(self, board_key):
+        with self.app.db.getSession() as session:
+            board = session.getBoard(board_key)
+            board.subscribed = not board.subscribed
+            ret = board.subscribed
+        return ret
+
+    def onSelect(self, button, data):
+        board_key, board_name = data
+        self.app.changeScreen(view_board.BoardView(self.app, board_key))
+
+    def toggleMark(self):
+        if not len(self.listbox.body):
+            return
+        pos = self.listbox.focus_position
+        row = self.listbox.body[pos]
+        row.toggleMark()
+        self.advance()
+
+    def getSelectedRows(self, cls):
+        ret = []
+        for row in self.listbox.body:
+            if isinstance(row, cls) and row.mark:
+                ret.append(row)
+        if ret:
+            return ret
+        pos = self.listbox.focus_position
+        row = self.listbox.body[pos]
+        if isinstance(row, cls):
+            return [row]
+        return []
+
+    def toggleSubscribed(self):
+        rows = self.getSelectedRows(BoardRow)
+        if not rows:
+            return
+        keys = [row.board_key for row in rows]
+        subscribed_keys = []
+        with self.app.db.getSession() as session:
+            for key in keys:
+                board = session.getBoard(key)
+                board.subscribed = not board.subscribed
+                if board.subscribed:
+                    subscribed_keys.append(key)
+        for row in rows:
+            if row.mark:
+                row.toggleMark()
+        for key in subscribed_keys:
+            self.app.sync.submitTask(sync.SyncBoardTask(key))
+        self.refresh()
+
+    def keypress(self, size, key):
+        if self.searchKeypress(size, key):
+            return None
+
+        if not self.app.input_buffer:
+            key = super(BoardListView, self).keypress(size, key)
+        keys = self.app.input_buffer + [key]
+        commands = self.app.config.keymap.getCommands(keys)
+        ret = self.handleCommands(commands)
+        if ret is True:
+            if keymap.FURTHER_INPUT not in commands:
+                self.app.clearInputBuffer()
+            return None
+        return key
+
+    def handleCommands(self, commands):
+        if keymap.TOGGLE_LIST_ACTIVE in commands:
+            self.active = not self.active
+            self.refresh()
+            return True
+        if keymap.TOGGLE_LIST_SUBSCRIBED in commands:
+            self.subscribed = not self.subscribed
+            self.refresh()
+            return True
+        if keymap.TOGGLE_SUBSCRIBED in commands:
+            self.toggleSubscribed()
+            return True
+        if keymap.TOGGLE_MARK in commands:
+            self.toggleMark()
+            return True
+        if keymap.REFRESH in commands:
+            self.app.sync.submitTask(
+                sync.SyncSubscribedBoardsTask(sync.HIGH_PRIORITY))
+            self.app.status.update()
+            self.refresh()
+            return True
+        if keymap.INTERACTIVE_SEARCH in commands:
+            self.searchStart()
+            return True
+        return False
diff --git a/boartty/view/story.py b/boartty/view/story.py
index 1d1421e..ef665bb 100644
--- a/boartty/view/story.py
+++ b/boartty/view/story.py
@@ -591,6 +591,7 @@ class StoryView(urwid.WidgetWrap):
             # The set of task keys currently displayed
             unseen_keys = set(self.task_rows.keys())
             for task in story.tasks:
+                self.log.debug(task)
                 if task.pending_delete:
                     continue
                 row = self.task_rows.get(task.key)