diff --git a/base_kanban_stage/README.rst b/base_kanban_stage/README.rst
new file mode 100644
index 000000000..1c7c354ea
--- /dev/null
+++ b/base_kanban_stage/README.rst
@@ -0,0 +1,110 @@
+.. image:: https://img.shields.io/badge/licence-lgpl--3-blue.svg
+ :target: http://www.gnu.org/licenses/LGPL-3.0-standalone.html
+ :alt: License: LGPL-3
+
+======================
+Kanban - Stage Support
+======================
+
+This module provides a stage model compatible with Kanban views and the
+standard views needed to manage these stages. It also provides the
+``base.kanban.abstract`` model, which can be inherited to add support for
+Kanban views with stages to any other model. Lastly, it includes a base Kanban
+view that can be extended as needed.
+
+Installation
+============
+
+To install this module, simply follow the standard install process.
+
+Configuration
+=============
+
+No configuration is needed or possible.
+
+Usage
+=====
+
+* Inherit from ``base.kanban.abstract`` to add Kanban stage functionality to
+ the child model:
+
+ .. code-block:: python
+
+ class MyModel(models.Model):
+ _name = 'my.model'
+ _inherit = 'base.kanban.abstract'
+
+* Extend the provided base Kanban view (``base_kanban_abstract_view_kanban``)
+ as needed by the child model while making sure to set the ``mode`` to
+ ``primary`` so that inheritance works properly. The base view has four
+ ``name`` attributes intended to provide convenient XPath access to different
+ parts of the Kanban card. They are ``card_dropdown_menu``, ``card_header``,
+ ``card_body``, and ``card_footer``:
+
+ .. code-block:: xml
+
+
+ My Model - Kanban View
+ my.model
+ primary
+
+
+
+
+
+
+
+
+
+
+
+* To manage stages, go to Settings > Technical > Kanban > Stages.
+
+.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
+ :alt: Try me on Runbot
+ :target: https://runbot.odoo-community.org/runbot/162/9.0
+
+Known Issues / Roadmap
+======================
+
+* The grouping logic used by ``base.kanban.abstract`` currently does not
+ support additional domains and alternate sort orders
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues `_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smash it by providing detailed and welcomed
+feedback.
+
+Credits
+=======
+
+Images
+------
+
+* Odoo Community Association:
+ `Icon `_.
+
+Contributors
+------------
+
+* Dave Lasley
+* Oleg Bulkin
+* Daniel Reis
+
+Maintainer
+----------
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+This module is maintained by the OCA.
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+To contribute to this module, please visit http://odoo-community.org.
diff --git a/base_kanban_stage/__init__.py b/base_kanban_stage/__init__.py
new file mode 100644
index 000000000..6b7b00c35
--- /dev/null
+++ b/base_kanban_stage/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from . import models
diff --git a/base_kanban_stage/__openerp__.py b/base_kanban_stage/__openerp__.py
new file mode 100644
index 000000000..58ee4345a
--- /dev/null
+++ b/base_kanban_stage/__openerp__.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+{
+ 'name': 'Kanban - Stage Support',
+ 'summary': 'Provides stage model and abstract logic for inheritance',
+ 'version': '9.0.1.0.0',
+ 'author': "LasLabs, Odoo Community Association (OCA)",
+ 'category': 'base',
+ 'depends': [
+ 'base',
+ ],
+ 'website': 'https://laslabs.com',
+ 'license': 'LGPL-3',
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/base_kanban_abstract.xml',
+ 'views/base_kanban_stage.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+}
diff --git a/base_kanban_stage/models/__init__.py b/base_kanban_stage/models/__init__.py
new file mode 100644
index 000000000..abde8b5bd
--- /dev/null
+++ b/base_kanban_stage/models/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from . import base_kanban_abstract
+from . import base_kanban_stage
diff --git a/base_kanban_stage/models/base_kanban_abstract.py b/base_kanban_stage/models/base_kanban_abstract.py
new file mode 100644
index 000000000..2e8ed21c8
--- /dev/null
+++ b/base_kanban_stage/models/base_kanban_abstract.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from openerp import api, fields, models
+
+
+class BaseKanbanAbstract(models.AbstractModel):
+ """ Inherit from this class to add support for Kanban stages to your model.
+ All public properties are preceded with kanban_ in order to isolate from
+ child models, with the exception of stage_id, which is a required field in
+ the Kanban widget and must be defined as such. """
+
+ _name = 'base.kanban.abstract'
+ _order = 'kanban_priority desc, kanban_sequence'
+ _group_by_full = {
+ 'stage_id': lambda s, *a, **k: s._read_group_stage_ids(*a, **k),
+ }
+
+ kanban_sequence = fields.Integer(
+ default=10,
+ index=True,
+ help='Order of record in relation to other records in the same Kanban'
+ ' stage and with the same priority',
+ )
+ kanban_priority = fields.Selection(
+ selection=[('0', 'Normal'), ('1', 'Medium'), ('2', 'High')],
+ index=True,
+ default='0',
+ help='The priority of the record (shown as stars in Kanban views)',
+ )
+ stage_id = fields.Many2one(
+ string='Kanban Stage',
+ comodel_name='base.kanban.stage',
+ track_visibility='onchange',
+ index=True,
+ copy=False,
+ help='The Kanban stage that this record is currently in',
+ default=lambda s: s._default_stage_id(),
+ domain=lambda s: [('res_model.model', '=', s._name)],
+ )
+ user_id = fields.Many2one(
+ string='Assigned To',
+ comodel_name='res.users',
+ index=True,
+ track_visibility='onchange',
+ help='User that the record is currently assigned to',
+ )
+ kanban_color = fields.Integer(
+ string='Color Index',
+ help='Color index to be used for the record\'s Kanban card',
+ )
+ kanban_legend_priority = fields.Text(
+ string='Priority Explanation',
+ related='stage_id.legend_priority',
+ help='Explanation text to help users understand how the priority/star'
+ ' mechanism applies to this record (depends on current stage)',
+ )
+ kanban_legend_blocked = fields.Text(
+ string='Special Handling Explanation',
+ related='stage_id.legend_blocked',
+ help='Explanation text to help users understand how the special'
+ ' handling status applies to this record (depends on current'
+ ' stage)',
+ )
+ kanban_legend_done = fields.Text(
+ string='Ready Explanation',
+ related='stage_id.legend_done',
+ help='Explanation text to help users understand how the ready'
+ ' status applies to this record (depends on current stage)',
+ )
+ kanban_legend_normal = fields.Text(
+ string='Normal Handling Explanation',
+ related='stage_id.legend_normal',
+ help='Explanation text to help users understand how the normal'
+ ' handling status applies to this record (depends on current'
+ ' stage)',
+ )
+ kanban_status = fields.Selection(
+ selection=[
+ ('normal', 'Normal Handling'),
+ ('done', 'Ready'),
+ ('blocked', 'Special Handling'),
+ ],
+ string='Kanban Status',
+ default='normal',
+ track_visibility='onchange',
+ required=True,
+ copy=False,
+ help='A record can have one of several Kanban statuses, which are used'
+ ' to indicate whether there are any special situations affecting'
+ ' it. The exact meaning of each status is allowed to vary based'
+ ' on the stage the record is in but they are roughly as follow:\n'
+ '* Normal Handling: Default status, no special situations\n'
+ '* Ready: Ready to transition to the next stage\n'
+ '* Special Handling: Blocked in some way (e.g. must be handled by'
+ ' a specific user)\n'
+ )
+
+ @api.model
+ def _default_stage_id(self):
+ return self.env['base.kanban.stage']
+
+ @api.multi
+ def _read_group_stage_ids(
+ self, domain=None, read_group_order=None, access_rights_uid=None
+ ):
+ stage_model = self.env['base.kanban.stage']
+ if access_rights_uid:
+ stage_model = stage_model.sudo(access_rights_uid)
+ stages = stage_model.search([('res_model.model', '=', self._name)])
+ names = [(r.id, r.display_name) for r in stages]
+ fold = {r.id: r.fold for r in stages}
+ return names, fold
diff --git a/base_kanban_stage/models/base_kanban_stage.py b/base_kanban_stage/models/base_kanban_stage.py
new file mode 100644
index 000000000..046fb5a65
--- /dev/null
+++ b/base_kanban_stage/models/base_kanban_stage.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from openerp import api, fields, models
+
+
+class BaseKanbanStage(models.Model):
+ _name = 'base.kanban.stage'
+ _description = 'Kanban Stage'
+ _order = 'res_model, sequence'
+
+ name = fields.Char(
+ string='Stage Name',
+ translate=True,
+ required=True,
+ help='Displayed as the header for this stage in Kanban views',
+ )
+ description = fields.Text(
+ translate=True,
+ help='Short description of the stage\'s meaning/purpose',
+ )
+ sequence = fields.Integer(
+ default=1,
+ required=True,
+ index=True,
+ help='Order of stage in relation to other stages available for the'
+ ' same model',
+ )
+ legend_priority = fields.Text(
+ string='Priority Explanation',
+ translate=True,
+ default='Mark a card as medium or high priority (one or two stars) to'
+ ' indicate that it should be escalated ahead of others with'
+ ' lower priority/star counts.',
+ help='Explanation text to help users understand how the priority/star'
+ ' mechanism applies to this stage',
+ )
+ legend_blocked = fields.Text(
+ string='Special Handling Explanation',
+ translate=True,
+ default='Give a card the special handling status to indicate that it'
+ ' requires handling by a special user or subset of users.',
+ help='Explanation text to help users understand how the special'
+ ' handling status applies to this stage',
+ )
+ legend_done = fields.Text(
+ string='Ready Explanation',
+ translate=True,
+ default='Mark a card as ready when it has been fully processed.',
+ help='Explanation text to help users understand how the ready status'
+ ' applies to this stage',
+ )
+ legend_normal = fields.Text(
+ string='Normal Handling Explanation',
+ translate=True,
+ default='This is the default status and indicates that a card can be'
+ ' processed by any user working this queue.',
+ help='Explanation text to help users understand how the normal'
+ ' handling status applies to this stage',
+ )
+ fold = fields.Boolean(
+ string='Collapse?',
+ help='Determines whether this stage will be collapsed down in Kanban'
+ ' views',
+ )
+ res_model = fields.Many2one(
+ string='Associated Model',
+ comodel_name='ir.model',
+ required=True,
+ index=True,
+ help='The model that this Kanban stage will be used for',
+ domain=[('transient', '=', False)],
+ default=lambda s: s._default_res_model(),
+ )
+
+ @api.model
+ def _default_res_model(self):
+ '''Useful when creating stages from a Kanban view for another model'''
+ action_id = self.env.context.get('params', {}).get('action')
+ action = self.env['ir.actions.act_window'].browse(action_id)
+ default_model = action.res_model
+ if default_model != self._name:
+ return self.env['ir.model'].search([('model', '=', default_model)])
diff --git a/base_kanban_stage/security/ir.model.access.csv b/base_kanban_stage/security/ir.model.access.csv
new file mode 100644
index 000000000..b47795a31
--- /dev/null
+++ b/base_kanban_stage/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_user,Kanban Stage - User Access,model_base_kanban_stage,base.group_user,1,0,0,0
+access_manager,Kanban Stage - Manager Access,model_base_kanban_stage,base.group_erp_manager,1,1,1,1
diff --git a/base_kanban_stage/static/description/icon.png b/base_kanban_stage/static/description/icon.png
new file mode 100644
index 000000000..3a0328b51
Binary files /dev/null and b/base_kanban_stage/static/description/icon.png differ
diff --git a/base_kanban_stage/tests/__init__.py b/base_kanban_stage/tests/__init__.py
new file mode 100644
index 000000000..05cdecf9a
--- /dev/null
+++ b/base_kanban_stage/tests/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from . import test_base_kanban_abstract
+from . import test_base_kanban_stage
diff --git a/base_kanban_stage/tests/test_base_kanban_abstract.py b/base_kanban_stage/tests/test_base_kanban_abstract.py
new file mode 100644
index 000000000..e11291722
--- /dev/null
+++ b/base_kanban_stage/tests/test_base_kanban_abstract.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from openerp import models
+from openerp.tests.common import TransactionCase
+
+
+class BaseKanbanAbstractTester(models.Model):
+ _name = 'base.kanban.abstract.tester'
+ _inherit = 'base.kanban.abstract'
+
+
+class TestBaseKanbanAbstract(TransactionCase):
+ def setUp(self):
+ super(TestBaseKanbanAbstract, self).setUp()
+
+ BaseKanbanAbstractTester._build_model(self.registry, self.cr)
+ self.test_model = self.env[BaseKanbanAbstractTester._name]
+
+ test_model_type = self.env['ir.model'].create({
+ 'model': BaseKanbanAbstractTester._name,
+ 'name': 'Kanban Abstract - Test Model',
+ 'state': 'base',
+ })
+
+ test_stage_1 = self.env['base.kanban.stage'].create({
+ 'name': 'Test Stage 1',
+ 'res_model': test_model_type.id,
+ })
+ test_stage_2 = self.env['base.kanban.stage'].create({
+ 'name': 'Test Stage 2',
+ 'res_model': test_model_type.id,
+ 'fold': True,
+ })
+
+ self.id_1 = test_stage_1.id
+ self.id_2 = test_stage_2.id
+
+ def test_read_group_stage_ids_base_case(self):
+ '''It should return a structure with the proper content'''
+ self.assertEqual(
+ self.test_model._read_group_stage_ids(),
+ (
+ [(self.id_1, 'Test Stage 1'), (self.id_2, 'Test Stage 2')],
+ {self.id_1: False, self.id_2: True},
+ )
+ )
+
+ def test_read_group_stage_ids_correct_associated_model(self):
+ '''It should only return info for stages with right associated model'''
+ stage_model = self.env['ir.model'].search([
+ ('model', '=', 'base.kanban.stage'),
+ ])
+ self.env['base.kanban.stage'].create({
+ 'name': 'Test Stage 3',
+ 'res_model': stage_model.id,
+ })
+
+ self.assertEqual(
+ self.test_model._read_group_stage_ids(),
+ (
+ [(self.id_1, 'Test Stage 1'), (self.id_2, 'Test Stage 2')],
+ {self.id_1: False, self.id_2: True},
+ )
+ )
+
+ def test_default_stage_id(self):
+ ''' It should return an empty RecordSet '''
+ self.assertEqual(
+ self.env['base.kanban.abstract']._default_stage_id(),
+ self.env['base.kanban.stage']
+ )
diff --git a/base_kanban_stage/tests/test_base_kanban_stage.py b/base_kanban_stage/tests/test_base_kanban_stage.py
new file mode 100644
index 000000000..7e089ff7b
--- /dev/null
+++ b/base_kanban_stage/tests/test_base_kanban_stage.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016 LasLabs Inc.
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
+
+from openerp.tests.common import TransactionCase
+
+
+class TestBaseKanbanStage(TransactionCase):
+ def test_default_res_model_no_params(self):
+ '''It should return empty ir.model Recordset if no params in context'''
+ test_stage = self.env['base.kanban.stage'].with_context({})
+ res_model = test_stage._default_res_model()
+
+ self.assertFalse(res_model)
+ self.assertEqual(res_model._name, 'ir.model')
+
+ def test_default_res_model_no_action(self):
+ '''It should return empty ir.model Recordset if no action in params'''
+ test_stage = self.env['base.kanban.stage'].with_context(params={})
+ res_model = test_stage._default_res_model()
+
+ self.assertFalse(res_model)
+ self.assertEqual(res_model._name, 'ir.model')
+
+ def test_default_res_model_info_in_context(self):
+ '''It should return correct ir.model record if info in context'''
+ test_action = self.env['ir.actions.act_window'].create({
+ 'name': 'Test Action',
+ 'res_model': 'res.users',
+ })
+ test_stage = self.env['base.kanban.stage'].with_context(
+ params={'action': test_action.id},
+ )
+
+ self.assertEqual(
+ test_stage._default_res_model(),
+ self.env['ir.model'].search([('model', '=', 'res.users')])
+ )
+
+ def test_default_res_model_ignore_self(self):
+ '''It should not return ir.model record corresponding to stage model'''
+ test_action = self.env['ir.actions.act_window'].create({
+ 'name': 'Test Action',
+ 'res_model': 'base.kanban.stage',
+ })
+ test_stage = self.env['base.kanban.stage'].with_context(
+ params={'action': test_action.id},
+ )
+
+ self.assertFalse(test_stage._default_res_model())
diff --git a/base_kanban_stage/views/base_kanban_abstract.xml b/base_kanban_stage/views/base_kanban_abstract.xml
new file mode 100644
index 000000000..984889565
--- /dev/null
+++ b/base_kanban_stage/views/base_kanban_abstract.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+ Kanban Abstract - Base Kanban View
+ base.kanban.abstract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/base_kanban_stage/views/base_kanban_stage.xml b/base_kanban_stage/views/base_kanban_stage.xml
new file mode 100644
index 000000000..cc99617cf
--- /dev/null
+++ b/base_kanban_stage/views/base_kanban_stage.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ Kanban Stage - Form View
+ base.kanban.stage
+
+
+
+
+
+
+ Kanban Stages - Tree View
+ base.kanban.stage
+
+
+
+
+
+
+
+
+
+
+ Kanban Stages - Search View
+ base.kanban.stage
+
+
+
+
+
+
+
+
+
+
+ Kanban Stages
+ base.kanban.stage
+ ir.actions.act_window
+ form
+ tree,form
+
+
+
+
+
+