boartty/boartty/view/story_list.py

514 lines
19 KiB
Python

# 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 datetime
import logging
import six
import urwid
from boartty import keymap
from boartty import mywid
from boartty import sync
from boartty.view import story as view_story
from boartty.view import mouse_scroll_decorator
import boartty.view
class ColumnInfo(object):
def __init__(self, name, packing, value):
self.name = name
self.packing = packing
self.value = value
self.options = (packing, value)
if packing == 'given':
self.spacing = value + 1
else:
self.spacing = (value * 8) + 1
COLUMNS = [
ColumnInfo('ID', 'given', 8),
ColumnInfo('Title', 'weight', 4),
ColumnInfo('Status', 'weight', 1),
ColumnInfo('Creator', 'weight', 1),
ColumnInfo('Updated', 'given', 10),
]
class StoryListColumns(object):
def updateColumns(self):
del self.columns.contents[:]
cols = self.columns.contents
options = self.columns.options
for colinfo in COLUMNS:
if colinfo.name in self.enabled_columns:
attr = colinfo.name.lower().replace(' ', '_')
cols.append((getattr(self, attr),
options(*colinfo.options)))
class StoryRow(urwid.Button, StoryListColumns):
story_focus_map = {None: 'focused',
'active-story': 'focused-active-story',
'inactive-story': 'focused-inactive-story',
'starred-story': 'focused-starred-story',
'held-story': 'focused-held-story',
'marked-story': 'focused-marked-story',
}
def selectable(self):
return True
def __init__(self, app, story,
enabled_columns, callback=None):
super(StoryRow, self).__init__('', on_press=callback, user_data=story.key)
self.app = app
self.story_key = story.key
self.enabled_columns = enabled_columns
self.title = mywid.SearchableText(u'', wrap='clip')
self.id = mywid.SearchableText(u'')
self.updated = mywid.SearchableText(u'')
self.status = mywid.SearchableText(u'')
self.creator = mywid.SearchableText(u'', wrap='clip')
self.mark = False
self.columns = urwid.Columns([], dividechars=1)
self.row_style = urwid.AttrMap(self.columns, '')
self._w = urwid.AttrMap(self.row_style, None, focus_map=self.story_focus_map)
self.update(story)
def search(self, search, attribute):
if self.title.search(search, attribute):
return True
if self.id.search(search, attribute):
return True
if self.creator.search(search, attribute):
return True
if self.status.search(search, attribute):
return True
if self.updated.search(search, attribute):
return True
return False
def update(self, story):
if story.status != 'active' or story.hidden:
style = 'inactive-story'
else:
style = 'active-story'
title = story.title
flag = ' '
#if story.starred:
# flag = '*'
# style = 'starred-story'
#if story.held:
# flag = '!'
# style = 'held-story'
if self.mark:
flag = '%'
style = 'marked-story'
title = flag + title
self.row_style.set_attr_map({None: style})
self.title.set_text(title)
self.id.set_text(str(story.id))
self.creator.set_text(story.creator_name)
self.status.set_text(story.status)
today = self.app.time(datetime.datetime.utcnow()).date()
updated_time = self.app.time(story.updated)
if updated_time:
if today == updated_time.date():
self.updated.set_text(updated_time.strftime("%I:%M %p").upper())
else:
self.updated.set_text(updated_time.strftime("%Y-%m-%d"))
else:
self.updated.set_text('Unknown')
self.updateColumns()
class StoryListHeader(urwid.WidgetWrap, StoryListColumns):
def __init__(self, enabled_columns):
self.enabled_columns = enabled_columns
self.title = urwid.Text(u'Title', wrap='clip')
self.id = urwid.Text(u'ID')
self.updated = urwid.Text(u'Updated')
self.status = urwid.Text(u'Status')
self.creator = urwid.Text(u'Creator', wrap='clip')
self.columns = urwid.Columns([], dividechars=1)
super(StoryListHeader, self).__init__(self.columns)
def update(self):
self.updateColumns()
@mouse_scroll_decorator.ScrollByWheel
class StoryListView(urwid.WidgetWrap, mywid.Searchable):
required_columns = set(['ID', 'Title', 'Updated'])
optional_columns = set([])
def getCommands(self):
if self.project_key:
refresh_help = "Sync current project"
else:
refresh_help = "Sync subscribed projects"
return [
(keymap.TOGGLE_HELD,
"Toggle the held flag for the currently selected story"),
(keymap.TOGGLE_HIDDEN,
"Toggle the hidden flag for the currently selected story"),
(keymap.TOGGLE_LIST_ACTIVE,
"Toggle whether only active or all changes are displayed"),
(keymap.TOGGLE_STARRED,
"Toggle the starred flag for the currently selected story"),
(keymap.TOGGLE_MARK,
"Toggle the process mark for the currently selected story"),
(keymap.REFINE_STORY_SEARCH,
"Refine the current search query"),
(keymap.REFRESH,
refresh_help),
(keymap.SORT_BY_NUMBER,
"Sort stories by number"),
(keymap.SORT_BY_UPDATED,
"Sort stories by how recently the story was updated"),
(keymap.SORT_BY_REVERSE,
"Reverse the sort"),
(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, query, query_desc=None, project_key=None,
active=False, sort_by=None, reverse=None):
super(StoryListView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('boartty.view.story_list')
self.searchInit()
self.app = app
self.query = query
self.query_desc = query_desc or query
self.active = active
self.story_rows = {}
self.enabled_columns = set()
for colinfo in COLUMNS:
if (colinfo.name in self.required_columns or
colinfo.name not in self.optional_columns):
self.enabled_columns.add(colinfo.name)
self.disabled_columns = set()
self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
self.project_key = project_key
if 'Project' not in self.required_columns and project_key is not None:
self.enabled_columns.discard('Project')
self.disabled_columns.add('Project')
#storyboard: creator
if 'Owner' not in self.required_columns and 'owner:' in query:
# This could be or'd with something else, but probably
# not.
self.enabled_columns.discard('Owner')
self.disabled_columns.add('Owner')
self.sort_by = sort_by or app.config.story_list_options['sort-by']
if reverse is not None:
self.reverse = reverse
else:
self.reverse = app.config.story_list_options['reverse']
self.header = StoryListHeader(self.enabled_columns)
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 ((self.project_key is not None and
isinstance(event, sync.StoryAddedEvent) and
self.project_key in event.related_project_keys)
or
(self.project_key is None and
isinstance(event, sync.StoryAddedEvent))
or
(isinstance(event, sync.StoryUpdatedEvent) and
event.story_key in self.story_rows.keys())):
self.log.debug("Ignoring refresh story list due to event %s" % (event,))
return False
self.log.debug("Refreshing story list due to event %s" % (event,))
return True
def refresh(self):
unseen_keys = set(self.story_rows.keys())
with self.app.db.getSession() as session:
story_list = session.getStories(self.query, self.active,
sort_by=self.sort_by)
if self.active:
self.title = (u'Active %d stories in %s' %
(len(story_list), self.query_desc))
else:
self.title = (u'All %d stories in %s' %
(len(story_list), self.query_desc))
self.short_title = self.query_desc
if '/' in self.short_title and ' ' not in self.short_title:
i = self.short_title.rfind('/')
self.short_title = self.short_title[i+1:]
self.app.status.update(title=self.title)
if 'Status' not in self.required_columns and self.active:
self.enabled_columns.discard('Status')
self.disabled_columns.add('Status')
else:
self.enabled_columns.add('Status')
self.disabled_columns.discard('Status')
self.chooseColumns()
self.header.update()
i = 0
if self.reverse:
story_list.reverse()
new_rows = []
if len(self.listbox.body):
focus_pos = self.listbox.focus_position
focus_row = self.listbox.body[focus_pos]
else:
focus_pos = 0
focus_row = None
for story in story_list:
row = self.story_rows.get(story.key)
if not row:
row = StoryRow(self.app, story,
self.enabled_columns,
callback=self.onSelect)
self.listbox.body.insert(i, row)
self.story_rows[story.key] = row
else:
row.update(story)
unseen_keys.remove(story.key)
new_rows.append(row)
i += 1
self.listbox.body[:] = new_rows
if focus_row in self.listbox.body:
pos = self.listbox.body.index(focus_row)
else:
pos = min(focus_pos, len(self.listbox.body)-1)
self.listbox.body.set_focus(pos)
for key in unseen_keys:
row = self.story_rows[key]
del self.story_rows[key]
def chooseColumns(self):
currently_enabled_columns = self.enabled_columns.copy()
size = self.app.loop.screen.get_cols_rows()
cols = size[0]
for colinfo in COLUMNS:
if (colinfo.name not in self.disabled_columns):
cols -= colinfo.spacing
for colinfo in COLUMNS:
if colinfo.name in self.optional_columns:
if cols >= colinfo.spacing:
self.enabled_columns.add(colinfo.name)
cols -= colinfo.spacing
else:
self.enabled_columns.discard(colinfo.name)
if currently_enabled_columns != self.enabled_columns:
self.header.updateColumns()
for key, value in six.iteritems(self.story_rows):
value.updateColumns()
def getQueryString(self):
if self.project_key is not None:
return "project:%s %s" % (self.query_desc, self.app.config.project_story_list_query)
return self.app.config.project_story_list_query
return self.query
def clearStoryList(self):
for key, value in six.iteritems(self.story_rows):
self.listbox.body.remove(value)
self.story_rows = {}
def getNextStoryKey(self, story_key):
row = self.story_rows.get(story_key)
try:
i = self.listbox.body.index(row)
except ValueError:
return None
if i+1 >= len(self.listbox.body):
return None
row = self.listbox.body[i+1]
return row.story_key
def getPrevStoryKey(self, story_key):
row = self.story_rows.get(story_key)
try:
i = self.listbox.body.index(row)
except ValueError:
return None
if i <= 0:
return None
row = self.listbox.body[i-1]
return row.story_key
def toggleStarred(self, story_key):
with self.app.db.getSession() as session:
story = session.getStory(story_key)
story.starred = not story.starred
ret = story.starred
story.pending_starred = True
self.app.sync.submitTask(
sync.StoryStarredTask(story_key, sync.HIGH_PRIORITY))
return ret
def toggleHeld(self, story_key):
return self.app.toggleHeldStory(story_key)
def toggleHidden(self, story_key):
with self.app.db.getSession() as session:
story = session.getStory(story_key)
story.hidden = not story.hidden
ret = story.hidden
hidden_str = 'hidden' if story.hidden else 'visible'
self.log.debug("Set story %s to %s", story_key, hidden_str)
return ret
def advance(self):
pos = self.listbox.focus_position
if pos < len(self.listbox.body)-1:
pos += 1
self.listbox.focus_position = pos
def keypress(self, size, key):
if self.searchKeypress(size, key):
return None
if not self.app.input_buffer:
key = super(StoryListView, 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 onResize(self):
self.chooseColumns()
def handleCommands(self, commands):
if keymap.TOGGLE_LIST_ACTIVE in commands:
self.active = not self.active
self.refresh()
return True
if keymap.TOGGLE_HIDDEN in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
hidden = self.toggleHidden(story_key)
if hidden:
# Here we can avoid a full refresh by just removing the particular
# row from the story list
row = self.story_rows[story_key]
self.listbox.body.remove(row)
del self.story_rows[story_key]
else:
# Just fall back on doing a full refresh if we're in a situation
# where we're not just popping a row from the list of stories.
self.refresh()
self.advance()
return True
if keymap.TOGGLE_HELD in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
self.toggleHeld(story_key)
row = self.story_rows[story_key]
with self.app.db.getSession() as session:
story = session.getStory(story_key)
row.update(story)
self.advance()
return True
if keymap.TOGGLE_STARRED in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
self.toggleStarred(story_key)
row = self.story_rows[story_key]
with self.app.db.getSession() as session:
story = session.getStory(story_key)
row.update(story)
self.advance()
return True
if keymap.TOGGLE_MARK in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
row = self.story_rows[story_key]
row.mark = not row.mark
with self.app.db.getSession() as session:
story = session.getStory(story_key)
row.update(story)
self.advance()
return True
if keymap.REFRESH in commands:
if self.project_key:
self.app.sync.submitTask(
sync.SyncProjectTask(self.project_key, sync.HIGH_PRIORITY))
else:
self.app.sync.submitTask(
sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY))
self.app.status.update()
return True
if keymap.SORT_BY_NUMBER in commands:
if not len(self.listbox.body):
return True
self.sort_by = 'number'
self.clearStoryList()
self.refresh()
return True
if keymap.SORT_BY_UPDATED in commands:
if not len(self.listbox.body):
return True
self.sort_by = 'updated'
self.clearStoryList()
self.refresh()
return True
if keymap.SORT_BY_REVERSE in commands:
if not len(self.listbox.body):
return True
if self.reverse:
self.reverse = False
else:
self.reverse = True
self.clearStoryList()
self.refresh()
return True
if keymap.REFINE_STORY_SEARCH in commands:
default = self.getQueryString()
self.app.searchDialog(default)
return True
if keymap.INTERACTIVE_SEARCH in commands:
self.searchStart()
return True
return False
def onSelect(self, button, story_key):
try:
view = view_story.StoryView(self.app, story_key)
self.app.changeScreen(view)
except boartty.view.DisplayError as e:
self.app.error(str(e))