diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ca3aed --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/.settings +/.project +/.pydevproject +/.idea +/venv +/filestore + +*.pyc +*~ +*.log +*.tmp +*.pid +/scrummer/static/src/.sass-cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e113954 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ + +Scrummer +================================= +This project aims to extend Odoo with agile project management methodologies like: + + * Scrum + * Kanban + * Scrumban + * Lean + * ... + +as well as to introduce a completely fresh UI framework for agile project management. + +[//]: # (addons) + + +Available addons +---------------- +addon | version | summary +--- | --- | --- +[scrummer](scrummer/) | 11.0.1.0.0 | Base module for development of all scrummer components. +[scrummer_git](scrummer_git/) | 11.0.1.0.0 | Module which brings integration with [project_git](project_git/) module. +[scrummer_kanban](scrummer_kanban/) | 11.0.1.0.0 | Module which brings integration with [project_agile_kanban](project_agile_kanban/) module. +[scrummer_scrum](scrummer_scrum/) | 11.0.1.0.0 | Module which brings integration with [project_agile_scrum](project_agile_scrum/) module. +[scrummer_timesheet_category](scrummer_timesheet_category/) | 11.0.1.0.0 | Module which brings integration with [project_timesheet_category](project_timesheet_category/) module. +[scrummer_workflow_security](scrummer_workflow_security/) | 11.0.1.0.0 | Module which brings integration with [project_workflow_security](project_workflow_security/) module. +[scrummer_workflow_transition_by_project](scrummer_workflow_transition_by_project/) | 11.0.1.0.0 | Module which brings integration with [project_workflow_transition_by_project](project_workflow_transition_by_project/) module. +[scrummer_workflow_transitions_by_task_type](scrummer_workflow_transitions_by_task_type/) | 11.0.1.0.0 | Module which brings integration with [project_agile_workflow_transitions_by_task_type](project_agile_workflow_transitions_by_task_type/) module. +[project_agile](project_agile/) | 11.0.1.0.0 | Base module for development of all agile methodologies. +[project_agile_analytic](project_agile_analytic/) | 11.0.1.0.0 | Module which bring simple analytics for project tasks. +[project_agile_jira](project_agile_jira/) | 11.0.1.0.0 | Module which brings interface for migration from JIRA to Odoo. Very light. +[project_agile_kanban](project_agile_kanban/) | 11.0.1.0.0 | Module which brings agile kanban methodology. +[project_agile_scrum](project_agile_scrum/) | 11.0.1.0.0 | Module which brings agile scrum methodology +[project_agile_timesheet_category](project_agile_timesheet_category/) | 11.0.1.0.0 | Module which integrates [project_timesheet_category](project_timesheet_category/) with project_agile +[project_agile_workflow_transitions_by_task_type](project_agile_workflow_transitions_by_task_type/) | 11.0.1.0.0 | Module which integrates [project_workflow_transitions_by_task_type](project_workflow_transitions_by_task_type/) with project agile. +[project_git](project_git/) | 11.0.1.0.0 | Base module for development of other modules which will bring integration with specific git services like: GitHub, BitBucket, GitLab, etc. +[project_git_bitbucket](project_git_bitbucket/) | 11.0.1.0.0 | Module which extends [project_git](project_git/) module with BitBucket integration. +[project_git_github](project_git_github/) | 11.0.1.0.0 | Module which extends [project_git](project_git/) module with GitHub integration. +[project_git_gitlab](project_git_gitlab/) | 11.0.1.0.0 | Module which extends [project_git](project_git/) module with GitLab integration. +[project_key](project_key/) | 11.0.1.0.0 | Module which brings functionality to uniquely identify projects and tasks by simple auto generated ``key`` field. +[project_portal](project_portal/) | 11.0.1.0.0 | Module which extends project portal controller to be more extendable. +[project_task_archiving](project_task_archiving/) | 11.0.1.0.0 | Module which enables task archiving based on number of days task stays in a specific stage. +[project_timesheet_category](project_timesheet_category/) | 11.0.1.0.0 | Module which brings categorization to the project timesheet. +[project_workflow](project_workflow/) | 11.0.1.0.0 | This module provides functionality to create fully configurable workflow around ``project.task`` +[project_workflow_action](project_workflow_action/) | 11.0.1.0.0 | This module provides functionality to execute server actions when executing task workflow. +[project_workflow_default_state_per_group](project_workflow_default_state_per_group/) | 11.0.1.0.0 | This module provides functionality to assign different initial state to task depending on the security group. +[project_workflow_security](project_workflow_security/) | 11.0.1.0.0 | Module which extends [project_workflow](project_workflow/) to provide allowed security groups for workflow transitions. +[project_workflow_transitions_by_project](project_workflow_transitions_by_project/) | 11.0.1.0.0 | Module which extends [project_workflow](project_workflow/) to provide project constraints for workflow transitions. +[web_diagram_position](web_diagram_position/) | 11.0.1.0.0 | Module provides functionality to save workflow elements coordinates. +[web_ir_actions_act_multi](web_ir_actions_act_multi/) | 11.0.1.0.0 | Module which brings new type of action to ActionManager which can execute provided list of actions. +[web_ir_actions_act_view_reload](web_ir_actions_act_view_reload/) | 11.0.1.0.0 | Module which brings new type of action to ActionManager which can reload currently active view only. +[web_syncer](web_syncer/) | 11.0.1.0.0 | Module which provides generic interface to receive CUD model notifications on web client side. +[web_widget_image_url](web_widget_image_url/) | 11.0.1.0.0 | Module which provides web widget for displaying image from an URL. + +[//]: # (end addons) + + +Roadmap +======= +Roadmap for further development can be found [here](roadmap.md). + +Credits +======= + +Contributors +------------ + +* Igor Jovanović +* Petar Najman +* Aleksandar Gajić +* Jasmina Nikolić +* Sladjan Kantar +* Miroslav Nikolić +* Mladen Meseldžija + +Maintainer +---------- +![Modoolar logo](https://www.modoolar.com/web/image/ir.attachment/3461/datas) + +This repository is maintained by Modoolar. + +As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. +Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com \ No newline at end of file diff --git a/project_agile/README.rst b/project_agile/README.rst new file mode 100644 index 0000000..4bd8590 --- /dev/null +++ b/project_agile/README.rst @@ -0,0 +1,38 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +============= +Project Agile +============= + +This module provides core framework for development of the agile methodologies like kanban, scrum, scrumban, etc + + +Credits +======= + + +Contributors +------------ +* Aleksandar Gajić +* Petar Najman +* Jasmina Nikolić +* Igor Jovanović +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com \ No newline at end of file diff --git a/project_agile/__init__.py b/project_agile/__init__.py new file mode 100644 index 0000000..2bb8480 --- /dev/null +++ b/project_agile/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import tools +from . import controllers +from . import models +from . import wizards +from .hooks import post_init_hook diff --git a/project_agile/__manifest__.py b/project_agile/__manifest__.py new file mode 100644 index 0000000..304e9da --- /dev/null +++ b/project_agile/__manifest__.py @@ -0,0 +1,59 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +{ + "name": "Project Agile", + "summary": "Framework for development of agile methodologies", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "web", + "project_key", + "project_workflow", + "hr_timesheet", + "website", + "web_editor", + "project_portal", + "project_task_archiving", + ], + + "data": [ + # security + "security/security.xml", + "security/ir.model.access.csv", + + # wizards + "wizards/board_export_wizard.xml", + "wizards/board_import_wizard.xml", + "wizards/board_create_wizard.xml", + "wizards/project_task_worklog_wizard.xml", + "wizards/add_subtask_wizard.xml", + "wizards/add_task_link_wizard.xml", + "wizards/stage_change_confirmation_wizard.xml", + + # views + "views/project_project_views.xml", + "views/project_task_views.xml", + "views/project_workflow.xml", + "views/project_agile_team_views.xml", + "views/project_agile_board_views.xml", + "views/project_agile.xml", + + # Menus + "views/menu.xml", + + # data + "data/project_task.xml", + "data/project_project.xml", + ], + + "demo": [ + ], + + "qweb": ["static/src/xml/*.xml"], + "post_init_hook": "post_init_hook", + "application": False, + "installable": True, +} diff --git a/project_agile/controllers/__init__.py b/project_agile/controllers/__init__.py new file mode 100644 index 0000000..15b73ff --- /dev/null +++ b/project_agile/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import portal diff --git a/project_agile/controllers/portal.py b/project_agile/controllers/portal.py new file mode 100644 index 0000000..311abf1 --- /dev/null +++ b/project_agile/controllers/portal.py @@ -0,0 +1,62 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import _ +from odoo.http import request +from odoo.osv.expression import OR +from odoo.addons.project_portal.controllers.portal import CustomerPortal + + +class CustomerPortal(CustomerPortal): + + def portal_my_tasks_prepare_searchbar(self): + searchbar = super(CustomerPortal, self).\ + portal_my_tasks_prepare_searchbar() + + searchbar['sorting'].update({ + 'type': {'label': _('Type'), 'order': 'type_id'}, + 'priority': {'label': _('Priority'), 'order': 'priority_id'}, + }) + + return searchbar + + def portal_my_tasks_prepare_task_search_domain(self, search_in, search): + domain = super(CustomerPortal, self).\ + portal_my_tasks_prepare_task_search_domain(search_in, search) + + if search and search_in: + if search_in in ('content', 'all'): + domain = OR([domain, [ + '|', + ('type_id', 'ilike', search), + ('priority_id', 'ilike', search) + ]]) + return domain + + def portal_my_tasks_prepare_values(self, + page=1, date_begin=None, date_end=None, + sortby=None, filterby=None, search=None, + search_in='content', **kw): + + values = super(CustomerPortal, self).portal_my_tasks_prepare_values( + page, date_begin, date_end, sortby, + filterby, search, search_in, **kw + ) + + values["priorities"] = request.env["project.task.priority"]\ + .sudo().search([]) + + values["types"] = request.env["project.task.type2"]\ + .sudo().search([]) + return values + + def portal_my_task_prepare_values(self, task_id=None, **kw): + values = super(CustomerPortal, self)\ + .portal_my_task_prepare_values(task_id, **kw) + + values["types"] = request.env["project.task.type2"]\ + .sudo().search([]) + + values["priorities"] = request.env["project.task.priority"]\ + .sudo().search([]) + return values diff --git a/project_agile/data/project_project.xml b/project_agile/data/project_project.xml new file mode 100644 index 0000000..46150d7 --- /dev/null +++ b/project_agile/data/project_project.xml @@ -0,0 +1,52 @@ + + + + + + Software + This type is for software developing projects. + + + + + + + Technical Support + This type is for technical support projects. + + + + + + + Business + This type is for general business projects. It is used by default if not specified. + + + + + + diff --git a/project_agile/data/project_task.xml b/project_agile/data/project_task.xml new file mode 100644 index 0000000..4bfc1c5 --- /dev/null +++ b/project_agile/data/project_task.xml @@ -0,0 +1,555 @@ + + + + + + + + + blocks + is blocked by + 1 + + + is blocked by + blocks + 2 + + + clones + is cloned by + 3 + + + is cloned by + clones + 4 + + + duplicates + is duplicated by + 5 + + + is duplicated by + duplicates + 6 + + + causes + is caused by + 7 + + + is caused by + causes + 8 + + + relates to + relates to + 9 + + + + + + Blocker + Block development and/or testing work,production could not run. + + + + + Critical + Crashed, loss of data,severe memory leak. + + + + + Major + Major loss of function. + + + + + Minor + Minor loss of function or other problem where easy workaround is present. + + + + + Trivial + Cosmetic problem like misspelt words or misaligned text + + + + + + Must have + + ‘Must Haves‘ are features that must be included before the product can be launched. +It is good to have clarity on this before a project begins, as this is the minimum scope for the product to be useful. + ]]> + + + + + + Should have + + ‘Should Haves‘ are features that are not critical to launch, but are considered to be important and of a high value to the user. + ]]> + + + + + + Could have + + ‘Could Haves‘ are features that are nice to have and could potentially be included without incurring too much effort or cost. +These will be the first features to be removed from scope if the project’s timescales are later at risk. + ]]> + + + + + + + Won't have (but Would like in future) + + ‘Won’t Haves‘ are features that have been reqeusted but are explicitly excluded from scope for the planned duration, and may be included in a future phase of development. + ]]> + + + + + + + + Done + Work on this item has been finished. + + + + Duplicate + This issue is a duplicate of another issue in the system. + + + + Won't Do + Work on this issue will not be accepted. + + + + + + ToDo + This stage means that the task is waiting on TODO list to be picked up for work. + + + + + Done + This stage means that the task has been completed. + + + + + Waiting Approval + This stage means that the task is waiting for a "green light" to be worked on. + + + + + Rejected + This stage means that the task has been rejected. + + + + + Init + This stage means that the task is under construction. It should be ignored until reporter says otherwise. + + + + + Actionable + This stage means that the task definition has been completed and that it's ready for review by project manager. + + + + + Accepted + This stage means that the task has been accepted by the project manager + + + + + Open + This stage means that it can be worked on this task. + + + + + Closed + This stage means that this task is closed. + + + + + Reopened + This means that the task has been reopened for some reason. + + + + + In Progress + This stage means that on this task is been actively worked on. + + + + + Blocked + This means that on this task could not be worked anymore due the missing information or similar. + + + + + Code Review + This means that on this task requires code review. + + + + + Integration + This means that on this task requires integration testing (i.e. Unit Testing). + + + + + QA + This means that this task is ready for QA + + + + + In Progress QA + This means that QA is working on this task. + + + + + Deployment UAT + This means that the task is ready to be deployed on UAT. + + + + + UAT QA + This means that the task is ready to be tested on UAT. + + + + + In Progress UAT QA + This means that QA is testing this task on UAT environment. + + + + + Resolved + This stage means that a task has been successfully test on UAT. + + + + + Deployment + This stage means that a task is ready to be deployed to production. + + + + + QA Production + This stage means that a ticket has been deployed to production and it's waiting to be verified on production if possible.. + + + + + + + Sub Task + A subtask that needs to be done.. + + + + + + + + + + Task + A task that needs to be done. + + + + + + + + + New Feature + A new feature of the product, which has yet to be developed. + + + + + + + Improvement + An improvement or enhancement to an existing feature or task. + + + + + + + Bug + A problem which impairs or prevents the functions of the product. + + + + + + + + User Story Item + + + + + + + + + + + + + User Story + + + + + + + + + + + + Spike + + + + + + + + + + + Epic + + + + + + + + + + + + Change + Change Management ITIL Improvement + + + + + + + Access + Errors access / Can't access / Password Change. + + + + + + + Fault + System Error Fault Problem + + + + + + + Purchase + Track items that need to be bought. + + + + + + + IT Help + IT help Issue + + + + + + diff --git a/project_agile/examples/default_board.xml b/project_agile/examples/default_board.xml new file mode 100644 index 0000000..9d649a7 --- /dev/null +++ b/project_agile/examples/default_board.xml @@ -0,0 +1,55 @@ + + + + + + + Default Board + This is a default board. Every newly created project will be assigned to this board + scrum + + live + + + + + ToDo + + + + + + + + + + + + In Progress + + + + + + + + + + + + Done + + + + + + + + + + + + diff --git a/project_agile/examples/default_workflow.xml b/project_agile/examples/default_workflow.xml new file mode 100644 index 0000000..9a194f2 --- /dev/null +++ b/project_agile/examples/default_workflow.xml @@ -0,0 +1,89 @@ + + + + + + + Default Workflow + Workflow for simple software development. + live + + + + + + + todo + + + + + + in_progress + + + + + + done + + + + + + + Start Progress + + + Start working on this task. + + + + + + Stop Progress + + + Stop working on this task. + + + + + + Finish Work + + + Work on this task has been finished. + + + + + + Start Progress + + + Start working on this task again. + + + + + + Back on the list + + + Put the task back to TDOD list. + + + + + + Done + + + Put the task directly to done. + + + diff --git a/project_agile/hooks.py b/project_agile/hooks.py new file mode 100644 index 0000000..cf8ea6b --- /dev/null +++ b/project_agile/hooks.py @@ -0,0 +1,64 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + + +def post_init_hook(cr, registry): + import os + from odoo import api, SUPERUSER_ID + from odoo.tools import misc + + env = api.Environment(cr, SUPERUSER_ID, {}) + + # Load project workflow + workflow_pathname = os.path.join( + 'project_agile', 'import', 'project_workflow.xml' + ) + with misc.file_open(workflow_pathname) as stream: + importer = env['project.workflow.importer'] + reader = env['project.workflow.xml.reader'] + workflow = importer.run(reader, stream) + workflow.write({'state': 'live'}) # Publish imported workflow + + # Assign simple workflow to all project types + env['project.type'].search([]).write({'workflow_id': workflow.id}) + + # We need to assign initial agile order. + # It would be nicer if latest tasks were in the top of the backlog. + cr.execute("SELECT COUNT(*) FROM project_task") + count = cr.fetchone()[0] + cr.execute(""" + UPDATE project_task + SET agile_order = %s - id + WHERE agile_order IS NULL + """, (int(count),) + ) + + # Epics allow sub epics + task_type_epic = env.ref("project_agile.project_task_type_epic") + type_ids = task_type_epic.type_ids.ids + type_ids.append(task_type_epic.id) + task_type_epic.write({'type_ids': [(6, 0, type_ids)]}) + + # Set default project task type to the existing projects + env['project.project'].sudo().with_context( + no_workflow=True + )._set_default_project_type_id() + + # and set ``type_id`` field to not null + cr.execute( + "ALTER TABLE project_project ALTER COLUMN type_id SET NOT NULL;" + ) + + # Set default project task type to the existing tasks + env['project.task'].sudo()._set_default_task_type_id() + + # and set ``type_id`` field to not null + cr.execute("ALTER TABLE project_task ALTER COLUMN type_id SET NOT NULL;") + + # Set default task priority to the existing tasks + env['project.task'].sudo()._set_default_task_priority_id() + + # and set ``priority_id`` field to not null + cr.execute( + "ALTER TABLE project_task ALTER COLUMN priority_id SET NOT NULL;" + ) diff --git a/project_agile/import/project_workflow.xml b/project_agile/import/project_workflow.xml new file mode 100644 index 0000000..101dede --- /dev/null +++ b/project_agile/import/project_workflow.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/project_agile/models/__init__.py b/project_agile/models/__init__.py new file mode 100644 index 0000000..fcc88ac --- /dev/null +++ b/project_agile/models/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import mixins +from . import base +from . import project_agile_board +from . import project_agile_board_importer +from . import project_agile_board_reader +from . import project_agile_board_writer +from . import project_agile_report +from . import project_agile_team +from . import project_project +from . import project_task +from . import project_workflow +from . import project_workflow_publisher +from . import res_users diff --git a/project_agile/models/base.py b/project_agile/models/base.py new file mode 100644 index 0000000..9efaa48 --- /dev/null +++ b/project_agile/models/base.py @@ -0,0 +1,47 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, exceptions, api, _ + + +class AgileSystemCodeItem(models.AbstractModel): + _name = 'project.agile.code_item' + _inherit = ['project.agile.mixin.id_search'] + _description = 'Agile Code Item' + _order = 'sequence' + + name = fields.Char( + string="Name", + required=True, + ) + + description = fields.Html( + string="Description", + required=False, + ) + + system = fields.Boolean( + string='Is System Type', + readonly=True, + default=False, + ) + + active = fields.Boolean( + string='Active', + default=True, + ) + + sequence = fields.Integer( + string='Sequence', + default=10, + ) + + @api.multi + def unlink(self): + for item in self: + if item.system: + raise exceptions.ValidationError(_( + "%s '%s' is a system record!\n" + "You are not allowed to delete system record!" + ) % (self._description or self._name, item.name_get()[0][1])) + return super(AgileSystemCodeItem, self).unlink() diff --git a/project_agile/models/mixins.py b/project_agile/models/mixins.py new file mode 100644 index 0000000..769453d --- /dev/null +++ b/project_agile/models/mixins.py @@ -0,0 +1,43 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging +from odoo import models, api + +_logger = logging.getLogger(__name__) + + +class IdSearchMixin(models.AbstractModel): + _name = 'project.agile.mixin.id_search' + + @api.model + def id_search(self, name='', args=None, context=None, operator='ilike', + limit=100, order=None): + """ Works exactly like name_search, + except that returns only Array of ids, without names + """ + if context: + self = self.with_context(context=context) + + return self._id_search(name, args, operator, limit, order) + + @api.model + def _id_search(self, name='', args=None, operator='ilike', + limit=100, order=None, name_get_uid=None): + + # private implementation of id_search, allows passing a dedicated user + # for the name_get part to solve some access rights issues + args = list(args or []) + + # Optimize out the default criterion of ``ilike ''`` + # that matches everything + if not self._rec_name: + _logger.warning( + "Cannot execute name_search, no _rec_name defined on %s", + self._name + ) + + elif not (name == '' and operator == 'ilike'): + args += [(self._rec_name, operator, name)] + + return self.search(args, order=order).ids diff --git a/project_agile/models/project_agile_board.py b/project_agile/models/project_agile_board.py new file mode 100644 index 0000000..f67790a --- /dev/null +++ b/project_agile/models/project_agile_board.py @@ -0,0 +1,392 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, exceptions, _ + + +class Board(models.Model): + _name = 'project.agile.board' + _inherit = ['project.agile.mixin.id_search'] + _description = "Agile Board" + + name = fields.Char() + description = fields.Char() + + type = fields.Selection( + selection=[], + ) + + workflow_ids = fields.Many2many( + comodel_name='project.workflow', + compute="_compute_workflow_ids", + string="Workflows", + ) + + project_ids = fields.Many2many( + comodel_name="project.project", + relation="board_project_rel", + column1="board_id", + column2="project_id", + string="Projects", + ) + + column_ids = fields.One2many( + comodel_name="project.agile.board.column", + inverse_name="board_id", + string="Columns", + required=False, + ) + + status_ids = fields.One2many( + comodel_name="project.agile.board.column.status", + inverse_name="board_id", + string="Statuses", + readonly=True, + ) + + is_default = fields.Boolean( + string='Default?', + default=False, + ) + + unmapped_state_ids = fields.One2many( + comodel_name='project.workflow.state', + compute="_compute_unmapped_state_ids", + readonly=True, + ) + unmapped_task_stage_ids = fields.One2many( + comodel_name='project.task.type', + compute="_compute_unmapped_task_stage_ids", + readonly=True, + ) + + report_ids = fields.One2many( + comodel_name='project.agile.board.report', + compute="_compute_report_ids", + ) + + board_task_type_ids = fields.Many2many( + comodel_name='project.task.type2', + relation="project_agile_scrum_board_table_task_type_rel", + column1="board_id", + column2="type_id", + string="Board Task Types", + help='List of available task types for active sprint.' + 'If left empty task types from registered projects will be used', + ) + + backlog_task_type_ids = fields.Many2many( + comodel_name='project.task.type2', + relation="project_agile_board_backlog_task_type_rel", + column1="board_id", + column2="type_id", + string="Backlog Task Types", + help='List of available task types for this board.' + 'If left empty task types from registered projects will be used', + ) + + visibility = fields.Selection( + selection=[('global', 'Global'), ('team', 'Team'), ('user', 'User')], + default='global', + required=True, + string='Visibility' + ) + + team_id = fields.Many2one( + comodel_name='project.agile.team', + string='Team', + help='Team which owns this board' + ) + + user_id = fields.Many2one( + comodel_name='res.users', + string='User', + help='User which owns this board', + ) + + @api.one + @api.depends("project_ids") + def _compute_workflow_ids(self): + self.workflow_ids = self.mapped("project_ids.workflow_id").ids + + @api.multi + def _compute_report_ids(self): + for rec in self: + rec.project_ids = self.env['project.agile.board.report'].search([ + ('type', '=', rec.type) + ]).ids or [] + + @api.multi + def get_mapped_states(self): + self.ensure_one() + mapped_states = [] + for column in self.column_ids: + mapped_states.extend([x.state_id.id for x in column.status_ids]) + return mapped_states + + @api.multi + def _compute_unmapped_task_stage_ids(self): + for record in self: + record.unmapped_task_stage_ids = [ + x.stage_id.id for x in record.unmapped_state_ids + ] + + @api.multi + def _compute_unmapped_state_ids(self): + for record in self: + mapped_states = set(record.get_mapped_states()) + workflow_states = set(record.workflow_ids.mapped("state_ids").ids) + diff = workflow_states - mapped_states + + if diff: + record.unmapped_state_ids = list(diff) + else: + record.unmapped_state_ids = [] + + @api.constrains('type') + def _constraint_type_projects(self): + for project in self.project_ids: + if project.agile_method != self.type: + raise exceptions.ValidationError(_( + "Agile method on assigned projects does not match " + "selected board type!" + )) + + @api.model + def create(self, vals): + new = super(Board, self).create(vals) + self.clear_caches() + return new + + @api.multi + def write(self, vals): + res = super(Board, self).write(vals) + self.clear_caches() + return res + + + @api.multi + def create_task_domain(self): + self.ensure_one() + return [("project_id", "in", self.project_ids.ids)] + + @api.multi + def task_tree_view(self): + self.ensure_one() + + type_story = self.env.ref("project_agile.project_task_type_story") + default_filter = [ + ("sprint_id", "=", False), + ("type_id", "=", type_story.id) + ] + + domain = self.create_task_domain() + default_filter + ctx = { + 'default_res_model': self._name, + 'default_res_id': self.id, + } + return { + 'name': 'Tasks', + 'domain': domain, + 'res_model': 'project.task', + 'type': 'ir.actions.act_window', + 'view_id': False, + 'view_mode': 'tree,form', + 'view_type': 'form', + 'help': '''

+ Some help, change to appropriate +

''', + 'limit': 80, + 'context': ctx, + } + + @api.multi + def declare_default(self): + self.ensure_one() + self.search([ + ('type', '=', self.type), + ('is_default', '=', True) + ]).write({'is_default': False}) + + self.is_default = True + + @api.multi + def export_board(self): + self.ensure_one() + wizard = self.env['project.agile.board.export.wizard'].create({ + 'board_id': self.id + }) + return wizard.button_export() + + +class Column(models.Model): + _name = 'project.agile.board.column' + _description = "Agile Board Column" + _order = 'order' + + name = fields.Char() + order = fields.Float() + board_id = fields.Many2one( + comodel_name="project.agile.board", + string="Board", + ondelete='cascade' + ) + + status_ids = fields.One2many( + comodel_name="project.agile.board.column.status", + inverse_name="column_id", + string="Statuses", + required=True + ) + + min = fields.Integer( + "Min", + help="The minimum number of issues in this column" + ) + + max = fields.Integer( + "Max", + help="The maximum number of issues int this column" + ) + + min_max_visible = fields.Boolean( + compute="_compute_min_max_visible" + ) + + notification_level = fields.Selection( + selection=[('warning', 'Warning'), ('error', 'Error')], + string='Notification Level', + help="Level of notification when the maximum number of issues is " + "exceeded!" + ) + + workflow_ids = fields.Many2many( + comodel_name='project.workflow', + compute="_compute_workflow_ids", + string="Workflows", + ) + + _sql_constraints = [ + ('name_unique', 'UNIQUE(board_id,name)', + "The name of the column must be unique per board"), + ] + + @api.multi + @api.depends("board_id.type") + def _compute_min_max_visible(self): + min_max_types = self._min_max_available_for_types() + for column in self: + column.min_max_visible = column.board_id.type in min_max_types + + @api.one + @api.depends( + "board_id", + "board_id.project_ids", + "board_id.project_ids.workflow_id") + def _compute_workflow_ids(self): + self.workflow_ids = self.board_id.mapped("project_ids.workflow_id").ids + + def _min_max_available_for_types(self): + return [] + + +class ColumnStatus(models.Model): + _name = 'project.agile.board.column.status' + _description = "Agile Board Column Status" + + name = fields.Char( + related="state_id.name", + readonly=True + ) + + column_id = fields.Many2one( + comodel_name="project.agile.board.column", + string="Column", + required=False, + ondelete='cascade' + ) + + board_id = fields.Many2one( + comodel_name="project.agile.board", + string="Board", + related="column_id.board_id", + readonly=True, + store=True, + ) + + state_id = fields.Many2one( + comodel_name='project.workflow.state', + string='Name', + oldname='status_id', + required=True, + ) + + stage_id = fields.Many2one( + comodel_name="project.task.type", + string="Name", + oldname='state_id', + related="state_id.stage_id", + ) + + order = fields.Float( + string='Order', + required=False + ) + + workflow_ids = fields.Many2many( + comodel_name='project.workflow', + compute="_compute_workflow_ids", + string="Workflows", + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + domain="[('id', 'in', workflow_ids)]", + string='Workflow', + required=True, + index=True, + ) + + workflow_stage_ids = fields.Many2many( + comodel_name='project.task.type', + compute="compute_workflow_stage_ids", + readonly=True + ) + + _sql_constraints = [ + ('stage_unique', 'UNIQUE(board_id, state_id)', + "Column state must be unique per board!"), + ] + + @api.multi + def compute_workflow_stage_ids(self): + for rec in self: + rec.workflow_stage_ids = rec.workflow_ids.mapped("stage_ids") + + @api.one + @api.depends( + "board_id", + "board_id.project_ids", + "board_id.project_ids.workflow_id") + def _compute_workflow_ids(self): + self.workflow_ids = self.board_id.mapped("project_ids.workflow_id").ids + + @api.multi + @api.onchange("workflow_id") + def calculate_workflow_stage_ids(self): + for record in self: + record.workflow_stage_ids = [ + x.id for x in self.workflow_id.stage_ids + ] + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + if args is None: + args = [] + if 'filter_statuses_in_column' in self.env.context: + columns = self.env.context.get('filter_statuses_in_column', []) + ids = [x[1] for x in columns] + args.append(('id', 'in', ids)) + return super(ColumnStatus, self).name_search( + name, args=args, operator=operator, limit=limit + ) diff --git a/project_agile/models/project_agile_board_importer.py b/project_agile/models/project_agile_board_importer.py new file mode 100644 index 0000000..0553f7b --- /dev/null +++ b/project_agile/models/project_agile_board_importer.py @@ -0,0 +1,84 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models + + +class AgileBoardImporter(models.AbstractModel): + _name = 'project.agile.board.importer' + _description = 'Agile Board Importer' + + def run(self, reader, stream): + """ + Runs import process of the given project workflow data stream. + :param reader: The reader to be used to read the given stream. + :param stream: The stream of data to be imported. + :return: Returns + """ + board = reader.board_read(stream) + return self._import_board(board) + + def _import_board(self, board): + """ + Imports given workflow into odoo database + :param board: The board to be imported. + :return: Returns instance of the imported project workflow. + """ + + workflow = self.env['project.workflow'].search([ + ('name', '=', board['workflow']) + ]) + wkf_states = dict([(s.name, s) for s in workflow.state_ids]) + + board_data = self.prepare_board(board, workflow, [ + (0, 0, self.prepare_column(workflow, wkf_states, column)) + for column in board['columns'] + ]) + + is_default = board_data.pop('is_default', False) + the_board = self.env['project.agile.board'].create(board_data) + + if is_default: + the_board.declare_default() + + return the_board + + def prepare_board(self, board, workflow, column_ids): + """ + Prepares ``project.workflow`` data. + :param workflow: The workflow to be mapped to the odoo workflow + :param state_ids: The list of already odoo mapped states. + :return: Returns dictionary with workflow data ready to be saved + within odoo database. + """ + return { + 'name': board['name'], + 'workflow_id': workflow.id, + 'description': board['description'], + 'type': board['type'], + 'is_default': board['is_default'], + 'column_ids': column_ids, + } + + def prepare_column(self, workflow, wkf_states, column): + """ + Prepares ``project.workflow.state`` dictionary for saving. + :return: Returns prepared ``project.workflow.state`` values. + """ + return { + 'name': column['name'], + 'order': column['order'], + 'status_ids': [ + (0, 0, self.prepare_status(workflow, wkf_states, status)) + for status in column['statuses'] + ] + } + + def prepare_status(self, workflow, wkf_states, status): + """ + Prepares ``project.workflow.transition`` dictionary for saving. + :return: Returns prepared ``project.workflow.transition`` values. + """ + return { + 'state_id': wkf_states[status['wkf_state']].id, + 'order': status['order'], + } diff --git a/project_agile/models/project_agile_board_reader.py b/project_agile/models/project_agile_board_reader.py new file mode 100644 index 0000000..fbaccc7 --- /dev/null +++ b/project_agile/models/project_agile_board_reader.py @@ -0,0 +1,270 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import os +import logging +from lxml import etree +from lxml.builder import ElementMaker + +from odoo import models, tools, exceptions, _ + +_logger = logging.getLogger(__name__) + + +class XmlAgileBoardReader(models.AbstractModel): + _name = 'project.agile.board.xml.reader' + _description = 'Agile Board Xml Reader' + + _rng_namespace = 'http://relaxng.org/ns/structure/1.0' + _rng_namespace_map = {'rng': 'http://relaxng.org/ns/structure/1.0'} + + def get_element_maker(self): + return ElementMaker( + namespace=self._rng_namespace, + nsmap=self._rng_namespace_map, + ) + + def validate_schema(self, xml): + """ + Validates given ``xml`` against RelaxedNG validation schema. + In case xml is invalid and ~openerp.exceptions.ValidationError + is raised. + :param xml: Xml string to be validated against RelaxedNG schema + :return: Void + """ + validator = self.create_validator() + if not validator.validate(xml): + errors = [] + + for error in validator.error_log: + error = tools.ustr(error) + _logger.error(error) + errors.append(error) + + raise exceptions.ValidationError(_( + "Agile Board File Validation Error: %s" + ) % ",".join(errors)) + + def create_validator(self): + """ + Instantiates RelaxedNG schema validator + :return: Returns RelaxedNG validator + """ + rng_file = tools.file_open(self.get_rng_file_path()) + try: + rng = etree.parse(rng_file) + rng = self.extend_rng(rng) + return etree.RelaxNG(rng) + except Exception: + raise + finally: + rng_file.close() + + def extend_rng(self, rng_etree): + """ + This method is a hook from where you can modify rng schema in cases + where you have extended agile boarD from another module and you want + to support import/export functionality for your extensions. + :param rng_etree: The tng tree which needs to be extended. + :return: Returns extended rng tree. + """ + return rng_etree + + def get_rng_file_path(self): + return os.path.join('project_agile', 'rng', 'board.rng') + + def board_read(self, stream): + """ + Reads workflow from the given xml string + :param stream: The stream providing xml data + :return: Returns parsed workflow data. + """ + + board_tree = etree.parse(stream) + self.validate_schema(board_tree) + + board_xml = board_tree.getroot() + + board = self.read_board(board_xml) + self.validate_board(board) + + return board + + def validate_board(self, board): + """ + This method validates the logic of the given agile board object. + It will check if all mapped states within columns are used only once + :param board: The agile board to be validated + :return: + """ + + workflows = self.env['project.workflow'].search([ + ('name', '=', board['workflow']) + ]) + + workflow_count = len(workflows) + if workflow_count == 0: + raise exceptions.ValidationError(_( + "Workflow with name '%s' could not be found in database" + ) % board['workflow']) + + if workflow_count > 1: + raise exceptions.ValidationError(_( + "Found multiple instances of workflow with name '%s' " + ) % board['workflow']) + + wkf_states = set([state.name for state in workflows.state_ids]) + + counter = dict() + multiples = [] + lost_and_found = set() + for column in board['columns']: + for status in column['statuses']: + status_name = status['wkf_state'] + counter[status_name] = counter.get(status_name, 0) + 1 + if counter[status_name] > 1: + multiples.append(status_name) + + if status_name not in wkf_states: + lost_and_found.add(status_name) + + error_messages = [] + + if multiples: + error_messages.append(_( + "Following states: [%s] are assigned to multiple columns!" + ) % multiples) + + if lost_and_found: + error_messages.append(_( + "Following states [%] are referenced in the board but are not " + "found in the related workflow!" + ) % lost_and_found) + + if error_messages: + raise exceptions.ValidationError("\n".join(error_messages)) + + def read_board(self, element): + """ + Reads workflow data out of the given xml element. + :param element: The xml element which holds information + about project workflow. + :return: Returns workflow dictionary. + """ + return { + 'name': self.read_string(element, 'name'), + 'description': self.read_string(element, 'description'), + 'type': self.read_string(element, 'type'), + 'is_default': self.read_boolean(element, 'is_default'), + 'columns': self.read_columns(element), + 'workflow': self.read_string(element, 'workflow'), + } + + def read_columns(self, element): + """ + Reads workflow states data out of the given xml element. + :param element: The xml element which holds information about + project workflow states + :return: Returns the list of the workflow states + """ + columns = [] + for e in element.iterfind('columns/column'): + columns.append(self.read_column(e)) + return columns + + def read_column(self, element): + """ + Reads workflow state data out of the given xml element. + :param element: The xml element which holds information + about project workflow state + :return: Returns workflow state dictionary + """ + return { + 'name': self.read_string(element, 'name'), + 'statuses': self.read_column_statuses(element), + 'order': self.read_integer(element, 'sequence', default_value=10) + } + + def read_column_statuses(self, element): + """ + Reads workflow transitions data out of the given xml element. + :param element: The xml element which holds information about + project workflow transitions. + :return: Returns the list of the workflow transitions. + """ + stauses = [] + for e in element.iterfind('statuses/status'): + stauses.append(self.read_status(e)) + return stauses + + def read_status(self, element): + """ + Reads ``project.workflow.transition`` data out of + the given xml element. + :param element: The xml element which holds information + about project workflow transition. + :return: Returns workflow transition dictionary. + """ + return { + 'wkf_state': self.read_string(element, 'wkf_state'), + 'order': self.read_float(element, 'order'), + } + + def read_string(self, element, attribute_name, default_value=''): + """ + Reads attribute of type ``string`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``string`` + """ + return self.read_attribute(element, attribute_name, default_value) + + def read_integer(self, element, attribute_name, default_value=0): + """ + Reads attribute of type ``integer`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``integer``. + """ + return int(self.read_attribute(element, attribute_name, default_value)) + + def read_float(self, element, attribute_name, default_value=0): + """ + Reads attribute of type ``integer`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``integer``. + """ + return float(self.read_attribute( + element, attribute_name, default_value + )) + + def read_boolean(self, element, attribute_name, default_value=False): + """ + Reads attribute of type ``boolean`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``boolean``. + """ + return bool(self.read_attribute( + element, attribute_name, default_value + )) + + def read_attribute(self, element, name, default_value=None): + """ + Reads attribute value of the given ``name`` from the given xml element. + :param element: The xml element from which attribute. + :param name: The name of the attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value or the default value. + """ + return element.attrib.get(name, default_value) diff --git a/project_agile/models/project_agile_board_writer.py b/project_agile/models/project_agile_board_writer.py new file mode 100644 index 0000000..42f8365 --- /dev/null +++ b/project_agile/models/project_agile_board_writer.py @@ -0,0 +1,172 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from lxml import etree +from odoo import models + + +DEFAULT_ENCODING = 'utf-8' + + +class XmlAgileBoardWriter(models.AbstractModel): + _name = 'project.agile.board.xml.writer' + _description = 'Xml agile board xml writer' + + def board_write(self, board, stream, encoding=DEFAULT_ENCODING): + """ + Converts given ``board`` object to the xml and then + writes it down to the given ``stream`` object. + :param board: The ``project.agile.board`` browse object + to be written down to the given stream object. + :param stream: This object represent any data stream object + but it must have write method. + :param encoding: Target encoding for xml string. + If not provided default encoding will be ``utf-8``. + """ + tree = self._build_xml(board, element_tree=True) + tree.write( + stream, encoding=encoding, xml_declaration=True, pretty_print=True + ) + + def to_string(self, board): + """ + Gets xml string representation of the given ``workflow`` object. + :param workflow: The ``project.workflow`` browse object + to be converted to the xml string. + :return: Returns xml string representation + of the give ``workflow`` object. + """ + return etree.tostring( + self._get_xml(board), + encoding=self.encoding, xml_declaration=True, pretty_print=True + ) + + def _build_xml(self, board, element_tree=False): + """ + Builds xml out of given ``workflow`` object. + :param workflow: The ``project.workflow`` browse object. + :param element_tree: Boolean indicating whether to wrap + root element into ``ElementTree`` or not. + :return: Returns workflow xml as a root element or as an element tree. + """ + root = self.create_board_element(board) + + columnsElement = self.create_columns_element(root, board) + for column in board.column_ids: + columnElement = self.create_column_element(columnsElement, column) + columnStusesElement = self.create_statuses_element( + columnElement, column + ) + for status in column.status_ids: + self.create_status_element(columnStusesElement, status) + + return element_tree and etree.ElementTree(root) or root + + def create_board_element(self, board): + """ + This method creates root workflow xml element. + :param workflow: The ``project.workflow`` browse object. + :return: Returns a new root workflow xml element. + """ + attributes = self.prepare_board_attributes(board) + return etree.Element('agile-board', attributes) + + def prepare_board_attributes(self, board): + """ + This method prepares attribute values for a workflow element. + :param state: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return { + 'name': board.name, + 'description': board.description or '', + 'type': board.type, + 'workflow': board.workflow_id.name, + 'is_default': str(board.is_default), + 'task_types': ",".join([x.name for x in board.task_type_ids]) + } + + def create_columns_element(self, parent, board): + """ + This method creates state xml element. + :param parent: The parent element of the new states element. + :param workflow: The ``project.workflow`` browse object. + :return: Returns a new state xml element. + """ + attributes = self.prepare_columns_attributes(board) + return etree.SubElement(parent, 'columns', attributes) + + def prepare_columns_attributes(self, board): + """ + This method prepares attribute values for a ``states`` element. + At the moment this method does nothing but it's added + here for possible future usage. + :param workflow: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return {} + + def create_column_element(self, parent, column): + """ + This method creates state xml element. + :param parent: The parent element of the new state element. + :param state: The ``project.workflow.state`` browse object. + :return: Returns a new state xml element. + """ + attributes = self.prepare_column_attributes(column) + columnElement = etree.SubElement(parent, 'column', attributes) + return columnElement + + def prepare_column_attributes(self, column): + """ + This method prepares attribute values for a state element. + :param state: The ``project.workflow.state`` browse object. + :return: Returns dictionary with attribute values. + """ + values = { + 'name': column.name, + 'order': str(column.order), + } + + return values + + def create_statuses_element(self, parent, column): + """ + This method creates transition xml element. + :param parent: The parent element of the new transition element. + :param workflow: The ``project.workflow`` browse object. + :return: Returns a new transition xml element. + """ + attributes = self.prepare_statuses_attributes(column) + return etree.SubElement(parent, 'statuses', attributes) + + def prepare_statuses_attributes(self, column): + """ + This method prepares attribute values for a ``statuses`` element. + At the moment this method does nothing but it's added here + for possible future usage. + :param workflow: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return {} + + def create_status_element(self, parent, status): + """ + This method creates transition xml element. + :param parent: The parent element of the new transition element. + :param transition: The ``project.workflow.transition`` browse object. + :return: Returns a new transition xml element. + """ + values = self.prepare_status_attributes(status) + return etree.SubElement(parent, 'status', values) + + def prepare_status_attributes(self, status): + """ + This method prepares attribute values for a transition element. + :return: Returns dictionary with attribute values. + """ + values = { + 'wkf_state': status.name, + 'order': str(status.order), + } + + return values diff --git a/project_agile/models/project_agile_report.py b/project_agile/models/project_agile_report.py new file mode 100644 index 0000000..fb14f13 --- /dev/null +++ b/project_agile/models/project_agile_report.py @@ -0,0 +1,27 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields + + +class AgileReport(models.AbstractModel): + _name = 'project.agile.report' + + name = fields.Char(required=True) + description = fields.Html(required=True) + type = fields.Selection(selection=[], string="Type", required=True) + image_url = fields.Char(required=True) + action_id = fields.Many2one( + comodel_name='ir.actions.client', + required=True + ) + + +class AgileTeamReport(models.Model): + _name = 'project.agile.team.report' + _inherit = 'project.agile.report' + + +class AgileBoardReport(models.Model): + _name = 'project.agile.board.report' + _inherit = 'project.agile.report' diff --git a/project_agile/models/project_agile_team.py b/project_agile/models/project_agile_team.py new file mode 100644 index 0000000..d4aa76f --- /dev/null +++ b/project_agile/models/project_agile_team.py @@ -0,0 +1,134 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, tools + + +class AgileTeam(models.Model): + _name = 'project.agile.team' + _inherit = ['mail.thread'] + + name = fields.Char( + string='Name', + ) + + description = fields.Html( + string='Description', + ) + + type = fields.Selection( + selection=[], + ) + + email = fields.Char( + string='E-mail', + ) + + member_ids = fields.Many2many( + comodel_name='res.users', + relation='project_agile_team_member_rel', + column1='team_id', + column2='member_id', + string='Scrum Members', + ) + + project_ids = fields.Many2many( + comodel_name="project.project", + relation="project_project_agile_team_rel", + column1="team_id", + column2="project_id", + string="Projects", + ) + + product_owner_ids = fields.One2many( + comodel_name='res.users', + string='Product Owner', + compute="_compute_product_owner_ids", + ) + + default_hrs = fields.Float( + string='Default daily hours', + default=8, + ) + + report_ids = fields.One2many( + comodel_name='project.agile.team.report', + compute="_compute_report_ids", + ) + + # image: all image fields are base64 encoded and PIL-supported + image = fields.Binary( + "Image", + attachment=True, + help="This field holds the image used as image for the agile team, " + "limited to 1024x1024px." + ) + + image_medium = fields.Binary( + "Medium-sized image", + compute='_compute_images', + inverse='_inverse_image_medium', + store=True, + attachment=True, + help="Medium-sized image of the agile team. It is automatically " + "resized as a 128x128px image, with aspect ratio preserved, " + "only when the image exceeds one of those sizes. " + "Use this field in form views or some kanban views." + ) + + image_small = fields.Binary( + "Small-sized image", + compute='_compute_images', + inverse='_inverse_image_small', + store=True, + attachment=True, + help="Small-sized image of the agile team. It is automatically " + "resized as a 64x64px image, with aspect ratio preserved. " + "Use this field anywhere a small image is required." + ) + + @api.multi + @api.depends("project_ids") + def _compute_product_owner_ids(self): + for rec in self: + rec.product_owner_ids = rec.project_ids.mapped("user_id") + + @api.multi + def _compute_report_ids(self): + for rec in self: + rec.report_ids = self.env['project.agile.team.report'].search([ + ('type', '=', rec.type) + ]).ids or [] + + @api.depends('image') + def _compute_images(self): + for rec in self: + rec.image_medium = tools.image_resize_image_medium( + rec.image, avoid_if_small=True + ) + rec.image_small = tools.image_resize_image_small(rec.image) + + def _inverse_image_medium(self): + for rec in self: + rec.image = tools.image_resize_image_big(rec.image_medium) + + def _inverse_image_small(self): + for rec in self: + rec.image = tools.image_resize_image_big(rec.image_small) + + @api.model + def create(self, vals): + new = super(AgileTeam, self).create(vals) + new.member_ids.fix_team_id() + return new + + @api.multi + def write(self, vals): + res = super(AgileTeam, self).write(vals) + + # Set default team for users without one + if 'member_ids' in vals: + self.mapped("member_ids").filtered( + lambda x: not x.team_id + ).fix_team_id() + return res diff --git a/project_agile/models/project_project.py b/project_agile/models/project_project.py new file mode 100644 index 0000000..f3fd4bf --- /dev/null +++ b/project_agile/models/project_project.py @@ -0,0 +1,413 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, tools, _ + + +class ProjectType(models.Model): + _name = "project.type" + _inherit = ['project.agile.code_item'] + + stage_ids = fields.Many2many( + comodel_name='project.task.type', + relation='project_type_task_stage_rel', + column1='project_id', + column2='stage_id', + string='Tasks Stages' + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + string='Workflow', + help="Workflow which will be used as a default project workflow", + ) + + task_type_ids = fields.Many2many( + comodel_name='project.task.type2', + relation="project_type_task_type_rel", + column1="project_type_id", + column2="task_type_id", + string="Task Types", + ) + + default_task_type_id = fields.Many2one( + comodel_name='project.task.type2', + string='Default Task Type', + ) + + _sql_constraints = [ + ('name_unique', + 'UNIQUE(name)', + "The name must be unique"), + ] + + @api.multi + def has_task_type(self, type_id): + self.ensure_one() + for task_type in self.task_type_ids: + if task_type.id == type_id: + return True + return False + + +class Project(models.Model): + _name = 'project.project' + _inherit = ['project.project', 'project.agile.mixin.id_search'] + + @api.model + def _get_default_type_id(self, ): + return self.env.ref_id("project_agile.project_type_software") + + @api.model + def _get_default_workflow_id(self): + type_id = self._get_default_type_id() + + project_type = False + if type_id: + project_type = self.env['project.type'].browse(type_id) + + workflow_id = False + if project_type and project_type.workflow_id: + workflow_id = project_type.workflow_id.id + return workflow_id + + @api.model + def _set_default_project_type_id(self): + project_type = self.env['project.type'].browse( + self._get_default_type_id() + ) + + workflow_id = project_type.workflow_id + + self.with_context(active_test=False, no_workflow=False) \ + .search([]) \ + .write(dict(type_id=project_type.id, workflow_id=workflow_id.id)) + + type_id = fields.Many2one( + comodel_name="project.type", + string="Project type", + required=True, + ondelete="restrict", + default=lambda s: s._get_default_type_id(), + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + default=lambda s: s._get_default_workflow_id(), + ) + + default_task_type_id = fields.Many2one( + comodel_name='project.task.type2', + related='type_id.default_task_type_id', + string='Default Task Type', + readonly=True, + ) + + agile_enabled = fields.Boolean( + string='Use Agile', + help='If checked project will be enabled for agile management', + ) + + agile_method = fields.Selection( + selection=[], + string='Agile Method', + ) + + board_ids = fields.Many2many( + comodel_name="project.agile.board", + relation="board_project_rel", + column1="project_id", + column2="board_id", + string="Boards" + ) + + boards_count = fields.Integer( + string="Board Count", + compute="_compute_board_count" + ) + + team_ids = fields.Many2many( + comodel_name="project.agile.team", + relation="project_project_agile_team_rel", + column1="project_id", + column2="team_id", + string="Agile Teams", + ) + + user_story_count = fields.Integer( + string='User Story Count', + compute="_compute_user_story_count" + ) + + epics_count = fields.Integer( + string='Epics Count', + compute="_compute_epics_count" + ) + + todo_estimation = fields.Integer( + string="Todo estimation", + compute="_compute_estimations", + ) + in_progress_estimation = fields.Integer( + string="In progress estimation", + compute="_compute_estimations", + ) + done_estimation = fields.Integer( + string="Done estimation", + compute="_compute_estimations", + ) + + # image: all image fields are base64 encoded and PIL-supported + image = fields.Binary( + "Image", + attachment=True, + help="This field holds the image used as image for the project, " + "limited to 1024x1024px." + ) + + image_medium = fields.Binary( + "Medium-sized image", + compute='_compute_images', + inverse='_inverse_image_medium', + store=True, + attachment=True, + help="Medium-sized image of the project. It is automatically " + "resized as a 128x128px image, with aspect ratio preserved," + "only when the image exceeds one of those sizes. " + "Use this field in form views or some kanban views." + ) + + image_small = fields.Binary( + "Small-sized image", + compute='_compute_images', + inverse='_inverse_image_small', + store=True, + attachment=True, + help="Small-sized image of the project. It is automatically " + "resized as a 64x64px image, with aspect ratio preserved. " + "Use this field anywhere a small image is required." + ) + + @api.multi + def _compute_board_count(self): + for record in self: + if record.agile_enabled: + record.boards_count = len(record.board_ids) + + @api.multi + def _compute_user_story_count(self): + user_story_type = self.env.ref('project_agile.project_task_type_story') + for prj in self: + prj.user_story_count = self.env['project.task'].search_count([ + ('project_id', '=', prj.id), + ('type_id', '=', user_story_type.id) + ]) + + @api.multi + def _compute_epics_count(self): + epics_type = self.env.ref('project_agile.project_task_type_epic') + for prj in self: + prj.epics_count = self.env['project.task'].search_count([ + ('project_id', '=', prj.id), + ('type_id', '=', epics_type.id) + ]) + + @api.multi + def _compute_estimations(self): + for record in self: + o = {"todo": 0, "in_progress": 0, "done": 0} + + if record.agile_enabled: + for task in self.env["project.task"].search([ + ('project_id', '=', record.id) + ]): + if task.wkf_state_type: + o[task.wkf_state_type] += task.story_points or 0 + for key, value in o.items(): + record["%s_estimation" % (key,)] = value + + @api.depends('image') + def _compute_images(self): + for rec in self: + rec.image_medium = tools.image_resize_image_medium( + rec.image, avoid_if_small=True + ) + rec.image_small = tools.image_resize_image_small(rec.image) + + def _inverse_image_medium(self): + for rec in self: + rec.image = tools.image_resize_image_big(rec.image_medium) + + def _inverse_image_small(self): + for rec in self: + rec.image = tools.image_resize_image_big(rec.image_small) + + @api.onchange('type_id') + def _onchange_type(self): + if self.type_id: + if self.env.context.get('apply_stages', False) and \ + self.type_id.stage_ids: + self.type_ids = [x.id for x in self.type_id.stage_ids] + + if isinstance(self.id, models.NewId): + self.workflow_id = self.type_id.workflow_id.id + else: + self.type_ids = [] + + @api.onchange("agile_enabled") + def _onchange_agile_enabled(self): + if self.agile_enabled: + self.allow_workflow = True + + # CRUD Overrides + @api.model + @api.returns('self', lambda value: value.id) + def create(self, vals): + # Activating agile will automatically activate workflow as well + if vals.get('agile_enabled', False): + vals['allow_workflow'] = True + + new = super(Project, self).create(vals) + new.subtask_project_id = new.id + + if new.agile_enabled: + board = self.env['project.agile.board'].search([ + ('type', '=', new.agile_method), + ('is_default', '=', True) + ]) + + if board: + new.write({'board_ids': [(6, 0, [board.id])]}) + + return new + + @api.multi + def write(self, vals): + self._fix_type_ids(vals) + + if vals.get('agile_enabled', True): + vals['allow_workflow'] = True + + return super(Project, self).write(vals) + + def _fix_type_ids(self, vals): + if 'type_ids' not in vals: + return + + type_ids = vals.get('type_ids', []) + + new_type_ids = [] + for type_id in type_ids: + if type_id[0] in [1]: + new_type_ids.append(type_id[1]) + elif type_id[0] == 6: + new_type_ids.extend(type_id[2]) + + vals['type_ids'] = [(6, 0, new_type_ids)] + + @api.multi + def open_board_tree_view(self): + self.ensure_one() + + if not self.agile_enabled: + return + + domain = [('project_ids', 'in', [self.id])] + + return { + 'name': 'Agile Boards', + 'domain': domain, + 'res_model': 'project.agile.board', + 'type': 'ir.actions.act_window', + 'view_id': False, + 'view_mode': 'tree,form', + 'view_type': 'form', + 'limit': 80 + } + + @api.multi + def open_user_stories(self): + self.ensure_one() + action = self.env.ref_action( + "project.act_project_project_2_project_task_all" + ) + + type = self.env.ref( + 'project_agile.project_task_type_story' + ) + + action['display_name'] = _("User Stories") + action['context'] = { + 'group_by': 'stage_id', + 'search_default_project_id': [self.id], + 'default_project_id': self.id, + 'search_default_type_id': type.id, + 'default_type_id': type.id, + } + + if self.agile_enabled: + del action['context']['group_by'] + action['view_mode'] = 'tree, form, calendar, pivot, graph' + views = [] + for view in action.get('views'): + if view[1] == 'kanban': + continue + views.append(view) + action['views'] = views + + return action + + @api.multi + def open_epics(self): + self.ensure_one() + action = self.env.ref_action( + "project.act_project_project_2_project_task_all" + ) + + type = self.env.ref('project_agile.project_task_type_epic') + action['display_name'] = _("Epics") + action['context'] = { + 'group_by': 'stage_id', + 'search_default_project_id': [self.id], + 'default_project_id': self.id, + 'search_default_type_id': type.id, + 'default_type_id': type.id, + } + + if self.agile_enabled: + del action['context']['group_by'] + action['view_mode'] = 'tree, form, calendar, pivot, graph' + views = [] + for view in action.get('views'): + if view[1] == 'kanban': + continue + views.append(view) + action['views'] = views + + return action + + @api.multi + def open_tasks(self): + self.ensure_one() + + action = self.env.ref_action( + "project.act_project_project_2_project_task_all" + ) + + action['context'] = { + 'group_by': 'stage_id', + 'search_default_project_id': [self.id], + 'default_project_id': self.id, + } + + if self.agile_enabled: + del action['context']['group_by'] + action['view_mode'] = 'tree, form, calendar, pivot, graph' + views = [] + for view in action.get('views'): + if view[1] == 'kanban': + continue + views.append(view) + action['views'] = views + + return action diff --git a/project_agile/models/project_task.py b/project_agile/models/project_task.py new file mode 100644 index 0000000..5d6933d --- /dev/null +++ b/project_agile/models/project_task.py @@ -0,0 +1,765 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, exceptions, _ + + +class TaskResolution(models.Model): + _name = 'project.task.resolution' + _description = 'Task Resolution' + _inherit = ['project.agile.code_item'] + + +class ProjectTaskLinkRelation(models.Model): + _name = "project.task.link.relation" + _description = 'Project Task Link Relation Type' + + name = fields.Char( + string="Name", + required=True, + ) + + inverse_name = fields.Char( + string="Reverse Name", + required=True, + ) + + sequence = fields.Integer( + string="Order", + required=True, + ) + + +class ProjectTaskLink(models.Model): + _name = "project.task.link" + _description = "Project Task Link" + + name = fields.Char( + string="Name", + compute="_compute_display_name", + ) + + comment = fields.Char( + string="Comment", + ) + + relation_id = fields.Many2one( + comodel_name="project.task.link.relation", + string="Relation", + required=True, + ) + + task_left_id = fields.Many2one( + comodel_name="project.task", + string="Task on the left", + required=True, + ondelete="cascade", + ) + + task_right_id = fields.Many2one( + comodel_name="project.task", + string="Task on the right", + required=True, + ondelete="cascade", + ) + + relation_name = fields.Char( + string="Relation", + compute="_compute_relation_name", + ) + + related_task_id = fields.Many2one( + comodel_name="project.task", + string="Related task", + compute="_compute_related_task_id", + ) + + @api.multi + def _compute_display_name(self): + for record in self: + + other_task = record.task_right_id + if record.task_right_id: + other_task = record.task_left_id + + record.name = "[%s] %s" % (other_task.key, other_task.name) + + @api.multi + def _compute_relation_name(self): + task_id = self.env.context.get('task_id', -1) + for record in self: + relation = record.relation_id + + rel_name = record.relation_id.name + if task_id == record.task_right_id.id: + rel_name = relation.inverse_name + + record.relation_name = rel_name + + @api.multi + def _compute_related_task_id(self): + task_id = self.env.context.get('task_id', -1) + for record in self: + other_task = record.task_right_id + if task_id == record.task_right_id.id: + other_task = record.task_left_id + + record.related_task_id = other_task + + @api.multi + def delete_task_link(self): + self.ensure_one() + self.unlink() + + @api.multi + def open_task_link(self): + self.ensure_one() + + other_task = self.task_right_id + if self.related_task_id.id == self.task_right_id.id: + other_task = self.task_left_id + + return { + 'name': "Related task", + 'view_mode': 'form', + 'view_type': 'form', + 'view_id': self.env.ref("project.view_task_form2").id, + 'res_model': 'project.task', + 'type': 'ir.actions.act_window', + 'res_id': self.related_task_id.id, + 'context': { + 'active_model': "project.task", + 'active_ids': [self.related_task_id.id], + 'active_id': self.related_task_id.id, + 'task_id': other_task.id, + }, + } + + +class TaskType(models.Model): + _name = 'project.task.type2' + _description = "Task Type" + _inherit = ['project.agile.code_item'] + + project_type_ids = fields.Many2many( + comodel_name='project.type', + relation="project_type_task_type_rel", + column1="task_type_id", + column2="project_type_id", + string="Project Types" + ) + + icon = fields.Binary( + string='Icon' + ) + + priority_ids = fields.Many2many( + comodel_name='project.task.priority', + relation='project_task_type2_task_priority_rel', + column1='type_id', + column2='priority_id', + string='Priorities', + ) + + default_priority_id = fields.Many2one( + comodel_name='project.task.priority', + string='Default Task Priority', + ) + + allow_story_points = fields.Boolean( + string='Allow Story Points', + default=True, + ) + + allow_sub_tasks = fields.Boolean( + string="Allow Sub-Items", + default=False, + ) + + type_ids = fields.Many2many( + comodel_name='project.task.type2', + relation='project_task_type2_sub_types_rel', + column1='type_id', + column2='sub_type_id', + string='Sub Types', + ) + + task_ids = fields.One2many( + comodel_name='project.task', + inverse_name='type_id', + string='Related Tasks' + ) + + portal_visible = fields.Boolean( + string='Visible on Portal', + default=True, + ) + + @api.model + def name_search(self, name='', args=None, operator='ilike', limit=100): + if args is None: + args = [] + + # The key 'selected_task_type_ids' is defined on the + # project.type form view. + if 'selected_task_type_ids' in self.env.context: + args.append(( + 'id', 'in', [ + x[1] + for x in self.env.context['selected_task_type_ids'][0][2] + ] + )) + + if 'board_project_ids' in self.env.context: + project_ids = self.env.context.get('board_project_ids', []) + if project_ids: + args.append([ + 'id', 'in', self.env['project.project'].browse( + project_ids[0][2] + ).mapped('type_id.task_type_ids').fetch_all() + ]) + + return super(TaskType, self).name_search( + name=name, args=args, operator=operator, limit=limit + ) + + @api.multi + def apply_portal_status(self): + self.ensure_one() + self.task_ids.write({'portal_visible': self.portal_visible}) + + @api.multi + def fetch_all(self): + task_type_ids = [] + + def collect_task_types(tt): + if tt.id in task_type_ids: + return + + task_type_ids.append(tt.id) + for subtype in tt.type_ids: + collect_task_types(subtype) + + for task_type in self: + collect_task_types(task_type) + + return task_type_ids + + +class TaskPriority(models.Model): + _name = 'project.task.priority' + _description = 'Task Priority' + _inherit = ['project.agile.code_item'] + + type_ids = fields.Many2many( + comodel_name='project.task.type2', + relation='project_task_type2_task_priority_rel', + column1='priority_id', + column2='type_id', + string='Types', + ) + + icon = fields.Binary( + string='Icon' + ) + + _sql_constraints = [ + ('project_task_priority_name_unique', 'unique(name)', + 'Priority name already exists') + ] + + @api.model + def name_search(self, name='', args=None, operator='ilike', limit=100): + if args is None: + args = [] + + # The key 'selected_priority_ids' is defined on the + # project.task.type2 form view. + if 'selected_priority_ids' in self.env.context: + args.append(( + 'id', 'in', self.env.context['selected_priority_ids'][0][2] + )) + + return super(TaskPriority, self).name_search( + name=name, args=args, operator=operator, limit=limit + ) + + +class Task(models.Model): + _name = 'project.task' + _inherit = ['project.task', 'project.agile.mixin.id_search'] + + def _default_agile_order(self): + self.env.cr.execute("SELECT MAX(agile_order) + 1 FROM project_task") + r = self.env.cr.fetchone() + return r[0] + + type_id = fields.Many2one( + comodel_name='project.task.type2', + string='Type', + required=True, + ondelete="restrict", + ) + agile_order = fields.Float( + required=False, + default=_default_agile_order, + lira=True + ) + agile_enabled = fields.Boolean( + related='project_id.agile_enabled', + ) + + resolution_id = fields.Many2one( + comodel_name='project.task.resolution', + string='Resolution', + index=True, + ) + + allow_story_points = fields.Boolean( + related='type_id.allow_story_points', + string='Allow Story Points', + readonly=True, + ) + + project_type_id = fields.Many2one( + comodel_name='project.type', + related='project_id.type_id', + string='Project Type', + readonly=True, + ) + + is_user_story = fields.Boolean( + string='Is Story', + compute="_compute_is_story", + store=True, + ) + + is_epic = fields.Boolean( + string='Is Epic', + compute="_compute_is_epic", + store=True, + ) + + epic_id = fields.Many2one( + comodel_name='project.task', + string='Epic', + compute="_compute_epic_id", + store=True, + readonly=True, + help='This field represents the first' + ) + + assigned_to_me = fields.Boolean( + string='Assigned to Me', + compute="_compute_assigned_to_me" + ) + + user_id = fields.Many2one( + comodel_name="res.users", + default=False, + ) + + create_uid = fields.Many2one( + comodel_name='res.users', + string='Reported By', + readonly=True, + ) + + create_date = fields.Datetime( + string='Created', + readonly=True, + ) + + write_date = fields.Datetime( + string='Updated', + readonly=True, + ) + + team_id = fields.Many2one( + comodel_name="project.agile.team", + string="Committed team", + ) + + type_ids = fields.Many2many( + comodel_name='project.task.type2', + related="type_id.type_ids", + readonly=True, + string='Allowed Subtypes' + ) + + task_count = fields.Integer( + compute="_compute_task_count", + string="Number of SubTasks" + ) + + allow_sub_tasks = fields.Boolean( + related="type_id.allow_sub_tasks", + string="Allow Sub-Tasks", + stored=True, + ) + + priority_id = fields.Many2one( + comodel_name='project.task.priority', + string='Priority', + required=True, + ondelete="restrict", + ) + + story_points = fields.Integer( + string='Story points', + default=0 + ) + + doc_count = fields.Integer( + compute="_compute_doc_count", + string="Number of documents attached" + ) + + link_ids = fields.One2many( + comodel_name="project.task.link", + compute="_compute_links", + string="Links", + syncer={'inverse_names': ['task_left_id', 'task_right_id']}, + ) + + link_count = fields.Integer( + compute="_compute_link_count", + string="Number of Links" + ) + + activity_date_deadline = fields.Date(groups='') + + @api.multi + @api.depends('type_id') + def _compute_is_story(self): + ptts = self.env.ref( + 'project_agile.project_task_type_story', raise_if_not_found=False + ) + for record in self: + record.is_user_story = ptts and record.type_id.id == ptts.id + + @api.multi + @api.depends('type_id') + def _compute_is_epic(self): + ptte = self.env.ref( + 'project_agile.project_task_type_epic', raise_if_not_found=False + ) + for record in self: + record.is_epic = ptte and record.type_id.id == ptte.id + + @api.multi + @api.depends('parent_id', 'parent_id.epic_id') + def _compute_epic_id(self): + epic_type = self.env.ref( + 'project_agile.project_task_type_epic', raise_if_not_found=False + ) + + if epic_type: + for record in self: + epic_id = False + + current = record.parent_id + while current: + if current.type_id.id == epic_type.id: + epic_id = current.id + break + current = current.parent_id + record.epic_id = epic_id + else: + for record in self: + record.epic_id = False + + @api.multi + @api.depends('user_id') + def _compute_assigned_to_me(self): + for task in self: + task.assigned_to_me = task.user_id.id == self.env.user.id + + @api.multi + def _compute_task_count(self): + data = self.env['project.task'].read_group( + [('parent_id', 'in', self.ids)], ['parent_id'], ['parent_id'] + ) + + data = dict([(m['parent_id'], m['parent_id_count']) for m in data]) + + for record in self: + record.task_count = data.get(record.id, 0) + + @api.multi + def _compute_doc_count(self): + attachment_data = self.env['ir.attachment'].read_group( + [('res_model', '=', self._name), ('res_id', 'in', self.ids)], + ['res_id', 'res_model'], + ['res_id', 'res_model'] + ) + + mapped_data = dict([ + (m['res_id'], m['res_id_count']) + for m in attachment_data + ]) + + for record in self: + record.doc_count = mapped_data.get(record.id, 0) + + @api.multi + def _compute_links(self): + for record in self: + record.link_ids = self.env["project.task.link"].search([ + "|", + ("task_left_id", "=", record.id), + ("task_right_id", "=", record.id) + ]) + + @api.multi + def _compute_link_count(self): + for record in self: + record.link_count = len(record.link_ids) + + @api.onchange('project_id') + def _onchange_project(self): + super(Task, self)._onchange_project() + + default_type_id = self.env.context.get( + 'default_type_id', False + ) + + if self.project_id: + task_type_id = self.project_id.type_id.default_task_type_id.id + if default_type_id and \ + self.project_id.type_id.has_task_type(default_type_id): + task_type_id = default_type_id + + self.type_id = task_type_id + else: + self.type_id = False + + @api.onchange('type_id') + def _onchange_type_id(self): + self.priority_id = False + self.portal_visible = False + if self.type_id: + if self.type_id.default_priority_id: + self.priority_id = self.type_id.default_priority_id.id + + self.portal_visible = self.type_id.portal_visible + + @api.model + @api.returns('self', lambda value: value.id) + def create(self, vals): + project_id = vals.get( + 'project_id', + self.env.context.get('default_project_id', False) + ) + + if project_id: + project = self.env['project.project'].browse(project_id) + + if not vals.get('type_id', False): + default_type_id = self.env.context.get( + 'default_type_id', False + ) + if default_type_id: + vals['type_id'] = default_type_id + else: + dtt = project.type_id.default_task_type_id + vals['type_id'] = dtt and dtt.id or False + + if 'portal_visible' not in vals: + vals['portal_visible'] = self.env['project.task.type2'].browse( + vals['type_id'] + ).portal_visible + + if not vals.get('priority_id', False) and vals.get('type_id'): + task_type = self.env['project.task.type2'].browse( + vals.get('type_id') + ) + vals['priority_id'] = task_type.default_priority_id.id or False + + new = super(Task, self).create(vals) + + if new.parent_id and new.parent_id.stage_id: + new._write({'stage_id': new.parent_id.stage_id.id}) + + return new + + @api.model + def name_search(self, name='', args=None, operator='ilike', limit=100): + if args is None: + args = [] + + if 'filter_user_stories' in self.env.context: + args.append(( + 'type_id', '=', self.env.ref_id( + 'project_agile.project_task_type_story' + ) + )) + + return super(Task, self).name_search( + name=name, args=args, operator=operator, limit=limit + ) + + @api.model + def search(self, args, offset=0, limit=None, order=None, count=False): + if args is None: + args = [] + + if 'filter_user_stories' in self.env.context: + args.append(( + 'type_id', '=', self.env.ref_id( + self, 'project_agile.project_task_type_story' + ) + )) + + if 'filter_tasks' in self.env.context: + args.append(( + 'type_id', '=', self.env.ref_id( + self, 'project_agile.project_task_type_task' + ) + )) + + return super(Task, self).search(args, offset, limit, order, count) + + @api.multi + def open_sub_task(self): + self.ensure_one() + ctx = self._context.copy() + ctx.update({ + 'active_model': "project.task", + 'active_ids': [self.id], + 'active_id': self.id + }) + return { + 'name': "Sub-Task", + 'view_mode': 'form', + 'view_type': 'form', + 'view_id': self.env.ref("project.view_task_form2").id, + 'res_model': 'project.task', + 'type': 'ir.actions.act_window', + 'res_id': self.id, + 'context': ctx, + } + + @api.multi + def attachment_tree_view(self): + self.ensure_one() + + action = self.env.ref_action("base.action_attachment") + + action['domain'] = [ + ('res_model', '=', 'project.task'), + ('res_id', '=', self.id) + ] + + action['context'] = { + 'default_res_model': self._name, + 'default_res_id': self.id, + } + + return action + + # Following methods will be called from hooks file, + # so we can leave project.task in valid state. + @api.model + def _set_default_task_priority_id(self): + for res in self.with_context(active_test=False).search([ + ('priority_id', '=', False) + ]): + res.priority_id = res.type_id.default_priority_id or False + + @api.model + def _set_default_task_type_id(self): + for task in self.with_context(active_test=False).search([ + ('type_id', '=', False) + ]): + dtt = task.project_id.type_id.default_task_type_id + task.type_id = dtt and dtt.id or False + + +class PortalTask(models.Model): + _inherit = 'project.task' + + portal_visible = fields.Boolean( + string='Visible on Portal', + default=False, + help="This field enables you to override settings from task type" + " and display this task on portal" + ) + + @api.model + def create_task_portal(self, values): + if not (self.task_portal_check_mandatory_fields(values)): + return { + 'errors': _('Fields marked with "*" are required!') + } + + vals = { + "name": values['name'], + "priority_id": values['priority_id'], + "project_id": values['project_id'], + "type_id": values['type_id'], + } + + for field in ['date_deadline', 'description']: + if values[field]: + vals[field] = values[field] + + task = self.create(vals) + + return { + 'id': task.id + } + + @api.multi + def update_task_portal(self, values): + task_values = { + "name": values['name'], + "date_deadline": values['date_deadline'] or False, + "description": values['description'], + "type_id": values['type_id'], + "priority_id": values['priority_id'], + } + self.write(task_values) + + def task_portal_check_mandatory_fields(self, values): + for fname in ['name', 'type_id', 'priority_id', 'project_id']: + if not values[fname]: + return False + return True + + +class Timesheet(models.Model): + _inherit = 'account.analytic.line' + + portal_approved = fields.Boolean( + string='Portal Approved?', + default=False, + index=True, + ) + + portal_approved_by = fields.Many2one( + comodel_name='res.users', + string='Portal Approved By', + index=True, + ) + + @api.multi + def button_portal_open(self): + self.ensure_one() + if self.portal_approved: + return + + self.sudo().write(dict( + portal_approved=True, + portal_approved_by=self.env.user.id + )) + + @api.multi + def button_portal_close(self): + self.ensure_one() + if not self.portal_approved: + return + + self.sudo().write(dict( + portal_approved=False, + )) diff --git a/project_agile/models/project_workflow.py b/project_agile/models/project_workflow.py new file mode 100644 index 0000000..789574c --- /dev/null +++ b/project_agile/models/project_workflow.py @@ -0,0 +1,97 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class WorkflowTransition(models.Model): + _inherit = 'project.workflow.transition' + + resolution_id = fields.Many2one( + comodel_name='project.task.resolution', + string='Resolution', + ) + + +class WorkflowState(models.Model): + _inherit = 'project.workflow.state' + + board_column_status_ids = fields.One2many( + comodel_name='project.agile.board.column.status', + inverse_name='state_id', + string='Columns Statuses' + ) + + @api.multi + def name_get(self): + if not self.env.context.get("workflow_name", False): + return super(WorkflowState, self).name_get() + + result = [] + for rec in self: + name = "%s (%s)" % (rec.name, rec.workflow_id.name) + result.append((rec.id, name)) + return result + + +class WorkflowTaskType(models.Model): + _name = 'project.workflow.task.type' + _description = 'Project Workflow Task Type' + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + string='Workflow', + required=True, + ondelete="cascade", + ) + + type_id = fields.Many2one( + comodel_name='project.task.type2', + string='Task Type', + required=True, + ) + + is_default = fields.Boolean( + string='Is Default?', + default=False + ) + + +class Workflow(models.Model): + _inherit = 'project.workflow' + + task_type_ids = fields.One2many( + comodel_name='project.workflow.task.type', + inverse_name='workflow_id', + string='Task Types', + copy=True, + ) + + default_task_type_id = fields.Many2one( + comodel_name='project.workflow.task.type', + string='Default Task Type', + compute="_compute_default_task_type_id" + ) + + @api.multi + @api.depends('task_type_ids', 'task_type_ids.is_default') + def _compute_default_task_type_id(self): + for wkf in self: + for type in wkf.task_type_ids: + if type.is_default: + wkf.default_task_type_id = type + break + + @api.multi + def has_task_type(self, type_id): + self.ensure_one() + for task_type in self.task_type_ids: + if task_type.type_id.id == type_id: + return True + return False + + @api.model + def _populate_state_for_widget(self, transition): + values = super(Workflow, self)._populate_state_for_widget(transition) + values['resolution_id'] = transition.resolution_id.id or False + return values diff --git a/project_agile/models/project_workflow_publisher.py b/project_agile/models/project_workflow_publisher.py new file mode 100644 index 0000000..40bc994 --- /dev/null +++ b/project_agile/models/project_workflow_publisher.py @@ -0,0 +1,61 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models + + +class ProjectWorkflowPublisher(models.AbstractModel): + _inherit = 'project.workflow.publisher' + + def _do_publish(self, old, new, project_id=None, switch=False): + + if not switch: + + # We need to map workflow states by their assigned task stage + wkf_states = dict() + for wkf_state in new.state_ids: + wkf_states[wkf_state.stage_id.id] = wkf_state + + for board in self.env['project.agile.board'].sudo().search([]): + if old.id not in board.workflow_ids.ids: + continue + + status_tree = dict() + for status in board.mapped("column_ids.status_ids"): + status_tree[status.stage_id.id] = status + self.update_board_no_switch( + old, board, wkf_states, status_tree + ) + + elif project_id: + project_id.write({'board_ids': [(5,)]}) + + if project_id.agile_enabled: + default_board = self.env['project.agile.board'].search([ + ('type', '=', project_id.agile_method), + ('is_default', '=', True) + ]) + + if default_board.exists(): + project_id.write({'board_ids': [(4, default_board.id,)]}) + + return super(ProjectWorkflowPublisher, self)._do_publish( + old, new, project_id, switch + ) + + def update_board_no_switch(self, old, board, wkf_states, status_tree): + # Detect and delete states which does not exists anymore + to_delete = [] + + for status in board.status_ids.filtered( + lambda s: s.workflow_id == old + ): + if status.stage_id.id not in wkf_states: + to_delete.append(status.id) + else: + wkf_state = wkf_states[status.stage_id.id] + status.write({'state_id': wkf_state.id}) + + if to_delete: + self.env['project.agile.board.column.status']\ + .browse(to_delete).unlink() diff --git a/project_agile/models/res_users.py b/project_agile/models/res_users.py new file mode 100644 index 0000000..fc9cb7f --- /dev/null +++ b/project_agile/models/res_users.py @@ -0,0 +1,66 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, exceptions, _ + + +class Users(models.Model): + _inherit = "res.users" + + team_ids = fields.Many2many( + comodel_name='project.agile.team', + relation='project_agile_team_member_rel', + column1='member_id', + column2='team_id', + string='Enroled in teams' + ) + + team_id = fields.Many2one( + comodel_name="project.agile.team", + string="Current team", + ) + + @api.multi + def write(self, vals): + super(Users, self).write(vals) + if 'team_ids' in vals: + self.fix_team_id() + if 'team_id' in vals: + self.invalidate_cache() + self.env["ir.rule"].invalidate_cache() + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + if args is None: + args = [] + + if 'filter_by_team_id' in self.env.context and \ + self.env.context.get('filter_by_team_id', False): + args.append(( + 'team_ids', 'in', [self.env.context['filter_by_team_id']]) + ) + + return super(Users, self).name_search( + name, args=args, operator=operator, limit=limit + ) + + @api.multi + def change_team(self, team_id): + self.ensure_one() + if self.id == self.env.user.id and self.team_id in self.team_ids: + self.sudo().team_id = team_id + self.env["ir.rule"].invalidate_cache() + else: + exceptions.AccessDenied( + _("You are allowed only to change current team for yourself") + ) + + @api.multi + def fix_team_id(self): + for record in self: + if record.team_id not in record.team_ids: + team_id = False + if len(record.team_ids) > 0: + team_id = record.team_ids[0].id + record.sudo().team_id = team_id + self.env["ir.rule"].invalidate_cache() diff --git a/project_agile/rng/board.rng b/project_agile/rng/board.rng new file mode 100644 index 0000000..0f9d67e --- /dev/null +++ b/project_agile/rng/board.rng @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + scrum + kanban + scrumban + + + + + + True + False + + + + + + + + + + + + + + diff --git a/project_agile/security/ir.model.access.csv b/project_agile/security/ir.model.access.csv new file mode 100644 index 0000000..b1fcffa --- /dev/null +++ b/project_agile/security/ir.model.access.csv @@ -0,0 +1,50 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_project_agile_team_user,project.agile.team,model_project_agile_team,project.group_project_user,1,0,0,0 +access_project_agile_team_manager,project.agile.team,model_project_agile_team,project.group_project_manager,1,1,1,1 + +access_project_agile_board_user,project.agile.board,model_project_agile_board,project.group_project_user,1,0,0,0 +access_project_agile_board_manager,project.agile.board,model_project_agile_board,project.group_project_manager,1,1,1,1 + +access_project_agile_board_column_user,project.agile.board.column,model_project_agile_board_column,project.group_project_user,1,0,0,0 +access_project_agile_board_column_manager,project.agile.board.column,model_project_agile_board_column,project.group_project_manager,1,1,1,1 + +access_project_agile_board_column_status_user,project.agile.board.column.status,model_project_agile_board_column_status,project.group_project_user,1,0,0,0 +access_project_agile_board_column_status_manager,project.agile.board.column.status,model_project_agile_board_column_status,project.group_project_manager,1,1,1,1 + +access_project_type_user,project.type,model_project_type,project.group_project_user,1,0,0,0 +access_project_type_manager,project.type,model_project_type,project.group_project_manager,1,1,1,1 +access_project_type_portal,project.type,model_project_type,base.group_portal,1,0,0,0 + +access_project_task_priority_user,project.task.priority,model_project_task_priority,project.group_project_user,1,0,0,0 +access_project_task_priority_manager,project.task.priority,model_project_task_priority,project.group_project_manager,1,1,1,1 +access_project_task_priority_portal,project.task.priority,model_project_task_priority,base.group_portal,1,0,0,0 + +access_project_task_link_relation_user,project.task.link.relation,model_project_task_link_relation,project.group_project_user,1,0,0,0 +access_project_task_link_relation_manager,project.task.link.relation,model_project_task_link_relation,project.group_project_manager,1,1,1,1 + +access_project_task_link_user,project.task.link,model_project_task_link,project.group_project_user,1,1,1,1 + +access_project_task_type2_user,project.task.type2,model_project_task_type2,project.group_project_user,1,0,0,0 +access_project_task_type2_manager,project.task.type2,model_project_task_type2,project.group_project_manager,1,1,1,1 +access_project_task_type2_portal,project.task.type2,model_project_task_type2,base.group_portal,1,0,0,0 + +access_project_task_resolution_user,project.task.resolution,model_project_task_resolution,project.group_project_user,1,0,0,0 +access_project_task_resolution_manager,project.task.resolution,model_project_task_resolution,project.group_project_manager,1,1,1,1 + +access_project_workflow_task_type_user,project.workflow.task.type,model_project_workflow_task_type,project.group_project_user,1,0,0,0 +access_project_workflow_task_type_manager,project.workflow.task.type,model_project_workflow_task_type,project.group_project_manager,1,1,1,1 + + +access_project_res_user,res.users,model_res_users,project.group_project_user,1,0,0,0 +access_project_res_manager,res.users,model_res_users,project.group_project_manager,1,1,1,1 + +access_project_agile_board_report_user,project.agile.board.report,model_project_agile_board_report,project.group_project_user,1,0,0,0 +access_project_agile_board_report_manager,project.agile.board.report,model_project_agile_board_report,project.group_project_manager,1,1,1,1 + +access_project_agile_team_report_user,project.agile.team.report,model_project_agile_team_report,project.group_project_user,1,0,0,0 +access_project_agile_team_report_manager,project.agile.team.report,model_project_agile_team_report,project.group_project_manager,1,1,1,1 + +access_project_task_portal_create,project.task,project.model_project_task,group_project_portal_task_create,1,1,1,0 +access_project_task_portal_edit,project.task,project.model_project_task,group_project_portal_task_edit,1,1,0,0 +access_project_task_portal_wkf,project.task,project.model_project_task,group_project_portal_task_wkf,1,1,0,0 diff --git a/project_agile/security/security.xml b/project_agile/security/security.xml new file mode 100644 index 0000000..1b7fa67 --- /dev/null +++ b/project_agile/security/security.xml @@ -0,0 +1,116 @@ + + + + + + + Agile + Helps you manage your agile team! + 3 + + + + Agile Team Member + + + + + + Agile Scrum Master + + + + + + Agile Product Owner + + + + + + Agile Stake Holder + + + + + + Agile Admin + + + + + + Portal Task Create + Portal members have specific access rights (such as record rules and restricted menus). + They usually do not belong to the usual Odoo groups. + + + + + Portal Task Edit + Portal members have specific access rights (such as record rules and restricted menus). + They usually do not belong to the usual Odoo groups. + + + + + Portal Task Workflow + Portal members have specific access rights (such as record rules and restricted menus). + They usually do not belong to the usual Odoo groups. + + + + + Portal Task View Timesheet + Portal members have specific access rights (such as record rules and restricted menus). + They usually do not belong to the usual Odoo groups. + + + + + Agile Board Visibility Rule + + [ + '|', + ('visibility', '=', 'global'), + '|', + '&', ('visibility', '=', 'team'), + ('team_id', 'in', user.team_ids.ids), + '&', ('visibility', '=', 'user'), + ('user_id', '=', user.id) + ] + + + + + [ + ('portal_visible', '=', True), + '|', + '&', + ('project_id.privacy_visibility', '=', 'portal'), + ('project_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]), + '&', + ('project_id.privacy_visibility', '=', 'portal'), + ('message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]), + ] + + + + [ + ('portal_approved', '=', True), + ('task_id.portal_visible', '=', True), + '|', + '&', + ('task_id.project_id.privacy_visibility', '=', 'portal'), + ('task_id.project_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]), + '&', + ('task_id.project_id.privacy_visibility', '=', 'portal'), + ('task_id.message_partner_ids', 'child_of', [user.partner_id.commercial_partner_id.id]), + ] + + + + diff --git a/project_agile/static/description/icon.png b/project_agile/static/description/icon.png new file mode 100644 index 0000000..9d2d1e1 Binary files /dev/null and b/project_agile/static/description/icon.png differ diff --git a/project_agile/static/img/priority-blocker.png b/project_agile/static/img/priority-blocker.png new file mode 100644 index 0000000..2db2e6f Binary files /dev/null and b/project_agile/static/img/priority-blocker.png differ diff --git a/project_agile/static/img/priority-could-have.png b/project_agile/static/img/priority-could-have.png new file mode 100644 index 0000000..8181c30 Binary files /dev/null and b/project_agile/static/img/priority-could-have.png differ diff --git a/project_agile/static/img/priority-critical.png b/project_agile/static/img/priority-critical.png new file mode 100644 index 0000000..1a3248c Binary files /dev/null and b/project_agile/static/img/priority-critical.png differ diff --git a/project_agile/static/img/priority-major.png b/project_agile/static/img/priority-major.png new file mode 100644 index 0000000..76e4491 Binary files /dev/null and b/project_agile/static/img/priority-major.png differ diff --git a/project_agile/static/img/priority-minor.png b/project_agile/static/img/priority-minor.png new file mode 100644 index 0000000..049c0ff Binary files /dev/null and b/project_agile/static/img/priority-minor.png differ diff --git a/project_agile/static/img/priority-must-have.png b/project_agile/static/img/priority-must-have.png new file mode 100644 index 0000000..a756ebe Binary files /dev/null and b/project_agile/static/img/priority-must-have.png differ diff --git a/project_agile/static/img/priority-should-have.png b/project_agile/static/img/priority-should-have.png new file mode 100644 index 0000000..3fd29ce Binary files /dev/null and b/project_agile/static/img/priority-should-have.png differ diff --git a/project_agile/static/img/priority-trivial.png b/project_agile/static/img/priority-trivial.png new file mode 100644 index 0000000..3483c86 Binary files /dev/null and b/project_agile/static/img/priority-trivial.png differ diff --git a/project_agile/static/img/priority-would-have.png b/project_agile/static/img/priority-would-have.png new file mode 100644 index 0000000..05173e0 Binary files /dev/null and b/project_agile/static/img/priority-would-have.png differ diff --git a/project_agile/static/img/type-access.png b/project_agile/static/img/type-access.png new file mode 100644 index 0000000..3f570d0 Binary files /dev/null and b/project_agile/static/img/type-access.png differ diff --git a/project_agile/static/img/type-bug.png b/project_agile/static/img/type-bug.png new file mode 100644 index 0000000..e083cae Binary files /dev/null and b/project_agile/static/img/type-bug.png differ diff --git a/project_agile/static/img/type-change.png b/project_agile/static/img/type-change.png new file mode 100644 index 0000000..6aaa119 Binary files /dev/null and b/project_agile/static/img/type-change.png differ diff --git a/project_agile/static/img/type-epic.png b/project_agile/static/img/type-epic.png new file mode 100644 index 0000000..47070b3 Binary files /dev/null and b/project_agile/static/img/type-epic.png differ diff --git a/project_agile/static/img/type-fault.png b/project_agile/static/img/type-fault.png new file mode 100644 index 0000000..d89f289 Binary files /dev/null and b/project_agile/static/img/type-fault.png differ diff --git a/project_agile/static/img/type-improvement.png b/project_agile/static/img/type-improvement.png new file mode 100644 index 0000000..fd14db6 Binary files /dev/null and b/project_agile/static/img/type-improvement.png differ diff --git a/project_agile/static/img/type-it-help.png b/project_agile/static/img/type-it-help.png new file mode 100644 index 0000000..7aaf422 Binary files /dev/null and b/project_agile/static/img/type-it-help.png differ diff --git a/project_agile/static/img/type-new-feature.png b/project_agile/static/img/type-new-feature.png new file mode 100644 index 0000000..3923de1 Binary files /dev/null and b/project_agile/static/img/type-new-feature.png differ diff --git a/project_agile/static/img/type-purchase.png b/project_agile/static/img/type-purchase.png new file mode 100644 index 0000000..68afa2a Binary files /dev/null and b/project_agile/static/img/type-purchase.png differ diff --git a/project_agile/static/img/type-task.png b/project_agile/static/img/type-task.png new file mode 100644 index 0000000..f02a09e Binary files /dev/null and b/project_agile/static/img/type-task.png differ diff --git a/project_agile/static/img/type-user-story.png b/project_agile/static/img/type-user-story.png new file mode 100644 index 0000000..12069ca Binary files /dev/null and b/project_agile/static/img/type-user-story.png differ diff --git a/project_agile/static/src/js/portal_extensions.js b/project_agile/static/src/js/portal_extensions.js new file mode 100644 index 0000000..156f795 --- /dev/null +++ b/project_agile/static/src/js/portal_extensions.js @@ -0,0 +1,155 @@ +// Copyright 2017-2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +"use strict"; +odoo.define('project_portal_task_edit', function (require) { + + var rpc = require('web.rpc'); + require('web_editor.summernote'); // wait that summernote is loaded + require('web.dom_ready'); + /* + * This file is intended to add interactivity to survey forms rendered by + * the website engine. + */ + + let summernoteOptions = { + focus: false, + height: 180, + toolbar: [ + ['style', ['style']], + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + // ['insert', ['link', 'picture']], + ['history', ['undo', 'redo']], + ['fullscreen',['fullscreen']] + ], + prettifyHtml: false, + styleWithSpan: false, + inlinemedia: ['p'], + lang: "odoo" + }; + + $('.edit_task_form .description.summernote').summernote(summernoteOptions); + $('.new_task_form .description.summernote').summernote(summernoteOptions); + + $('.new_task_confirm').on('click', function (e) { + var $btn = $(this); + $btn.prop('disabled', true); + rpc.query({ + model: 'project.task', + method: 'create_task_portal', + args: [{ + name: $('.new_task_form .name').val(), + priority_id: parseInt($('.new_task_form .priority').find(":selected").attr('data')), + type_id: parseInt($('.new_task_form .type').find(":selected").attr('data')), + project_id: parseInt($('.new_task_form .project').find(":selected").attr('data')), + date_deadline: $('.new_task_form .date_deadline').val(), + description: $('.new_task_form .description.summernote').code(), + }], + }) + .done(function (response) { + if (response.errors) { + $('#new-opp-dialog .alert').remove(); + $('#new-opp-dialog div:first').prepend("
" + response.errors + "
"); + $btn.prop('disabled', false); + + } + else { + window.location = '/my/task/' + response.id; + } + }) + .fail(function () { + $btn.prop('disabled', false); + }); + return false; + }); + $('.edit_task_confirm').on('click', function () { + var $btn = $(this); + $btn.prop('disabled', true); + rpc.query({ + model: 'project.task', + method: 'update_task_portal', + args: [[parseInt($('.edit_task_form .task_id').val())], { + name: $('.edit_task_form .name').val(), + type_id: parseInt($('.edit_task_form .type').find(":selected").attr('data')), + priority_id: parseInt($('.edit_task_form .priority').find(":selected").attr('data')), + date_deadline: $('.edit_task_form .date_deadline').val(), + description: $('.edit_task_form .description.summernote').code(), + }], + }) + .fail(function () { + $btn.prop('disabled', false); + }) + .done(function () { + window.location.reload(); + }); + return false; + }); + + $("div.input-group span.fa-calendar").on('click', function (e) { + $(e.currentTarget).closest("div.date").datetimepicker({ + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down' + }, + }); + }); + + $('.edit_task_form .type, .new_task_form .type').on('change', function () { + let form = $(this).closest("form"); + let selectedType = $(this).find(":selected"); + let allowed_priority_ids = JSON.parse(selectedType.attr("data-priorities")); + let default_priority_id = JSON.parse(selectedType.attr("data-default-priority")); + form.find("select.priority option").each((i, opt) => { + let option = $(opt); + if (allowed_priority_ids.includes(parseInt(option.attr("data")))) { + option.show(); + } else { + option.hide(); + } + }); + let selectedPriority = parseInt(form.find("select.priority option:selected").attr("data")); + if(!allowed_priority_ids.includes(selectedPriority)){ + form.find("select.priority option[data=" + default_priority_id + "]").prop('selected', true); + form.find("select.priority").change(); + } + }); + + $('.new_task_form .project').on('change', function () { + let form = $(this).closest("form"); + let selectedProject = $(this).find(":selected"); + let allowed_task_types_ids = JSON.parse(selectedProject.attr("data-types")); + let default_task_type_id = parseInt(selectedProject.attr("data-default-type")); + form.find("select.type option").each((i, opt) => { + let option = $(opt); + if (allowed_task_types_ids.includes(parseInt(option.attr("data")))) { + option.show(); + } else { + option.hide(); + } + }); + let selectedType = parseInt(form.find("select.type option:selected").attr("data")); + if(!allowed_task_types_ids.includes(selectedType)){ + form.find("select.type option[data=" + default_task_type_id + "]").prop('selected', true); + form.find("select.type").change(); + } + }); + $('.new_task_form .project').change(); + + $('[data-toggle="tooltip"]').tooltip(); + $("button[name='new_task'], button[name='new_task']").click(function(e){ + let btn = $(this); + let modal = $(btn.attr("data-target") +">.modal-dialog"); + modal.removeClass("container"); + modal.find(".note-editor").removeClass("fullscreen"); + modal.find(".note-editable.panel-body").css("height","180px"); + }); + $('button[data-event="fullscreen"]').click(function(evt){ + $(this).closest(".modal-dialog").toggleClass("container"); + }); + +}); diff --git a/project_agile/static/src/js/project_workflow.js b/project_agile/static/src/js/project_workflow.js new file mode 100644 index 0000000..09d65ac --- /dev/null +++ b/project_agile/static/src/js/project_workflow.js @@ -0,0 +1,24 @@ +// Copyright 2017 - 2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +odoo.define('project_agile.TaskWorkflow', function (require) { + "use strict"; + + var TaskWorkflow = require('project_workflow.TaskWorkflow'); + + TaskWorkflow.include({ + + prepare_values_for_update: function (state) { + var values = this._super(state); + values.resolution_id = state.resolution_id; + return values; + }, + + build_confirmation_context: function(state){ + var context = this._super(state); + context.default_resolution_id = state.resolution_id; + return context; + }, + + }); +}); diff --git a/project_agile/tools.py b/project_agile/tools.py new file mode 100644 index 0000000..25266f3 --- /dev/null +++ b/project_agile/tools.py @@ -0,0 +1,19 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import api + + +def xmlid_to_res_id(self, xml_id, raise_if_not_found=False): + return self['ir.model.data'].xmlid_to_res_id( + xml_id, raise_if_not_found=raise_if_not_found + ) + + +def xmlid_to_action(self, xml_id): + module, xml_id = xml_id.split(".") + return self['ir.actions.act_window'].for_xml_id(module, xml_id) + + +api.Environment.ref_id = xmlid_to_res_id +api.Environment.ref_action = xmlid_to_action diff --git a/project_agile/views/menu.xml b/project_agile/views/menu.xml new file mode 100644 index 0000000..a5beddf --- /dev/null +++ b/project_agile/views/menu.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project_agile/views/project_agile.xml b/project_agile/views/project_agile.xml new file mode 100644 index 0000000..b0b8dc9 --- /dev/null +++ b/project_agile/views/project_agile.xml @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + diff --git a/project_agile/views/project_agile_board_views.xml b/project_agile/views/project_agile_board_views.xml new file mode 100644 index 0000000..1a06866 --- /dev/null +++ b/project_agile/views/project_agile_board_views.xml @@ -0,0 +1,187 @@ + + + + + agile_board_form + project.agile.board + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+
+ + + agile_board_tree + project.agile.board + + + + + + + + + + + Board + project.agile.board + tree,form + +
diff --git a/project_agile/views/project_agile_team_views.xml b/project_agile/views/project_agile_team_views.xml new file mode 100644 index 0000000..4dc2cbf --- /dev/null +++ b/project_agile/views/project_agile_team_views.xml @@ -0,0 +1,182 @@ + + + + + project_agile_team_tree + project.agile.team + + + + + + + + + + + project_agile_team_form + project.agile.team + +
+
+
+ +
+
+ + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ +
+ +
+
+ +
+
+
+
+
+ +
+
+ + +
+ +
+
+ + + + + project.agile.team.kanban + project.agile.team + + + + + + > + + +
+
+
+ + + +
+
+ +
    +
  • +
+ +
+ + + + + + + + + project.agile.team + form + Teams + kanban,tree,form + + +

+ With this view you can create your agile teams. +

+
+
+ diff --git a/project_agile/views/project_project_views.xml b/project_agile/views/project_project_views.xml new file mode 100644 index 0000000..073be90 --- /dev/null +++ b/project_agile/views/project_project_views.xml @@ -0,0 +1,235 @@ + + + + + + + + project.type.tree" + project.type + + + + + + + + + + + project.type.form + project.type + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + Project types + ir.actions.act_window + project.type + tree,form,search + + + + + + + + + project.project.form + project.project + + + + + + + + + + + + + {'readonly': [('agile_enabled', '=', True)]} + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + open_tasks + object + + + + + + + + + + + +
+
+ + + + project.project.view.form.simplified + project.project + + + + + + + + + + + + + + + + project.type.search + project.project + + + + + + + + + + + + + + + + + + + + project.project.kanban + project.project + + + + + + + + + + + + + + + + +
+ + + + + + open_tasks + object + + + + open_tasks + object + + + + open_tasks + object + + + + diff --git a/project_agile/views/project_task_views.xml b/project_agile/views/project_task_views.xml new file mode 100644 index 0000000..512aeb5 --- /dev/null +++ b/project_agile/views/project_task_views.xml @@ -0,0 +1,473 @@ + + + + + + + + project_task_type2_tree + project.task.type2 + + + + + + + + + + + + + project_task_type2_form + project.task.type2 + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + project.task.type2.search.form + project.task.type2 + + + + + + + + + + + Task Types + project.task.type2 + tree,form + + + + + + + + + project_task_priority_tree + project.task.priority + + + + + + + + + + + project_task_priority_form + project.task.priority + +
+ + +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + Task Priorities + project.task.priority + tree,form + + + + + + + + project_task_resolution_tree + project.task.resolution + + + + + + + + + + + project_task_resolution_form + project.task.resolution + +
+ +
+
+ +
+
+
+
+ + + + Task Resolutions + project.task.resolution + tree,form + + + + + + + + project_task_link_tree_view + project.task.link + + + + + + + + + + + + + + + + view.task.tree2 + project.task + + + + + + + + + + + + + + + + + view.task.form2 + project.task + + + + + + + + + + +
+ + + + + + + +
+
+ + + +
+
+ + + + project.task.form.inherited + project.task + + + + + +
+ + + + + + edit.project.analytics + project.project + + + +
+ + +
+
+
+ diff --git a/project_agile_jira/README.rst b/project_agile_jira/README.rst new file mode 100644 index 0000000..d4a3edd --- /dev/null +++ b/project_agile_jira/README.rst @@ -0,0 +1,39 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +================== +Project Agile JIRA +================== + +This module enables you to migrate your projects and tasks from JIRA to Odoo. + +Usage +===== + +TBD + +Credits +======= + +Contributors +------------ + +* Sladjan Kantar +* Petar Najman + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_agile_jira/__init__.py b/project_agile_jira/__init__.py new file mode 100644 index 0000000..5866267 --- /dev/null +++ b/project_agile_jira/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import wizards +from . import models diff --git a/project_agile_jira/__manifest__.py b/project_agile_jira/__manifest__.py new file mode 100644 index 0000000..9127857 --- /dev/null +++ b/project_agile_jira/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Agile Jira Extension", + "summary": "Enables you to migrate projects and tasks from JIRA to Odoo", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_agile", + ], + + 'external_dependencies': { + 'python': ['jira'], + }, + + "data": [ + "security/ir.model.access.csv", + + "data/sequences.xml", + "data/crons.xml", + + "wizards/task_import_view.xml", + + "views/jira_request_views.xml", + "views/jira_config_views.xml" + ], + "application": True, +} diff --git a/project_agile_jira/data/crons.xml b/project_agile_jira/data/crons.xml new file mode 100644 index 0000000..83bd3ed --- /dev/null +++ b/project_agile_jira/data/crons.xml @@ -0,0 +1,18 @@ + + + + + + Proccess JIRA requests + 30 + minutes + -1 + True + project.agile.jira.worker + execute_requests + + + diff --git a/project_agile_jira/data/sequences.xml b/project_agile_jira/data/sequences.xml new file mode 100644 index 0000000..f780bca --- /dev/null +++ b/project_agile_jira/data/sequences.xml @@ -0,0 +1,17 @@ + + + + + + Jira Request Job + project.agile.jira.request.job + Jira Job No. + + + 6 + + + diff --git a/project_agile_jira/models/__init__.py b/project_agile_jira/models/__init__.py new file mode 100644 index 0000000..f2e024b --- /dev/null +++ b/project_agile_jira/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from . import jira_config +from . import jira_request +from . import jira_worker diff --git a/project_agile_jira/models/jira_config.py b/project_agile_jira/models/jira_config.py new file mode 100644 index 0000000..484c4ec --- /dev/null +++ b/project_agile_jira/models/jira_config.py @@ -0,0 +1,97 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging + +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + +try: + import jira +except (ImportError, IOError) as err: + _logger.debug(err) + + +class JiraConfig(models.Model): + _name = "project.agile.jira.config" + + name = fields.Char( + string="Name", + help="Config Name" + ) + + location = fields.Char( + string="Location", + required=True, + help="Url to jira application" + ) + + username = fields.Char( + string="Username", + help="Webservice user" + ) + + password = fields.Char( + string="Password", + help="Webservice password" + ) + + request_ids = fields.One2many( + comodel_name="project.agile.jira.request", + inverse_name="config_id", + string="Requests" + ) + + @api.multi + def synchronize_projects(self): + for server in self: + client = jira.JIRA( + server=server.location, + basic_auth=(server.username, server.password) + ) + + for simple_project in client.projects(): + project_data = client.project(simple_project.id).raw + + project_type = self.env["project.type"].search([ + ("name", "ilike", project_data["projectTypeKey"]) + ], limit=1) + + if not project_type: + task_types = [] + for issue_type in project_data["issueTypes"]: + task_type = self.env["project.task.type2"].search([ + ("description", "=", issue_type["name"]) + ]) + + if not task_type: + task_type = self.env["project.task.type2"].create({ + "description": issue_type["name"] + }) + task_types.append(task_type.id) + + project_type = self.env["project.type"].create({ + "name": project_data["projectTypeKey"], + "task_type_ids": [(6, 0, task_types)] + }) + + project_manager = self.env["res.users"].search([ + ("name", "ilike", project_data["lead"]["displayName"]) + ], limit=1) + + project = self.env["project.project"].search([ + ("key", "=", project_data["key"]) + ], limit=1) + + new_data = { + "name": project_data["name"], + "key": project_data["key"], + "user_id": project_manager and project_manager.id or False, + "type_id": project_type.id, + } + + if project: + project.write(new_data) + else: + self.env["project.project"].create(new_data) diff --git a/project_agile_jira/models/jira_request.py b/project_agile_jira/models/jira_request.py new file mode 100644 index 0000000..54e0a9a --- /dev/null +++ b/project_agile_jira/models/jira_request.py @@ -0,0 +1,165 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging +from contextlib import contextmanager + +from odoo import models, fields, api + + +_logger = logging.getLogger(__name__) + +REQUEST_STATES = [ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("processing", "Processing"), + ("processed", "Processed"), + ("error", "Error"), +] + +JOB_TYPE = [ + ("prepare_issues_import", "Prepare issues for import"), + ("import_issue", "Import issue"), + ("add_relation", "Add Relation"), + ("add_link", "Add Link"), + ("add_worklog", "Add Worklog"), +] + + +class JiraRequest(models.Model): + _name = "project.agile.jira.request" + + state = fields.Selection( + selection=REQUEST_STATES, + string="State", + required=True, + default="confirmed" + ) + + name = fields.Char( + string="Name", + required=True, + copy=False, + default=lambda self: self.env["ir.sequence"].next_by_code( + "project.agile.jira.request.job" + ) + ) + + attempt = fields.Integer( + string="Import attempt", + default=1 + ) + + log_ids = fields.One2many( + comodel_name="project.agile.jira.request.log", + inverse_name="request_id", + string="Logs" + ) + + config_id = fields.Many2one( + comodel_name="project.agile.jira.config", + string="Config", + ondelete="cascade" + ) + + project_id = fields.Many2one( + comodel_name="project.project", + string="Project" + ) + + job_type = fields.Selection( + selection=JOB_TYPE, + string="Job type" + ) + + args = fields.Text( + string="args" + ) + + kwargs = fields.Text( + string="kwargs" + ) + + @contextmanager + def session(self): + with api.Environment.manage(): + new_cr = self.pool.cursor() + try: + yield new_cr + new_cr.commit() + except Exception as ex: + _logger.info("Failed to write to database: %s", str(ex)) + new_cr.rollback() + finally: + new_cr.close() + + @api.multi + def create_task(self, data): + self.ensure_one() + with self.session() as new_cr: + self.with_env(self.env(cr=new_cr)).env["project.task"].create(data) + + @api.multi + def write_dict(self, vals): + self.ensure_one() + with self.session() as new_cr: + self.with_env(self.env(cr=new_cr)).write(vals) + + @api.multi + def requeue_request(self): + self.ensure_one() + self.write_dict({"state": "confirmed", "attempt": self.attempt + 1}) + + @api.multi + def _create_log(self, message, stack_trace=None, log_type="error"): + """Create requestion log in a separate db connection.""" + + self.ensure_one() + + vals = { + "log_type": log_type, + "attempt": self.attempt, + "message": message, + "stack_trace": stack_trace, + "request_id": self.id + } + with self.session() as new_cr: + new = self.with_env(self.env(cr=new_cr)) + new.env["project.agile.jira.request.log"].create(vals) + + +LOG_TYPES = [ + ("warning", "Warning"), + ("error", "Error"), +] + + +class JiraRequestLog(models.Model): + _name = "project.agile.jira.request.log" + + _rec_name = "log_type" + _order = "attempt desc, log_type" + + request_id = fields.Many2one( + comodel_name="project.agile.jira.request", + string="Request", + ondelete="cascade" + ) + + log_type = fields.Selection( + selection=LOG_TYPES, + string="Type", + default="error" + ) + + attempt = fields.Integer( + string="Attempt" + ) + + message = fields.Text( + string="Message" + ) + + stack_trace = fields.Text( + string="Stack Trace" + ) diff --git a/project_agile_jira/models/jira_worker.py b/project_agile_jira/models/jira_worker.py new file mode 100644 index 0000000..1cb3f72 --- /dev/null +++ b/project_agile_jira/models/jira_worker.py @@ -0,0 +1,299 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging +import traceback +import ast +import re + +from contextlib import contextmanager + +from odoo import models, api + + +_logger = logging.getLogger(__name__) + +try: + import jira +except (ImportError, IOError) as err: + _logger.debug(err) + + +class JiraWorker(models.AbstractModel): + _name = "project.agile.jira.worker" + + @contextmanager + def session(self): + with api.Environment.manage(): + new_cr = self.pool.cursor() + try: + yield new_cr + new_cr.commit() + except Exception as ex: + _logger.info("Failed to write to database!: %s", str(ex)) + new_cr.rollback() + finally: + new_cr.close() + + @api.model + def execute_requests(self): + requests = self.env["project.agile.jira.request"].search([ + ("state", "=", "confirmed") + ]) + + for request in requests: + request.write_dict({"state": "processing"}) + with self.session() as new_cr: + transaction = self.with_env(self.env(cr=new_cr)) + try: + if request.job_type == "prepare_issues_import": + transaction.prepare_issuses_import(request) + elif request.job_type == "import_issue": + transaction.import_issue(request) + elif request.job_type == "add_relation": + transaction.add_relation(request) + elif request.job_type == "add_link": + transaction.add_link(request) + elif request.job_type == "add_worklog": + transaction.add_worklog(request) + else: + pass + except BaseException as ex: + error_message = str(ex) + stack_trace = traceback.format_exc() + request._create_log(error_message, stack_trace=stack_trace) + request.write_dict({"state": "error"}) + else: + request.write_dict({"state": "processed"}) + + def prepare_issuses_import(self, request): + + max_issue_number = -1 + + # (parent, child) + relationships = list() + links = list() + worklogs = list() + + args = list() + if request.args: + args = ast.literal_eval(request.args) + + kwargs = dict() + if request.kwargs: + kwargs = ast.literal_eval(request.kwargs) + + def get_trailing_number(s): + m = re.search(r'\d+$', s) + return int(m.group()) if m else None + + def create_issue_import_job(): + + for subtask in issue.fields.subtasks: + relationships.append((issue.key, subtask.key)) + + for link in issue.fields.issuelinks: + if hasattr(link, 'outwardIssue'): + links.append( + (issue.key, link.outwardIssue.key, link.type.outward) + ) + + for worklog in issue.fields.worklog.worklogs: + worklogs.append({ + "user": worklog.author.displayName, + "duration": worklog.timeSpentSeconds/3600, + "description": worklog.comment, + "issue": issue.key, + }) + + request.config_id.write({ + "request_ids": [(0, 0, { + "project_id": request.project_id.id, + "args": [issue.id] + args, + "kwargs": kwargs, + "job_type": "import_issue" + })] + }) + + return get_trailing_number(issue.key) + + def create_add_relation_job(): + request.config_id.write({ + "request_ids": [(0, 0, { + "project_id": request.project_id.id, + "args": [relation] + args, + "kwargs": kwargs, + "job_type": "add_relation" + })] + }) + + def create_add_link_job(): + request.config_id.write({ + "request_ids": [(0, 0, { + "project_id": request.project_id.id, + "args": [link] + args, + "kwargs": kwargs, + "job_type": "add_link" + })] + }) + + def create_add_worklog_job(): + request.config_id.write({ + "request_ids": [(0, 0, { + "project_id": request.project_id.id, + "args": [worklog], + "kwargs": {}, + "job_type": "add_worklog" + })] + }) + + client = jira.JIRA( + server=request.config_id.location, + basic_auth=(request.config_id.username, request.config_id.password) + ) + + issues = client.search_issues( + "project=%s" % request.project_id.key, + fields="subtasks, issuelinks, worklog" + ) + + for issue in issues: + number = create_issue_import_job() + if number > max_issue_number: + max_issue_number = number + + for relation in relationships: + create_add_relation_job() + + for link in links: + create_add_link_job() + + for worklog in worklogs: + create_add_worklog_job() + + request.project_id.write({"task_sequence": max_issue_number}) + + def import_issue(self, request): + + args = list() + kwargs = dict() + + if request.args: + args = ast.literal_eval(request.args) + + if request.kwargs: + kwargs = ast.literal_eval(request.kwargs) + + client = jira.JIRA( + server=request.config_id.location, + basic_auth=( + request.config_id.username, request.config_id.password + ) + ) + + issue = client.issue(args[0]) + + data = { + "project_id": request.project_id.id, + "name": issue.raw["fields"]["summary"], + "description": issue.raw["fields"]["description"], + "user_id": False, + "create_uid": False, + } + + key = issue.raw["key"] + + if issue.raw["fields"]["priority"]: + task_priority = self.env["project.task.priority"].search([ + ("name", "ilike", issue.raw["fields"]["priority"]["name"]) + ]) + priority_id = task_priority and task_priority.id or False + data["priority_id"] = priority_id + + if issue.raw["fields"]["assignee"]: + name = issue.raw["fields"]["assignee"]["displayName"] + assignee_id = self.env["res.users"].search([ + ("name", "ilike", name) + ]) + data["user_id"] = assignee_id and assignee_id.id or False + + if issue.raw["fields"]["reporter"]: + name = issue.raw["fields"]["reporter"]["displayName"] + reporter_id = self.env["res.users"].search([ + ("name", "ilike", name) + ]) + data["create_uid"] = reporter_id and reporter_id.id or False + + if issue.raw["fields"]["issuetype"]: + type_id = kwargs[issue.raw["fields"]["issuetype"]["name"]] + data["type_id"] = type_id + + task = self.env["project.task"].create(data) + task.write({"key": key}) + + def add_relation(self, request): + args = list() + + if request.args: + args = ast.literal_eval(request.args) + + for arg in args: + parent_task = self.env["project.task"].search([ + ("key", "=", arg[0]) + ]) + task = self.env["project.task"].search([("key", "=", arg[1])]) + + if parent_task and task: + task.write({ + "parent_id": task.id + }) + + def add_link(self, request): + args = list() + + if request.args: + args = ast.literal_eval(request.args) + + for arg in args: + + task = self.env["project.task"].search([("key", "=", arg[0])]) + related_task = self.env["project.task"].search([ + ("key", "=", arg[1]) + ]) + + relation = self.env["project.task.link.relation"].search([ + ("name", "=", arg[2]) + ]) + + if task and related_task and relation: + self.env['project.task.link'].create({ + "task_left_id": task.id, + "task_right_id": related_task.id, + "relation_id": relation.id, + }) + + def add_worklog(self, request): + args = list() + + if request.args: + args = ast.literal_eval(request.args) + + for arg in args: + data = { + "unit_amount": arg["duration"], + "name": arg["description"], + "account_id": request.project_id.analytic_account_id.id + } + + user = self.env["res.users"].search([ + ("name", "ilike", arg["user"]) + ]) + data["user_id"] = user and user.id or False + + task = self.env["project.task"].search([ + ("key", "=", arg["issue"]) + ]) + + task.write({ + "timesheet_ids": [(0, 0, data)] + }) diff --git a/project_agile_jira/security/ir.model.access.csv b/project_agile_jira/security/ir.model.access.csv new file mode 100644 index 0000000..4617218 --- /dev/null +++ b/project_agile_jira/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_jira_config_manager,project.agile.jira.config,model_project_agile_jira_config,project.group_project_manager,1,1,1,1 +access_jira_worker_manager,project.agile.jira.worker,model_project_agile_jira_worker,project.group_project_manager,1,1,1,1 +access_jira_request_manager,project.agile.jira.request,model_project_agile_jira_request,project.group_project_manager,1,1,1,1 diff --git a/project_agile_jira/static/description/icon.png b/project_agile_jira/static/description/icon.png new file mode 100644 index 0000000..a81b109 Binary files /dev/null and b/project_agile_jira/static/description/icon.png differ diff --git a/project_agile_jira/views/jira_config_views.xml b/project_agile_jira/views/jira_config_views.xml new file mode 100644 index 0000000..140d5eb --- /dev/null +++ b/project_agile_jira/views/jira_config_views.xml @@ -0,0 +1,64 @@ + + + + + project.agile.jira.config.form + project.agile.jira.config + form + +
+ +
+
+
+
+ + + + + + + + + + +
+
+
+
+ + + project.agile.jira.config.tree + project.agile.jira.config + tree + + + + + + + + + + + Jira + project.agile.jira.config + form + kanban,tree,form + + {} + Config + + + +
diff --git a/project_agile_jira/views/jira_request_views.xml b/project_agile_jira/views/jira_request_views.xml new file mode 100644 index 0000000..4a02e9a --- /dev/null +++ b/project_agile_jira/views/jira_request_views.xml @@ -0,0 +1,91 @@ + + + + + project.agile.jira.request.form + project.agile.jira.request + form + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+ + + + + + +
+
+ + + + + + +
+
+ +
+
+ + + project.agile.jira.request.tree + project.agile.jira.request + tree + + + + + + + + + + +
diff --git a/project_agile_jira/wizards/__init__.py b/project_agile_jira/wizards/__init__.py new file mode 100644 index 0000000..0264973 --- /dev/null +++ b/project_agile_jira/wizards/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import task_import diff --git a/project_agile_jira/wizards/task_import.py b/project_agile_jira/wizards/task_import.py new file mode 100644 index 0000000..bc36000 --- /dev/null +++ b/project_agile_jira/wizards/task_import.py @@ -0,0 +1,104 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging + +from odoo import models, fields, api, exceptions, _ + +_logger = logging.getLogger(__name__) + +try: + import jira +except (ImportError, IOError) as err: + _logger.debug(err) + + +class TaskImport(models.TransientModel): + _name = "project.agile.jira.task.import.wizard" + + project_id = fields.Many2one( + comodel_name="project.project", + string="Project", + required=True, + ) + + issue_type_mapper_ids = fields.One2many( + comodel_name="project.agile.jira.issue.type.mapper", + string="Type mapper", + inverse_name="task_import_id" + ) + + @api.onchange("project_id") + def change_issue_types(self): + if not self.project_id: + return + + jira_config = self.env[self.env.context.get("active_model")].browse( + self.env.context.get("active_id") + ) + client = jira.JIRA( + server=jira_config.location, + basic_auth=(jira_config.username, jira_config.password) + ) + + jira_project = client.project(self.project_id.key) + + issue_types = [] + for issue_type in jira_project.issueTypes: + issue_types.append((0, 0, {"issue_type": issue_type.name})) + + self.issue_type_mapper_ids = issue_types + + @api.multi + def button_import(self): + + if self.project_id.task_ids: + raise exceptions.Warning(_( + "Project %s has tasks... Possible problem with task KEY" + ) % self.project_id.name) + + if not self.project_id.workflow_id: + raise exceptions.ValidationError(_( + "Project %s must have workflow defined!" + ) % self.project_id.name) + + mapper = dict() + + for issue_type_mapper in self.issue_type_mapper_ids: + mapper.update({ + issue_type_mapper.issue_type: issue_type_mapper.task_type_id.id + }) + + jira_config = self.env[self.env.context.get("active_model")].browse( + self.env.context.get("active_id") + ) + + jira_config.write({ + "request_ids": [(0, 0, { + "project_id": self.project_id.id, + "kwargs": mapper, + "job_type": "prepare_issues_import" + })] + }) + + return { + "type": "ir.actions.act_window_close" + } + + +class IssueTypeMapper(models.TransientModel): + _name = "project.agile.jira.issue.type.mapper" + + task_import_id = fields.Many2one( + comodel_name="project.agile.jira.task.import.wizard", + string="Task Importer" + ) + + issue_type = fields.Char( + string="Issue Type" + ) + + task_type_id = fields.Many2one( + comodel_name="project.task.type2", + string="Task Type" + ) diff --git a/project_agile_jira/wizards/task_import_view.xml b/project_agile_jira/wizards/task_import_view.xml new file mode 100644 index 0000000..e677237 --- /dev/null +++ b/project_agile_jira/wizards/task_import_view.xml @@ -0,0 +1,40 @@ + + + + + Task Import Wizard + ir.actions.act_window + project.agile.jira.task.import.wizard + form + new + + + + Task Import Wizard + project.agile.jira.task.import.wizard + +
+ + + + + + + + + + + + +
+
+
+
+
+
+
diff --git a/project_agile_kanban/README.rst b/project_agile_kanban/README.rst new file mode 100644 index 0000000..9df81d5 --- /dev/null +++ b/project_agile_kanban/README.rst @@ -0,0 +1,39 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +==================== +Project Agile Kanban +==================== + +This module enables you to manage your projects by using agile kanban methodology. +Please note that this module is not yet fully functional. +It won't break anything in case you install it but at the moment you won't be able to drag&drop tasks from backlog to the kanban board. + +Credits +======= + +Contributors +------------ + +* Aleksandar Gajić +* Petar Najman +* Jasmina Nikolić +* Igor Jovanović +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com \ No newline at end of file diff --git a/project_agile_kanban/__init__.py b/project_agile_kanban/__init__.py new file mode 100644 index 0000000..e33eeed --- /dev/null +++ b/project_agile_kanban/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models + +from .hooks import post_init_hook diff --git a/project_agile_kanban/__manifest__.py b/project_agile_kanban/__manifest__.py new file mode 100644 index 0000000..fe3dee6 --- /dev/null +++ b/project_agile_kanban/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Agile Kanban", + "summary": "Manage your projects by using agile kanban methodology", + "category": "Project", + "version": "11.0.0.1.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_agile", + "project_agile_analytic" + ], + + "data": [ + "security/ir.model.access.csv", + "views/project_agile_board_views.xml", + ], + + "demo": [], + "qweb": [], + "application": True, + "post_init_hook": "post_init_hook", +} diff --git a/project_agile_kanban/hooks.py b/project_agile_kanban/hooks.py new file mode 100644 index 0000000..e4d6985 --- /dev/null +++ b/project_agile_kanban/hooks.py @@ -0,0 +1,18 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + + +def post_init_hook(cr, registry): + import os + from odoo.tools import misc + from odoo import api, SUPERUSER_ID + + env = api.Environment(cr, SUPERUSER_ID, {}) + + board_pathname = os.path.join( + 'project_agile_kanban', 'import', 'board.xml' + ) + with misc.file_open(board_pathname) as stream: + importer = env['project.agile.board.importer'] + reader = env['project.agile.board.xml.reader'] + importer.run(reader, stream) diff --git a/project_agile_kanban/import/board.xml b/project_agile_kanban/import/board.xml new file mode 100644 index 0000000..852da06 --- /dev/null +++ b/project_agile_kanban/import/board.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/project_agile_kanban/models/__init__.py b/project_agile_kanban/models/__init__.py new file mode 100644 index 0000000..081d252 --- /dev/null +++ b/project_agile_kanban/models/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import project_agile_board +from . import project_workflow +from . import project_workflow_publisher +from . import project_agile_team +from . import project_agile_report +from . import project diff --git a/project_agile_kanban/models/project.py b/project_agile_kanban/models/project.py new file mode 100644 index 0000000..0fb265a --- /dev/null +++ b/project_agile_kanban/models/project.py @@ -0,0 +1,36 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class Project(models.Model): + _inherit = 'project.project' + + agile_method = fields.Selection( + selection_add=[('kanban', 'Kanban')], + default='kanban', + ) + + @api.multi + def agile_kanban_enabled(self): + self.ensure_one() + return self.agile_enabled and self.agile_method == 'kanban' + + def get_analytic_types(self): + types = super(Project, self).get_analytic_types() + types.append('kanban') + return types + + +class Task(models.Model): + _inherit = 'project.task' + + @api.multi + def write(self, vals): + ret = super(Task, self).write(vals) + if self.env.context.get('kanban_backlog'): + if 'stage_id' in vals: + for record in self: + record.child_ids.write({'stage_id': vals['stage_id']}) + return ret diff --git a/project_agile_kanban/models/project_agile_board.py b/project_agile_kanban/models/project_agile_board.py new file mode 100644 index 0000000..32301f1 --- /dev/null +++ b/project_agile_kanban/models/project_agile_board.py @@ -0,0 +1,138 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class ProjectAgileBoard(models.Model): + _inherit = 'project.agile.board' + + type = fields.Selection( + selection_add=[('kanban', 'Kanban')], + ) + + kanban_backlog_column_id = fields.Many2one( + comodel_name="project.agile.board.column", + string="Backlog Column", + help="This column will be used for moving items from backlog" + " to the kanban board.", + ) + + kanban_backlog_column_status_ids = fields.One2many( + comodel_name='project.agile.board.kanban.backlog.column.status', + inverse_name='board_id', + string='Backlog columns' + ) + + kanban_backlog_state_ids = fields.One2many( + comodel_name='project.agile.board.kanban.backlog.state', + inverse_name='board_id', + string='Backlog states' + ) + + +class BacklogColumnStatus(models.Model): + _name = 'project.agile.board.kanban.backlog.column.status' + + board_id = fields.Many2one( + comodel_name='project.agile.board', + string='Board', + required=True, + index=True, + ondelete='cascade' + ) + + workflow_ids = fields.Many2many( + comodel_name='project.workflow', + compute="_compute_workflow_ids", + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + required=True, + string='Workflow', + ) + + column_id = fields.Many2one( + comodel_name="project.agile.board.column", + related="board_id.kanban_backlog_column_id" + ) + + status_id = fields.Many2one( + comodel_name="project.agile.board.column.status", + required=True, + string="Backlog Column Status", + help="Every task moved to the column will be put in this status", + ) + + stage_id = fields.Many2one( + comodel_name="project.task.type", + string="Backlog Column Stage", + related="status_id.stage_id", + help="Every task moved to the column will be put in this stage", + ) + + _sql_constraints = [ + ("unique_board_workflow", "unique(board_id,workflow_id)", + "Workflow must be unique per kanban board") + ] + + @api.one + @api.depends("board_id", "board_id.project_ids") + def _compute_workflow_ids(self): + self.workflow_ids = self.mapped("board_id.project_ids.workflow_id").ids + + +class BacklogStates(models.Model): + _name = 'project.agile.board.kanban.backlog.state' + + board_id = fields.Many2one( + comodel_name='project.agile.board', + string='Board', + required=True, + index=True, + ondelete='cascade' + ) + + workflow_ids = fields.Many2many( + comodel_name='project.workflow', + compute="_compute_workflow_ids", + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + required=True, + string='Workflow', + ) + + state_id = fields.Many2one( + comodel_name="project.workflow.state", + required=True, + string="Backlog State", + ) + + stage_id = fields.Many2one( + comodel_name="project.task.type", + string="Backlog Column Stage", + related="state_id.stage_id", + ) + + _sql_constraints = [ + ("unique_board_workflow", "unique(board_id,workflow_id)", + "One workflow state per board is allowed!") + ] + + @api.one + @api.depends("board_id", "board_id.project_ids") + def _compute_workflow_ids(self): + self.workflow_ids = self.mapped("board_id.project_ids.workflow_id").ids + + +class ProjectAgileBoardColumn(models.Model): + _inherit = 'project.agile.board.column' + + def _min_max_available_for_types(self): + types = super(ProjectAgileBoardColumn, self)\ + ._min_max_available_for_types() + types.append('kanban') + return types diff --git a/project_agile_kanban/models/project_agile_board_reader.py b/project_agile_kanban/models/project_agile_board_reader.py new file mode 100644 index 0000000..896b5e2 --- /dev/null +++ b/project_agile_kanban/models/project_agile_board_reader.py @@ -0,0 +1,131 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models, exceptions, _ + + +def name(value): + return {'name': value} + + +class XmlAgileBoardReader(models.AbstractModel): + _inherit = 'project.agile.board.xml.reader' + + def validate_board(self, board): + """ + This method validates the logic of the given agile board object. + It will check if all mapped states within columns are used only once + :param board: The agile board to be validated + :return: + """ + + workflows = self.env['project.workflow'].search([ + ('name', '=', board['workflow']) + ]) + + workflow_count = len(workflows) + if workflow_count == 0: + raise exceptions.ValidationError(_( + "Workflow with name '%s' could not be found in database" + ) % board['workflow']) + + if workflow_count > 1: + raise exceptions.ValidationError(_( + "Found multiple instances of workflow with name '%s' " + ) % board['workflow']) + + wkf_states = set([state.name for state in workflows.state_ids]) + + counter = dict() + multiples = [] + lost_and_found = set() + for column in board['columns']: + for status in column['statuses']: + status_name = status['wkf_state'] + counter[status_name] = counter.get(status_name, 0) + 1 + if counter[status_name] > 1: + multiples.append(status_name) + + if status_name not in wkf_states: + lost_and_found.add(status_name) + + error_messages = [] + + if multiples: + error_messages.append(_( + "Following states: [%s] are assigned to multiple columns!" + ) % multiples) + + if lost_and_found: + error_messages.append(_( + "Following states [%] are referenced in the board but are not " + "found in the related workflow!" + ) % lost_and_found) + + if error_messages: + raise exceptions.ValidationError("\n".join(error_messages)) + + def read_board(self, element): + """ + Reads workflow data out of the given xml element. + :param element: The xml element which holds information + about project workflow. + :return: Returns workflow dictionary. + """ + return { + 'name': self.read_string(element, 'name'), + 'description': self.read_string(element, 'description'), + 'type': self.read_string(element, 'type'), + 'is_default': self.read_boolean(element, 'is_default'), + 'columns': self.read_columns(element), + 'workflow': self.read_string(element, 'workflow'), + } + + def extend_rng(self, rng_etree): + rng_etree = super(XmlAgileBoardReader, self).extend_rng(rng_etree) + root = rng_etree.getroot() + + root.insert(0, self.rng_define_task_types()) + + transition = root.xpath( + "//rng:define[@name='transition']//" + "rng:element[@name='transition']", + namespaces=self._rng_namespace_map + )[0] + + transition.append(self.rng_task_type_element()) + return rng_etree + + def rng_define_task_types(self): + E = self.get_element_maker() + + doc = E.grammar( + E.define( + name('task-type'), + E.element( + name('task-type'), + E.attribute( + name('name'), + E.text() + ) + ) + ) + ) + return doc[0] + + def rng_task_type_element(self): + E = self.get_element_maker() + doc = E.grammar( + E.optional( + E.element( + name('task-types'), + E.optional( + E.oneOrMore( + E.ref( + name("task-type") + ) + ) + ) + ) + ) + ) + return doc[0] diff --git a/project_agile_kanban/models/project_agile_board_writer.py b/project_agile_kanban/models/project_agile_board_writer.py new file mode 100644 index 0000000..1a6304f --- /dev/null +++ b/project_agile_kanban/models/project_agile_board_writer.py @@ -0,0 +1,16 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models + + +class XmlAgileBoardWriter(models.AbstractModel): + _inherit = 'project.agile.board.xml.writer' + + def prepare_board_attributes(self, board): + board_attributes = super(XmlAgileBoardWriter, self)\ + .prepare_board_attributes(board) + if board.kanban_task_type_ids: + board_attributes['kanban_task_type_ids'] = ",".join( + [x.name for x in board.kanban_task_type_ids] + ) + return board_attributes diff --git a/project_agile_kanban/models/project_agile_report.py b/project_agile_kanban/models/project_agile_report.py new file mode 100644 index 0000000..5e43bee --- /dev/null +++ b/project_agile_kanban/models/project_agile_report.py @@ -0,0 +1,11 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models, fields + + +class AgileReport(models.AbstractModel): + _inherit = 'project.agile.report' + + type = fields.Selection( + selection_add=[('kanban', 'Kanban')], + ) diff --git a/project_agile_kanban/models/project_agile_team.py b/project_agile_kanban/models/project_agile_team.py new file mode 100644 index 0000000..7bf388d --- /dev/null +++ b/project_agile_kanban/models/project_agile_team.py @@ -0,0 +1,11 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models, fields + + +class AgileTeam(models.Model): + _inherit = 'project.agile.team' + + type = fields.Selection( + selection_add=[('kanban', 'Kanban')], + ) diff --git a/project_agile_kanban/models/project_workflow.py b/project_agile_kanban/models/project_workflow.py new file mode 100644 index 0000000..8a42bd5 --- /dev/null +++ b/project_agile_kanban/models/project_workflow.py @@ -0,0 +1,20 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, api + + +class WorkflowState(models.Model): + _inherit = 'project.workflow.state' + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + if args is None: + args = [] + if 'filter_states' in self.env.context: + args.append(('id', 'in', [ + x[1] for x in self.env.context.get('filter_states', []) + ])) + return super(WorkflowState, self).name_search( + name, args=args, operator=operator, limit=limit + ) diff --git a/project_agile_kanban/models/project_workflow_publisher.py b/project_agile_kanban/models/project_workflow_publisher.py new file mode 100644 index 0000000..4241165 --- /dev/null +++ b/project_agile_kanban/models/project_workflow_publisher.py @@ -0,0 +1,36 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models + + +class ProjectWorkflowPublisher(models.AbstractModel): + _inherit = 'project.workflow.publisher' + + def update_board_no_switch(self, old, board, wkf_states, status_tree): + super(ProjectWorkflowPublisher, self).update_board_no_switch( + old, board, wkf_states, status_tree + ) + + if board.type != 'kanban': + return + + backlog_column_status = board.kanban_backlog_column_status_ids.filtered( + lambda s: s.workflow_id == old + ) + + if len(backlog_column_status): + status = status_tree.get( + backlog_column_status.stage_id.id, False + ) + + if status: + backlog_column_status.status_id = status.id + + backlog_state = board.kanban_backlog_state_ids.filtered( + lambda s: s.workflow_id == old + ) + + if len(backlog_state): + state = wkf_states.get(backlog_state.state_id.stage_id.id, False) + if state: + backlog_state.state_id = state.id diff --git a/project_agile_kanban/security/ir.model.access.csv b/project_agile_kanban/security/ir.model.access.csv new file mode 100644 index 0000000..8cac77a --- /dev/null +++ b/project_agile_kanban/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_project_agile_board_kanban_backlog_column_status_user,project.agile.board.kanban.backlog.column.status,model_project_agile_board_kanban_backlog_column_status,project.group_project_user,1,0,0,0 +access_project_agile_board_kanban_backlog_column_status_manager,project.agile.board.kanban.backlog.column.status,model_project_agile_board_kanban_backlog_column_status,project.group_project_manager,1,1,1,1 + +access_project_agile_board_kanban_backlog_state_user,project.agile.board.kanban.backlog.state,model_project_agile_board_kanban_backlog_state,project.group_project_user,1,0,0,0 +access_project_agile_board_kanban_backlog_state_manager,project.agile.board.kanban.backlog.state,model_project_agile_board_kanban_backlog_state,project.group_project_manager,1,1,1,1 diff --git a/project_agile_kanban/static/description/icon.png b/project_agile_kanban/static/description/icon.png new file mode 100644 index 0000000..dcc8be7 Binary files /dev/null and b/project_agile_kanban/static/description/icon.png differ diff --git a/project_agile_kanban/views/project_agile_board_views.xml b/project_agile_kanban/views/project_agile_board_views.xml new file mode 100644 index 0000000..083c6af --- /dev/null +++ b/project_agile_kanban/views/project_agile_board_views.xml @@ -0,0 +1,113 @@ + + + + + agile_board_form + project.agile.board + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+
+ +
+ + + + + + + + +
+ + + + + + + + + + + + +
+
+
+
+
+
+
diff --git a/project_agile_scrum/README.rst b/project_agile_scrum/README.rst new file mode 100644 index 0000000..417170a --- /dev/null +++ b/project_agile_scrum/README.rst @@ -0,0 +1,37 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +=================== +Project Agile Scrum +=================== + +This module enables you to manage your projects by using agile scrum methodology + +Credits +======= + +Contributors +------------ + +* Aleksandar Gajić +* Petar Najman +* Jasmina Nikolić +* Igor Jovanović +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_agile_scrum/__init__.py b/project_agile_scrum/__init__.py new file mode 100644 index 0000000..68c4bef --- /dev/null +++ b/project_agile_scrum/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models +from . import wizards + +from .hooks import post_init_hook diff --git a/project_agile_scrum/__manifest__.py b/project_agile_scrum/__manifest__.py new file mode 100644 index 0000000..2a1dc99 --- /dev/null +++ b/project_agile_scrum/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Agile Scrum", + "summary": "Manage your projects by using agile scrum methodology.", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_agile", + ], + + "data": [ + 'security/ir.model.access.csv', + + "views/project_agile_scrum_sprint_views.xml", + "views/project_views.xml", + "views/project_agile_team_views.xml", + + "views/menu.xml", + "views/project_agile_board_views.xml", + ], + + "demo": [], + "qweb": [], + "application": True, + "post_init_hook": "post_init_hook", +} diff --git a/project_agile_scrum/hooks.py b/project_agile_scrum/hooks.py new file mode 100644 index 0000000..d828052 --- /dev/null +++ b/project_agile_scrum/hooks.py @@ -0,0 +1,16 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + + +def post_init_hook(cr, registry): + import os + from odoo.tools import misc + from odoo import api, SUPERUSER_ID + + env = api.Environment(cr, SUPERUSER_ID, {}) + + board_pathname = os.path.join('project_agile_scrum', 'import', 'board.xml') + with misc.file_open(board_pathname) as stream: + importer = env['project.agile.board.importer'] + reader = env['project.agile.board.xml.reader'] + importer.run(reader, stream) diff --git a/project_agile_scrum/import/board.xml b/project_agile_scrum/import/board.xml new file mode 100644 index 0000000..4288349 --- /dev/null +++ b/project_agile_scrum/import/board.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/project_agile_scrum/models/__init__.py b/project_agile_scrum/models/__init__.py new file mode 100644 index 0000000..c99acfd --- /dev/null +++ b/project_agile_scrum/models/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import project_agile_team +from . import project_agile_board +from . import project_agile_scrum_sprint +from . import project_workflow_publisher +from . import project_agile_report +from . import project diff --git a/project_agile_scrum/models/project.py b/project_agile_scrum/models/project.py new file mode 100644 index 0000000..f8019ce --- /dev/null +++ b/project_agile_scrum/models/project.py @@ -0,0 +1,73 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models, fields, api + + +class Project(models.Model): + _inherit = 'project.project' + + agile_method = fields.Selection( + selection_add=[ + ('scrum', 'Scrum') + ], + default='scrum', + ) + + @api.multi + def agile_scrum_enabled(self): + self.ensure_one() + return self.agile_enabled and self.agile_method == 'scrum' + + +class Task(models.Model): + _inherit = 'project.task' + + sprint_id = fields.Many2one( + comodel_name="project.agile.scrum.sprint", + string="Current sprint", + domain="[('team_id','=',team_id)]", + ) + + sprint_ids = fields.Many2many( + comodel_name="project.agile.scrum.sprint", + column1="task_id", + column2="sprint_id", + string="Sprint history", + ) + + sprint_state = fields.Char(compute="_compute_sprint_state", store=True) + + @api.multi + @api.depends('sprint_id', 'sprint_id.state') + def _compute_sprint_state(self): + for record in self: + record.sprint_state = record.sprint_id.state + + @api.model + @api.returns('self', lambda value: value.id) + def create(self, vals): + new = super(Task, self).create(vals) + if new.parent_id and new.parent_id.sprint_id: + new.set_sprint(new.parent_id.sprint_id.id) + return new + + @api.multi + def write(self, vals): + ret = super(Task, self).write(vals) + + if 'stage_id' in vals: + for rec in self: + if rec.wkf_state_type == 'done' and not rec.date_end: + rec.write({'date_end': fields.Datetime.now()}) + + if 'sprint_id' in vals: + for record in self: + if len(record.child_ids) > 0: + record.child_ids.write({ + 'sprint_id': vals['sprint_id'] + }) + return ret + + @api.multi + def set_sprint(self, sprint_id): + self.write({'sprint_id': sprint_id}) diff --git a/project_agile_scrum/models/project_agile_board.py b/project_agile_scrum/models/project_agile_board.py new file mode 100644 index 0000000..42bf1d3 --- /dev/null +++ b/project_agile_scrum/models/project_agile_board.py @@ -0,0 +1,20 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields + + +class Board(models.Model): + _inherit = 'project.agile.board' + + type = fields.Selection( + selection_add=[('scrum', 'Scrum')], + ) + + scrum_backlog_state_ids = fields.Many2many( + comodel_name='project.workflow.state', + relation="project_agile_scrum_board_backlog_state_rel", + column1='board_id', + column2='state_id', + string='Backlog states' + ) diff --git a/project_agile_scrum/models/project_agile_board_writer.py b/project_agile_scrum/models/project_agile_board_writer.py new file mode 100644 index 0000000..9cf5bad --- /dev/null +++ b/project_agile_scrum/models/project_agile_board_writer.py @@ -0,0 +1,16 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models + + +class XmlAgileBoardWriter(models.AbstractModel): + _inherit = 'project.agile.board.xml.writer' + + def prepare_board_attributes(self, board): + board_attributes = super(XmlAgileBoardWriter, self)\ + .prepare_board_attributes(board) + if board.scrum_task_type_ids: + board_attributes['scrum_task_type_ids'] = ",".join([ + x.name for x in board.scrum_task_type_ids + ]) + return board_attributes diff --git a/project_agile_scrum/models/project_agile_report.py b/project_agile_scrum/models/project_agile_report.py new file mode 100644 index 0000000..b61da98 --- /dev/null +++ b/project_agile_scrum/models/project_agile_report.py @@ -0,0 +1,12 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields + + +class AgileReport(models.AbstractModel): + _inherit = 'project.agile.report' + + type = fields.Selection( + selection_add=[('scrum', 'Scrum')], + ) diff --git a/project_agile_scrum/models/project_agile_scrum_sprint.py b/project_agile_scrum/models/project_agile_scrum_sprint.py new file mode 100644 index 0000000..330dcb6 --- /dev/null +++ b/project_agile_scrum/models/project_agile_scrum_sprint.py @@ -0,0 +1,161 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, exceptions, _ + + +class Sprint(models.Model): + _name = 'project.agile.scrum.sprint' + _inherit = ['project.agile.mixin.id_search'] + _order = 'state, start_date' + + name = fields.Char( + default=lambda self: self.env['ir.sequence'].next_by_code( + 'project_agile.sprint.sequence' + ), + ) + + description = fields.Html(default="") + start_date = fields.Datetime() + end_date = fields.Datetime() + actual_end_date = fields.Datetime() + + total_story_points = fields.Integer( + compute="_compute_total_story_points", + ) + + task_ids = fields.One2many( + comodel_name="project.task", + inverse_name="sprint_id", + string="Tasks", + required=False, + ) + + team_id = fields.Many2one( + comodel_name='project.agile.team', + string='Agile Team', + required=True, + ) + + team_image_small = fields.Binary( + string='Team Image', + related='team_id.image_small' + ) + + state = fields.Selection( + selection=[ + ('draft', 'Draft'), + ('active', 'Active'), + ('completed', 'Completed') + ], + required=True, + default='draft', + ) + + velocity = fields.Integer( + string="Velocity", + compute="_compute_velocity", + store=True, + ) + + task_count = fields.Integer( + string="Task count", + compute="_compute_task_count", + store=True, + ) + + order = fields.Float(required=False, default=1) + + active = fields.Boolean( + string='Active?', + default=True, + ) + + sprint_length_hrs = fields.Integer( + compute='_compute_length_hrs', + string='Sprint length(in hrs)', + store=True + ) + + sprint_length_days = fields.Integer( + compute='_compute_length_days', + string='Sprint length(in days)', + store=True + ) + + sprint_length_week = fields.Integer( + compute='_compute_length_week', + string='Sprint length(in weeks)', + ) + + default_hrs = fields.Float( + related='team_id.default_hrs', + string='Default daily hours' + ) + + @api.multi + @api.depends('task_ids', 'task_ids.story_points') + def _compute_total_story_points(self): + for record in self: + record.total_story_points = sum( + int(t.story_points) for t in record.task_ids + ) + + @api.multi + @api.depends('task_ids', 'task_ids.story_points', 'state') + def _compute_velocity(self): + for sprint in self: + sprint.velocity = sum( + int(t.story_points) + for t in sprint.task_ids.filtered( + lambda x: x.wkf_state_type == "done" + ) + ) + + @api.multi + @api.depends("task_ids") + def _compute_task_count(self): + for record in self: + record.task_count = len(record.task_ids) + + @api.multi + @api.depends('start_date', 'end_date') + def _compute_length_week(self): + for rec in self: + rec.sprint_length_week = (rec.sprint_length_days + 1) / 7 + + @api.multi + @api.depends('start_date', 'end_date') + def _compute_length_days(self): + for rec in self: + if rec.start_date and rec.end_date: + end_date = fields.Date.from_string(rec.end_date) + start_date = fields.Date.from_string(rec.start_date) + rec.sprint_length_days = (end_date - start_date).days + else: + rec.sprint_length_days = 0 + + @api.multi + @api.depends('start_date', 'end_date') + def _compute_length_hrs(self): + for rec in self: + days = float(rec.sprint_length_days) + hrs = days * float(rec.default_hrs) + hrs_int = int(hrs) + rec.sprint_length_hrs = hrs_int + + @api.model + @api.returns('self', lambda value: value.id) + def create(self, vals): + if not self.env.user.team_id: + raise exceptions.ValidationError(_( + "You have to be part of an agile team in order to " + "create a new sprint" + )) + + if 'name' not in vals: + vals['name'] = "Sprint %s" % self.env.user.team_id.sprint_sequence + + self.env.user.team_id.sprint_sequence += 1 + + return super(Sprint, self).create(vals) diff --git a/project_agile_scrum/models/project_agile_team.py b/project_agile_scrum/models/project_agile_team.py new file mode 100644 index 0000000..8f6a3aa --- /dev/null +++ b/project_agile_scrum/models/project_agile_team.py @@ -0,0 +1,126 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, tools, _ + + +class ScrumTeam(models.Model): + _inherit = 'project.agile.team' + + type = fields.Selection( + selection_add=[('scrum', 'Scrum')], + ) + + master_id = fields.Many2one( + comodel_name='res.users', + string='Scrum Master' + ) + + sprint_ids = fields.One2many( + comodel_name='project.agile.scrum.sprint', + inverse_name='team_id', + string='Sprints', + readonly=True, + ) + + velocity = fields.Integer( + string='Velocity', + ) + + sprint_sequence = fields.Integer( + string='Sprint Sequence', + default=1 + ) + + active_sprint_id = fields.Many2one( + comodel_name='project.agile.scrum.sprint', + string='Active Sprint', + compute="_compute_active_sprint", + ) + + active_sprint_count = fields.Integer( + string="Active Sprint Count", + compute="_compute_active_sprint_count", + ) + + future_sprint_count = fields.Integer( + string="Future Sprint Count", + compute="_compute_future_sprint_count", + ) + + completed_sprint_count = fields.Integer( + string="Future Sprint Count", + compute="_compute_completed_sprint_count", + ) + + default_sprint_length = fields.Selection( + selection=[ + ('1', 'One Week'), + ('2', 'Two Weeks'), + ('3', 'Tree Weeks'), + ('4', 'Four Weeks'), + ], + string='Default sprint length', + default='2', + help="Default Sprint time for this project" + ) + + @api.multi + @api.depends('sprint_ids', 'sprint_ids.state') + def _compute_active_sprint(self): + for rec in self: + rec.active_sprint_id = rec.sprint_ids.filtered( + lambda r: r.state == 'active' + ).id or False + + @api.multi + @api.depends("sprint_ids", "sprint_ids.state") + def _compute_active_sprint_count(self): + for record in self: + record.active_sprint_count = len(record.sprint_ids.filtered( + lambda r: r.state == 'active' + )) + + @api.multi + @api.depends("sprint_ids", "sprint_ids.state") + def _compute_future_sprint_count(self): + for record in self: + record.future_sprint_count = len(record.sprint_ids.filtered( + lambda r: r.state == 'draft' + )) + + @api.multi + @api.depends("sprint_ids", "sprint_ids.state") + def _compute_completed_sprint_count(self): + for record in self: + record.completed_sprint_count = len(record.sprint_ids.filtered( + lambda r: r.state == 'completed' + )) + + @api.multi + def open_active_sprint(self): + return self.active_sprint_id.get_formview_action() + + @api.multi + def open_future_sprints(self): + self.ensure_one() + action = self.env.ref_action("project_agile_scrum.open_agile_sprint") + action['name'] = _("Future Sprints") + ctx = tools.safe_eval(action.get('context', "{}")) + ctx['search_default_draft'] = 1 + ctx['search_default_team_id'] = [self.id] + ctx['default_team_id'] = self.id + action['context'] = ctx + return action + + @api.multi + def open_completed_sprints(self): + self.ensure_one() + action = self.env.ref_action("project_agile_scrum.open_agile_sprint") + action['name'] = _("Completed Sprints") + ctx = tools.safe_eval(action.get('context', "{}")) + ctx['search_default_completed'] = 1 + ctx['search_default_team_id'] = [self.id] + ctx['default_team_id'] = self.id + action['context'] = ctx + return action diff --git a/project_agile_scrum/models/project_workflow_publisher.py b/project_agile_scrum/models/project_workflow_publisher.py new file mode 100644 index 0000000..bf2e9b5 --- /dev/null +++ b/project_agile_scrum/models/project_workflow_publisher.py @@ -0,0 +1,29 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models + + +class ProjectWorkflowPublisher(models.AbstractModel): + _inherit = 'project.workflow.publisher' + + def update_board_no_switch(self, old, board, wkf_states, status_tree): + super(ProjectWorkflowPublisher, self).update_board_no_switch( + old, board, wkf_states, status_tree + ) + + if board.type != 'scrum': + return + + states = [] + for state in board.scrum_backlog_state_ids: + if state.workflow_id != old: + states.append(state.id) + else: + wkf_state = wkf_states[state.stage_id.id] + states.append(wkf_state.id) + + if states: + board.write({ + 'scrum_backlog_state_ids': [(6, 0, states)] + }) diff --git a/project_agile_scrum/security/ir.model.access.csv b/project_agile_scrum/security/ir.model.access.csv new file mode 100644 index 0000000..4c62d60 --- /dev/null +++ b/project_agile_scrum/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_project_agile_scrum_sprint_user,project.agile.scrum.sprint,model_project_agile_scrum_sprint,project.group_project_user,1,0,0,0 +access_project_agile_scrum_sprint_manager,project.agile.scrum.sprint,model_project_agile_scrum_sprint,project.group_project_manager,1,1,1,1 diff --git a/project_agile_scrum/static/description/icon.png b/project_agile_scrum/static/description/icon.png new file mode 100644 index 0000000..ce6e7ee Binary files /dev/null and b/project_agile_scrum/static/description/icon.png differ diff --git a/project_agile_scrum/views/menu.xml b/project_agile_scrum/views/menu.xml new file mode 100644 index 0000000..2f7d820 --- /dev/null +++ b/project_agile_scrum/views/menu.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/project_agile_scrum/views/project_agile_board_views.xml b/project_agile_scrum/views/project_agile_board_views.xml new file mode 100644 index 0000000..7dd6dea --- /dev/null +++ b/project_agile_scrum/views/project_agile_board_views.xml @@ -0,0 +1,23 @@ + + + + + agile_board_form + project.agile.board + + + + + + + + diff --git a/project_agile_scrum/views/project_agile_scrum_sprint_views.xml b/project_agile_scrum/views/project_agile_scrum_sprint_views.xml new file mode 100644 index 0000000..67deffb --- /dev/null +++ b/project_agile_scrum/views/project_agile_scrum_sprint_views.xml @@ -0,0 +1,137 @@ + + + + + agile_sprint_tree + project.agile.scrum.sprint + + + + + + + + + + + + + + project.agile.sprint.filter + project.agile.scrum.sprint + + + + + + + + + + + + + + + + + + + + agile_sprint_form + project.agile.scrum.sprint + +
+
+ +
+ + +
+ +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + project.agile.scrum.sprint.kanban + project.agile.scrum.sprint + + + + + + + + + + + + +
+
+
+ + + +
+
+ +
    +
  • +
  • +
  • +
  • +
  • +
+ +
+ + + + + + + + + + Sprint + project.agile.scrum.sprint + kanban,tree,form + {'search_default_gb_status': 1} + + + diff --git a/project_agile_scrum/views/project_agile_team_views.xml b/project_agile_scrum/views/project_agile_team_views.xml new file mode 100644 index 0000000..0f376ec --- /dev/null +++ b/project_agile_scrum/views/project_agile_team_views.xml @@ -0,0 +1,61 @@ + + + + + project_agile_team_form + project.agile.team + + +
+ + + + + +
+ + + + + + + + + + + +
+ + + + + project.agile.team.kanban + project.agile.team + + + + + + + + + + +
  • +
  • +
  • +
  • + +
    +
    + +
    diff --git a/project_agile_scrum/views/project_views.xml b/project_agile_scrum/views/project_views.xml new file mode 100644 index 0000000..35684c6 --- /dev/null +++ b/project_agile_scrum/views/project_views.xml @@ -0,0 +1,20 @@ + + + + + view_task_form2_agile + project.task + + + + + + + + + + + diff --git a/project_agile_scrum/wizards/__init__.py b/project_agile_scrum/wizards/__init__.py new file mode 100644 index 0000000..e588aff --- /dev/null +++ b/project_agile_scrum/wizards/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import add_subtask_wizard diff --git a/project_agile_scrum/wizards/add_subtask_wizard.py b/project_agile_scrum/wizards/add_subtask_wizard.py new file mode 100644 index 0000000..4895e25 --- /dev/null +++ b/project_agile_scrum/wizards/add_subtask_wizard.py @@ -0,0 +1,18 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models + + +class AddSubTaskWizard(models.TransientModel): + _inherit = 'project.sub_task.add_wizard' + + def _populate_sub_task(self): + sub_task_values = super(AddSubTaskWizard, self)._populate_sub_task() + story_type = self.env.ref("project_agile.project_task_type_story") + if self.task_id.type_id.id == story_type.id: + sprint_id = self.task_id.sprint_id + sub_task_values.update({ + 'sprint_id': sprint_id and sprint_id.id or False + }) + + return sub_task_values diff --git a/project_agile_timesheet_category/README.rst b/project_agile_timesheet_category/README.rst new file mode 100644 index 0000000..417170a --- /dev/null +++ b/project_agile_timesheet_category/README.rst @@ -0,0 +1,37 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +=================== +Project Agile Scrum +=================== + +This module enables you to manage your projects by using agile scrum methodology + +Credits +======= + +Contributors +------------ + +* Aleksandar Gajić +* Petar Najman +* Jasmina Nikolić +* Igor Jovanović +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_agile_timesheet_category/__init__.py b/project_agile_timesheet_category/__init__.py new file mode 100644 index 0000000..286acc0 --- /dev/null +++ b/project_agile_timesheet_category/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import wizards + diff --git a/project_agile_timesheet_category/__manifest__.py b/project_agile_timesheet_category/__manifest__.py new file mode 100644 index 0000000..9817e6f --- /dev/null +++ b/project_agile_timesheet_category/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Agile Timesheet Category", + "summary": "Extends project agile with timesheet category.", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_agile", + "project_timesheet_category", + "timesheet_grid", + ], + + "data": [ + "views/timesheet.xml", + "wizards/project_task_worklog_wizard.xml", + ], + + "demo": [], + "qweb": [], + "application": False, +} diff --git a/project_agile_timesheet_category/static/description/icon.png b/project_agile_timesheet_category/static/description/icon.png new file mode 100644 index 0000000..a81b109 Binary files /dev/null and b/project_agile_timesheet_category/static/description/icon.png differ diff --git a/project_agile_timesheet_category/views/timesheet.xml b/project_agile_timesheet_category/views/timesheet.xml new file mode 100644 index 0000000..a467022 --- /dev/null +++ b/project_agile_timesheet_category/views/timesheet.xml @@ -0,0 +1,20 @@ + + + + + + account.analytic.line.form + account.analytic.line + + 4567 + + + + + + + + \ No newline at end of file diff --git a/project_agile_timesheet_category/wizards/__init__.py b/project_agile_timesheet_category/wizards/__init__.py new file mode 100644 index 0000000..2c6413b --- /dev/null +++ b/project_agile_timesheet_category/wizards/__init__.py @@ -0,0 +1 @@ +from . import project_task_worklog_wizard diff --git a/project_agile_timesheet_category/wizards/project_task_worklog_wizard.py b/project_agile_timesheet_category/wizards/project_task_worklog_wizard.py new file mode 100644 index 0000000..744003d --- /dev/null +++ b/project_agile_timesheet_category/wizards/project_task_worklog_wizard.py @@ -0,0 +1,38 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class ProjectTaskWorklogWizard(models.TransientModel): + _inherit = 'project.task.worklog.wizard' + + category_id = fields.Many2one( + comodel_name='project.timesheet.category', + default=lambda self: self.env.user.default_timesheet_category_id, + string='Category', + required=True, + ) + + billable = fields.Selection( + selection=[ + ('yes', 'Yes'), + ('no', 'No'), + ], + default='yes', + required=True, + string='Billable' + ) + + @api.onchange("category_id") + def onchange_category(self): + if self.category_id: + self.billable = self.category_id.billable + + def _prepare_worklog(self): + data = super(ProjectTaskWorklogWizard, self)._prepare_worklog() + + data['category_id'] = self.category_id.id + data['billable'] = self.billable + + return data diff --git a/project_agile_timesheet_category/wizards/project_task_worklog_wizard.xml b/project_agile_timesheet_category/wizards/project_task_worklog_wizard.xml new file mode 100644 index 0000000..94562f2 --- /dev/null +++ b/project_agile_timesheet_category/wizards/project_task_worklog_wizard.xml @@ -0,0 +1,20 @@ + + + + + + Project Task Worklog + project.task.worklog.wizard + + + + + + + + + diff --git a/project_agile_workflow_transitions_by_task_type/README.rst b/project_agile_workflow_transitions_by_task_type/README.rst new file mode 100644 index 0000000..63739ec --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/README.rst @@ -0,0 +1,37 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +=============================================== +Project Agile Workflow Transitions By Task Type +=============================================== + +This module integrates module ``project_workflow_transitions_by_task_type`` with module ``project_agile``. + + +Credits +======= + + +Contributors +------------ + +* Petar Najman +* Aleksandar Gajić +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_agile_workflow_transitions_by_task_type/__init__.py b/project_agile_workflow_transitions_by_task_type/__init__.py new file mode 100644 index 0000000..bda7381 --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models diff --git a/project_agile_workflow_transitions_by_task_type/__manifest__.py b/project_agile_workflow_transitions_by_task_type/__manifest__.py new file mode 100644 index 0000000..3f0929b --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Agile Workflow Transitions By Task Type", + "summary": "Extend project workflow transitions with allowed task types", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_agile", + ], + + "data": [ + "views/project_workflow_views.xml", + ], + "images": [], + "installable": True, +} diff --git a/project_agile_workflow_transitions_by_task_type/models/__init__.py b/project_agile_workflow_transitions_by_task_type/models/__init__.py new file mode 100644 index 0000000..d7c05a1 --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import project_workflow +from . import project_workflow_importer +from . import project_workflow_reader +from . import project_workflow_writer diff --git a/project_agile_workflow_transitions_by_task_type/models/project_workflow.py b/project_agile_workflow_transitions_by_task_type/models/project_workflow.py new file mode 100644 index 0000000..8fc2671 --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/models/project_workflow.py @@ -0,0 +1,33 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields + + +class Workflow(models.Model): + _inherit = 'project.workflow' + + def get_available_transitions(self, task, state): + transitions = super(Workflow, self).get_available_transitions( + task, state + ) + task_types = frozenset([task.type_id.id]) + return [ + x + for x in transitions + if not (x.task_type_ids and + task_types.isdisjoint(x.task_type_ids.ids) + ) + ] + + +class WorkflowTransition(models.Model): + _inherit = 'project.workflow.transition' + + task_type_ids = fields.Many2many( + comodel_name='project.task.type2', + column1='transition_id', + column2='type_id', + relation='project_workflow_transition_task_type_rel', + string='Task Types' + ) diff --git a/project_agile_workflow_transitions_by_task_type/models/project_workflow_importer.py b/project_agile_workflow_transitions_by_task_type/models/project_workflow_importer.py new file mode 100644 index 0000000..f4760b7 --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/models/project_workflow_importer.py @@ -0,0 +1,39 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, tools + + +class WorkflowImporter(models.AbstractModel): + _inherit = 'project.workflow.importer' + + def prepare_transition(self, transition, states): + data = super(WorkflowImporter, self).prepare_transition( + transition, states + ) + data['task_type_ids'] = [ + (6, 0, self.prepare_transition_task_types(transition)) + ] + return data + + def prepare_transition_task_types(self, transition): + task_types = [] + for task_type in transition.get('task_types', []): + task_type_id = self.prepare_transition_task_type(task_type) + if task_type_id: + task_types.append(task_type_id) + return task_types + + def prepare_transition_task_type(self, task_type): + return self.get_task_type_id(task_type['name']) + + @tools.ormcache("task_type_name") + def get_task_type_id(self, task_type_name): + task_types = self.env['project.task.type2'].search([ + ('name', '=', task_type_name) + ]) + + if task_types.exists(): + return task_types.id + + return False diff --git a/project_agile_workflow_transitions_by_task_type/models/project_workflow_reader.py b/project_agile_workflow_transitions_by_task_type/models/project_workflow_reader.py new file mode 100644 index 0000000..653e16c --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/models/project_workflow_reader.py @@ -0,0 +1,86 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models + + +def name(value): + return {'name': value} + + +class XmlWorkflowReader(models.AbstractModel): + _inherit = 'project.workflow.xml.reader' + + def read_transition(self, element): + data = super(XmlWorkflowReader, self).read_transition(element) + + data['task_types'] = self.read_transition_task_types(element) + + return data + + def read_transition_task_types(self, element): + """ + Reads workflow security groups data out of the given xml element. + :param element: The xml element which holds information + about project workflow transitions. + :return: Returns the workflow transitions. + """ + groups = [] + for e in element.iterfind('task-types/task-type'): + groups.append(self.read_transition_task_type(e)) + return groups + + def read_transition_task_type(self, element): + return { + 'name': self.read_string(element, 'name'), + } + + def extend_rng(self, rng_etree): + rng_etree = super(XmlWorkflowReader, self).extend_rng(rng_etree) + root = rng_etree.getroot() + + root.insert(0, self.rng_define_task_types()) + + transition = root.xpath( + "//rng:define[@name='transition']" + "//rng:element[@name='transition']", + namespaces=self._rng_namespace_map + )[0] + + transition.append(self.rng_task_type_element()) + return rng_etree + + def rng_define_task_types(self): + E = self.get_element_maker() + + doc = E.grammar( + E.define( + name('task-type'), + E.element( + name('task-type'), + E.attribute( + name('name'), + E.text() + ) + ) + ) + ) + return doc[0] + + def rng_task_type_element(self): + E = self.get_element_maker() + doc = E.grammar( + E.optional( + E.element( + name('task-types'), + E.optional( + E.oneOrMore( + E.ref( + name("task-type") + ) + ) + ) + ) + ) + ) + return doc[0] diff --git a/project_agile_workflow_transitions_by_task_type/models/project_workflow_writer.py b/project_agile_workflow_transitions_by_task_type/models/project_workflow_writer.py new file mode 100644 index 0000000..ee78641 --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/models/project_workflow_writer.py @@ -0,0 +1,64 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from lxml import etree +from odoo import models + + +class XmlWorkflowWriter(models.AbstractModel): + _inherit = 'project.workflow.xml.writer' + + def create_transition_task_types_element(self, parent, transition): + """ + This method creates groups xml element. + :param parent: The parent element of the new groups element. + :param transition: The ``project.workflow`` browse object. + :return: Returns a new groups xml element. + """ + attributes = self.prepare_security_groups_attributes(transition) + return etree.SubElement(parent, 'task-types', attributes) + + def prepare_transition_task_types_attributes(self, transition): + """ + This method prepares attribute values for a ``transitions`` element. + At the moment this method does nothing but it's added here + for possible future usage. + :param transition: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return {} + + def create_transition_task_type_element(self, parent, group): + """ + This method creates transition xml element. + :param parent: The parent element of the new transition element. + :param group: The ``project.workflow.transition`` browse object. + :return: Returns a new transition xml element. + """ + values = self.prepare_transition_task_type_attributes(group) + return etree.SubElement(parent, 'task-type', values) + + def prepare_transition_task_type_attributes(self, task_type): + """ + This method prepares attribute values for a transition element. + :return: Returns dictionary with attribute values. + """ + values = { + 'name': task_type.name, + } + + return values + + def create_transition_element(self, parent, transition): + transition_element = super(XmlWorkflowWriter, self)\ + .create_transition_element(parent, transition) + task_types_element = self.create_transition_task_types_element( + transition_element, transition + ) + + for task_type in transition.task_type_ids: + self.create_transition_task_type_element( + task_types_element, task_type + ) + + return transition_element diff --git a/project_agile_workflow_transitions_by_task_type/static/description/icon.png b/project_agile_workflow_transitions_by_task_type/static/description/icon.png new file mode 100644 index 0000000..86af07c Binary files /dev/null and b/project_agile_workflow_transitions_by_task_type/static/description/icon.png differ diff --git a/project_agile_workflow_transitions_by_task_type/views/project_workflow_views.xml b/project_agile_workflow_transitions_by_task_type/views/project_workflow_views.xml new file mode 100644 index 0000000..3024b97 --- /dev/null +++ b/project_agile_workflow_transitions_by_task_type/views/project_workflow_views.xml @@ -0,0 +1,32 @@ + + + + + project.workflow.transition.form + project.workflow.transition + + + + + + + + + + + + project.workflow.transition.form + project.workflow.transition + + + + + + + + + + diff --git a/project_git/README.rst b/project_git/README.rst new file mode 100644 index 0000000..017addb --- /dev/null +++ b/project_git/README.rst @@ -0,0 +1,42 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +=========== +Project GIT +=========== + +Base module for development of other modules which will bring integration with specific git services, like: GitHub, BitBucket, GitLab, etc. + + +Usage +===== + +TBD + +Credits +======= + + +Contributors +------------ + +* Sladjan Kantar +* Petar Najman +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_git/__init__.py b/project_git/__init__.py new file mode 100644 index 0000000..193cfcd --- /dev/null +++ b/project_git/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import utils +from . import models +from . import controller diff --git a/project_git/__manifest__.py b/project_git/__manifest__.py new file mode 100644 index 0000000..8fd7d0f --- /dev/null +++ b/project_git/__manifest__.py @@ -0,0 +1,36 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Git", + "summary": "Integrates your projects with git based services", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_key", + "web_widget_image_url", + ], + "data": [ + "data/mail_message.xml", + + "security/ir.model.access.csv", + + "views/project_git_commit_views.xml", + "views/project_git_user_views.xml", + "views/project_git_branch_views.xml", + "views/project_git_repository_views.xml", + "views/project_project_views.xml", + "views/project_task_views.xml", + "views/menu_views.xml", + ], + + "demo": [], + "qweb": [ + "static/src/xml/agile_git.xml", + ], + "application": False, + "installable": True, +} diff --git a/project_git/controller/__init__.py b/project_git/controller/__init__.py new file mode 100644 index 0000000..6746e66 --- /dev/null +++ b/project_git/controller/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import controller diff --git a/project_git/controller/controller.py b/project_git/controller/controller.py new file mode 100644 index 0000000..5790e5f --- /dev/null +++ b/project_git/controller/controller.py @@ -0,0 +1,425 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import re +import json +import logging +from abc import ABCMeta + +from odoo import http + +from ..utils.utils import hmac_new, digest_compare + +_logger = logging.getLogger(__name__) + + +class GitContext(object): + __metaclass__ = ABCMeta + + def __init__(self, type, token, raw_payload): + self._request = http.request + self._type = type + self._token = token + self._raw_payload = raw_payload + self._repository = False + + self._header = False + self._payload = False + + @property + def request(self): + return self._request + + @property + def env(self): + return self._request.env + + @property + def type(self): + return self._type + + @property + def token(self): + return self._token + + @property + def repository(self): + return self._repository + + @repository.setter + def repository(self, repository): + self._repository = repository + + @property + def parser(self): + return self.env['project.git.payload.parser'] + + @property + def header(self): + if not self._header: + self._header = self.parser.parse_header(self) + return self._header + + @property + def signature(self): + return self.header['signature'] + + @property + def has_signature(self): + return 'signature' in self.header + + @property + def delivery(self): + return self.header['delivery'] + + @property + def raw_payload(self): + return self._raw_payload + + @property + def payload(self): + if not self._payload: + self._payload = self.parser.parse(self) + return self._payload + + @property + def action_type(self): + return self.header['action_type'] + + @property + def has_action(self): + return self.header['action_type'] + + @property + def action(self): + return self.header['action'] + + def validate_payload(self, payload): + payload_digest = hmac_new(self.repository.secret, payload, True) + return digest_compare(payload_digest, self.signature) + + def not_found(self): + if isinstance(self._request, http.HttpRequest): + return self._request.not_found() + + return json.dumps({"response": "Not found!"}) + + def render(self, template_xml_id, values): + return self.env.ref(template_xml_id).render(values) + + def render_task_subject(self, values): + return self.render( + 'project_git.task_code_committed_notification_subject', values + ) + + def render_task_body(self, values): + return self.render( + 'project_git.task_code_committed_notification_body', values + ) + + def render_orphan_subject(self, values): + return self.render( + 'project_git.orphan_code_committed_notification_subject', values + ) + + def render_orphan_body(self, values): + return self.render( + 'project_git.orphan_code_committed_notification_body', values + ) + + +class GitController(http.Controller): + + def process_request(self, context): + repository = context.env["project.git.repository"].sudo().search([ + ("type", "=", context.type), + ("odoo_uuid", "=", context.token), + ], limit=1) + + if not repository: + return context.not_found() + + context.repository = repository + + if not self.validate_payload(context): + return context.not_found() + + return getattr(self, context.action)(context) + + def find_repository(self, context): + repositories = context.env["project.git.repository"].sudo().search([ + ("type", "=", context.type) + ]) + for repository in repositories: + if context.token_match(repository): + return repository + return False + + def validate_payload(self, context): + if not (context.has_action and context.repository.project_id): + return False + + validation_method_name = "validate_%s_payload" % context.type + if hasattr(self, validation_method_name): + return getattr(self, validation_method_name)(context) + + return True + + def reponse_ok(self): + return "OK" + + def reponse_nok(self): + return "NOK" + + def git_ping(self, context): + return "PONG" + + def git_delete(self, context): + for branch_data in context.payload['braches']: + branch = context.env["project.git.branch"].sudo().search([ + ("name", "=", branch_data["name"]), + ("repository_id", "=", branch_data["repository_id"]), + ("type", "=", branch_data["type"]) + ], limit=1) + + if branch: + branch.unlink() + return self.reponse_ok() + + def compile_task_key_pattern(self, context): + pattern = context.repository.project_id.key + " ?-? ?[0-9]+" + return re.compile(pattern, re.IGNORECASE) + + def git_push(self, context): + try: + GitUser = context.env["project.git.user"].sudo() + ResUser = context.env['res.users'].sudo() + + # ============================================================ + # UPDATE/CREATE Repository owner + # ------------------------------------------------------------ + + try: + payload = context.payload + except Exception as ex: + import traceback + _logger.error( + "Something went wrong while parsing " + "repository web hook payload " + "(repository_id='%s', type='%s', delivery='%s'): %s" + "\n stack_trace:%s" % ( + context.repository.id, context.type, context.delivery, + str(ex), traceback.format_exc() + ) + ) + return self.reponse_nok() + + repository_data = payload['repository'] + + repository_owner_data = repository_data.pop("owner") + + if 'email' in repository_owner_data: + repository_owner_user = ResUser.search( + [("email", "=", repository_owner_data["email"])], limit=1 + ) + repository_owner_data['user_id'] = \ + repository_owner_user and repository_owner_user.id or False + + repository_owner = GitUser.search([ + ("username", '=', repository_owner_data["username"]), + ("type", "=", repository_owner_data['type']) + ], limit=1) + + if repository_owner: + repository_owner.write(repository_owner_data) + else: + repository_owner = GitUser.create(repository_owner_data) + # ----------------------------------------------------------- + + # ========================================================== + # UPDATE/CREATE Repository + # ---------------------------------------------------------- + repository_data = context.payload['repository'] + repository_data["user_id"] = repository_owner.id + context.repository.sudo().write(repository_data) + # ---------------------------------------------------------- + + task_commits = {} + orphan_commits = [] + + # ========================================================== + # UPDATE/CREATE Branch + # ---------------------------------------------------------- + for branch_data in context.payload['branches']: + self.process_branch(branch_data, + task_commits, + orphan_commits, + context) + + # CREATE email notification for every affected task + sender = context.payload['sender'] + sender = GitUser.search([ + ("username", "=", sender["username"]) + ], limit=1) + + if len(task_commits): + self.send_task_commits(task_commits, sender. context) + + if len(orphan_commits): + self.send_orphan_commits(orphan_commits, sender, context) + + return self.reponse_ok() + except BaseException as ex: + import traceback + _logger.error( + "Something went wrong while processing repository web hook " + "(repository_id='%s', type='%s', delivery='%s'): %s" + "\n stack_trace:%s" % ( + context.repository.id, context.type, context.delivery, + str(ex), traceback.format_exc() + ) + ) + return self.reponse_nok() + + def process_branch(self, branch_data, task_commits, orphan_commits, + context): + + def sanitize_key(key): + key = key.upper() + pk_len = len(context.repository.project_id.key) + key = key.replace(" ", "") + if key[pk_len] != '-': + key = key[:pk_len] + '-' + key[pk_len:] + return key + + def find_task_keys(pattern, message): + task_keys = [] + for key in re.findall(pattern, message): + task_keys.append(sanitize_key(key)) + return task_keys + + GitBranch = context.env["project.git.branch"].sudo() + GitCommit = context.env["project.git.commit"].sudo() + ProjectTask = context.env["project.task"].sudo() + GitUser = context.env["project.git.user"].sudo() + ResUser = context.env['res.users'].sudo() + + # Preparing regular expression used to search for task keys + # inside of commit message + task_key_pattern = self.compile_task_key_pattern(context) + + commits = branch_data.pop('commits') + + branch = GitBranch.search([ + ("name", '=', branch_data["name"]), + ("repository_id", "=", branch_data["repository_id"]), + ("type", "=", branch_data["type"]) + ], limit=1) + + if branch: + branch.write(branch_data) + else: + branch = GitBranch.create(branch_data) + + for commit_data in commits: + task_keys = find_task_keys( + task_key_pattern, commit_data["message"] + ) + tasks = len(task_keys) and ProjectTask.search([ + ("key", "in", task_keys) + ]) or [] + + # ---------------------------------------------------- + # UPDATE/CREATE Commit Author + # ---------------------------------------------------- + commit_author_data = commit_data.pop('author') + commit_author = GitUser.search([ + ("email", '=', commit_author_data["email"]), + ("type", "=", commit_author_data["type"]) + ], limit=1) + + author_user = ResUser.search([ + ('email', '=', commit_author_data["email"]) + ], limit=1) + if author_user: + commit_author["user_id"] = author_user.id + + if commit_author: + commit_author.write(commit_author_data) + else: + commit_author = GitUser.create(commit_author_data) + # ---------------------------------------------------- + + commit_data["branch_id"] = branch.id + commit_data["author_id"] = commit_author.id + commit_data["task_ids"] = \ + len(tasks) and [(6, 0, tasks.ids)] or [] + + commit = GitCommit.search([ + ("name", '=', commit_data["name"]), + ("type", "=", commit_data["type"]) + ], limit=1) + + if commit: + commit.write(commit_data) + else: + commit = GitCommit.create(commit_data) + + if commit.is_orphan(): + orphan_commits.append(commit) + else: + for task in tasks: + item = task_commits.get( + task.id, {'task': task, 'commits': []} + ) + item['commits'].append(commit) + task_commits[task.id] = item + + def send_task_commits(self, task_commits, sender, context): + values = { + "context": context, + "sender": sender, + } + + subtype = context.env.ref("project_git.mt_task_code_committed").id + author = sender.user_id and sender.user_id.partner_id.id or False + email_from = "%s <%s>" % (sender.name, sender.email) + + for item in task_commits.values(): + task = item['task'] + commits = item['commits'] + + values['task'] = task + values['commits'] = commits + + subject = context.render_task_subject(values) + body = context.render_task_body(values) + + task.message_post( + subject=subject, + body=body, + subtype_id=subtype, + author_id=author, + email_from=email_from, + ) + + def send_orphan_commits(self, orphan_commits, sender, context): + values = { + "context": context, + "sender": sender, + 'commits': orphan_commits, + } + + author = sender.user_id and sender.user_id.partner_id.id or False + email_from = "%s <%s>" % (sender.name, sender.email) + subtype = context.env.ref("project_git.mt_project_code_committed").id + subject = context.render_orphan_subject(values) + body = context.render_orphan_body(values) + + context.repository.project_id.message_post( + subject=subject, + body=body, + subtype_id=subtype, + author_id=author, + email_from=email_from, + ) diff --git a/project_git/data/mail_message.xml b/project_git/data/mail_message.xml new file mode 100644 index 0000000..49caa46 --- /dev/null +++ b/project_git/data/mail_message.xml @@ -0,0 +1,165 @@ + + + + + + + Code committed + 10 + project.task + + Code Committed + commit_ids + + + + + + Code committed + 9 + project.project + + + Code Committed + project_id + + + + + + + + + + + + + + + diff --git a/project_git/models/__init__.py b/project_git/models/__init__.py new file mode 100644 index 0000000..2728d24 --- /dev/null +++ b/project_git/models/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import git_user +from . import git_repository +from . import git_branch +from . import git_commit +from . import git_parser +from . import project_project +from . import project_task diff --git a/project_git/models/git_branch.py b/project_git/models/git_branch.py new file mode 100644 index 0000000..61f7900 --- /dev/null +++ b/project_git/models/git_branch.py @@ -0,0 +1,90 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api +from ..utils.utils import get_image_type, get_avatar + + +class GitBranch(models.Model): + _name = "project.git.branch" + + name = fields.Char( + string="Name", + size=256, + required=True, + index=True, + ) + + url = fields.Char( + string="URL", + required=True + ) + + uuid = fields.Char( + string="UUID", + size=256 + ) + + repository_id = fields.Many2one( + comodel_name="project.git.repository", + string="Repository", + ondelete="cascade", + index=True, + ) + + project_id = fields.Many2one( + comodel_name="project.project", + string="Project", + related="repository_id.project_id", + store=True + ) + + type = fields.Selection( + selection=[], + string="Type", + required=False, + related="repository_id.type", + store=True, + index=True, + ) + + commit_ids = fields.One2many( + comodel_name="project.git.commit", + string="Commits", + inverse_name="branch_id" + ) + + commit_count = fields.Integer( + compute="_compute_commit_count" + ) + + avatar = fields.Char( + string="Avatar", + compute="_compute_avatar", + ) + + image_type = fields.Char( + string="Type", + compute="_compute_image_type" + ) + + user_id = fields.Many2one( + comodel_name="project.git.user", + string="Owner", + ondelete="cascade", + ) + + @api.multi + @api.depends("commit_ids") + def _compute_commit_count(self): + for rec in self: + rec.commit_count = len(rec.commit_ids) + + @api.multi + def _compute_avatar(self): + get_avatar(self, 'branch') + + @api.multi + @api.depends("type") + def _compute_image_type(self): + get_image_type(self) diff --git a/project_git/models/git_commit.py b/project_git/models/git_commit.py new file mode 100644 index 0000000..65e411b --- /dev/null +++ b/project_git/models/git_commit.py @@ -0,0 +1,144 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, _ +from ..utils.utils import get_image_type, get_avatar + + +class GitCommit(models.Model): + _name = "project.git.commit" + + name = fields.Char( + string="Name", + size=256, + required=True, + index=True, + ) + + author_id = fields.Many2one( + comodel_name="project.git.user", + string="Author", + required=True, + ondelete="cascade", + index=True, + ) + + message = fields.Text( + string="Message", + required=True, + ) + + message_short = fields.Text( + compute="_compute_message_short" + ) + + url = fields.Char( + string="URL", + required=True + ) + + date = fields.Datetime( + string="Date", + required=True + ) + + branch_id = fields.Many2one( + comodel_name="project.git.branch", + string="Branch", + ondelete="cascade", + index=True, + ) + + repository_id = fields.Many2one( + comodel_name="project.git.repository", + related="branch_id.repository_id", + string="Repository", + readonly=True, + store=True, + ) + + task_ids = fields.Many2many( + comodel_name="project.task", + id1="commit_id", + id2="task_id", + relation="task_commit_rel", + string="Tasks" + ) + + task_count = fields.Integer( + compute="_compute_task_count" + ) + + author_username = fields.Char( + string="Username", + related="author_id.username" + ) + + author_avatar = fields.Char( + string="Avatar", + related="author_id.avatar" + ) + + type = fields.Selection( + selection=[], + string="Type", + required=False, + related="branch_id.type", + store=True, + index=True, + ) + + image_type = fields.Char( + string="Type", + compute="_compute_image_type" + ) + + avatar = fields.Char( + string="Avatar", + compute="_compute_avatar", + ) + + @api.multi + def _compute_message_short(self): + for rec in self: + rec.message_short = rec.message and rec.message[:75] + " ..." or "" + + @api.multi + @api.depends("task_ids") + def _compute_task_count(self): + for rec in self: + rec.task_count = len(rec.task_ids) + + @api.multi + @api.depends("type") + def _compute_image_type(self): + get_image_type(self) + + @api.multi + def calculate_number(self): + from random import randint + return randint(0, 10) + + @api.multi + def _compute_avatar(self): + get_avatar(self, 'commit') + + def is_orphan(self): + return len(self.task_ids) == 0 + + @api.multi + def open_tasks(self): + self.ensure_one() + + action = self.env['ir.actions.act_window'].for_xml_id( + 'project', 'act_project_project_2_project_task_all' + ) + + action['display_name'] = action['name'] = _("Commit tasks") + action['context'] = { + 'group_by': 'stage_id', + 'default_project_id': self.repository_id.project_id.id, + 'create': False, + } + action['domain'] = [('commit_ids', 'in', [self.id])] + return action diff --git a/project_git/models/git_parser.py b/project_git/models/git_parser.py new file mode 100644 index 0000000..369b23c --- /dev/null +++ b/project_git/models/git_parser.py @@ -0,0 +1,30 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from odoo import models, exceptions, _ + + +class GitPayloadParser(models.AbstractModel): + _name = 'project.git.payload.parser' + + def parse_header(self, context): + parse_method_name = "parse_%s_header" % context.type + + if not hasattr(self, parse_method_name): + raise exceptions.ValidationError( + _("Unable to find header parsing method for '%s'") % + context.type + ) + + return getattr(self, parse_method_name)( + context.type, context.raw_payload + ) + + def parse(self, context): + parse_method_name = "parse_%s_payload" % context.type + + if not hasattr(self, parse_method_name): + raise exceptions.ValidationError( + _("Unable to find parsing method for '%s'") % (context.type, ) + ) + + return getattr(self, parse_method_name)(context) diff --git a/project_git/models/git_repository.py b/project_git/models/git_repository.py new file mode 100644 index 0000000..fe6b31c --- /dev/null +++ b/project_git/models/git_repository.py @@ -0,0 +1,155 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import string +import random +import uuid + +from odoo import models, fields, api +from ..utils.utils import get_image_type, get_avatar, urljoin + + +class GitRepository(models.Model): + _name = 'project.git.repository' + + def _default_secret(self): + alphabet = string.ascii_letters + string.digits + secret = ''.join( + random.SystemRandom().choice(alphabet) for i in range(30) + ) + return secret + + name = fields.Char( + string='Name', + size=256, + ) + + repo_name = fields.Char(related="name") + + uuid = fields.Char( + string='UUID', + size=256, + index=True, + ) + + full_name = fields.Char( + string='Full Name', + size=256 + ) + + odoo_uuid = fields.Char( + string='UUID', + size=256, + default=lambda *a: uuid.uuid4(), + index=True, + ) + + avatar = fields.Char( + string='Avatar', + compute='_compute_avatar', + ) + + url = fields.Char( + string='URL', + default='#' + ) + + project_id = fields.Many2one( + comodel_name='project.project', + string='Project', + required=True, + index=True, + ) + + branch_ids = fields.One2many( + comodel_name='project.git.branch', + string='Branches', + inverse_name='repository_id' + ) + + branch_count = fields.Integer( + compute="_compute_branch_count" + ) + + user_id = fields.Many2one( + comodel_name='project.git.user', + string='Owner', + ondelete='cascade', + index=True, + ) + + type = fields.Selection( + selection=[], + string='Type', + index=True, + ) + + webhook_url = fields.Char( + string='Webhook Url', + compute='_compute_webhook_url', + ) + + secret = fields.Char( + default=lambda s: s._default_secret() + ) + + use_secret = fields.Boolean( + compute='_compute_use_secret' + ) + + image_type = fields.Char( + string='Type', + compute='_compute_image_type' + ) + + @api.multi + @api.depends("branch_ids") + def _compute_branch_count(self): + for rec in self: + rec.branch_count = len(rec.branch_ids) + + @api.multi + @api.depends('type') + def _compute_avatar(self): + get_avatar(self, 'repository') + + @api.multi + @api.depends('type') + def _compute_use_secret(self): + secret_types = self._secret_visible_for_types() + for rec in self: + rec.use_secret = rec.type in secret_types + + def _secret_visible_for_types(self): + return [] + + @api.multi + @api.depends('type') + def _compute_image_type(self): + get_image_type(self) + + @api.onchange('project_id', 'type') + def _onchange_name_components(self): + if not self.project_id or self.type: + return + self.name = '%s - %s' % ( + self.project_id.key, self._get_selection_label(self.type) + ) + + def _get_selection_label(self, type): + for item in self._fields['type'].selection: + if item[0] == type: + return item[1] + return '' + + @api.multi + @api.depends('odoo_uuid', 'type') + def _compute_webhook_url(self): + base_url = self.env['ir.config_parameter']\ + .sudo()\ + .get_param('web.base.url') + for record in self: + if record.type: + record.webhook_url = urljoin( + base_url, record.type, 'payload', record.odoo_uuid + ) diff --git a/project_git/models/git_user.py b/project_git/models/git_user.py new file mode 100644 index 0000000..ce9fa87 --- /dev/null +++ b/project_git/models/git_user.py @@ -0,0 +1,66 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api +from ..utils.utils import get_image_type + + +class GitUser(models.Model): + _name = "project.git.user" + + name = fields.Char( + string="Name", + size=256 + ) + + username = fields.Char( + string="Username", + size=256, + index=True, + ) + + email = fields.Char( + string="Email", + size=128, + index=True, + ) + + uuid = fields.Char( + string="UUID", + size=256 + ) + + avatar = fields.Char( + string="Avatar" + ) + + url = fields.Char( + string="URL" + ) + + type = fields.Selection( + selection=[], + string="Type", + index=True, + ) + + user_id = fields.Many2one( + comodel_name='res.users', + string='Related User', + ) + + repository_ids = fields.One2many( + comodel_name="project.git.repository", + string="Repositories", + inverse_name="user_id" + ) + + image_type = fields.Char( + string="Type", + compute="_compute_image_type" + ) + + @api.multi + @api.depends("type") + def _compute_image_type(self): + get_image_type(self) diff --git a/project_git/models/project_project.py b/project_git/models/project_project.py new file mode 100644 index 0000000..fa19d57 --- /dev/null +++ b/project_git/models/project_project.py @@ -0,0 +1,14 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields + + +class ProjectProject(models.Model): + _inherit = "project.project" + + repository_ids = fields.One2many( + comodel_name="project.git.repository", + string="Repositories", + inverse_name="project_id" + ) diff --git a/project_git/models/project_task.py b/project_git/models/project_task.py new file mode 100644 index 0000000..276e4e4 --- /dev/null +++ b/project_git/models/project_task.py @@ -0,0 +1,36 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class ProjectTask(models.Model): + _inherit = "project.task" + + commit_ids = fields.Many2many( + comodel_name="project.git.commit", + id1="task_id", + id2="commit_id", + relation="task_commit_rel", + string="Commits" + ) + + commits_count = fields.Integer( + string="Commits Count", + compute="_compute_commits_count", + ) + + @api.multi + @api.depends('commit_ids') + def _compute_commits_count(self): + for record in self: + record.commits_count = len(record.commit_ids) + + @api.multi + def open_commits(self): + action = self.env["ir.actions.act_window"].for_xml_id( + "project_git", + "action_git_commit", + ) + action["domain"] = [("task_ids", "in", [self.id])] + return action diff --git a/project_git/security/ir.model.access.csv b/project_git/security/ir.model.access.csv new file mode 100644 index 0000000..6bfd27e --- /dev/null +++ b/project_git/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_project_git_commit_user,project.git.commit,model_project_git_commit,project.group_project_user,1,1,1,1 +access_project_git_branch_user,project.git.branch,model_project_git_branch,project.group_project_user,1,1,1,1 +access_project_git_repository_user,project.git.repository,model_project_git_repository,project.group_project_user,1,1,1,1 +access_project_git_user_user,project.git.user,model_project_git_user,project.group_project_user,1,1,1,1 diff --git a/project_git/static/description/icon.png b/project_git/static/description/icon.png new file mode 100644 index 0000000..3f55f83 Binary files /dev/null and b/project_git/static/description/icon.png differ diff --git a/project_git/static/src/img/branch.png b/project_git/static/src/img/branch.png new file mode 100644 index 0000000..bd9a417 Binary files /dev/null and b/project_git/static/src/img/branch.png differ diff --git a/project_git/static/src/img/commit.png b/project_git/static/src/img/commit.png new file mode 100644 index 0000000..7f4411f Binary files /dev/null and b/project_git/static/src/img/commit.png differ diff --git a/project_git/static/src/img/git.png b/project_git/static/src/img/git.png new file mode 100644 index 0000000..effbe0b Binary files /dev/null and b/project_git/static/src/img/git.png differ diff --git a/project_git/static/src/img/repository.png b/project_git/static/src/img/repository.png new file mode 100644 index 0000000..5d72e73 Binary files /dev/null and b/project_git/static/src/img/repository.png differ diff --git a/project_git/utils/__init__.py b/project_git/utils/__init__.py new file mode 100644 index 0000000..dcf76f3 --- /dev/null +++ b/project_git/utils/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import utils +from . import mail diff --git a/project_git/utils/mail.py b/project_git/utils/mail.py new file mode 100644 index 0000000..6b3639f --- /dev/null +++ b/project_git/utils/mail.py @@ -0,0 +1,8 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo.tools.mail import _Cleaner + +_Cleaner._style_whitelist.append("vertical-align") +_Cleaner._style_whitelist.append("display") +_Cleaner._style_whitelist.append("text-decoration") diff --git a/project_git/utils/utils.py b/project_git/utils/utils.py new file mode 100644 index 0000000..af5c812 --- /dev/null +++ b/project_git/utils/utils.py @@ -0,0 +1,48 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import os +import urllib.parse +import hmac + +from hashlib import sha1 +from passlib.utils import consteq + + +def urljoin(base_url, *args): + postfix = os.path.join(*args) + return urllib.parse.urljoin(base_url, postfix) + + +def get_image_type(self): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + for record in self: + if not record.type: + continue + record.image_type = "{}/project_git_{}/static/src/img/{}.png".format( + base_url, record.type, record.type + ) + + +def get_avatar(self, name): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + for record in self: + record.avatar = urljoin( + base_url, 'project_git', 'static', 'src', 'img', '%s.png' % name + ) + + +def hmac_new(secret, message, add_sha1=False): + digest = hmac.new( + secret.encode('utf-8'), + message.encode('utf-8'), + sha1 + ).hexdigest() + + if add_sha1: + digest = 'sha1=' + digest + return digest + + +def digest_compare(left, right): + return consteq(left.encode('utf-8'), right.encode('utf-8')) diff --git a/project_git/views/menu_views.xml b/project_git/views/menu_views.xml new file mode 100644 index 0000000..36c0452 --- /dev/null +++ b/project_git/views/menu_views.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/project_git/views/project_git_branch_views.xml b/project_git/views/project_git_branch_views.xml new file mode 100644 index 0000000..b2c3dab --- /dev/null +++ b/project_git/views/project_git_branch_views.xml @@ -0,0 +1,145 @@ + + + + + project.git.branch.form + project.git.branch + form + +
    + +
    + +
    + +
    + +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + project.git.branch.tree + project.git.branch + tree + + + + + + + + + + + + + project.git.branch.kanban + project.git.branch + kanban + + + + + + + + + + +
    + +
    +
    + +
    +
    +
    + +
    +
    + + +
    +
    +
    + + + + + + + + project.git.branch.search + project.git.branch + search + + + + + + + + + + + + + + + + Branches + project.git.branch + form + kanban,tree,form + + {'search_default_group_by_repository': 1} + + Git Branches + + + + Branches + project.git.branch + form + kanban,tree,form + + {'search_default_repository_id': [active_id]} + + Git Branches + + diff --git a/project_git/views/project_git_commit_views.xml b/project_git/views/project_git_commit_views.xml new file mode 100644 index 0000000..0c89e9b --- /dev/null +++ b/project_git/views/project_git_commit_views.xml @@ -0,0 +1,151 @@ + + + + + project.git.commit.form + project.git.commit + form + +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    + + + + + + + + + + +
    +
    +
    +
    + + + project.git.commit.tree + project.git.commit + tree + + + + + + + + + + + + + + project.git.commit.kanban + project.git.commit + kanban + + + + + + + + + + + + +
    + +
    + + +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + project.git.commit.search + project.git.commit + search + + + + + + + + + + + + + + + + + + + + Commits + project.git.commit + form + kanban,tree,form + + {'search_default_group_by_branch': 1} + + Recent activity + + + + Branch Commits + project.git.commit + form + kanban,tree,form + + {'search_default_branch_id': [active_id]} + + Recent activity + +
    diff --git a/project_git/views/project_git_repository_views.xml b/project_git/views/project_git_repository_views.xml new file mode 100644 index 0000000..75ce22d --- /dev/null +++ b/project_git/views/project_git_repository_views.xml @@ -0,0 +1,148 @@ + + + + + project.git.repository.form + project.git.repository + form + +
    + +
    + +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + + project.git.repository.tree + project.git.repository + tree + + + + + + + + + + + + + + + project.git.repository.kanban + project.git.repository + kanban + + + + + + + + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + + + + + + + + project.git.repository.search + project.git.repository + search + + + + + + + + + + + + + + + + + + Repositories + project.git.repository + form + kanban,tree,form + + {'search_default_group_by_type': 1} + + Git Repositories + + diff --git a/project_git/views/project_git_user_views.xml b/project_git/views/project_git_user_views.xml new file mode 100644 index 0000000..705bc98 --- /dev/null +++ b/project_git/views/project_git_user_views.xml @@ -0,0 +1,159 @@ + + + + + project.git.user.form + project.git.user + form + +
    + +
    + +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + project.git.user.tree + project.git.user + tree + + + + + + + + + + + + + + project.git.user.kanban + project.git.user + kanban + + + + + + + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + + + + + + + + project.git.user.search + project.git.user + search + + + + + + + + + + + + + + + + + Users + project.git.user + form + kanban,tree,form + + {'search_default_group_by_type': 1} + + Git Users + + diff --git a/project_git/views/project_project_views.xml b/project_git/views/project_project_views.xml new file mode 100644 index 0000000..5d177ea --- /dev/null +++ b/project_git/views/project_project_views.xml @@ -0,0 +1,50 @@ + + + + + project.git.project.project.form.inherit + project.project + + + + + + + + + + + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + + + + + + + + + diff --git a/project_git/views/project_task_views.xml b/project_git/views/project_task_views.xml new file mode 100644 index 0000000..c22eed9 --- /dev/null +++ b/project_git/views/project_task_views.xml @@ -0,0 +1,23 @@ + + + + + project.git.project.task.form.inherit + project.task + + + + + + + diff --git a/project_git_bitbucket/README.rst b/project_git_bitbucket/README.rst new file mode 100644 index 0000000..c2f685f --- /dev/null +++ b/project_git_bitbucket/README.rst @@ -0,0 +1,37 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +================= +Project Bitbucket +================= + +This module extends ``project_git`` module with BitBucket integration. + + +Credits +======= + + +Contributors +------------ + +* Sladjan Kantar +* Petar Najman +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_git_bitbucket/__init__.py b/project_git_bitbucket/__init__.py new file mode 100644 index 0000000..f25cd71 --- /dev/null +++ b/project_git_bitbucket/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models +from . import controllers diff --git a/project_git_bitbucket/__manifest__.py b/project_git_bitbucket/__manifest__.py new file mode 100644 index 0000000..ea0ea45 --- /dev/null +++ b/project_git_bitbucket/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project BitBucket Integration", + "summary": "Enables you to integrate your projects with BitBucket", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_git" + ], + "data": [ + "views/project_git_bitbucket_views.xml" + ], + + "demo": [], + "qweb": [], + "application": True, +} diff --git a/project_git_bitbucket/controllers/__init__.py b/project_git_bitbucket/controllers/__init__.py new file mode 100644 index 0000000..b3b8a0d --- /dev/null +++ b/project_git_bitbucket/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import bitbucket diff --git a/project_git_bitbucket/controllers/bitbucket.py b/project_git_bitbucket/controllers/bitbucket.py new file mode 100644 index 0000000..1359ff0 --- /dev/null +++ b/project_git_bitbucket/controllers/bitbucket.py @@ -0,0 +1,25 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import http +from odoo.addons.project_git.controller.controller import \ + GitController, GitContext + + +class BitBucketContext(GitContext): + def __init__(self, token, payload): + GitContext.__init__(self, 'bitbucket', token, payload) + + +class BitBucketController(GitController): + + # Payload verification thread: + # https://bitbucket.org/site/master/issues/12195/webhook-hmac-signature-security-issue + + @http.route([ + '/bitbucket/payload/' + ], type='json', auth='public', website=True) + def process_request_bitbucket(self, token, *args, **kw): + return self.process_request( + BitBucketContext(token, http.request.jsonrequest) + ) diff --git a/project_git_bitbucket/models/__init__.py b/project_git_bitbucket/models/__init__.py new file mode 100644 index 0000000..b3b8a0d --- /dev/null +++ b/project_git_bitbucket/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import bitbucket diff --git a/project_git_bitbucket/models/bitbucket.py b/project_git_bitbucket/models/bitbucket.py new file mode 100644 index 0000000..bcf4899 --- /dev/null +++ b/project_git_bitbucket/models/bitbucket.py @@ -0,0 +1,185 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import re +from odoo import models, fields, http + +TYPE = [("bitbucket", "BitBucket")] + + +class GitUser(models.Model): + _inherit = "project.git.user" + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitRepository(models.Model): + _inherit = "project.git.repository" + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitCommit(models.Model): + _inherit = "project.git.commit" + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitBranch(models.Model): + _inherit = "project.git.branch" + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitPayloadParser(models.AbstractModel): + _inherit = "project.git.payload.parser" + + # ------------------------------------------- + # Header + # ------------------------------------------- + def parse_bitbucket_header(self, type, raw_payload): + headers = http.request.httprequest.headers + action_type = self._map_bitbucket_action_type(headers) + + data = { + "event": headers.get("X-Event-Key"), + "delivery": headers.get("X-Request-UUID"), + "action_type": action_type, + "action": self._format_action_name(action_type), + } + + return data + + def _map_bitbucket_action_type(self, headers): + event = headers.get("X-Event-Key") + + if event not in self._bitbucket_supported_event_types(): + return False + + event = event.split(":")[1] + return event + + def _format_action_name(self, action_type): + return "git_%s" % (action_type,) + + def _bitbucket_supported_event_types(self): + return ["repo:push"] + + # ------------------------------------------- + # Payload + # ------------------------------------------- + def parse_bitbucket_payload(self, context): + method_name = "parse_bitbucket_%s" % context.action_type + parse_event_method = getattr(self, method_name) + return parse_event_method(context) + + # ------------------------------------------- + # Action Push + # ------------------------------------------- + def parse_bitbucket_push(self, context): + return { + "repository": self.parse_bitbucket_repository(context), + "branches": self.parse_bitbucket_branches(context), + "sender": self.parse_bitbucket_sender(context) + } + + # ------------------------------------------- + # Action Delete + # ------------------------------------------- + def parse_bitbucket_delete(self, context): + return { + "branches": self.parse_gitlab_branches(context, False) + } + + # ------------------------------------------- + # Paring methods + # ------------------------------------------- + def parse_bitbucket_branches(self, context, commits=True): + branches = [] + for branch in context.raw_payload["push"]["changes"]: + branch_data = self.parse_bitbucket_branch(context, branch, commits) + branches.append(branch_data) + return branches + + def parse_bitbucket_branch(self, context, branch, commits=True): + data = { + "name": branch["new"]["name"], + "url": branch["new"]["links"]["html"]["href"], + "type": context.type, + "repository_id": context.repository.id + } + + if commits: + data["commits"] = self.parse_bitbucket_commits(context, branch) + + return data + + def parse_bitbucket_commits(self, context, branch): + commits = [] + for commit in branch["commits"]: + commits.append(self.parse_bitbucket_commit(context, commit)) + return commits + + def parse_bitbucket_commit(self, context, commit): + from dateutil.parser import parse + return { + "name": commit["hash"][:8], + "message": commit["message"], + "url": commit["links"]["html"]["href"], + "date": parse(commit["date"]).strftime("%Y-%m-%d %H:%M:%S"), + "type": context.type, + "author": self.parse_bitbucket_commit_author(context, commit), + } + + def parse_bitbucket_commit_author(self, context, commit): + author = commit["author"] + user = author["user"] + email = re.search("%s(.*)%s" % ("<", ">"), author["raw"]).group(1) + return { + "name": user["display_name"], + "username": user["username"].lower(), + "uuid": user["uuid"][1:-1], + "avatar": user["links"]["avatar"]["href"], + "url": user["links"]["html"]["href"], + "email": email, + "type": context.type, + } + + def parse_bitbucket_sender(self, context): + sender = context.raw_payload["actor"] + return { + "name": sender["display_name"], + "username": sender["username"], + "avatar": sender["links"]["avatar"], + "type": context.type, + } + + def parse_bitbucket_repository(self, context): + repository = context.raw_payload["repository"] + return { + "name": repository["name"], + "full_name": repository["full_name"], + "uuid": repository["uuid"][1:-1], + "url": repository["links"]["html"]["href"], + "owner": self.parse_bitbucket_repository_owner(context), + "type": context.type, + } + + def parse_bitbucket_repository_owner(self, context): + owner = context.raw_payload["repository"]["owner"] + return { + "name": owner["display_name"], + "username": owner["username"].lower(), + "uuid": owner["uuid"][1:-1], + "avatar": owner["links"]["avatar"]["href"], + "url": owner["links"]["html"]["href"], + "type": context.type, + } diff --git a/project_git_bitbucket/static/description/icon.png b/project_git_bitbucket/static/description/icon.png new file mode 100644 index 0000000..a506e1f Binary files /dev/null and b/project_git_bitbucket/static/description/icon.png differ diff --git a/project_git_bitbucket/static/src/img/bitbucket.png b/project_git_bitbucket/static/src/img/bitbucket.png new file mode 100644 index 0000000..6cd90db Binary files /dev/null and b/project_git_bitbucket/static/src/img/bitbucket.png differ diff --git a/project_git_bitbucket/views/project_git_bitbucket_views.xml b/project_git_bitbucket/views/project_git_bitbucket_views.xml new file mode 100644 index 0000000..896ee4a --- /dev/null +++ b/project_git_bitbucket/views/project_git_bitbucket_views.xml @@ -0,0 +1,65 @@ + + + + + project.git.project.inherit.bitbucket + project.project + + + + + + + + + + project.git.user.kanban.inherit.bitbucket + project.git.user + kanban + + + + + + + + + + project.git.commit.kanban.inherit.bitbucket + project.git.commit + kanban + + + + + + + + + + project.git.repository.kanban.inherited.bitbucket + project.git.repository + kanban + + + + + + + + + + project.git.branch.kanban.inherited.bitbucket + project.git.branch + kanban + + + + + + + + diff --git a/project_git_github/README.rst b/project_git_github/README.rst new file mode 100644 index 0000000..221bbc6 --- /dev/null +++ b/project_git_github/README.rst @@ -0,0 +1,36 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +================= +Project GitHub +================= + +This module extends ``project_git`` module with GitHub integration. + + +Credits +======= + +Contributors +------------ + +* Sladjan Kantar +* Petar Najman +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_git_github/__init__.py b/project_git_github/__init__.py new file mode 100644 index 0000000..f25cd71 --- /dev/null +++ b/project_git_github/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models +from . import controllers diff --git a/project_git_github/__manifest__.py b/project_git_github/__manifest__.py new file mode 100644 index 0000000..5e4c0b5 --- /dev/null +++ b/project_git_github/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project GitHub Integration", + "summary": "Enables you to integrate your projects with GitHub", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_git" + ], + "data": [ + "views/project_git_github_views.xml" + ], + + "demo": [], + "qweb": [ + + ], + "application": True, +} diff --git a/project_git_github/controllers/__init__.py b/project_git_github/controllers/__init__.py new file mode 100644 index 0000000..c106dcc --- /dev/null +++ b/project_git_github/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import github diff --git a/project_git_github/controllers/github.py b/project_git_github/controllers/github.py new file mode 100644 index 0000000..6dd50e9 --- /dev/null +++ b/project_git_github/controllers/github.py @@ -0,0 +1,45 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import json +import urllib.parse + +from odoo import http, _ +from odoo.addons.project_git.controller.controller\ + import GitController, GitContext + +import logging + +_logger = logging.getLogger(__name__) + + +class GitHubContext(GitContext): + def __init__(self, token, payload): + GitContext.__init__(self, 'github', token, payload) + + +class GitHubController(GitController): + @http.route([ + '/github/payload/' + ], methods=['post'], type='http', auth='public', csrf=False) + def process_github(self, token, *args, **kw): + payload = json.loads(http.request.httprequest.form['payload']) + return self.process_request(GitHubContext(token, payload)) + + def validate_github_payload(self, context): + + # We need secret on one of the ends in order to validate payload + if not context.has_signature and not context.repository.secret: + return True + + payload = http.request.httprequest.form['payload'].encode("utf-8") + payload = "payload=" + urllib.parse.quote_plus(payload) + payload_valid = context.validate_payload(payload) + + if not payload_valid: + _logger.warning( + _("GitHub (delivery='%s'): received for repository (id='%s') " + "which could not be validated!.") + % (context.delivery, context.repository.id) + ) + return payload_valid diff --git a/project_git_github/models/__init__.py b/project_git_github/models/__init__.py new file mode 100644 index 0000000..c106dcc --- /dev/null +++ b/project_git_github/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import github diff --git a/project_git_github/models/github.py b/project_git_github/models/github.py new file mode 100644 index 0000000..94fa029 --- /dev/null +++ b/project_git_github/models/github.py @@ -0,0 +1,192 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from dateutil.parser import parse + +from odoo import models, fields, http + +TYPE = [('github', 'GitHub')] + + +class GitUser(models.Model): + _inherit = 'project.git.user' + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitRepository(models.Model): + _inherit = 'project.git.repository' + + type = fields.Selection( + selection_add=TYPE, + ) + + def _secret_visible_for_types(self): + types = super(GitRepository, self)._secret_visible_for_types() + types.append(TYPE[0][0]) + return types + + +class GitCommit(models.Model): + _inherit = 'project.git.commit' + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitBranch(models.Model): + _inherit = 'project.git.branch' + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitPayloadParser(models.AbstractModel): + _inherit = 'project.git.payload.parser' + + # ------------------------------------------- + # Header + # ------------------------------------------- + def parse_github_header(self, type, raw_payload): + headers = http.request.httprequest.headers + action_type = self._map_github_action_type(headers) + + data = { + "event": headers.get("X-GitHub-Event"), + "delivery": headers.get("X-GitHub-Delivery"), + "action_type": action_type, + "action": self._format_action_name(action_type), + } + + signature = headers.get("X-Hub-Signature", False) + if signature: + data["signature"] = str(signature) + + return data + + def _map_github_action_type(self, headers): + event = headers.get("X-GitHub-Event") + return event in self._github_supported_event_types() and event or False + + def _format_action_name(self, action_type): + return "git_%s" % (action_type,) + + def _github_supported_event_types(self): + return ["push", "delete", "ping"] + + # ------------------------------------------- + # Payload + # ------------------------------------------- + def parse_github_payload(self, context): + method_name = self, "parse_github_%s" % context.action_type + parse_event_method = getattr(method_name) + return parse_event_method(context) + + # ------------------------------------------- + # Action Push + # ------------------------------------------- + def parse_github_push(self, context): + return { + "repository": self.parse_github_repository(context), + "branches": [self.parse_github_branch(context)], + "sender": self.parse_github_sender(context) + } + + # ------------------------------------------- + # Action Delete + # ------------------------------------------- + def parse_github_delete(self, context): + return { + "branches": [self.parse_github_branch(context, False)] + } + + # ------------------------------------------- + # Paring methods + # ------------------------------------------- + def parse_github_branch(self, context, commits=True): + payload = context.raw_payload + name = payload["ref"] and payload["ref"].rsplit('/', 1)[-1] or "None" + data = { + "name": name, + "url": payload["compare"], + "type": context.type, + "repository_id": context.repository.id + } + + if commits: + data["commits"] = self.parse_github_commits(context) + + return data + + def parse_github_commits(self, context): + commits = [] + for commit in context.raw_payload["commits"]: + commits.append(self.parse_github_commit(context, commit)) + return commits + + def parse_github_commit(self, context, commit): + return { + "name": commit["id"][:8], + "message": commit["message"] and commit["message"].strip() or '', + "url": commit["url"], + "type": context.type, + "date": fields.Datetime.to_string(parse(commit["timestamp"])), + 'author': self.parse_github_commit_author(context, commit), + } + + def parse_github_commit_author(self, context, commit): + sender = context.raw_payload["sender"] + author = commit["author"] + + data = { + "name": author["name"], + "username": author["username"], + "email": author["email"].lower(), + "type": context.type, + "avatar": "", + "url": "", + "uuid": "", + } + + if sender["login"] == author["username"]: + data["avatar"] = sender["avatar_url"] + data["url"] = sender["html_url"] + data["uuid"] = sender["id"] + + return data + + def parse_github_sender(self, context): + sender = context.raw_payload["sender"] + return { + "username": sender["login"], + "type": context.type, + "avatar": sender['avatar_url'], + "url": sender['html_url'], + "uuid": sender['id'], + } + + def parse_github_repository(self, context): + repository = context.raw_payload["repository"] + return { + "name": repository["name"], + "uuid": repository["id"], + "url": repository["html_url"], + "type": context.type, + 'owner': self.parse_github_repository_owner(context) + } + + def parse_github_repository_owner(self, context): + owner = context.raw_payload["repository"]["owner"] + return { + "name": owner["name"], + "username": owner["login"], + "email": owner["email"], + "uuid": owner["id"], + "avatar": owner["avatar_url"], + "url": owner["html_url"], + "type": context.type, + } diff --git a/project_git_github/static/description/icon.png b/project_git_github/static/description/icon.png new file mode 100644 index 0000000..883f485 Binary files /dev/null and b/project_git_github/static/description/icon.png differ diff --git a/project_git_github/static/src/img/github.png b/project_git_github/static/src/img/github.png new file mode 100644 index 0000000..5a1b9b1 Binary files /dev/null and b/project_git_github/static/src/img/github.png differ diff --git a/project_git_github/views/project_git_github_views.xml b/project_git_github/views/project_git_github_views.xml new file mode 100644 index 0000000..ed533d8 --- /dev/null +++ b/project_git_github/views/project_git_github_views.xml @@ -0,0 +1,65 @@ + + + + + project.git.project.inherit.github + project.project + + + + + + + + + + project.git.user.kanban.inherit.github + project.git.user + kanban + + + + + + + + + + project.git.commit.kanban.inherit.github + project.git.commit + kanban + + + + + + + + + + project.git.repository.kanban.inherited.github + project.git.repository + kanban + + + + + + + + + + project.git.branch.kanban.inherited.github + project.git.branch + kanban + + + + + + + + diff --git a/project_git_gitlab/README.rst b/project_git_gitlab/README.rst new file mode 100644 index 0000000..b1762c0 --- /dev/null +++ b/project_git_gitlab/README.rst @@ -0,0 +1,36 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +============== +Project Gitlab +============== + +This module extends ``project_git`` module with GitLab integration. + + +Credits +======= + +Contributors +------------ + +* Sladjan Kantar +* Petar Najman +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_git_gitlab/__init__.py b/project_git_gitlab/__init__.py new file mode 100644 index 0000000..f25cd71 --- /dev/null +++ b/project_git_gitlab/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models +from . import controllers diff --git a/project_git_gitlab/__manifest__.py b/project_git_gitlab/__manifest__.py new file mode 100644 index 0000000..3ff1199 --- /dev/null +++ b/project_git_gitlab/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project GitLab Integration", + "summary": "Enables you to integrate your projects with GitLab", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project_git" + ], + "data": [ + "views/project_git_gitlab_views.xml" + ], + + "demo": [], + "qweb": [ + + ], + "application": True, +} diff --git a/project_git_gitlab/controllers/__init__.py b/project_git_gitlab/controllers/__init__.py new file mode 100644 index 0000000..f0bc832 --- /dev/null +++ b/project_git_gitlab/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import gitlab diff --git a/project_git_gitlab/controllers/gitlab.py b/project_git_gitlab/controllers/gitlab.py new file mode 100644 index 0000000..82cebf6 --- /dev/null +++ b/project_git_gitlab/controllers/gitlab.py @@ -0,0 +1,40 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import http +from odoo.addons.project_git.controller.controller \ + import GitController, GitContext + + +class GitLabContext(GitContext): + def __init__(self, token, payload): + GitContext.__init__(self, 'gitlab', token, payload) + + @property + def gitlab_token(self): + return self.header['token'] + + @property + def has_gitlab_token(self): + return 'token' in self.header + + +class GitLabController(GitController): + + @http.route([ + '/gitlab/payload/' + ], type='json', auth='public', website=True) + def process_request_gitlab(self, token, *args, **kw): + return self.process_request( + GitLabContext(token, http.request.jsonrequest) + ) + + # There is an open issue: + # URL: https://gitlab.com/gitlab-org/gitlab-ce/issues/37380 + # for implementation of the GitHub like web hook auth. + def validate_gitlab_payload(self, context): + if not context.has_gitlab_token and not context.repository.secret: + return True + + # This method is getting too silly! + return context.gitlab_token == context.repository.secret diff --git a/project_git_gitlab/models/__init__.py b/project_git_gitlab/models/__init__.py new file mode 100644 index 0000000..f0bc832 --- /dev/null +++ b/project_git_gitlab/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import gitlab diff --git a/project_git_gitlab/models/gitlab.py b/project_git_gitlab/models/gitlab.py new file mode 100644 index 0000000..dbb317a --- /dev/null +++ b/project_git_gitlab/models/gitlab.py @@ -0,0 +1,219 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import re +from odoo import models, fields, http +from odoo.addons.project_git.utils.utils import urljoin + +from dateutil.parser import parse + +TYPE = [('gitlab', 'GitLab')] + + +class GitUser(models.Model): + _inherit = 'project.git.user' + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitRepository(models.Model): + _inherit = 'project.git.repository' + + type = fields.Selection( + selection_add=TYPE, + ) + + def _secret_visible_for_types(self): + types = super(GitRepository, self)._secret_visible_for_types() + types.append(TYPE[0][0]) + return types + + +class GitCommit(models.Model): + _inherit = 'project.git.commit' + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitBranch(models.Model): + _inherit = 'project.git.branch' + + type = fields.Selection( + selection_add=TYPE, + ) + + +class GitPayloadParser(models.AbstractModel): + _inherit = 'project.git.payload.parser' + + # ------------------------------------------- + # Header + # ------------------------------------------- + def parse_gitlab_header(self, type, raw_payload): + headers = http.request.httprequest.headers + action_type = self._map_gitlab_action_type(raw_payload) + + data = { + "event": raw_payload['object_kind'], + "delivery": raw_payload['checkout_sha'], + "action_type": action_type, + "action": self._format_action_name(action_type), + } + + token = headers.get("X-Gitlab-Token", False) + if token: + data["token"] = token + + return data + + def _map_gitlab_action_type(self, raw_payload): + + def is_delete_event(evt): + after = raw_payload['after'] + return evt == 'push' and (after.isdigit() and not int(after)) + + event = raw_payload['object_kind'] + + if event not in self._gitlab_supported_event_types(): + return False + + # In case of a push we need to check if we have a delete event + if is_delete_event(event): + event = 'delete' + + return event + + def _format_action_name(self, action_type): + return "git_%s" % (action_type,) + + def _gitlab_supported_event_types(self): + return ["push"] + + # ------------------------------------------- + # Payload + # ------------------------------------------- + def parse_gitlab_payload(self, context): + method_name = "parse_gitlab_%s" % context.action_type + parse_event_method = getattr(self, method_name) + return parse_event_method(context) + + # ------------------------------------------- + # Action Push + # ------------------------------------------- + def parse_gitlab_push(self, context): + return { + "repository": self.parse_gitlab_repository(context), + "branches": [self.parse_gitlab_branch(context)], + "sender": self.parse_gitlab_sender(context) + } + + # ------------------------------------------- + # Action Delete + # ------------------------------------------- + def parse_gitlab_delete(self, context): + return { + "branches": [self.parse_gitlab_branch(context, False)] + } + + # ------------------------------------------- + # Paring methods + # ------------------------------------------- + def parse_gitlab_branch(self, context, commits=True): + payload = context.raw_payload + branch_name = payload["ref"].split('/')[-1] + data = { + "name": branch_name, + "type": context.type, + "repository_id": context.repository.id, + "url": urljoin( + payload['project']['homepage'] + '/', 'tree', branch_name + ), + } + + if commits: + data["commits"] = self.parse_gitlab_commits(context) + + return data + + def parse_gitlab_commits(self, context): + commits = [] + for commit in context.raw_payload["commits"]: + commits.append(self.parse_gitlab_commit(context, commit)) + return commits + + def parse_gitlab_commit(self, context, commit): + return { + "name": commit["id"][:8], + "message": commit["message"], + "url": commit["url"], + "date": fields.Datetime.to_string(parse(commit["timestamp"])), + "type": context.type, + "author": self.parse_gitlab_commit_author(context, commit), + } + + def parse_gitlab_commit_author(self, context, commit): + author_data = dict( + name=commit["author"]["name"], + email=commit["author"]["email"] + ) + + repository_owner = self.parse_gitlab_repository_owner(context) + if repository_owner['email'] == author_data['email']: + return repository_owner + + return author_data + + def parse_gitlab_sender(self, context): + raw_payload = context.raw_payload + + return { + "name": raw_payload["user_name"], + "username": raw_payload["user_username"], + "email": raw_payload["user_email"], + "uuid": raw_payload["user_id"], + "avatar": raw_payload["user_avatar"], + "type": context.type, + } + + def parse_gitlab_repository(self, context): + project = context.raw_payload["project"] + return { + "name": project["name"], + "full_name": project["path_with_namespace"], + "url": project['homepage'], + "type": context.type, + "owner": self.parse_gitlab_repository_owner(context) + } + + def parse_gitlab_repository_owner(self, context): + raw_payload = context.raw_payload + + utils = re.search( + r"(https?://.+/)(\w+)/.+", + raw_payload["repository"]["git_http_url"] + ) + + link = utils.group(1) + username = utils.group(2) + + data = { + "name": username.title(), + "username": username, + "uuid": "", + "avatar": "", + "url": urljoin(link, username), + "email": "", + "type": context.type, + } + + if username == raw_payload["user_username"]: + data["name"] = raw_payload["user_name"] + data["uuid"] = raw_payload["user_id"] + data["avatar"] = urljoin(link, raw_payload["user_avatar"]) + data["email"] = raw_payload["user_email"] + + return data diff --git a/project_git_gitlab/static/description/icon.png b/project_git_gitlab/static/description/icon.png new file mode 100644 index 0000000..70bd536 Binary files /dev/null and b/project_git_gitlab/static/description/icon.png differ diff --git a/project_git_gitlab/static/src/img/gitlab.png b/project_git_gitlab/static/src/img/gitlab.png new file mode 100644 index 0000000..7ab2f24 Binary files /dev/null and b/project_git_gitlab/static/src/img/gitlab.png differ diff --git a/project_git_gitlab/views/project_git_gitlab_views.xml b/project_git_gitlab/views/project_git_gitlab_views.xml new file mode 100644 index 0000000..4b18fe6 --- /dev/null +++ b/project_git_gitlab/views/project_git_gitlab_views.xml @@ -0,0 +1,65 @@ + + + + + project.git.project.inherit.gitlab + project.project + + + + + + + + + + project.git.user.kanban.inherit.gitlab + project.git.user + kanban + + + + + + + + + + project.git.commit.kanban.inherit.gitlab + project.git.commit + kanban + + + + + + + + + + project.git.repository.kanban.inherited.gitlab + project.git.repository + kanban + + + + + + + + + + project.git.branch.kanban.inherited.gitlab + project.git.branch + kanban + + + + + + + + diff --git a/project_key/README.rst b/project_key/README.rst new file mode 100644 index 0000000..3a41c7a --- /dev/null +++ b/project_key/README.rst @@ -0,0 +1,72 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +=========== +Project Key +=========== + +This module provides functionality to uniquely identify projects and tasks by simple ``key`` field. + + +Usage +===== + +To use this module functionality you just need to: + +On ``project.project`` level: + +In Kanban View: + +#. Go to Project > Dashboard +#. Create +#. Enter project name and use auto generated key or simply override value by entering your own key value. + +In Tree View: + +#. Go to Project > Configuration > Projects +#. Create +#. Enter project name and use auto generated key or simply override value by entering your own key value. + +In form View: + +#. Go to Project > Dashboard +#. Open the projects settings +#. Modify the "key" value +#. After modifying project key any existing task will be reindexed automatically. + +On ``project.task`` level: + +#. Actually there is nothing to be done here +#. Task keys are auto generated based on project key value with per project auto incremented number (i.e. PA-1, PA-2, etc) + +In browser address bar: + +#. Navigate to your project by entering following url: http://<>/browse/PROJECT-KEY +#. Navigate to your task by entering following url: http://<>/browse/TASK-KEY + +Credits +======= + +Contributors +------------ + +* Petar Najman +* Sladjan Kantar +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_key/__init__.py b/project_key/__init__.py new file mode 100644 index 0000000..67f2274 --- /dev/null +++ b/project_key/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models +from . import controllers +from .hooks import post_init_hook diff --git a/project_key/__manifest__.py b/project_key/__manifest__.py new file mode 100644 index 0000000..8ae7b72 --- /dev/null +++ b/project_key/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Key", + "summary": "Module decorates project task with ``key`` index", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project", + ], + "data": [ + "views/project_key_views.xml", + "views/project_portal_templates.xml" + ], + + "demo": [], + "qweb": [ + + ], + "application": False, + "post_init_hook": "post_init_hook", +} diff --git a/project_key/controllers/__init__.py b/project_key/controllers/__init__.py new file mode 100644 index 0000000..1361b0f --- /dev/null +++ b/project_key/controllers/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import task_browser +from . import portal diff --git a/project_key/controllers/portal.py b/project_key/controllers/portal.py new file mode 100644 index 0000000..a4a7f3b --- /dev/null +++ b/project_key/controllers/portal.py @@ -0,0 +1,28 @@ +# Copyright 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import _ +from odoo.osv.expression import OR +from odoo.addons.project_portal.controllers.portal import CustomerPortal + + +class CustomerPortal(CustomerPortal): + + def portal_my_tasks_prepare_task_search_domain(self, search_in, search): + domain = super(CustomerPortal, self)\ + .portal_my_tasks_prepare_task_search_domain(search_in, search) + + if search and search_in: + if search_in in ('content', 'all'): + domain = OR([domain, [('key', 'ilike', search)]]) + return domain + + def portal_my_tasks_prepare_searchbar(self): + searchbar = super(CustomerPortal, self)\ + .portal_my_tasks_prepare_searchbar() + + searchbar['sorting'].update({ + 'key': {'label': _('Key'), 'order': 'key'}, + }) + + return searchbar diff --git a/project_key/controllers/task_browser.py b/project_key/controllers/task_browser.py new file mode 100644 index 0000000..e8b5b0e --- /dev/null +++ b/project_key/controllers/task_browser.py @@ -0,0 +1,41 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import werkzeug +import odoo.http as http +from odoo.http import request + + +class TaskBrowser(http.Controller): + + def get_task_url(self, key): + env = request.env() + + Task = env['project.task'] + tasks = Task.search([('key', '=ilike', key)]) + task_action = env.ref('project.action_view_task') + + url = "/web#id=%s&view_type=form&model=project.task&action=%s" + return url % (tasks and tasks.id or -1, task_action.id) + + def get_project_url(self, key): + env = request.env() + + Project = env['project.project'] + projects = Project.search([('key', '=ilike', key)]) + if not projects.exists(): + return False + + url = "/web#id=%s&view_type=form&model=project.project&action=%s" + project_action = env.ref('project.open_view_project_all_config') + return url % (projects and projects.id or -1, project_action.id) + + @http.route([ + '/browse/', + '/web/browse/', + ], type='http', auth="user") + def open(self, key, **kwargs): + redirect_url = self.get_project_url(key) + if not redirect_url: + redirect_url = self.get_task_url(key) + return werkzeug.utils.redirect(redirect_url or '', 301) diff --git a/project_key/hooks.py b/project_key/hooks.py new file mode 100644 index 0000000..4ff1746 --- /dev/null +++ b/project_key/hooks.py @@ -0,0 +1,12 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + + +def post_init_hook(cr, registry): + from odoo import api, SUPERUSER_ID + env = api.Environment(cr, SUPERUSER_ID, {}) + env['project.project']._set_default_project_key() + cr.execute( + "ALTER TABLE project_project ALTER COLUMN key SET NOT NULL", + log_exceptions=False + ) diff --git a/project_key/models/__init__.py b/project_key/models/__init__.py new file mode 100644 index 0000000..d1bc94c --- /dev/null +++ b/project_key/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import project_project +from . import project_task diff --git a/project_key/models/project_project.py b/project_key/models/project_project.py new file mode 100644 index 0000000..4338e92 --- /dev/null +++ b/project_key/models/project_project.py @@ -0,0 +1,188 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, _ +from odoo.osv import expression + + +class ProjectProject(models.Model): + _inherit = "project.project" + + task_key_sequence_id = fields.Many2one( + comodel_name='ir.sequence', + string='Key Sequence', + ondelete="restrict", + ) + + key = fields.Char( + string='Key', + size=10, + required=True, + index=True, + ) + + _sql_constraints = [ + ("project_key_unique", "UNIQUE(key)", "Project key must be unique!") + ] + + @api.multi + @api.onchange('name') + def _onchange_project_name(self): + for rec in self: + if rec.name: + rec.key = self.generate_project_key(rec.name) + else: + rec.key = '' + + @api.model + def create(self, vals): + if 'key' not in vals: + if vals['name']: + vals['key'] = self.generate_project_key(vals['name']) + else: + vals['key'] = '' + + new_project = super(ProjectProject, self).create(vals) + new_project.create_sequence() + + return new_project + + @api.multi + def write(self, values): + + reindex = False + + if 'key' in values: + key = values['key'] + reindex = self.key != key + + res = super(ProjectProject, self).write(values) + + if reindex: + # Here we don't expect to have more than one record + # because we can not have multiple projects with same KEY. + self.update_sequence() + self._reindex_task_keys() + + return res + + @api.multi + def unlink(self): + sequences_to_delete = []; + for project in self: + sequences_to_delete.append(project.task_key_sequence_id.id) + + ret = super(ProjectProject, self).unlink() + + self.env['ir.sequence'].sudo().browse(sequences_to_delete).unlink() + return ret + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + args = args or [] + domain = [] + if name: + domain = [ + '|', ('key', 'ilike', name + '%'), ('name', operator, name) + ] + if operator in expression.NEGATIVE_TERM_OPERATORS: + domain = ['&'] + domain + projects = self.search(domain + args, limit=limit) + return projects.name_get() + + def create_sequence(self): + """ + This method creates ir.sequence fot the current project + :return: Returns create sequence + """ + self.ensure_one() + sequence_data = self._prepare_sequence_data() + sequence = self.env['ir.sequence'].sudo().create(sequence_data) + self.write({'task_key_sequence_id': sequence.id}) + return sequence + + def update_sequence(self): + """ + This method updates existing task sequence + :return: + """ + sequence_data = self._prepare_sequence_data(init=False) + self.task_key_sequence_id.sudo().write(sequence_data) + + def _prepare_sequence_data(self, init=True): + """ + This method prepares data for create/update_sequence methods + :param init: Set to False in case you don't want to set initial values + for number_increment and number_next_actual + """ + values = { + 'name': "%s %s" % ( + _("Project task sequence for project "), self.name + ), + 'implementation': 'standard', + 'code': 'project.task.key.%s' % (self.id,), + 'prefix': "%s-" % (self.key,), + 'use_date_range': False, + 'active': False, + } + + if init: + values.update(dict(number_increment=1, number_next_actual=1)) + + return values + + def get_next_task_key(self): + return self.sudo().task_key_sequence_id.next_by_id() + + def generate_project_key(self, text): + if not text: + return '' + + data = text.split(' ') + if len(data) == 1: + return data[0][:3].upper() + + key = [] + for item in data: + key.append(item[0].upper()) + return "".join(key) + + @api.multi + def _reindex_task_keys(self): + """ + This method will reindex task keys of the current project. + """ + self.ensure_one() + + reindex_query = """ + UPDATE project_task + SET key = x.key + FROM ( + SELECT t.id, p.key || '-' || split_part(t.key, '-', 2) AS key + FROM project_task t + INNER JOIN project_project p ON t.project_id = p.id + WHERE t.project_id = %s + ) AS x + WHERE project_task.id = x.id; + """ + + self.env.cr.execute(reindex_query, (self.id,)) + self.env['project.task'].invalidate_cache(['key'], self.task_ids.ids) + + @api.model + def _set_default_project_key(self): + """ + This method will be called from the post_init hook in order to set + default values on project.project and + project.task, so we leave those tables nice and clean after module + installation. + :return: + """ + for project in self.with_context(active_test=False).search([ + ('key', '=', False) + ]): + project.key = self.generate_project_key(project.name) + project.create_sequence() + + for task in project.task_ids: + task.key = project.get_next_task_key() diff --git a/project_key/models/project_task.py b/project_key/models/project_task.py new file mode 100644 index 0000000..556dfe0 --- /dev/null +++ b/project_key/models/project_task.py @@ -0,0 +1,101 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api +from odoo.osv import expression + +TASK_URL = "/web#id=%s&view_type=form&model=project.task&action=%s" + + +class Task(models.Model): + _inherit = 'project.task' + + key = fields.Char( + string='Key', + size=20, + required=False, + index=True, + ) + + url = fields.Char( + string='URL', + compute="_compute_task_url", + ) + + _sql_constraints = [ + ("task_key_unique", "UNIQUE(key)", "Task key must be unique!") + ] + + @api.multi + def _compute_task_url(self): + task_action = self.env.ref('project.action_view_task') + for task in self: + task.url = TASK_URL % (task.id, task_action.id) + + @api.model + @api.returns('self', lambda value: value.id) + def create(self, vals): + if vals.get('project_id', False): + project_id = vals['project_id'] + elif self._context.get('default_project_id', False): + project_id = self._context.get('default_project_id', False) + elif self._context.get('active_model', False) == 'project.project' and\ + self._context.get('active_id', False): + project_id = self._context.get('active_id') + if project_id: + project = self.env['project.project'].browse(project_id) + vals['key'] = project.get_next_task_key() + return super(Task, self).create(vals) + + @api.multi + def write(self, vals): + if vals.get('project_id', False): + project = self.env['project.project'].browse(vals['project_id']) + for rec in self: + if not rec.key or rec.project_id.id != project.id: + task_data = self._prepare_task_for_project_switch( + rec, project + ) + super(Task, rec).write(task_data) + return super(Task, self).write(vals) + + def _prepare_task_for_project_switch(self, task, project): + def get_task_data(t): + t_data = { + 'key': project.get_next_task_key(), + 'project_id': project.id + } + if len(t.child_ids) > 0: + children = [] + for child in t.child_ids: + children.append((1, child.id, get_task_data(child))) + t_data['child_ids'] = children + return t_data + + return get_task_data(task) + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + args = args or [] + domain = [] + if name: + domain = [ + '|', ('key', 'ilike', name + '%'), ('name', operator, name) + ] + if operator in expression.NEGATIVE_TERM_OPERATORS: + domain = ['&'] + domain + tasks = self.search(domain + args, limit=limit) + return tasks.name_get() + + @api.multi + def name_get(self): + result = [] + + for record in self: + task_name = [] + if record.key: + task_name.append(record.key) + task_name.append(record.name) + result.append((record.id, " - ".join(task_name))) + + return result diff --git a/project_key/static/description/icon.png b/project_key/static/description/icon.png new file mode 100644 index 0000000..e8444f8 Binary files /dev/null and b/project_key/static/description/icon.png differ diff --git a/project_key/views/project_key_views.xml b/project_key/views/project_key_views.xml new file mode 100644 index 0000000..ca8bb93 --- /dev/null +++ b/project_key/views/project_key_views.xml @@ -0,0 +1,123 @@ + + + + + project.edit.project.inherited + project.project + + + + + + + + + + project.project.tree + project.project + + + + + + + + + + project.project.select + project.project + + + + ['|',('name','ilike',self),('key','ilike',self)] + + + + + + project.task.form.key + project.task + + + + + + + + + + project.task.tree + project.task + + + + + + + + + + + project.task.search.key + project.task + + + + ['|',('name','ilike',self),('key','ilike',self)] + + + + + + project.task.kanban.key + project.task + + + + + + + + + + + + + + + + + + + + + + + project.project.view.form.simplified + project.project + + +
    + +
    +
    +
    + + + project.project.kanban + project.project + + + + + + + + - + + + +
    diff --git a/project_key/views/project_portal_templates.xml b/project_key/views/project_portal_templates.xml new file mode 100644 index 0000000..9f2c900 --- /dev/null +++ b/project_key/views/project_portal_templates.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/project_portal/README.rst b/project_portal/README.rst new file mode 100644 index 0000000..adb9d0d --- /dev/null +++ b/project_portal/README.rst @@ -0,0 +1,40 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +================ +Project Portal +================ + +This module overrides methods of the project portal controller to make them more usable for other modules. + +Usage +===== + +TBD + +Credits +======= + + +Contributors +------------ + +* Petar Najman + + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_portal/__init__.py b/project_portal/__init__.py new file mode 100644 index 0000000..f164423 --- /dev/null +++ b/project_portal/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from . import controllers diff --git a/project_portal/__manifest__.py b/project_portal/__manifest__.py new file mode 100644 index 0000000..7787501 --- /dev/null +++ b/project_portal/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Portal", + "summary": "Makes project portal controller more extendable", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project", + ], + "data": [ + ], + + "demo": [], + "qweb": [ + + ], + "application": False, +} diff --git a/project_portal/controllers/__init__.py b/project_portal/controllers/__init__.py new file mode 100644 index 0000000..b68a9b7 --- /dev/null +++ b/project_portal/controllers/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from . import portal diff --git a/project_portal/controllers/portal.py b/project_portal/controllers/portal.py new file mode 100644 index 0000000..4de79a1 --- /dev/null +++ b/project_portal/controllers/portal.py @@ -0,0 +1,373 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from collections import OrderedDict +from operator import itemgetter + +from odoo import http, _ +from odoo.http import request +from odoo.tools import groupby as groupbyelem +from odoo.addons.portal.controllers.portal\ + import get_records_pager, pager as portal_pager + +from odoo.addons.project.controllers.portal \ + import CustomerPortal + +from odoo.osv.expression import OR + + +class CustomerPortal(CustomerPortal): + + # ======================== + # Portal My Projects + # ======================== + @http.route([ + '/my/projects', + '/my/projects/page/' + ], type='http', auth="user", website=True) + def portal_my_projects(self, page=1, date_begin=None, date_end=None, + sortby=None, **kw): + values = self._prepare_portal_layout_values() + values.update(self.portal_my_projects_prepare_values( + page, date_begin, date_end, sortby, **kw) + ) + return self.portal_my_projects_render(values) + + def portal_my_projects_render(self, values): + return request.render("project.portal_my_projects", values) + + def portal_my_projects_prepare_values(self, page=1, date_begin=None, + date_end=None, sortby=None, **kw): + Project = request.env['project.project'] + domain = [('privacy_visibility', '=', 'portal')] + + searchbar_sortings = { + 'date': {'label': _('Newest'), 'order': 'create_date desc'}, + 'name': {'label': _('Name'), 'order': 'name'}, + } + if not sortby: + sortby = 'date' + order = searchbar_sortings[sortby]['order'] + + # archive groups - Default Group By 'create_date' + archive_groups = self._get_archive_groups('project.project', domain) + if date_begin and date_end: + domain += [ + ('create_date', '>', date_begin), + ('create_date', '<=', date_end) + ] + # projects count + project_count = Project.search_count(domain) + # pager + pager = portal_pager( + url="/my/projects", + url_args={ + 'date_begin': date_begin, + 'date_end': date_end, + 'sortby': sortby, + }, + total=project_count, + page=page, + step=self._items_per_page + ) + + # content according to pager and archive selected + projects = Project.search( + domain, + order=order, + limit=self._items_per_page, + offset=pager['offset'] + ) + request.session['my_projects_history'] = projects.ids[:100] + + return { + 'date': date_begin, + 'date_end': date_end, + 'projects': projects, + 'page_name': 'project', + 'archive_groups': archive_groups, + 'default_url': '/my/projects', + 'pager': pager, + 'searchbar_sortings': searchbar_sortings, + 'sortby': sortby + } + + # ======================== + # Portal My Project + # ======================== + @http.route([ + '/my/project/' + ], type='http', auth="user", website=True) + def portal_my_project(self, project_id=None, **kw): + values = self.portal_my_project_prepare_values(project_id, **kw) + return self.portal_my_project_render(values) + + def portal_my_project_prepare_values(self, project_id=None, **kw): + project = request.env['project.project'].browse(project_id) + vals = {'project': project} + history = request.session.get('my_projects_history', []) + vals.update(get_records_pager(history, project)) + return vals + + def portal_my_project_render(self, values): + return request.render("project.portal_my_project", values) + + # ======================== + # Portal My Tasks + # ======================== + @http.route([ + '/my/tasks', + '/my/tasks/page/' + ], type='http', auth="user", website=True) + def portal_my_tasks(self, page=1, date_begin=None, date_end=None, + sortby=None, filterby=None, search=None, + search_in='content', **kw): + + values = self._prepare_portal_layout_values() + + values.update(self.portal_my_tasks_prepare_values( + page, date_begin, date_end, sortby, filterby, + search, search_in, **kw) + ) + + return self.portal_my_tasks_render(values) + + def portal_my_tasks_prepare_searchbar(self): + return { + 'sorting': { + 'date': { + 'label': _('Newest'), + 'order': 'create_date desc' + }, + 'name': { + 'label': _('Title'), + 'order': 'name' + }, + 'stage': { + 'label': _('Stage'), + 'order': 'stage_id' + }, + 'update': { + 'label': _('Last Stage Update'), + 'order': 'date_last_stage_update desc' + }, + }, + + 'filters': { + 'all': {'label': _('All'), 'domain': []}, + }, + + 'inputs': { + 'content': { + 'input': 'content', + 'label': + _('Search (in Content)') + }, + 'message': { + 'input': 'message', + 'label': _('Search in Messages') + }, + 'customer': { + 'input': 'customer', + 'label': _('Search in Customer') + }, + 'stage': { + 'input': 'stage', + 'label': _('Search in Stages') + }, + 'all': { + 'input': 'all', + 'label': _('Search in All') + }, + }, + 'groupby': { + 'none': { + 'input': 'none', + 'label': _('None') + }, + 'project': { + 'input': 'project', + 'label': _('Project') + }, + } + } + + def portal_my_tasks_prepare_task_search_domain(self, search_in, search): + search_domain = [] + if search and search_in: + if search_in in ('content', 'all'): + search_domain = OR([ + search_domain, [ + '|', + ('name', 'ilike', search), + ('description', 'ilike', search) + ] + ]) + + if search_in in ('customer', 'all'): + search_domain = OR([ + search_domain, [('partner_id', 'ilike', search)] + ]) + if search_in in ('message', 'all'): + search_domain = OR([ + search_domain, [('message_ids.body', 'ilike', search)] + ]) + if search_in in ('stage', 'all'): + search_domain = OR([ + search_domain, [('stage_id', 'ilike', search)] + ]) + return search_domain + + def portal_my_tasks_prepare_task_search(self, projects, searchbar, + date_begin=None, date_end=None, + sortby=None, + filterby=None, search=None, + search_in='content', **kw): + + # This is a good place to add mandatory criteria + + domain = [('project_id', 'in', projects.ids)] + # domain = [('project_id.privacy_visibility', '=', 'portal')] + + for proj in projects: + searchbar['filters'].update({ + str(proj.id): { + 'label': proj.name, + 'domain': [('project_id', '=', proj.id)] + } + }) + + domain += searchbar['filters'][filterby]['domain'] + + # archive groups - Default Group By 'create_date' + archive_groups = self._get_archive_groups('project.task', domain) + if date_begin and date_end: + domain += [ + ('create_date', '>', date_begin), + ('create_date', '<=', date_end) + ] + + # search + search_domain = self.portal_my_tasks_prepare_task_search_domain( + search_in, search + ) + domain += search_domain + + return { + 'domain': domain, + 'archive_groups': archive_groups, + } + + def portal_my_tasks_prepare_values(self, page=1, date_begin=None, + date_end=None, sortby=None, + filterby=None, + search=None, search_in='content', **kw): + + groupby = kw.get('groupby', 'project') + searchbar = self.portal_my_tasks_prepare_searchbar() + + # default sort by value + if not sortby: + sortby = 'date' + + order = searchbar['sorting'][sortby]['order'] + + # default filter by value + if not filterby: + filterby = 'all' + + projects = request.env['project.project'].search([ + ('privacy_visibility', '=', 'portal') + ]) + + search_obj = self.portal_my_tasks_prepare_task_search( + projects, searchbar, date_begin, date_end, sortby, filterby, + search, search_in, **kw + ) + + # task count + task_count = request.env['project.task'].search_count(search_obj['domain']) + + # pager + pager = portal_pager( + url="/my/tasks", + url_args={ + 'date_begin': date_begin, + 'date_end': date_end, + 'search': search, + 'sortby': sortby, + 'filterby': filterby + }, + total=task_count, + page=page, + step=self._items_per_page + ) + + # content according to pager and archive selected + if groupby == 'project': + order = "project_id, %s" % order # force sort on project first to group by project in view + + tasks = request.env['project.task'].search( + search_obj['domain'], + order=order, + limit=self._items_per_page, + offset=pager['offset'] + ) + request.session['my_tasks_history'] = tasks.ids[:100] + + if groupby == 'project': + grouped_tasks = [ + request.env['project.task'].concat(*g) + for k, g in groupbyelem(tasks, itemgetter('project_id')) + ] + else: + grouped_tasks = [tasks] + + return { + 'date': date_begin, + 'date_end': date_end, + 'projects': projects, + 'tasks': tasks, + 'grouped_tasks': grouped_tasks, + 'page_name': 'task', + 'archive_groups': search_obj['archive_groups'], + 'default_url': '/my/tasks', + 'pager': pager, + 'searchbar_sortings': searchbar['sorting'], + 'searchbar_groupby': searchbar['groupby'], + 'searchbar_inputs': searchbar['inputs'], + 'search_in': search_in, + 'search': search, + 'sortby': sortby, + 'groupby': groupby, + 'searchbar_filters': OrderedDict( + sorted(searchbar['filters'].items()) + ), + 'filterby': filterby, + } + + def portal_my_tasks_render(self, values): + return request.render("project.portal_my_tasks", values) + + # ======================== + # Portal My Tasks + # ======================== + @http.route([ + '/my/task/' + ], type='http', auth="user", website=True) + def portal_my_task(self, task_id=None, **kw): + values = self.portal_my_task_prepare_values(task_id, **kw) + return self.portal_my_task_render(values) + + def portal_my_task_prepare_values(self, task_id=None, **kw): + task = request.env['project.task'].browse(task_id) + vals = { + 'task': task, + 'user': request.env.user + } + history = request.session.get('my_tasks_history', []) + vals.update(get_records_pager(history, task)) + return vals + + def portal_my_task_render(self, values): + return request.render("project.portal_my_task", values) diff --git a/project_portal/static/description/icon.png b/project_portal/static/description/icon.png new file mode 100644 index 0000000..a81b109 Binary files /dev/null and b/project_portal/static/description/icon.png differ diff --git a/project_task_archiving/README.rst b/project_task_archiving/README.rst new file mode 100644 index 0000000..e034541 --- /dev/null +++ b/project_task_archiving/README.rst @@ -0,0 +1,39 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +====================== +Project Task Archiving +====================== + +This module extends ``project.task.type`` with number of days +after which if task doesn't get moved from that stage will get archived by cron. + + +Usage +===== +- In project stage configuration, you can set Archive days limit + +Credits +======= + +Contributors +------------ + +* Aleksandar Gajić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_task_archiving/__init__.py b/project_task_archiving/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_task_archiving/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_task_archiving/__manifest__.py b/project_task_archiving/__manifest__.py new file mode 100644 index 0000000..8ee26a3 --- /dev/null +++ b/project_task_archiving/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Task Archiving", + "summary": "This module extends ``project.task.type`` with number of days " + "after which task from that stage will get archived by cron", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project", + ], + "data": [ + "data/crons.xml", + "views/project_task_type_views.xml", + ], + + "qweb": [ + + ], + "application": False, +} diff --git a/project_task_archiving/data/crons.xml b/project_task_archiving/data/crons.xml new file mode 100644 index 0000000..7b7a4bc --- /dev/null +++ b/project_task_archiving/data/crons.xml @@ -0,0 +1,20 @@ + + + + + + Task archiver + + code + model.run_task_archiver() + 1 + days + -1 + + + + + diff --git a/project_task_archiving/models/__init__.py b/project_task_archiving/models/__init__.py new file mode 100644 index 0000000..a34ae98 --- /dev/null +++ b/project_task_archiving/models/__init__.py @@ -0,0 +1 @@ +from . import project_task_type diff --git a/project_task_archiving/models/project_task_type.py b/project_task_archiving/models/project_task_type.py new file mode 100644 index 0000000..fec0a77 --- /dev/null +++ b/project_task_archiving/models/project_task_type.py @@ -0,0 +1,27 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api +from datetime import datetime, timedelta + + +class Workflow(models.Model): + _inherit = 'project.task.type' + + archive_days = fields.Integer( + string="Archive days", + required=True, + default=0 + ) + + @api.model + def run_task_archiver(self): + Task = self.env["project.task"] + for stage in self.search([]): + if stage.archive_days > 0: + edge_date = fields.Datetime.to_string( + datetime.now() - timedelta(stage.archive_days)) + tasks = Task.search([ + ("stage_id", "=", stage.id), + ("date_last_stage_update", "<", edge_date)]) + tasks.write({"active": False}) diff --git a/project_task_archiving/static/description/icon.png b/project_task_archiving/static/description/icon.png new file mode 100644 index 0000000..a81b109 Binary files /dev/null and b/project_task_archiving/static/description/icon.png differ diff --git a/project_task_archiving/views/project_task_type_views.xml b/project_task_archiving/views/project_task_type_views.xml new file mode 100644 index 0000000..9c4e30a --- /dev/null +++ b/project_task_archiving/views/project_task_type_views.xml @@ -0,0 +1,18 @@ + + + + + + project.task.type.form.inherit.archive + project.task.type + + + + + + + + diff --git a/project_timesheet_category/README.rst b/project_timesheet_category/README.rst new file mode 100644 index 0000000..85ab8c2 --- /dev/null +++ b/project_timesheet_category/README.rst @@ -0,0 +1,43 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +================================ +Project Agile Timesheet Category +================================ + +This module extends timesheet with category field. + + +Usage +===== + +Except ability of assigning timesheet a category, a default timesheet category can be assigned to each user. + + +Credits +======= + + +Contributors +------------ + +* Aleksandar Gajić +* Petar Najman + + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com diff --git a/project_timesheet_category/__init__.py b/project_timesheet_category/__init__.py new file mode 100644 index 0000000..2604c6f --- /dev/null +++ b/project_timesheet_category/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from . import models diff --git a/project_timesheet_category/__manifest__.py b/project_timesheet_category/__manifest__.py new file mode 100644 index 0000000..eeaad5c --- /dev/null +++ b/project_timesheet_category/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Timesheet Category", + "summary": """This module extends timesheet with category field.""", + "category": "Project", + "version": "10.0.1.0.0", + "license": "LGPL-3", + "author": "Modoolar", + "website": "https://www.modoolar.com/", + "depends": [ + "project", + "hr_timesheet", + ], + "data": [ + "security/ir.model.access.csv", + "security/security.xml", + "data/data.xml", + "views/timesheet_category_view.xml", + "views/menu.xml", + "views/account_analytic_line.xml", + "views/project_task.xml", + "views/res_users.xml", + ], + + "demo": [], + "qweb": [ + "static/src/xml/project_timesheet.xml", + ], + "application": False +} \ No newline at end of file diff --git a/project_timesheet_category/data/data.xml b/project_timesheet_category/data/data.xml new file mode 100644 index 0000000..14a923b --- /dev/null +++ b/project_timesheet_category/data/data.xml @@ -0,0 +1,39 @@ + + + + + + Project Management + PM + + + + Business Analysis + BA + + + + + Development + DEV + + + + Quality Assurance + QA + + + + Devops + DO + + + + Support + SUP + + + \ No newline at end of file diff --git a/project_timesheet_category/models/__init__.py b/project_timesheet_category/models/__init__.py new file mode 100644 index 0000000..5f66ee8 --- /dev/null +++ b/project_timesheet_category/models/__init__.py @@ -0,0 +1,3 @@ +from . import timesheet_category +from . import account_analytic_line +from . import res_users diff --git a/project_timesheet_category/models/account_analytic_line.py b/project_timesheet_category/models/account_analytic_line.py new file mode 100644 index 0000000..9c815c8 --- /dev/null +++ b/project_timesheet_category/models/account_analytic_line.py @@ -0,0 +1,38 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class AccountAnalyticLine(models.Model): + _inherit = 'account.analytic.line' + + category_id = fields.Many2one( + comodel_name='project.timesheet.category', + string='Category', + default=lambda self: self.env.user.default_timesheet_category_id, + ) + + billable = fields.Selection( + selection=[ + ('yes', 'Yes'), + ('no', 'No'), + ], + string='Billable', + required=True, + default='yes', + ) + + @api.onchange("user_id") + def default_category_id(self): + def_categ_id = self.user_id.default_timesheet_category_id + if self.user_id and def_categ_id: + self.category_id = def_categ_id.id + self.billable = def_categ_id.billable + else: + self.category_id = False + + @api.onchange("category_id") + def onchange_category(self): + if self.category_id: + self.billable = self.category_id.billable diff --git a/project_timesheet_category/models/res_users.py b/project_timesheet_category/models/res_users.py new file mode 100644 index 0000000..2efedbc --- /dev/null +++ b/project_timesheet_category/models/res_users.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields + + +class Users(models.Model): + _inherit = "res.users" + + default_timesheet_category_id = fields.Many2one( + comodel_name="project.timesheet.category", + string="Default Timesheet category", + ) diff --git a/project_timesheet_category/models/timesheet_category.py b/project_timesheet_category/models/timesheet_category.py new file mode 100644 index 0000000..58aef49 --- /dev/null +++ b/project_timesheet_category/models/timesheet_category.py @@ -0,0 +1,35 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api + + +class TimesheetCategory(models.Model): + _name = 'project.timesheet.category' + _order = 'name' + + name = fields.Char(string="Name", required=True) + code = fields.Char( + string="Code", + default='N/A', + required=True + ) + + billable = fields.Selection( + selection=[ + ('yes', 'Yes'), + ('no', 'No'), + ], + string='Billable', + required=True, + default='yes', + ) + + description = fields.Html( + string="Description", + required=False, + ) + active = fields.Boolean( + string='Active', + default=True, + ) diff --git a/project_timesheet_category/security/ir.model.access.csv b/project_timesheet_category/security/ir.model.access.csv new file mode 100644 index 0000000..afa25e7 --- /dev/null +++ b/project_timesheet_category/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_project_timesheet_category_hr_user,project.timesheet.category,model_project_timesheet_category,base.group_user,1,0,0,0 +access_project_timesheet_category_hr_manager,project.timesheet.category,model_project_timesheet_category,hr.group_hr_manager,1,1,1,1 diff --git a/project_timesheet_category/security/security.xml b/project_timesheet_category/security/security.xml new file mode 100644 index 0000000..48e21d7 --- /dev/null +++ b/project_timesheet_category/security/security.xml @@ -0,0 +1,13 @@ + + + + + + Enable Timesheet Billable + + + + diff --git a/project_timesheet_category/static/description/icon.png b/project_timesheet_category/static/description/icon.png new file mode 100644 index 0000000..a81b109 Binary files /dev/null and b/project_timesheet_category/static/description/icon.png differ diff --git a/project_timesheet_category/views/account_analytic_line.xml b/project_timesheet_category/views/account_analytic_line.xml new file mode 100644 index 0000000..70767b9 --- /dev/null +++ b/project_timesheet_category/views/account_analytic_line.xml @@ -0,0 +1,85 @@ + + + + project.task.form.timesheet.category.inherited + project.task + 100 + + + + + + + + + + + view.account.analytic.line.form.timesheet.category.inherit + account.analytic.line + + 100 + + + + + + + + + + account.analytic.line.tree.timesheet.category.inherit + account.analytic.line + + + + + + + + + + + + account.analytic.line.tree.hr_timesheet + account.analytic.line + + + + + + + + + + + account.analytic.line.form + account.analytic.line + + + + + + + + + + account.analytic.line.search + account.analytic.line + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/project_timesheet_category/views/menu.xml b/project_timesheet_category/views/menu.xml new file mode 100644 index 0000000..ea08062 --- /dev/null +++ b/project_timesheet_category/views/menu.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/project_timesheet_category/views/project_task.xml b/project_timesheet_category/views/project_task.xml new file mode 100644 index 0000000..40a0ae9 --- /dev/null +++ b/project_timesheet_category/views/project_task.xml @@ -0,0 +1,15 @@ + + + + project.task.form.timesheet.category.inherited + project.task + 100 + + + + + + + + + \ No newline at end of file diff --git a/project_timesheet_category/views/res_users.xml b/project_timesheet_category/views/res_users.xml new file mode 100644 index 0000000..a578b91 --- /dev/null +++ b/project_timesheet_category/views/res_users.xml @@ -0,0 +1,33 @@ + + + + + + res.users.preferences.timesheet.category.inherited + res.users + + + + + + + + + + + + res.users.form.timesheet.category.inherited + res.users + + + + + + + + + + diff --git a/project_timesheet_category/views/timesheet_category_view.xml b/project_timesheet_category/views/timesheet_category_view.xml new file mode 100644 index 0000000..d16b0f2 --- /dev/null +++ b/project_timesheet_category/views/timesheet_category_view.xml @@ -0,0 +1,52 @@ + + + + + project.timesheet.category + form + Timesheet categories + tree,form + +

    + Manage timesheet categories. +

    +
    +
    + + project.timesheet.category.tree + project.timesheet.category + + + + + + + + + + + project.timesheet.category.form + project.timesheet.category + +
    + +
    + +
    + + + + + + +
    +
    +
    +
    + +
    \ No newline at end of file diff --git a/project_workflow/README.rst b/project_workflow/README.rst new file mode 100644 index 0000000..d0bb3ac --- /dev/null +++ b/project_workflow/README.rst @@ -0,0 +1,45 @@ +.. image:: https://www.gnu.org/graphics/lgplv3-147x51.png + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: License: LGPL-v3 + +================ +Project Workflow +================ + +This module provides functionality to create fully configurable workflow around ``project.task`` + + +Usage +===== + +#. To manage project workflows got to: Project -> Configuration -> Workflow -> Workflow +#. To run workflow export wizard go to: Project -> Configuration -> Workflow -> Export +#. To run workflow import wizard go to: Project -> Configuration -> Workflow -> Import +#. To assign workflow to a project one must navigate to a project form view and there hit action button ``Change Workflow``. + +Credits +======= + + +Contributors +------------ + +* Petar Najman +* Igor Jovanović +* Miroslav Nikolić + +Maintainer +---------- + +.. image:: https://www.modoolar.com/web/image/ir.attachment/3461/datas + :alt: Modoolar + :target: https://modoolar.com + +This module is maintained by Modoolar. + +:: + + As Odoo Gold partner, our company is specialized in Odoo ERP customization and business solutions development. + Beside that, we build cool apps on top of Odoo platform. + +To contribute to this module, please visit https://modoolar.com \ No newline at end of file diff --git a/project_workflow/__init__.py b/project_workflow/__init__.py new file mode 100644 index 0000000..386613a --- /dev/null +++ b/project_workflow/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import models +from . import wizard +from . import controllers diff --git a/project_workflow/__manifest__.py b/project_workflow/__manifest__.py new file mode 100644 index 0000000..7764d2f --- /dev/null +++ b/project_workflow/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +{ + "name": "Project Workflow", + "summary": "This module provides workflow for project tasks", + "category": "Project", + "version": "11.0.1.0.0", + "license": "LGPL-3", + "author": "Odoo Community Association (OCA), Modoolar", + "website": "https://www.modoolar.com/", + + "depends": [ + "project", + "project_portal", + "web_diagram_position", + "web_ir_actions_act_multi", + "web_ir_actions_act_view_reload", + "website", + ], + + "data": [ + "security/ir.model.access.csv", + + "views/project_workflow.xml", + "views/project_workflow_views.xml", + "views/portal_templates.xml", + + "wizard/stage_change_confirmation_wizard.xml", + "wizard/workflow_import_wizard.xml", + "wizard/workflow_export_wizard.xml", + "wizard/workflow_edit_wizard.xml", + "wizard/workflow_mapping_wizard.xml", + "wizard/project_apply_workflow_wizard.xml", + ], + "images": [], + "qweb": [ + "static/src/xml/diagram.xml", + "static/src/xml/base.xml", + ], +} diff --git a/project_workflow/controllers/__init__.py b/project_workflow/controllers/__init__.py new file mode 100644 index 0000000..b68a9b7 --- /dev/null +++ b/project_workflow/controllers/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +from . import portal diff --git a/project_workflow/controllers/portal.py b/project_workflow/controllers/portal.py new file mode 100644 index 0000000..61837a8 --- /dev/null +++ b/project_workflow/controllers/portal.py @@ -0,0 +1,24 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo.http import request +from odoo.addons.project_portal.controllers.portal import CustomerPortal + + +class CustomerPortal(CustomerPortal): + + def portal_my_task_prepare_values(self, task_id=None, **kw): + values = super(CustomerPortal, self)\ + .portal_my_task_prepare_values(task_id, **kw) + + task = request.env['project.task'].browse(task_id) + + if task.project_id.allow_workflow: + transitions = task.project_id.workflow_id.find_transitions( + task, task.stage_id.id + ) + values['transitions'] = transitions + else: + values['transitions'] = [] + + return values diff --git a/project_workflow/example/workflow.xml b/project_workflow/example/workflow.xml new file mode 100644 index 0000000..d12ed35 --- /dev/null +++ b/project_workflow/example/workflow.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project_workflow/models/__init__.py b/project_workflow/models/__init__.py new file mode 100644 index 0000000..4defb59 --- /dev/null +++ b/project_workflow/models/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from . import project_workflow +from . import project +from . import project_workflow_xml +from . import project_workflow_importer +from . import project_workflow_publisher diff --git a/project_workflow/models/project.py b/project_workflow/models/project.py new file mode 100644 index 0000000..e4f68a2 --- /dev/null +++ b/project_workflow/models/project.py @@ -0,0 +1,264 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, exceptions, _ +from odoo.tools.safe_eval import safe_eval + + +class Project(models.Model): + _inherit = 'project.project' + + allow_workflow = fields.Boolean( + string='Allow Workflow?', + default=False, + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + string='Workflow', + ondelete="restrict", + help="Project Workflow" + ) + + @api.onchange('workflow_id') + def onchange_workflow_id(self): + """ + When a workflow gets changed we need to collect workflow stages + and link them to the project as well. + """ + if self.workflow_id: + self.type_ids = [x.stage_id.id for x in self.workflow_id.state_ids] + else: + self.type_ids = [] + + @api.model + def create(self, vals): + if not vals.get('allow_workflow', False): + vals['workflow_id'] = False + vals['type_ids'] = [] + new = super(Project, self).create(vals) + + if new.allow_workflow and new.workflow_id: + publisher = self.get_workflow_publisher() + publisher.publish( + False, new.workflow_id, project_id=new, switch=True + ) + + return new + + @api.multi + def write(self, vals): + if 'allow_workflow' in vals and not vals['allow_workflow']: + vals['workflow_id'] = False + vals['type_ids'] = [(5,)] + + return super(Project, self).write(vals) + + def get_workflow_publisher(self): + return self.env['project.workflow.publisher'] + + @api.multi + def button_run_workflow_wizard(self): + """ + This method opens ``project_edit_workflow_wizard_action`` wizard. + :return: Returns ``project_edit_workflow_wizard_action`` action. + """ + self.ensure_one() + return self.get_edit_workflow_wizard_action() + + @api.multi + def get_edit_workflow_wizard_action(self): + """ + Loads and prepares an action which opens a wizard for setting or + switching a workflow on current project. + :return: Returns a prepared action which opens a wizard for setting or + switching a workflow on current project. + """ + self.ensure_one() + workflow_id = self.workflow_id and self.workflow_id.id or False + action = self.load_edit_workflow_wizard_action() + action_context = action.get('context', False) + action_context = action_context and safe_eval(action_context) or {} + action_context['default_current_workflow_id'] = workflow_id + action_context['default_project_id'] = self.id + action['context'] = action_context + return action + + @api.model + def load_edit_workflow_wizard_action(self): + """ + Loads an action which opens a wizard for setting or switching + a workflow on a project. + :return: Returns an action which opens a wizard for setting or + switching a workflow on a project. + """ + return self.env['ir.actions.act_window'].for_xml_id( + 'project_workflow', 'project_edit_workflow_wizard_action' + ) + + +class Task(models.Model): + _inherit = 'project.task' + + allow_workflow = fields.Boolean( + related="project_id.allow_workflow", + readonly=True, + ) + + stage_id = fields.Many2one(group_expand='_read_workflow_stage_ids') + + # This field is here just so we can display stage information + # somewhere else on the task form view and to keep compatibility + # with other modules like "project_forecast" module. + wkf_stage_id = fields.Many2one( + comodel_name='project.task.type', + related='stage_id', + string='Workflow Stage', + readonly=True, + track_visibility='never', + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + related='project_id.workflow_id', + readonly=True, + ) + + wkf_state_id = fields.Many2one( + comodel_name='project.workflow.state', + string='Workflow State', + compute="_compute_workflow_state", + store=True + ) + + wkf_state_type = fields.Selection( + related='wkf_state_id.type', + string='Wkf State Type' + ) + + @api.model + def _read_workflow_stage_ids(self, stages, domain, order): + if 'default_project_id' not in self.env.context: + return self._read_group_stage_ids(stages, domain, order) + + # TODO: Fix this, it should browse as above user + project = self.env['project.project'].browse( + self.env.context['default_project_id'] + ) + + if not project.allow_workflow or \ + not project.workflow_id or not project.workflow_id.state_ids: + return self._read_group_stage_ids(stages, domain, order) + + sorted_state_ids = project.workflow_id.state_ids.sorted( + key=lambda s: s.kanban_sequence + ) + stage_ids = [x.stage_id.id for x in sorted_state_ids] + return stages.browse(stage_ids) + + @api.multi + @api.depends( + 'stage_id', 'workflow_id', 'project_id.workflow_id', + 'workflow_id.state_ids', 'workflow_id.state_ids.stage_id') + def _compute_workflow_state(self): + state = self.env['project.workflow.state'] + tasks = self.filtered("project_id.allow_workflow") + + with_workflow = self.filtered(lambda r: r.project_id.allow_workflow) + + for task in with_workflow: + if task.project_id.allow_workflow: + wkf_state = state.search([ + ('workflow_id', '=', task.workflow_id.id), + ('stage_id', '=', task.stage_id.id) + ]) + task.wkf_state_id = wkf_state.exists() and wkf_state.id or False + else: + task.wkf_state_id = False + + @api.cr_uid_context + def _get_default_stage_id(self): + if 'default_project_id' not in self.env.context and \ + 'project_id' not in self.env.context: + return False + + project_id = self.env.context.get( + 'default_project_id', + self.env.context.get('project_id') + ) + + project = self.env['project.project'].browse(project_id) + if project and project.allow_workflow and project.workflow_id: + if not project.workflow_id.default_state_id: + raise exceptions.ValidationError( + _( + "Project workflow '%s' has no default state." + "Please configure the workflow, so that we know what " + "default stage should be" + ) % project.workflow_id.name + ) + return project.workflow_id.default_state_id.stage_id.id + + return False + + @api.model + @api.returns('self', lambda value: value.id) + def create(self, vals): + project_id = vals.get( + 'project_id', self.env.context.get('default_project_id', False) + ) + stage_id = self.with_context( + default_project_id=project_id + )._get_default_stage_id() + + if stage_id: + vals['stage_id'] = stage_id + + new = super(Task, self).create(vals) + + if new.project_id.allow_workflow and new.workflow_id: + new.wkf_state_id.apply(new) + + return new + + @api.multi + def write(self, vals): + stage_id = vals.get('stage_id', False) + if stage_id: + withoutw = self.filtered(lambda k: not k.workflow_id) + if withoutw: + super(Task, withoutw).write(vals) + + withw = self.filtered( + lambda k: k.project_id.allow_workflow and k.workflow_id + ) + stage_id = vals.pop('stage_id') + for task in withw: + task.workflow_id.trigger(task, stage_id) + return super(Task, withw).write(vals) + else: + return super(Task, self).write(vals) + + def stage_find(self, section_id, domain=None, order='sequence'): + if self.project_id and \ + self.project_id.allow_workflow and self.project_id.workflow_id: + if not self.project_id.workflow_id.default_state_id: + raise exceptions.ValidationError(_( + "Project workflow '%s' has no default state." + "Please configure the workflow, so that we know what " + "default stage should be" + ) % self.project_id.workflow_id.name) + return self.project_id.workflow_id.default_state_id.stage_id.id + else: + if not domain: + domain = [] + return super(Task, self).stage_find(section_id, domain, order) + + @api.model + def _get_tracked_fields(self, updated_fields): + tracked_fields = super(Task, self)._get_tracked_fields(updated_fields) + + if 'wkf_stage_id' in tracked_fields: + del tracked_fields['wkf_stage_id'] + + return tracked_fields diff --git a/project_workflow/models/project_workflow.py b/project_workflow/models/project_workflow.py new file mode 100644 index 0000000..054071d --- /dev/null +++ b/project_workflow/models/project_workflow.py @@ -0,0 +1,564 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, fields, api, exceptions, _ +from odoo.tools import safe_eval + + +class Workflow(models.Model): + _name = 'project.workflow' + _description = 'Project Workflow' + + name = fields.Char( + string='Name', + required=True, + help="The name of the workflow. It has to be unique!" + ) + + description = fields.Html( + string='Description', + help="Describe this workflow for your colleagues ..." + ) + + state_ids = fields.One2many( + comodel_name='project.workflow.state', + inverse_name='workflow_id', + string='States', + copy=True, + help="The list of all possible states a task can be in." + ) + + default_state_id = fields.Many2one( + comodel_name='project.workflow.state', + string="Default state", + help="Stage from this state will be set by default if not specified " + "when creating task.", + ) + + transition_ids = fields.One2many( + comodel_name='project.workflow.transition', + inverse_name='workflow_id', + ondelete="cascade", + string='Transitions', + copy=True, + help="The list of all state transitions." + ) + + project_ids = fields.One2many( + comodel_name='project.project', + inverse_name='workflow_id', + string='Projects', + help="The list of related projects." + ) + + state = fields.Selection( + selection=[('draft', 'Draft'), ('live', 'Live')], + string='State', + default='draft', + copy=False, + index=True, + ) + + original_id = fields.Many2one( + comodel_name='project.workflow', + string='Origin', + ondelete="cascade", + ) + + original_name = fields.Char( + string='Original', + related='original_id.name', + readonly=True, + ) + + edit_ids = fields.One2many( + comodel_name='project.workflow', + inverse_name='original_id', + string='Drafts', + ) + + edit_count = fields.Integer( + string='Edit Count', + compute="_compute_edit_count", + ) + + stage_ids = fields.Many2many( + comodel_name='project.task.type', + compute="_compute_stage_ids", + string='All workflow task stages' + ) + + @api.multi + def _compute_stage_ids(self): + for record in self: + record.stage_ids = [x.stage_id.id for x in record.state_ids] + + _sql_constraints = [ + ('unique_workflow_name', 'UNIQUE(original_id, name)', + 'Project workflow with this name already exists!') + ] + + @api.depends('edit_ids') + @api.multi + def _compute_edit_count(self): + for workflow in self: + workflow.edit_count = len(workflow.edit_ids) + + @api.multi + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + new = super(Workflow, self).copy(default=default) + + states = dict() + for state in new.state_ids: + states[state.stage_id.id] = state + + def get_new_default_state(): + for state in new.state_ids: + if state.stage_id.id == self.default_state_id.stage_id.id: + return state + new_update = { + 'default_state_id': get_new_default_state().id + } + for transition in new.transition_ids: + src_id = states[transition.src_id.stage_id.id].id + dst_id = states[transition.dst_id.stage_id.id].id + transition.write({'src_id': src_id, 'dst_id': dst_id}) + + default = default or {} + if 'name' not in default: + new_update['name'] = "%s (COPY)" % new.name + + new.write(new_update) + + return new + + @api.multi + def unlink(self): + for workflow in self: + if len(workflow.project_ids) != 0: + projects = [p.name for p in workflow.project_ids] + raise exceptions.ValidationError(_( + "You are not allowed do delete this workflow because it is" + " being used by the following projects: %s" + ) % projects + ) + return super(Workflow, self).unlink() + + @api.multi + def is_live(self): + """ + Gets a value indicating whether this workflow has been published or not + :return: Returns a value indicating whether this workflow has been + published or not. + """ + self.ensure_one() + return self.state == 'live' + + @api.multi + def is_draft(self): + """ + Gets a value indicating whether this workflow has been published or not + :return: Returns a value indicating whether this workflow has been + published or not. + """ + self.ensure_one() + return self.state == 'draft' + + def find_transition(self, task, stage_id): + def check_transition(t, s): + return t.workflow_id and t.stage_id.id != s + + if not check_transition(task, stage_id): + return False + + transitions = self.find_transitions( + task, task.stage_id.id, group_by='stage_id' + ) + + if stage_id not in transitions: + raise exceptions.ValidationError(_( + "Transition to this state is not supported " + "from the current task state!\n" + "Please refer to the project workflow '%s' to " + "see all possible transitions from " + "the current state or you could view task in " + "form view and see possible transitions" + "from there." + ) % self.name) + + return transitions[stage_id] + + @api.multi + def trigger(self, task, target_stage_id): + self.ensure_one() + + transition = self.find_transition(task, target_stage_id) + + if not transition: + return + + if transition['global']: + self.env['project.workflow.state'].browse( + transition['state_id'] + ).apply(task) + else: + self.env['project.workflow.transition'].browse( + transition['transition_id'] + ).apply(task) + + @api.model + def get_state_transitions(self, workflow_id, stage_id, task_id): + if not workflow_id or not stage_id: + return [] + + workflow = self.browse(workflow_id) + task = self.env['project.task'].browse(task_id) + + transitions = workflow.find_transitions(task, stage_id) + return transitions + + def find_transitions(self, task, stage_id, group_by=None): + def get_state(stage_id): + for state in self.state_ids: + if state.stage_id.id == stage_id: + return state + return False + + state = get_state(stage_id) + if not state: + return [] + + transitions = [] + if state.is_global: + for transition in self.transition_ids: + transitions.append(self._populate_state_for_widget(transition)) + + else: + transitions = [ + self._populate_state_for_widget(x) + for x in self.get_available_transitions(task, state) + ] + + global_states = self.state_ids.filtered( + lambda r: r.is_global and r.stage_id.id != stage_id + ) + + for state in global_states: + transitions.append({ + 'global': True, + 'state_id': state.id, + 'name': state.name, + 'description': state.description, + 'confirmation': False, + 'id': state.stage_id.id, + 'sequence': state.sequence, + }) + + if group_by and group_by == 'stage_id': + grouped_by = dict() + for transition in transitions: + grouped_by[transition['id']] = transition + transitions = grouped_by + return transitions + + def get_available_transitions(self, task, state): + return state.out_transitions + + @api.model + def _populate_state_for_widget(self, transition): + return { + 'global': False, + 'transition_id': transition.id, + 'name': transition.name, + 'desc': transition.description, + 'confirmation': transition.user_confirmation, + 'id': transition.dst_id.stage_id.id, + 'sequence': transition.dst_id.sequence, + } + + @api.multi + def export_workflow(self): + self.ensure_one() + wizard = self.env['project.workflow.export.wizard'].create( + {'workflow_id': self.id} + ) + return wizard.button_export() + + @api.multi + def edit_workflow(self): + self.ensure_one() + + # By default we want to edit current workflow + edit = self + + # For live workflow we want to create working copy or reuse it. + if self.is_live(): + if len(self.edit_ids) == 0: + edit = self.copy({ + 'name': _("Draft Version of '%s'") % self.name, + 'state': 'draft', + 'default_state_id': self.default_state_id.id, + 'original_id': self.id + }) + pass + else: + edit = self.edit_ids[0] + + action = self.env['ir.actions.act_window'].for_xml_id( + 'project_workflow', 'project_workflow_diagram_edit_action' + ) + + ctx = safe_eval(action['context']) + + ctx['active_id'] = edit.id + ctx['active_ids'] = [edit.id] + action['res_id'] = edit.id + + action['context'] = ctx + + return action + + @api.multi + def publish_workflow(self): + self.ensure_one() + + if self.is_draft() and self.original_id: + publisher = self.get_workflow_publisher() + result = publisher.publish(self.original_id, self) + + if result.has_conflicts: + from_diagram = self.env.context.get('diagram', False) + action = result.action + action_context = safe_eval(action.get('context', '{}')) + action_context['default_from_diagram'] = from_diagram + action['context'] = action_context + return action + + else: + self.state = 'live' + + def get_workflow_publisher(self): + return self.env['project.workflow.publisher'] + + # This is a workaround! + # This should be checked once we upgrade to a newer version of Odoo. + # because it does not make sense for context not to be allowed on + # tree view buttons. + @api.multi + def discard_working_copy_from_tree(self): + self.ensure_one() + self.with_context(original=True, origin='tree').discard_working_copy() + + @api.multi + def discard_working_copy(self): + self.ensure_one() + + if self.env.context.get('original', False): + for edit in self.edit_ids: + edit.unlink() + return {'type': 'ir.actions.act_view_reload'} + + self.unlink() + + return { + 'type': 'ir.actions_act_multi', + 'actions': [ + {'type': 'history_back'}, + {'type': 'ir.actions_act_view_reload'}, + ] + } + + @api.multi + def get_formview_id(self): + return self.env.ref("project_workflow.project_workflow_form").id + + @api.multi + def get_formview_action(self): + view_id = self.get_formview_id() + ctx = dict(self._context) + ctx['edit'] = False + ctx['create'] = False + + return { + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'view_type': 'form', + 'view_mode': 'form', + 'views': [(view_id, 'form')], + 'target': 'current', + 'res_id': self.id, + 'context': ctx, + } + + +class WorkflowState(models.Model): + _name = 'project.workflow.state' + _description = 'Project Workflow State' + _order = 'sequence' + + stage_id = fields.Many2one( + comodel_name='project.task.type', + string='Stage', + required=True, + ondelete="restrict", + index=True, + ) + + name = fields.Char( + string='Name', + related='stage_id.name', + requried=True, + ) + + description = fields.Text( + string='Description', + related='stage_id.description', + requried=True, + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + string='Workflow', + required=True, + ondelete="cascade", + ) + + is_default = fields.Boolean( + string="Is default", + compute="_compute_is_default", + inverse="_inverse_is_default", + ) + + is_global = fields.Boolean( + string='Is global?', + default=False, + help="When checked it will allow all transitions from/to this state.", + ) + + out_transitions = fields.One2many( + comodel_name='project.workflow.transition', + inverse_name='src_id', + string='Outgoing Transitions' + ) + + in_transitions = fields.One2many( + comodel_name='project.workflow.transition', + inverse_name='dst_id', + string='Incoming Transitions' + ) + + type = fields.Selection( + selection=[ + ('todo', 'ToDo'), + ('in_progress', 'In Progress'), + ('done', 'Done'), + ], + default='in_progress', + string='Type', + required=True, + ) + + xpos = fields.Integer( + string='X', + default=50, + copy=True, + ) + + ypos = fields.Integer( + string='Y', + default=50, + copy=True, + ) + + sequence = fields.Integer( + string='Sequence', + default=0, + ) + + kanban_sequence = fields.Integer( + string='Kanban Sequence', + default=0, + ) + + _sql_constraints = [ + ('unique_state_stage', 'UNIQUE(workflow_id,stage_id)', + 'This state already exists!' + ) + ] + + @api.multi + def _compute_is_default(self): + for record in self: + default_state = record.workflow_id.default_state_id + record.is_default = default_state.id == record.id + + @api.multi + def _inverse_is_default(self): + for record in self: + if record.is_default: + record.workflow_id.default_state_id = record.id + + @api.model + def name_search(self, name='', args=None, operator='ilike', limit=100): + state_ids = self.env.context.get('state_ids', False) + if state_ids: + args = args or [] + args.append(('id', 'in', [x[1] for x in state_ids])) + + return super(WorkflowState, self).name_search( + name, args, operator, limit + ) + + def apply(self, task): + task._write({'stage_id': self.stage_id.id}) + + +class WorkflowTransition(models.Model): + _name = 'project.workflow.transition' + _description = 'Project Workflow Transition' + + name = fields.Char( + string='Name', + required=True, + ) + + description = fields.Html( + string='Description' + ) + + workflow_id = fields.Many2one( + comodel_name='project.workflow', + string='Workflow', + required=True, + ondelete="cascade", + ) + + src_id = fields.Many2one( + comodel_name='project.workflow.state', + string='Source Stage', + required=True, + index=True, + ondelete="cascade", + ) + + dst_id = fields.Many2one( + comodel_name='project.workflow.state', + string='Destination Stage', + required=True, + index=True, + ondelete="cascade", + ) + + user_confirmation = fields.Boolean( + string='User Confirmation?', + default=False + ) + + _sql_constraints = [ + ('unique_src_dst', 'UNIQUE(workflow_id,src_id,dst_id)', + 'This transition already exists!'), + ] + + def apply(self, task): + self.dst_id.apply(task) diff --git a/project_workflow/models/project_workflow_importer.py b/project_workflow/models/project_workflow_importer.py new file mode 100644 index 0000000..7a6b74c --- /dev/null +++ b/project_workflow/models/project_workflow_importer.py @@ -0,0 +1,152 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging +from odoo import models, exceptions, _ + +_logger = logging.getLogger(__name__) + + +class WorkflowImporter(models.AbstractModel): + _name = 'project.workflow.importer' + + def _load_task_stages(self): + """ + Creates name based dictionary out of all ``project.task.type`` records. + :return: Returns stage dictionary + """ + stages = dict() + for stage in self.env['project.task.type'].search([]): + stages[self.get_stage_name(stage)] = stage + return stages + + def get_stage_name(self, stage): + return stage.name + + def run(self, reader, stream): + """ + Runs import process of the given project workflow data stream. + :param stream: The stream of data to be imported. + :param reader: The stream data reader. + :return: Returns + """ + if reader is None: + raise exceptions.ValidationError( + _("Importer can not run without provided data reader!") + ) + + workflow = reader.wkf_read(stream) + return self._import_workflow(workflow) + + def _import_workflow(self, workflow): + """ + Imports given workflow into odoo database + :param workflow: The project workflow to be imported. + :return: Returns instance of the imported project workflow. + """ + + all_stages = self._load_task_stages() + + stages_to_create = [] + for state in workflow['states']: + if self.get_state_name(state) not in all_stages: + stages_to_create.append(state) + + # Create new stages and register them + for state in stages_to_create: + stage = self.create_stage(self.prepare_task_stage(state)) + all_stages[stage.name] = stage + + state_prep = self.prepare_state + state_name = self.get_state_name + + wkf = self.create_workflow(self.prepare_workflow(workflow, [ + (0, 0, state_prep(state, all_stages[state_name(state)].id)) + for state in workflow['states'] + ])) + + def get_state(stage_id): + for state in wkf.state_ids: + if state.stage_id == stage_id: + return state + return False + + wkf.default_state_id = get_state( + all_stages[workflow['default_state']] + ).id + + states = dict() + for state in wkf.state_ids: + states[state.name] = state + + transitions = [ + (0, 0, self.prepare_transition(t, states)) + for t in workflow['transitions'] + ] + + wkf.write({'transition_ids': transitions}) + return wkf + + def create_stage(self, stage_data): + return self.env['project.task.type'].create(stage_data) + + def create_workflow(self, workflow_data): + return self.env['project.workflow'].create(workflow_data) + + def prepare_workflow(self, workflow, state_ids): + """ + Prepares ``project.workflow`` data. + :param workflow: The workflow to be mapped to the odoo workflow + :param state_ids: The list of already odoo mapped states. + :return: Returns dictionary with workflow data ready to be saved + within odoo database. + """ + return { + 'name': workflow['name'], + 'description': workflow['description'], + 'state_ids': state_ids, + } + + def prepare_task_stage(self, state): + """ + Prepares ``project.task.type`` dictionary for saving. + :param state: Parsed state dictionary. + :return: Returns prepared ``project.task.type`` values. + """ + return { + 'name': state['name'], + 'description': state['description'], + } + + def get_state_name(self, state): + return state['name'] + + def prepare_state(self, state, stage_id): + """ + Prepares ``project.workflow.state`` dictionary for saving. + :param state: Parsed state dictionary. + :return: Returns prepared ``project.workflow.state`` values. + """ + return { + 'stage_id': stage_id, + 'sequence': state['sequence'], + 'kanban_sequence': state['kanban_sequence'], + 'type': state['type'], + 'xpos': state['xpos'], + 'ypos': state['ypos'], + } + + def prepare_transition(self, transition, states): + """ + Prepares ``project.workflow.transition`` dictionary for saving. + :param transition: Parsed transition dictionary. + :param states: Dictionary of state browse objects. + :return: Returns prepared ``project.workflow.transition`` values. + """ + return { + 'name': transition['name'], + 'description': transition['description'], + 'src_id': states[transition['src']].id, + 'dst_id': states[transition['dst']].id, + 'user_confirmation': transition['confirmation'], + } diff --git a/project_workflow/models/project_workflow_publisher.py b/project_workflow/models/project_workflow_publisher.py new file mode 100644 index 0000000..799d9e3 --- /dev/null +++ b/project_workflow/models/project_workflow_publisher.py @@ -0,0 +1,274 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +from odoo import models, exceptions, _ + + +class PublisherResult(object): + WORKFLOW_PUBLISHED = 0 + WORKFLOW_CONFLICTS = 1 + + def __init__(self, status, action=None): + self._status = status + self._action = action + + @property + def action(self): + return self._action + + @property + def status(self): + return self._status + + @property + def is_published(self): + return self.status == PublisherResult.WORKFLOW_PUBLISHED + + @property + def has_conflicts(self): + return self.status == PublisherResult.WORKFLOW_CONFLICTS + + @staticmethod + def success(): + return PublisherResult(PublisherResult.WORKFLOW_PUBLISHED) + + @staticmethod + def conflict(action): + return PublisherResult(PublisherResult.WORKFLOW_CONFLICTS, action) + + +class ProjectWorkflowPublisher(models.AbstractModel): + _name = 'project.workflow.publisher' + + def publish(self, old, new, mappings=None, project_id=None, switch=False): + if not new: + raise exceptions.ValidationError(_( + 'You have to provide the new workflow!') + ) + + if not old and not project_id: + raise exceptions.ValidationError(_( + "In case old workflow is not present, " + "you need to provide project to which, " + "a new workflow will be applied!" + )) + + diff = self.diff(old, new, project_id, switch) + + if diff['is_empty']: + return self._do_publish( + old, new, project_id=project_id, switch=switch + ) + + if self._do_map(diff, mappings, old, new, project_id): + return self._do_publish( + old, new, project_id=project_id, switch=switch + ) + + return PublisherResult.conflict( + self._get_wizard_action( + self._build_mapping_wizard( + old, new, diff, project_id=project_id, switch=switch + ) + ) + ) + + def diff(self, old, new, project_id, switch): + """ + This method will return compare result between old and new workflow + or project and workflow + :param old: The old workflow + :param new: The new workflow + :param project_id: The project on which we want to apply + the new workflow. + :return: Returns result of this comparison + """ + result = dict() + + def get_states(obj, is_workflow=True): + if is_workflow: + states = set() + for state in obj.state_ids: + states.add(state.stage_id.id) + return states + else: + self.env.cr.execute( + """ + SELECT distinct(stage_id) + FROM project_task + WHERE project_id IN %s + """, (tuple([obj.id]),) + ) + return set([x[0] for x in self.env.cr.fetchall()]) + + # Following stages has been removed from the workflow + # project_ids = [] + if old and new: + removed_stages = get_states(old) - get_states(new) + project_ids = switch and [project_id.id] or old.project_ids.ids + else: + removed_stages = get_states(project_id, False) - get_states(new) + project_ids = [project_id.id] + + # Now we need to see is any of removed stages requires mapping. + hits = [] + + for stage_id in removed_stages: + task_count = self.env['project.task'].search([ + ('project_id', 'in', project_ids), ('stage_id', '=', stage_id) + ], count=True) + + if task_count > 0: + hits.append({'id': stage_id or False, 'count': task_count}) + + result['stages'] = hits + result['is_empty'] = len(hits) == 0 + return result + + def _do_map(self, result, mappings, old, new, project_id=None): + if not self._can_be_mapped(result, mappings): + return False + + # project_ids = [] + if old and new: + project_ids = old.project_ids.ids + else: + project_ids = [project_id.id] + + if 'stages' in mappings: + for mapping in mappings['stages']: + tasks = self.env['project.task'].search([ + ('project_id', 'in', project_ids), + ('stage_id', '=', mapping['from'])]) + tasks._write({'stage_id': mapping['to']}) + return True + + def _can_be_mapped(self, result, mappings): + if not mappings: + return False + + if 'stages' in mappings: + data = set([m['from'] for m in mappings['stages']]) + for stage in result['stages']: + if stage['id'] not in data: + return False + + return True + + def _do_publish(self, old, new, project_id=None, switch=False): + if switch: + stage_ids = [(6, 0, [x.stage_id.id for x in new.state_ids])] + + if project_id: + project_id.write({ + 'workflow_id': new.id, + 'type_ids': stage_ids + }) + else: + old.project_ids.write({ + 'workflow_id': new.id, + 'type_ids': stage_ids + }) + else: + data = {} + if not new.name.startswith('Draft'): + data['name'] = new.name + + if new.description != old.description: + data['description'] = new.description + + data['default_state_id'] = False + if new.default_state_id: + data['default_state_id'] = new.default_state_id.id + + if data: + old.write(data) + + old.transition_ids.unlink() + old.state_ids.unlink() + new.transition_ids.write({'workflow_id': old.id}) + new.state_ids.write({'workflow_id': old.id}) + + new.unlink() + + return PublisherResult.success() + + def _build_mapping_wizard(self, old, new, result, project_id=None, + switch=False): + # Create Wizard + wizard = self.env['project.workflow.stage.mapping.wizard'].create( + self._prepare_mapping_wizard( + old, new, project_id=project_id, switch=switch + ) + ) + + # Bind stages for mapping + wstages = [] + for stage in result['stages']: + wstage = self.env['project.workflow.stage.mapping.wizard.stage']\ + .create(self._prepare_deleted_wizard_stage(wizard, stage)) + + wstages.append(wstage) + + # Bind list of possible stages + for state in new.state_ids: + self.env['project.workflow.stage.mapping.wizard.stage'].create( + self._prepare_possible_stage(wizard, state) + ) + + # Create finite mapping list + for wstage in wstages: + self.env['project.workflow.stage.mapping.wizard.line'].create( + self._prepare_wizard_line(wizard, wstage) + ) + + return wizard + + def _prepare_mapping_wizard(self, old, new, project_id, switch): + """ + Prepares data + :param old: + :param new: + :param switch: + :return: + """ + return { + 'from_id': old.exists() and old.id or False, + 'to_id': new.id, + 'project_id': project_id and project_id.id or False, + 'switch': switch, + 'from_diagram': self.env.context.get('diagram', False) + } + + def _prepare_deleted_wizard_stage(self, wizard, stage): + """ + Prepare data to create deleted wizard stage. + :param wizard: The wizard to which this stage belongs to. + :param stage: The deleted stage + :return: Returns prepared data + """ + return { + 'wizard_id': wizard.id, + 'type': 'from', + 'task_count': stage['count'], + 'stage_id': stage['id'], + } + + def _prepare_possible_stage(self, wizard, state): + return { + 'wizard_id': wizard.id, + 'type': 'to', + 'stage_id': state.stage_id.id, + } + + def _prepare_wizard_line(self, wizard, from_state): + return { + 'wizard_id': wizard.id, + 'from_id': from_state.id, + } + + def _get_wizard_action(self, wizard): + action = self.env['ir.actions.act_window'].for_xml_id( + 'project_workflow', 'project_workspace_mapping_wizard_action') + action['res_id'] = wizard.id + return action diff --git a/project_workflow/models/project_workflow_xml.py b/project_workflow/models/project_workflow_xml.py new file mode 100644 index 0000000..95becc1 --- /dev/null +++ b/project_workflow/models/project_workflow_xml.py @@ -0,0 +1,427 @@ +# Copyright 2017 - 2018 Modoolar +# License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import os +import logging + +from lxml import etree +from lxml.builder import ElementMaker + +from odoo import models, tools, exceptions, _ + +_logger = logging.getLogger(__name__) + + +class XmlWorkflowReader(models.AbstractModel): + _name = 'project.workflow.xml.reader' + + _rng_namespace = 'http://relaxng.org/ns/structure/1.0' + _rng_namespace_map = {'rng': 'http://relaxng.org/ns/structure/1.0'} + + def get_element_maker(self): + return ElementMaker( + namespace=self._rng_namespace, + nsmap=self._rng_namespace_map, + ) + + def validate_schema(self, xml): + """ + Validates given ``xml`` against RelaxedNG validation schema. + In case xml is invalid and ~openerp.exceptions.ValidationError + is raised. + :param xml: Xml string to be validated against RelaxedNG schema + :return: Void + """ + validator = self.create_validator() + if not validator.validate(xml): + errors = [] + for error in validator.error_log: + error = tools.ustr(error) + _logger.error(error) + errors.append(error) + raise exceptions.ValidationError( + _("Workflow File Validation Error: %s" % ",".join(errors)) + ) + + def create_validator(self): + """ + Instantiates RelaxedNG schema validator + :return: Returns RelaxedNG validator + """ + rng_file = tools.file_open(self.get_rng_file_path()) + try: + rng = etree.parse(rng_file) + rng = self.extend_rng(rng) + return etree.RelaxNG(rng) + except Exception: + raise + finally: + rng_file.close() + + def extend_rng(self, rng_etree): + """ + This method is a hook from where you can modify rng schema in cases + where you have extend workflow from another module and you want to + support import/export functionality for your extensions. + :param rng_etree: The tng tree which needs to be extended. + :return: Returns extended rng tree. + """ + return rng_etree + + def get_rng_file_path(self): + return os.path.join('project_workflow', 'rng', 'workflow.rng') + + def wkf_read(self, stream): + """ + Reads workflow from the given xml string + :param stream: The stream providing xml data + :return: Returns parsed workflow data. + """ + + workflow_tree = etree.parse(stream) + self.validate_schema(workflow_tree) + + workflow_xml = workflow_tree.getroot() + + workflow = self.read_workflow(workflow_xml) + self.validate_workflow(workflow) + + return workflow + + def validate_workflow(self, workflow): + """ + This method validates the logic of the given workflow object. + It will check if all source and destinations states referenced + in the transition element can be found within defined workflow + states. + :param workflow: The + :return: + """ + + # Convert list of workflow states into dictionary + states = dict((s['name'], s) for s in workflow['states']) + + # If the count of states in list and dictionary is different + # then we have a potential problem + if len(states) != len(workflow['states']): + raise exceptions.ValidationError( + _("You have defined one or more states with the same name!") + ) + + # Next we check if all source and destination states can be found + # in the states dictionary + missing_states = set() + for transition in workflow['transitions']: + for state in ['src', 'dst']: + value = transition[state] + if value not in states: + missing_states.add(value) + + # In case we have missing states we simply raise exception + if len(missing_states) > 0: + raise exceptions.ValidationError(_( + "Following state(s) are referenced in the transitions but can" + " not be found: [%s]" + ) % ",".join(missing_states)) + + if not workflow.get('default_state', False): + raise exceptions.ValidationError( + _("Workflow default state is missing!") + ) + + def read_workflow(self, element): + """ + Reads workflow data out of the given xml element. + :param element: The xml element which holds information + about project workflow. + :return: Returns workflow dictionary. + """ + return { + 'name': self.read_string(element, 'name'), + 'description': self.read_string(element, 'description'), + 'states': self.read_states(element), + 'transitions': self.read_transitions(element), + 'default_state': self.read_string(element, 'default-state') + } + + def read_states(self, element): + """ + Reads workflow states data out of the given xml element. + :param element: The xml element which holds information + about project workflow states + :return: Returns the list of the workflow states + """ + states = [] + for e in element.iterfind('states/state'): + states.append(self.read_state(e)) + return states + + def read_state(self, element): + """ + Reads workflow state data out of the given xml element. + :param element: The xml element which holds information + about project workflow state + :return: Returns workflow state dictionary + """ + return { + 'name': self.read_string(element, 'name'), + 'type': self.read_string(element, 'type', 'in_progress'), + 'description': self.read_string(element, 'description'), + 'xpos': self.read_integer(element, 'xpos', -1), + 'ypos': self.read_integer(element, 'ypos', -1), + 'sequence': self.read_integer( + element, 'sequence', default_value=1), + 'kanban_sequence': self.read_integer( + element, 'kanban_sequence', default_value=10) + } + + def read_transitions(self, element): + """ + Reads workflow transitions data out of the given xml element. + :param element: The xml element which holds information about + project workflow transitions. + :return: Returns the list of the workflow transitions. + """ + transitions = [] + for e in element.iterfind('transitions/transition'): + transitions.append(self.read_transition(e)) + return transitions + + def read_transition(self, element): + """ + Reads ``project.workflow.transition`` data + out of the given xml element. + :param element: The xml element which holds information + about project workflow transition. + :return: Returns workflow transition dictionary. + """ + return { + 'name': self.read_string(element, 'name'), + 'description': self.read_string(element, 'description'), + 'src': self.read_string(element, 'src'), + 'dst': self.read_string(element, 'dst'), + 'confirmation': self.read_string(element, 'confirmation'), + 'kanban_color': self.read_string( + element, 'kanban-color', default_value='1') + } + + def read_string(self, element, attribute_name, default_value=''): + """ + Reads attribute of type ``string`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``string`` + """ + return self.read_attribute(element, attribute_name, default_value) + + def read_integer(self, element, attribute_name, default_value=0): + """ + Reads attribute of type ``integer`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``integer``. + """ + return int(self.read_attribute(element, attribute_name, default_value)) + + def read_boolean(self, element, attribute_name, default_value=False): + """ + Reads attribute of type ``boolean`` from the given xml element. + :param element: The xml element from which the attribute value is read. + :param attribute_name: The name of the xml attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value of type ``boolean``. + """ + return bool(self.read_attribute( + element, attribute_name, default_value) + ) + + def read_attribute(self, element, name, default_value=None): + """ + Reads attribute value of the given ``name`` from the given xml element. + :param element: The xml element from which attribute. + :param name: The name of the attribute. + :param default_value: The default value in case + the attribute is not present within xml element. + :return: Returns attribute value or the default value. + """ + return element.attrib.get(name, default_value) + + +DEFAULT_ENCODING = 'utf-8' + + +class XmlWorkflowWriter(models.AbstractModel): + _name = 'project.workflow.xml.writer' + + def wkf_write(self, workflow, stream, encoding=DEFAULT_ENCODING): + """ + Converts given ``workflow`` object to the xml and then + writes it down to the given ``stream`` object. + :param workflow: The ``project.workflow`` browse object + to be written down to the given stream object. + :param stream: This object represent any data stream object + but it must have write method. + :return: + """ + str = self.to_string(workflow, encoding) + if encoding != "unicode": + str = str.decode(encoding) + stream.write(str) + + def to_string(self, workflow, encoding=DEFAULT_ENCODING): + """ + Gets xml string representation of the given ``workflow`` object. + :param workflow: The ``project.workflow`` browse object + to be converted to the xml string. + :return: Returns xml string representation + of the give ``workflow`` object. + """ + return etree.tostring( + self._build_xml(workflow, element_tree=True), + encoding=encoding, + pretty_print=True + ) + + def _build_xml(self, workflow, element_tree=False): + """ + Builds xml out of given ``workflow`` object. + :param workflow: The ``project.workflow`` browse object. + :param element_tree: Boolean indicating whteter to wrap + root element into ``ElementTree`` or not. + :return: Returns workflow xml as a root element or as an element tree. + """ + root = self.create_workflow_element(workflow) + + states = self.create_states_element(root, workflow) + for state in workflow.state_ids: + self.create_state_element(states, state) + + transitions = self.create_transitions_element(root, workflow) + for transition in workflow.transition_ids: + self.create_transition_element(transitions, transition) + + return element_tree and etree.ElementTree(root) or root + + def create_workflow_element(self, workflow): + """ + This method creates root workflow xml element. + :param workflow: The ``project.workflow`` browse object. + :return: Returns a new root workflow xml element. + """ + attributes = self.prepare_workflow_attributes(workflow) + return etree.Element('project-workflow', attributes) + + def prepare_workflow_attributes(self, workflow): + """ + This method prepares attribute values for a workflow element. + :param state: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return { + 'name': workflow.name, + 'description': workflow.description, + 'default-state': workflow.default_state_id.name + } + + def create_states_element(self, parent, workflow): + """ + This method creates state xml element. + :param parent: The parent element of the new states element. + :param workflow: The ``project.workflow`` browse object. + :return: Returns a new state xml element. + """ + attributes = self.prepare_states_attributes(workflow) + return etree.SubElement(parent, 'states', attributes) + + def prepare_states_attributes(self, workflow): + """ + This method prepares attribute values for a ``states`` element. + At the moment this method does nothing but it's added here + for possible future usage. + :param workflow: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return {} + + def create_state_element(self, parent, state): + """ + This method creates state xml element. + :param parent: The parent element of the new state element. + :param state: The ``project.workflow.state`` browse object. + :return: Returns a new state xml element. + """ + attributes = self.prepare_state_attributes(state) + return etree.SubElement(parent, 'state', attributes) + + def prepare_state_attributes(self, state): + """ + This method prepares attribute values for a state element. + :param state: The ``project.workflow.state`` browse object. + :return: Returns dictionary with attribute values. + """ + values = { + 'name': state.stage_id.name, + 'type': state.type, + 'xpos': str(state.xpos), + 'ypos': str(state.ypos), + 'sequence': str(state.sequence), + 'kanban_sequence': str(state.kanban_sequence), + } + + if state.stage_id.description: + values['description'] = state.description + + return values + + def create_transitions_element(self, parent, workflow): + """ + This method creates transition xml element. + :param parent: The parent element of the new transition element. + :param workflow: The ``project.workflow`` browse object. + :return: Returns a new transition xml element. + """ + attributes = self.prepare_transitions_attributes(workflow) + return etree.SubElement(parent, 'transitions', attributes) + + def prepare_transitions_attributes(self, workflow): + """ + This method prepares attribute values for a ``transitions`` element. + At the moment this method does nothing but it's added here + for possible future usage. + :param workflow: The ``project.workflow`` browse object. + :return: Returns dictionary with attribute values. + """ + return {} + + def create_transition_element(self, parent, transition): + """ + This method creates transition xml element. + :param parent: The parent element of the new transition element. + :param transition: The ``project.workflow.transition`` browse object. + :return: Returns a new transition xml element. + """ + values = self.prepare_transition_attributes(transition) + return etree.SubElement(parent, 'transition', values) + + def prepare_transition_attributes(self, transition): + """ + This method prepares attribute values for a transition element. + :param transition: The ``project.workflow.transition`` browse object. + :return: Returns dictionary with attribute values. + """ + values = { + 'name': transition.name, + 'src': transition.src_id.stage_id.name, + 'dst': transition.dst_id.stage_id.name, + 'confirmation': str(transition.user_confirmation or False), + } + + if transition.description: + values['description'] = transition.description + + return values diff --git a/project_workflow/rng/workflow.rng b/project_workflow/rng/workflow.rng new file mode 100644 index 0000000..632b13a --- /dev/null +++ b/project_workflow/rng/workflow.rng @@ -0,0 +1,68 @@ + + + + + + + + + + todo + in_progress + done + + + + + + + + + + + + + + + + + + + + True + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/project_workflow/security/ir.model.access.csv b/project_workflow/security/ir.model.access.csv new file mode 100644 index 0000000..e331260 --- /dev/null +++ b/project_workflow/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_project_task_workflow_user,project.workflow,model_project_workflow,project.group_project_user,1,0,0,0 +access_project_task_workflow_portal,project.workflow,model_project_workflow,base.group_portal,1,0,0,0 +access_project_task_workflow_manager,project.workflow,model_project_workflow,project.group_project_manager,1,1,1,1 + +access_project_task_workflow_state_user,project.workflow.state,model_project_workflow_state,project.group_project_user,1,0,0,0 +access_project_task_workflow_state_portal,project.workflow.state,model_project_workflow_state,base.group_portal,1,0,0,0 +access_project_task_workflow_state_manager,project.workflow.state,model_project_workflow_state,project.group_project_manager,1,1,1,1 + +access_project_task_workflow_transition_user,project.workflow.transition,model_project_workflow_transition,project.group_project_user,1,0,0,0 +access_project_task_workflow_transition_portal,project.workflow.transition,model_project_workflow_transition,base.group_portal,1,0,0,0 +access_project_task_workflow_transition_manager,project.workflow.transition,model_project_workflow_transition,project.group_project_manager,1,1,1,1 diff --git a/project_workflow/static/description/icon.png b/project_workflow/static/description/icon.png new file mode 100644 index 0000000..d3ec5d1 Binary files /dev/null and b/project_workflow/static/description/icon.png differ diff --git a/project_workflow/static/src/js/diagram_controller.js b/project_workflow/static/src/js/diagram_controller.js new file mode 100644 index 0000000..14aabd2 --- /dev/null +++ b/project_workflow/static/src/js/diagram_controller.js @@ -0,0 +1,132 @@ +// Copyright 2017 - 2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +odoo.define('project_workflow.DiagramController', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); +var rpc = require('web.rpc'); + +var _t = core._t; +var QWeb = core.qweb; + +require('web_diagram.DiagramController').include({ + renderButtons: function ($node) { + if (this.modelName === 'project.workflow') { + var self = this; + + this.$buttons = $(QWeb.render("ProjectWorkflow.buttons", {'widget': this})); + this.$buttons.on('click', '.o_diagram_edit', function() { + self.edit_workflow(); + }); + + this.$buttons.on('click', '.o_diagram_new_button', function() { + self._addNode(); + }); + + this.$buttons.on('click', '.o_diagram_publish', function() { + self.button_workflow_publish(); + }); + + this.$buttons.on('click', '.o_diagram_discard', function() { + self.button_workflow_discard(); + }); + + this.$buttons.on('click', '.o_diagram_export', function() { + self.button_workflow_export(); + }); + + $node = $node || this.options.$buttons; + this.$buttons.appendTo($node); + + } else { + this._super($node); + } + }, + + _addNode(){ + return this._super.apply(this, arguments); + }, + + edit_workflow(){ + var self = this; + rpc.query({ + model: 'ir.model.data', + method: 'xmlid_to_res_id', + args: ["project_workflow.edit_project_workflow"], + }).then(function(view_id){ + var title = _t('Workflow'); + new dialogs.FormViewDialog(self, { + res_model: self.modelName, + res_id: self.model.res_id, + view_id: view_id, + context: self.context, + title: _t("Edit:") + title, + disable_multiple_selection: true, + }).open(); + }); + }, + + button_workflow_publish: function(){ + var self = this; + var publish_workflow = function(){ + rpc.query({ + model: self.modelName, + method: 'read', + args: [[self.model.res_id], ['original_name']] + }).then(function(data){ + var wkf_name = data[0].original_name; + console.log(data); + + rpc.query({ + model: 'project.workflow', + method: 'publish_workflow', + args: [self.model.res_id], + context: {diagram:true} + }).then(function(result){ + console.log("Publish Action: ", result); + if (result) + self.do_action(result); + else + return self.do_action({'type': 'history.back'}).then(function () { + Dialog.alert(self, _t("Workflow '" + wkf_name + "' has been successfully published!")); + }); + }); + }); + }; + + Dialog.confirm(self, _t("Are you sure you want to publish this workflow?"), { confirm_callback: publish_workflow }) + }, + + button_workflow_discard: function(){ + var self = this; + + var discard_workflow = function(){ + rpc.query({ + model: 'project.workflow', + method: 'discard_working_copy', + args: [[self.model.res_id]], + }).then(function(result){ + if (result) + self.do_action(result); + }); + } + + Dialog.confirm(self, _t("Are you sure you want to discard this workflow?"), { confirm_callback: discard_workflow }) + }, + + button_workflow_export: function () { + var self = this; + + rpc.query({ + model: 'project.workflow', + method: 'export_workflow', + args: [self.model.res_id], + }).then(function(result) { + self.do_action(result); + }); + }, +}); + +}); diff --git a/project_workflow/static/src/js/diagram_model.js b/project_workflow/static/src/js/diagram_model.js new file mode 100644 index 0000000..dc3397a --- /dev/null +++ b/project_workflow/static/src/js/diagram_model.js @@ -0,0 +1,15 @@ +// Copyright 2017 - 2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +odoo.define('project_workflow.DiagramModel', function (require) { +"use strict"; + +require('web_diagram.DiagramModel').include({ + + _fetchDiagramInfo: function () { + if (!this.res_id) { + return this.do_action({'type': 'history.back'}); + } + return this._super.apply(this, arguments); + }, +}); +}); diff --git a/project_workflow/static/src/js/diagram_renderer.js b/project_workflow/static/src/js/diagram_renderer.js new file mode 100644 index 0000000..83e5c44 --- /dev/null +++ b/project_workflow/static/src/js/diagram_renderer.js @@ -0,0 +1,29 @@ +// Copyright 2017 - 2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +odoo.define('project_workflow.DiagramRenderer', function (require) { +"use strict"; + +require('web_diagram.DiagramRenderer').include({ + + _get_style: function(){ + var style = this._super(); + + if (this.getParent().modelName === 'project.workflow') { + style.yellow = "#f6c342"; + style.green = "#14892c"; + style.blue = "#4a6785"; + + // Original node size: + //style.node_size_x = 110; // width + //style.node_size_y = 80; // height + + style.node_size_x = 100; + style.node_size_y = 30; + } + + return style; + }, + +}); + +}); diff --git a/project_workflow/static/src/js/portal_task_workflow.js b/project_workflow/static/src/js/portal_task_workflow.js new file mode 100644 index 0000000..55863cc --- /dev/null +++ b/project_workflow/static/src/js/portal_task_workflow.js @@ -0,0 +1,38 @@ +// Copyright 2017 - 2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). +odoo.define('project_workflow.workflow', function (require) { +'use strict'; + +var rpc = require('web.rpc'); +require('web.dom_ready'); +/* + * This file is intended to add interactivity to task form rendered by + * the website engine. + */ + +var task_transition_buttons = $('.task-transition-button'); + +if(!task_transition_buttons.length) { + return $.Deferred().reject("DOM doesn't contain project_portal_workflow elements"); +} + +task_transition_buttons.on('click',function(e){ + var $btn = $(this); + $btn.prop('disabled', true); + rpc.query({ + model: 'project.task', + method: 'write', + args: [[parseInt(e.currentTarget.getAttribute('task'), 10)],{ + stage_id: parseInt(e.currentTarget.getAttribute('data'), 10), + },], + }) + .fail(function() { + $btn.prop('disabled', false); + }) + .done(function () { + window.location.reload(); + }); +}); + + +}); diff --git a/project_workflow/static/src/js/task_workflow.js b/project_workflow/static/src/js/task_workflow.js new file mode 100644 index 0000000..9b10ef4 --- /dev/null +++ b/project_workflow/static/src/js/task_workflow.js @@ -0,0 +1,159 @@ +// Copyright 2017 - 2018 Modoolar +// License LGPLv3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +odoo.define('project_workflow.TaskWorkflow', function (require) { + "use strict"; + + var config = require('web.config'); + var core = require('web.core'); + var field_registry = require('web.field_registry'); + + + var AbstractField = require('web.AbstractField'); + var rpc = require('web.rpc'); + var QWeb = core.qweb; + + var DEFAULT_VISIBLE_TRANSITIONS = 3; + + var TaskWorkflow = AbstractField.extend({ + className: 'o_statusbar_status', + events: { + 'click button:not(.dropdown-toggle)': '_onClickStage', + }, + supportedFieldTypes: ['many2one'], + + init: function () { + this._super.apply(this, arguments); + this._onClickStage = _.debounce(this._onClickStage, 300, true); // TODO maybe not useful anymore ? + + this.nodeOptions.no_visible_transitions = this.nodeOptions.no_visible_transitions || DEFAULT_VISIBLE_TRANSITIONS; + this.max_visible_transitions = this.nodeOptions.no_visible_transitions; + + this.confirmation_action = { + module:"project_workflow", + xml_id:"wkf_project_task_confirmation_action", + }; + }, + + willStart: function(){ + var self = this; + return this._super.apply(this, arguments).then(()=> { + return self._fetch_available_transitions(); + }); + }, + + _fetch_available_transitions: function () { + let self = this; + return rpc.query({ + model: 'project.workflow', + method: 'get_state_transitions', + args: [this.recordData.workflow_id.res_id, this.recordData.stage_id.res_id, this.recordData.id], + }).then(function (transitions) { + + self.transitions_available = {}; + + _.each(transitions, function (transition) { + self.transitions_available[transition.id] = transition; + }); + + let count = 0 + + transitions = transitions.sort((a, b) => a.sequence > b.sequence); + + self.transitions_visible = []; + while(count < transitions.length && count < self.max_visible_transitions){ + self.transitions_visible.push(transitions[count++]); + } + + self.transitions_hidden = []; + while (count < transitions.length){ + self.transitions_hidden.push(transitions[count++]); + } + }); + }, + + _render: function() { + this.$el.off('click','button[data-id]',this._onClickStage); + + var $content = $(QWeb.render("TaskWorkflowNavigation.content", { + 'widget': this, + })); + + this.$el.empty().append($content.get().reverse()); + this.$el.on('click','button[data-id]',this._onClickStage); + }, + + _onClickStage: function (e) { + let state = this.transitions_available[$(e.currentTarget).data("value")] + + if (state.confirmation){ + this._do_confirmation(state); + } else { + this._update_state(state); + } + }, + + _update_state: function(state){ + this.getParent().trigger_up('field_changed', { + dataPointID: this.getParent().state.id, + changes: this._prepare_values_for_update(state), + }); + }, + + _prepare_values_for_update(state){ + let changes = {}; + changes['stage_id'] = { id: state.id }; + return changes; + }, + + _do_confirmation: function(state){ + var self = this; + return rpc.query({ + model: 'ir.actions.act_window', + method: 'for_xml_id', + args: [this.confirmation_action.module, this.confirmation_action.xml_id], + }).then(function(action){ + var options = self.build_confirmation_options(state); + return self.do_action(action, options); + }); + }, + + build_confirmation_context: function(state){ + let parent = this.getParent(); + let parent_state = parent.state; + var context = parent_state.getContext(); + context['default_task_id'] = this.res_id; + context['default_stage_id'] = state.id; + return context; + }, + + build_confirmation_options: function (state) { + var self = this; + var options = {}; + options.additional_context = this.build_confirmation_context(state); + options.on_close = function(){ + self.getParent().getParent().reload.bind(self); + + // After action has been executed I have to check to see if the action has been canceled. + // The only way that I know to do this is to read stage value from the server. + // If the ID value of the clicked transition stage is the same as the value on the server + // then the user has applied transition + return rpc.query({ + model: 'project.task', + method: 'read', + args: [self.res_id, ['stage_id']], + }).then(function(record){ + if (record[0].stage_id[0] == state.id) + self._update_state(state); + }); + }; + return options; + }, + + }); + + field_registry.add('task_workflow', TaskWorkflow); + + return TaskWorkflow; + +}); diff --git a/project_workflow/static/src/xml/base.xml b/project_workflow/static/src/xml/base.xml new file mode 100644 index 0000000..02def0e --- /dev/null +++ b/project_workflow/static/src/xml/base.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/project_workflow/static/src/xml/diagram.xml b/project_workflow/static/src/xml/diagram.xml new file mode 100644 index 0000000..9bee933 --- /dev/null +++ b/project_workflow/static/src/xml/diagram.xml @@ -0,0 +1,32 @@ + + + diff --git a/project_workflow/views/portal_templates.xml b/project_workflow/views/portal_templates.xml new file mode 100644 index 0000000..e08b9f2 --- /dev/null +++ b/project_workflow/views/portal_templates.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/project_workflow/views/project_workflow.xml b/project_workflow/views/project_workflow.xml new file mode 100644 index 0000000..701a5cf --- /dev/null +++ b/project_workflow/views/project_workflow.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/scrummer/views/menu.xml b/scrummer/views/menu.xml new file mode 100644 index 0000000..e7477cb --- /dev/null +++ b/scrummer/views/menu.xml @@ -0,0 +1,19 @@ + + + + + Scrummer + /scrummer/web + self + + + + + diff --git a/scrummer/views/project_agile_team_views.xml b/scrummer/views/project_agile_team_views.xml new file mode 100644 index 0000000..185c50a --- /dev/null +++ b/scrummer/views/project_agile_team_views.xml @@ -0,0 +1,18 @@ + + + + + project_agile_team_form + project.agile.team + + +
    +
    +
    +
    +
    diff --git a/scrummer/views/project_project_views.xml b/scrummer/views/project_project_views.xml new file mode 100644 index 0000000..73cf458 --- /dev/null +++ b/scrummer/views/project_project_views.xml @@ -0,0 +1,35 @@ + + + + + + project.type.form + project.type + + + + + + + + + + + + project.project.form + project.project + + + + + + + + diff --git a/scrummer/views/scrummer.xml b/scrummer/views/scrummer.xml new file mode 100644 index 0000000..27fd73d --- /dev/null +++ b/scrummer/views/scrummer.xml @@ -0,0 +1,279 @@ + + + + + + + + + + + +