From ba407b35498a789dc2ca7e4ec1b34a75cf068906 Mon Sep 17 00:00:00 2001 From: "James E. Blair" 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 '' % ( + 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 '' % ( + 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 '' % ( + 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 '' % ( + 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 '' % ( + 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 '' % ( + 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 '' % ( + self.board_key) + + def __init__(self, board): + self.board_key = board.key + +class BoardUpdatedEvent(UpdateEvent): + def __repr__(self): + return '' % ( + self.board_key) + + def __init__(self, board): + self.board_key = board.key + +class WorklistAddedEvent(UpdateEvent): + def __repr__(self): + return '' % ( + self.worklist_key) + + def __init__(self, worklist): + self.worklist_key = worklist.key + +class WorklistUpdatedEvent(UpdateEvent): + def __repr__(self): + return '' % ( + 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 '' % (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 '' + + 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 '' + + 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 '' % (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 '' % (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)