From cfa725cf1e65644e744735fe7baf9eb249c3c49f Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Sun, 8 Nov 2015 13:07:49 -0800 Subject: [PATCH] Add project topics Adds project topics so that projects may be grouped together. Change-Id: I0216d802ccc0586ffce0182c2c8806d5df54cc2f --- .../versions/4388de50824a_add_topic_table.py | 35 +++ gertty/db.py | 125 ++++++-- gertty/keymap.py | 12 + gertty/mywid.py | 27 ++ gertty/view/project_list.py | 293 ++++++++++++++++-- 5 files changed, 451 insertions(+), 41 deletions(-) create mode 100644 gertty/alembic/versions/4388de50824a_add_topic_table.py diff --git a/gertty/alembic/versions/4388de50824a_add_topic_table.py b/gertty/alembic/versions/4388de50824a_add_topic_table.py new file mode 100644 index 0000000..6bdfe79 --- /dev/null +++ b/gertty/alembic/versions/4388de50824a_add_topic_table.py @@ -0,0 +1,35 @@ +"""add topic table + +Revision ID: 4388de50824a +Revises: 254ac5fc3941 +Create Date: 2015-10-31 19:06:38.538948 + +""" + +# revision identifiers, used by Alembic. +revision = '4388de50824a' +down_revision = '254ac5fc3941' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('topic', + sa.Column('key', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), index=True, nullable=False), + sa.Column('sequence', sa.Integer(), index=True, unique=True, nullable=False), + sa.PrimaryKeyConstraint('key') + ) + + op.create_table('project_topic', + sa.Column('key', sa.Integer(), nullable=False), + sa.Column('project_key', sa.Integer(), sa.ForeignKey('project.key'), index=True), + sa.Column('topic_key', sa.Integer(), sa.ForeignKey('topic.key'), index=True), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('key'), + sa.UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const'), + ) + +def downgrade(): + pass diff --git a/gertty/db.py b/gertty/db.py index 7ca4818..b6eefe6 100644 --- a/gertty/db.py +++ b/gertty/db.py @@ -21,7 +21,7 @@ import threading import alembic import alembic.config import sqlalchemy -from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text, UniqueConstraint from sqlalchemy.schema import ForeignKey from sqlalchemy.orm import mapper, sessionmaker, relationship, scoped_session from sqlalchemy.orm.session import Session @@ -43,6 +43,20 @@ branch_table = Table( Column('project_key', Integer, ForeignKey("project.key"), index=True), Column('name', String(255), index=True, nullable=False), ) +topic_table = Table( + 'topic', metadata, + Column('key', Integer, primary_key=True), + Column('name', String(255), index=True, nullable=False), + Column('sequence', Integer, index=True, unique=True, nullable=False), + ) +project_topic_table = Table( + 'project_topic', metadata, + Column('key', Integer, primary_key=True), + Column('project_key', Integer, ForeignKey("project.key"), index=True), + Column('topic_key', Integer, ForeignKey("topic.key"), index=True), + Column('sequence', Integer, nullable=False), + UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const'), + ) change_table = Table( 'change', metadata, Column('key', Integer, primary_key=True), @@ -200,6 +214,35 @@ class Branch(object): self.project_key = project.key self.name = name +class ProjectTopic(object): + def __init__(self, project, topic, sequence): + self.project_key = project.key + self.topic_key = topic.key + self.sequence = sequence + +class Topic(object): + def __init__(self, name, sequence): + self.name = name + self.sequence = sequence + + def addProject(self, project): + session = Session.object_session(self) + seq = max([x.sequence for x in self.project_topics] + [0]) + pt = ProjectTopic(project, self, seq+1) + self.project_topics.append(pt) + self.projects.append(project) + session.add(pt) + session.flush() + + def removeProject(self, project): + session = Session.object_session(self) + for pt in self.project_topics: + if pt.project_key == project.key: + self.project_topics.remove(pt) + session.delete(pt) + self.projects.remove(project) + session.flush() + class Change(object): def __init__(self, project, id, owner, number, branch, change_id, subject, created, updated, status, topic=None, @@ -506,28 +549,40 @@ class File(object): mapper(Account, account_table) mapper(Project, project_table, properties=dict( - branches=relationship(Branch, backref='project', - order_by=branch_table.c.name, - cascade='all, delete-orphan'), - changes=relationship(Change, backref='project', - order_by=change_table.c.number, - cascade='all, delete-orphan'), - unreviewed_changes=relationship(Change, - primaryjoin=and_(project_table.c.key==change_table.c.project_key, - change_table.c.hidden==False, - change_table.c.status!='MERGED', - change_table.c.status!='ABANDONED', - change_table.c.reviewed==False), - order_by=change_table.c.number, - ), - open_changes=relationship(Change, - primaryjoin=and_(project_table.c.key==change_table.c.project_key, - change_table.c.status!='MERGED', - change_table.c.status!='ABANDONED'), - order_by=change_table.c.number, - ), - )) + branches=relationship(Branch, backref='project', + order_by=branch_table.c.name, + cascade='all, delete-orphan'), + changes=relationship(Change, backref='project', + order_by=change_table.c.number, + cascade='all, delete-orphan'), + topics=relationship(Topic, + secondary=project_topic_table, + order_by=topic_table.c.name, + viewonly=True), + unreviewed_changes=relationship(Change, + primaryjoin=and_(project_table.c.key==change_table.c.project_key, + change_table.c.hidden==False, + change_table.c.status!='MERGED', + change_table.c.status!='ABANDONED', + change_table.c.reviewed==False), + order_by=change_table.c.number, + ), + open_changes=relationship(Change, + primaryjoin=and_(project_table.c.key==change_table.c.project_key, + change_table.c.status!='MERGED', + change_table.c.status!='ABANDONED'), + order_by=change_table.c.number, + ), +)) mapper(Branch, branch_table) +mapper(Topic, topic_table, properties=dict( + projects=relationship(Project, + secondary=project_topic_table, + order_by=project_table.c.name, + viewonly=True), + project_topics=relationship(ProjectTopic), +)) +mapper(ProjectTopic, project_topic_table) mapper(Change, change_table, properties=dict( owner=relationship(Account), revisions=relationship(Revision, backref='change', @@ -668,20 +723,26 @@ class DatabaseSession(object): def vacuum(self): self.session().execute("VACUUM") - def getProjects(self, subscribed=False, unreviewed=False): + def getProjects(self, subscribed=False, unreviewed=False, topicless=False): """Retrieve projects. :param subscribed: If True limit to only subscribed projects. :param unreviewed: If True limit to only projects with unreviewed changes. + :param topicless: If True limit to only projects without topics. """ query = self.session().query(Project) if subscribed: query = query.filter_by(subscribed=subscribed) if unreviewed: query = query.filter(exists().where(Project.unreviewed_changes)) + if topicless: + query = query.filter_by(topics=None) return query.order_by(Project.name).all() + def getTopics(self): + return self.session().query(Topic).order_by(Topic.sequence).all() + def getProject(self, key): try: return self.session().query(Project).filter_by(key=key).one() @@ -694,6 +755,18 @@ class DatabaseSession(object): except sqlalchemy.orm.exc.NoResultFound: return None + def getTopic(self, key): + try: + return self.session().query(Topic).filter_by(key=key).one() + except sqlalchemy.orm.exc.NoResultFound: + return None + + def getTopicByName(self, name): + try: + return self.session().query(Topic).filter_by(name=name).one() + except sqlalchemy.orm.exc.NoResultFound: + return None + def getSyncQueryByName(self, name): try: return self.session().query(SyncQuery).filter_by(name=name).one() @@ -872,3 +945,9 @@ class DatabaseSession(object): self.session().add(o) self.session().flush() return o + + def createTopic(self, *args, **kw): + o = Topic(*args, **kw) + self.session().add(o) + self.session().flush() + return o diff --git a/gertty/keymap.py b/gertty/keymap.py index fd3d3ce..ff79c59 100644 --- a/gertty/keymap.py +++ b/gertty/keymap.py @@ -70,6 +70,12 @@ SORT_BY_REVERSE = 'reverse the sort' TOGGLE_LIST_REVIEWED = 'toggle list reviewed' TOGGLE_LIST_SUBSCRIBED = 'toggle list subscribed' TOGGLE_SUBSCRIBED = 'toggle subscribed' +NEW_PROJECT_TOPIC = 'new project topic' +DELETE_PROJECT_TOPIC = 'delete project topic' +MOVE_PROJECT_TOPIC = 'move to project topic' +COPY_PROJECT_TOPIC = 'copy to project topic' +REMOVE_PROJECT_TOPIC = 'remove from project topic' +RENAME_PROJECT_TOPIC = 'rename project topic' # Diff screens: SELECT_PATCHSETS = 'select patchsets' NEXT_SELECTABLE = 'next selectable' @@ -129,6 +135,12 @@ DEFAULT_KEYMAP = { TOGGLE_LIST_REVIEWED: 'l', TOGGLE_LIST_SUBSCRIBED: 'L', TOGGLE_SUBSCRIBED: 's', + NEW_PROJECT_TOPIC: [['T', 'n']], + DELETE_PROJECT_TOPIC: [['T', 'delete']], + MOVE_PROJECT_TOPIC: [['T', 'm']], + COPY_PROJECT_TOPIC: [['T', 'c']], + REMOVE_PROJECT_TOPIC: [['T', 'D']], + RENAME_PROJECT_TOPIC: [['T', 'r']], SELECT_PATCHSETS: 'p', NEXT_SELECTABLE: 'tab', diff --git a/gertty/mywid.py b/gertty/mywid.py index 9151f86..82b304a 100644 --- a/gertty/mywid.py +++ b/gertty/mywid.py @@ -148,6 +148,33 @@ class ButtonDialog(urwid.WidgetWrap): listbox = urwid.ListBox(rows) super(ButtonDialog, self).__init__(urwid.LineBox(listbox, title)) +class LineEditDialog(ButtonDialog): + signals = ['save', 'cancel'] + def __init__(self, app, title, message, entry_prompt=None, + entry_text='', ring=None): + self.app = app + save_button = FixedButton('Save') + cancel_button = FixedButton('Cancel') + urwid.connect_signal(save_button, 'click', + lambda button:self._emit('save')) + urwid.connect_signal(cancel_button, 'click', + lambda button:self._emit('cancel')) + super(LineEditDialog, self).__init__(title, message, entry_prompt, + entry_text, + buttons=[save_button, + cancel_button], + ring=ring) + + def keypress(self, size, key): + if not self.app.input_buffer: + key = super(LineEditDialog, self).keypress(size, key) + keys = self.app.input_buffer + [key] + commands = self.app.config.keymap.getCommands(keys) + if keymap.ACTIVATE in commands: + self._emit('save') + return None + return key + class TextEditDialog(urwid.WidgetWrap): signals = ['save', 'cancel'] def __init__(self, title, prompt, button, text, ring=None): diff --git a/gertty/view/project_list.py b/gertty/view/project_list.py index da67d32..574ebff 100644 --- a/gertty/view/project_list.py +++ b/gertty/view/project_list.py @@ -17,6 +17,7 @@ import logging import urwid from gertty import keymap +from gertty import mywid from gertty import sync from gertty.view import change_list as view_change_list from gertty.view import mouse_scroll_decorator @@ -31,10 +32,14 @@ class ProjectRow(urwid.Button): def selectable(self): return True - def __init__(self, project, callback=None): + def __init__(self, project, topic, callback=None): super(ProjectRow, self).__init__('', on_press=callback, user_data=(project.key, project.name)) self.project_key = project.key + if topic: + self.topic_key = topic.key + else: + self.topic_key = None name = urwid.Text(project.name) name.set_wrap_mode('clip') self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT) @@ -60,6 +65,47 @@ class ProjectRow(urwid.Button): self.unreviewed_changes.set_text('%i ' % len(project.unreviewed_changes)) self.open_changes.set_text('%i ' % len(project.open_changes)) +class TopicRow(urwid.Button): + project_focus_map = {None: 'focused', + 'subscribed-project': 'focused-subscribed-project', + } + + def selectable(self): + return True + + def _setName(self, name): + self.name.set_text('[[ '+name+' ]]') + + def __init__(self, topic, callback=None): + super(TopicRow, self).__init__('', on_press=callback, + user_data=(topic.key, topic.name)) + self.topic_key = topic.key + self.name = urwid.Text('') + self._setName(topic.name) + self.name.set_wrap_mode('clip') + self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT) + self.open_changes = urwid.Text(u'', align=urwid.RIGHT) + col = urwid.Columns([ + self.name, + ('fixed', 11, self.unreviewed_changes), + ('fixed', 5, self.open_changes), + ]) + self.row_style = urwid.AttrMap(col, '') + self._w = urwid.AttrMap(self.row_style, None, focus_map=self.project_focus_map) + self.row_style.set_attr_map({None: 'subscribed-project'}) + self.update(topic) + + def update(self, topic, unreviewed_changes=None, open_changes=None): + self._setName(topic.name) + if unreviewed_changes is None: + self.unreviewed_changes.set_text('') + else: + self.unreviewed_changes.set_text('%i ' % unreviewed_changes) + if open_changes is None: + self.open_changes.set_text('') + else: + self.open_changes.set_text('%i ' % open_changes) + class ProjectListHeader(urwid.WidgetWrap): def __init__(self): cols = [urwid.Text(u'Project'), @@ -79,8 +125,20 @@ class ProjectListView(urwid.WidgetWrap): (key(keymap.TOGGLE_SUBSCRIBED), "Toggle the subscription flag for the currently selected project"), (key(keymap.REFRESH), - "Sync subscribed projects") - ] + "Sync subscribed projects"), + (key(keymap.NEW_PROJECT_TOPIC), + "Create project topic"), + (key(keymap.DELETE_PROJECT_TOPIC), + "Delete selected project topic"), + (key(keymap.MOVE_PROJECT_TOPIC), + "Move selected project to topic"), + (key(keymap.COPY_PROJECT_TOPIC), + "Copy selected project to topic"), + (key(keymap.REMOVE_PROJECT_TOPIC), + "Remove selected project from topic"), + (key(keymap.RENAME_PROJECT_TOPIC), + "Rename selected project topic"), + ] def __init__(self, app): super(ProjectListView, self).__init__(urwid.Pile([])) @@ -89,6 +147,8 @@ class ProjectListView(urwid.WidgetWrap): self.unreviewed = True self.subscribed = True self.project_rows = {} + self.topic_rows = {} + self.open_topics = set() self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) self.header = ProjectListHeader() self.refresh() @@ -110,6 +170,56 @@ class ProjectListView(urwid.WidgetWrap): self.log.debug("Refreshing project list due to event %s" % (event,)) return True + def _deleteRow(self, row): + if row in self.listbox.body: + self.listbox.body.remove(row) + if isinstance(row, ProjectRow): + del self.project_rows[(row.topic_key, row.project_key)] + else: + del self.topic_rows[row.topic_key] + + def _projectRow(self, i, project, topic): + # Ensure that the row at i is the given project. 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, project.key) + row = self.project_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, ProjectRow) and + current_row.project_key == project.key): + break + self._deleteRow(current_row) + if not row: + row = ProjectRow(project, topic, self.onSelect) + self.listbox.body.insert(i, row) + self.project_rows[key] = row + else: + row.update(project) + return i+1 + + def _topicRow(self, i, topic): + row = self.topic_rows.get(topic.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, TopicRow) and + current_row.topic_key == topic.key): + break + self._deleteRow(current_row) + if not row: + row = TopicRow(topic, self.onSelectTopic) + self.listbox.body.insert(i, row) + self.topic_rows[topic.key] = row + else: + row.update(topic) + return i + 1 + def refresh(self): if self.subscribed: self.title = u'Subscribed projects' @@ -118,24 +228,33 @@ class ProjectListView(urwid.WidgetWrap): else: self.title = u'All projects' self.app.status.update(title=self.title) - unseen_keys = set(self.project_rows.keys()) with self.app.db.getSession() as session: i = 0 - for project in session.getProjects( + for project in session.getProjects(topicless=True, subscribed=self.subscribed, unreviewed=self.unreviewed): - row = self.project_rows.get(project.key) - if not row: - row = ProjectRow(project, self.onSelect) - self.listbox.body.insert(i, row) - self.project_rows[project.key] = row - else: - row.update(project) - unseen_keys.remove(project.key) - i += 1 - for key in unseen_keys: - row = self.project_rows[key] - self.listbox.body.remove(row) - del self.project_rows[key] + #self.log.debug("project: %s" % project.name) + i = self._projectRow(i, project, None) + for topic in session.getTopics(): + #self.log.debug("topic: %s" % topic.name) + i = self._topicRow(i, topic) + topic_unreviewed = 0 + topic_open = 0 + for project in topic.projects: + #self.log.debug(" project: %s" % project.name) + topic_unreviewed += len(project.unreviewed_changes) + topic_open += len(project.open_changes) + if self.subscribed: + if not project.subscribed: + continue + if self.unreviewed and not project.unreviewed_changes: + continue + if topic.key in self.open_topics: + i = self._projectRow(i, project, topic) + topic_row = self.topic_rows.get(topic.key) + topic_row.update(topic, topic_unreviewed, topic_open) + while i < len(self.listbox.body): + current_row = self.listbox.body[i] + self._deleteRow(current_row) def toggleSubscribed(self, project_key): with self.app.db.getSession() as session: @@ -151,6 +270,126 @@ class ProjectListView(urwid.WidgetWrap): "_project_key:%s %s" % (project_key, self.app.config.project_change_list_query), project_name, project_key=project_key, unreviewed=True)) + def onSelectTopic(self, button, data): + topic_key = data[0] + self.open_topics ^= set([topic_key]) + self.refresh() + + def createTopic(self): + dialog = mywid.LineEditDialog(self.app, 'Topic', 'Create a new topic.', + 'Topic: ', '', self.app.ring) + urwid.connect_signal(dialog, 'save', + lambda button: self.closeCreateTopic(dialog, True)) + urwid.connect_signal(dialog, 'cancel', + lambda button: self.closeCreateTopic(dialog, False)) + self.app.popup(dialog) + + def closeCreateTopic(self, dialog, save): + if save: + last_topic_key = None + for row in self.listbox.body: + if isinstance(row, TopicRow): + last_topic_key = row.topic_key + with self.app.db.getSession() as session: + if last_topic_key: + last_topic = session.getTopic(last_topic_key) + seq = last_topic.sequence + 1 + else: + seq = 0 + t = session.createTopic(dialog.entry.edit_text, seq) + self.app.backScreen() + + def deleteTopic(self): + pos = self.listbox.focus_position + row = self.listbox.body[pos] + if not isinstance(row, TopicRow): + return + with self.app.db.getSession() as session: + topic = session.getTopic(row.topic_key) + session.delete(topic) + self.refresh() + + def renameTopic(self): + pos = self.listbox.focus_position + row = self.listbox.body[pos] + if not isinstance(row, TopicRow): + return + with self.app.db.getSession() as session: + topic = session.getTopic(row.topic_key) + name = topic.name + key = topic.key + dialog = mywid.LineEditDialog(self.app, 'Topic', 'Rename a new topic.', + 'Topic: ', name, self.app.ring) + urwid.connect_signal(dialog, 'save', + lambda button: self.closeRenameTopic(dialog, True, key)) + urwid.connect_signal(dialog, 'cancel', + lambda button: self.closeRenameTopic(dialog, False, key)) + self.app.popup(dialog) + + def closeRenameTopic(self, dialog, save, key): + if save: + with self.app.db.getSession() as session: + topic = session.getTopic(key) + topic.name = dialog.entry.edit_text + self.app.backScreen() + + def copyMoveToTopic(self, move): + if move: + verb = 'Move' + else: + verb = 'Copy' + pos = self.listbox.focus_position + row = self.listbox.body[pos] + if not isinstance(row, ProjectRow): + return + dialog = mywid.LineEditDialog(self.app, 'Topic', '%s to topic.' % verb, + 'Topic: ', '', self.app.ring) + urwid.connect_signal(dialog, 'save', + lambda button: self.closeCopyMoveToTopic(dialog, True, row, move)) + urwid.connect_signal(dialog, 'cancel', + lambda button: self.closeCopyMoveToTopic(dialog, False, row, move)) + self.app.popup(dialog) + + def closeCopyMoveToTopic(self, dialog, save, row, move): + error = None + if save: + with self.app.db.getSession() as session: + project = session.getProject(row.project_key) + topic_name = dialog.entry.edit_text + new_topic = session.getTopicByName(topic_name) + if not new_topic: + error = "Unable to find topic %s" % topic_name + else: + if move and row.topic_key: + old_topic = session.getTopic(row.topic_key) + self.log.debug("Remove %s from %s" % (project, old_topic)) + old_topic.removeProject(project) + self.log.debug("Add %s to %s" % (project, new_topic)) + new_topic.addProject(project) + self.app.backScreen() + if error: + self.app.error(error) + + def moveToTopic(self): + self.copyMoveToTopic(True) + + def copyToTopic(self): + self.copyMoveToTopic(False) + + def removeFromTopic(self): + pos = self.listbox.focus_position + row = self.listbox.body[pos] + if not isinstance(row, ProjectRow): + return + if not row.topic_key: + return + with self.app.db.getSession() as session: + project = session.getProject(row.project_key) + topic = session.getTopic(row.topic_key) + self.log.debug("Remove %s from %s" % (project, topic)) + topic.removeProject(project) + self.refresh() + def keypress(self, size, key): if not self.app.input_buffer: key = super(ProjectListView, self).keypress(size, key) @@ -182,6 +421,24 @@ class ProjectListView(urwid.WidgetWrap): if subscribed: self.app.sync.submitTask(sync.SyncProjectTask(project_key)) return None + if keymap.NEW_PROJECT_TOPIC in commands: + self.createTopic() + return None + if keymap.DELETE_PROJECT_TOPIC in commands: + self.deleteTopic() + return None + if keymap.COPY_PROJECT_TOPIC in commands: + self.copyToTopic() + return None + if keymap.MOVE_PROJECT_TOPIC in commands: + self.moveToTopic() + return None + if keymap.REMOVE_PROJECT_TOPIC in commands: + self.removeFromTopic() + return None + if keymap.RENAME_PROJECT_TOPIC in commands: + self.renameTopic() + return None if keymap.REFRESH in commands: self.app.sync.submitTask( sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY))