diff --git a/project_sla/__init__.py b/project_sla/__init__.py
new file mode 100644
index 00000000..9ea8fe78
--- /dev/null
+++ b/project_sla/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+import project_sla
+import analytic_account
+import project_sla_control
+import project_issue
diff --git a/project_sla/__openerp__.py b/project_sla/__openerp__.py
new file mode 100644
index 00000000..95664164
--- /dev/null
+++ b/project_sla/__openerp__.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (C) 2013 Daniel Reis
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+{
+ 'name': 'Service Level Agreements',
+ 'summary': 'Define SLAs for your Contracts',
+ 'version': '1.0',
+ "category": "Project Management",
+ 'description': """\
+Contract SLAs
+===============
+
+SLAs are assigned to Contracts, on the Analytic Account form, SLA Definition
+separator. This is also where new SLA Definitions are created.
+
+One Contract can have several SLA Definitions attached, allowing for
+"composite SLAs". For example, a contract could have a Response Time SLA (time
+to start resolution) and a Resolution Time SLA (time to close request).
+
+
+SLA Controlled Documents
+========================
+
+Only Project Issue documents are made SLA controllable.
+However, a framework is made available to easily build extensions to make
+other documents models SLA controlled.
+
+SLA controlled documents have attached information on the list of SLA rules
+they should meet (more than one in the case for composite SLAs) and a summary
+SLA status:
+
+ * "watching" the service level (it has SLA requirements to meet)
+ * under "warning" (limit dates are close, special attention is needed)
+ * "failed" (one on the SLA limits has not been met)
+ * "achieved" (all SLA limits have been met)
+
+Transient states, such as "watching" and "warning", are regularly updated by
+a hourly scheduled job, that reevaluates the warning and limit dates against
+the current time and changes the state when find dates that have been exceeded.
+
+To decide what SLA Definitions apply for a specific document, first a lookup
+is made for a ``analytic_account_id`` field. If not found, then it will
+look up for the ``project_id`` and it's corresponding ``analytic_account_id``.
+
+Specifically, the Service Desk module introduces a Analytic Account field for
+Project Issues. This makes it possible for a Service Team (a "Project") to
+have a generic SLA, but at the same time allow for some Contracts to have
+specific SLAs (such as the case for "premium" service conditions).
+
+
+SLA Definitions and Rules
+=========================
+
+New SLA Definitions are created from the Analytic Account form, SLA Definition
+field.
+
+Each definition can have one or more Rules.
+The particular rule to use is decided by conditions, so that you can set
+different service levels based on request attributes, such as Priority or
+Category.
+Each rule condition is evaluated in "sequence" order, and the first onea to met
+is the one to be used.
+In the simplest case, a single rule with no condition is just what is needed.
+
+Each rule sets a number of hours until the "limit date", and the number of
+hours until a "warning date". The former will be used to decide if the SLA
+was achieved, and the later can be used for automatic alarms or escalation
+procedures.
+
+Time will be counted from creation date, until the "Control Date" specified for
+the SLA Definition. That would usually be the "Close" (time until resolution)
+or the "Open" (time until response) dates.
+
+The working calendar set in the related Project definitions will be used (see
+the "Other Info" tab). If none is defined, a builtin "all days, 8-12 13-17"
+default calendar is used.
+
+A timezone and leave calendars will also used, based on either the assigned
+user (document's `user_id`) or on the current user.
+
+
+Setup checklist
+===============
+
+The basic steps to configure SLAs for a Project are:
+
+ * Set Project's Working Calendar, at Project definitions, "Other Info" tab
+ * Go to the Project's Analytic Account form; create and set SLA Definitions
+ * Use the "Reapply SLAs" button on the Analytic Account form
+ * See Project Issue's calculated SLAs in the new "Service Levels" tab
+
+
+Credits and Contributors
+========================
+
+ * Daniel Reis (https://launchpad.net/~dreis-pt)
+ * David Vignoni, author of the icon from the KDE 3.x Nuvola icon theme
+""",
+ 'author': 'Daniel Reis',
+ 'website': '',
+ 'depends': [
+ 'project_issue',
+ ],
+ 'data': [
+ 'project_sla_view.xml',
+ 'project_sla_control_view.xml',
+ 'project_sla_control_data.xml',
+ 'analytic_account_view.xml',
+ 'project_view.xml',
+ 'project_issue_view.xml',
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': ['project_sla_demo.xml'],
+ 'test': ['test/project_sla.yml'],
+ 'installable': True,
+}
diff --git a/project_sla/analytic_account.py b/project_sla/analytic_account.py
new file mode 100644
index 00000000..fcfbb13f
--- /dev/null
+++ b/project_sla/analytic_account.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (C) 2013 Daniel Reis
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from openerp.osv import fields, orm
+
+
+class AnalyticAccount(orm.Model):
+ """ Add SLA to Analytic Accounts """
+ _inherit = 'account.analytic.account'
+ _columns = {
+ 'sla_ids': fields.many2many(
+ 'project.sla', string='Service Level Agreement'),
+ }
+
+ def _reapply_sla(self, cr, uid, ids, recalc_closed=False, context=None):
+ """
+ Force SLA recalculation on open documents that already are subject to
+ this SLA Definition.
+ To use after changing a Contract SLA or it's Definitions.
+ The ``recalc_closed`` flag allows to also recompute closed documents.
+ """
+ ctrl_obj = self.pool.get('project.sla.control')
+ proj_obj = self.pool.get('project.project')
+ exclude_states = ['cancelled'] + (not recalc_closed and ['done'] or [])
+ for contract in self.browse(cr, uid, ids, context=context):
+ # for each contract, and for each model under SLA control ...
+ for m_name in set([sla.control_model for sla in contract.sla_ids]):
+ model = self.pool.get(m_name)
+ doc_ids = []
+ if 'analytic_account_id' in model._columns:
+ doc_ids += model.search(
+ cr, uid,
+ [('analytic_account_id', '=', contract.id),
+ ('state', 'not in', exclude_states)],
+ context=context)
+ if 'project_id' in model._columns:
+ proj_ids = proj_obj.search(
+ cr, uid, [('analytic_account_id', '=', contract.id)],
+ context=context)
+ doc_ids += model.search(
+ cr, uid,
+ [('project_id', 'in', proj_ids),
+ ('state', 'not in', exclude_states)],
+ context=context)
+ if doc_ids:
+ docs = model.browse(cr, uid, doc_ids, context=context)
+ ctrl_obj.store_sla_control(cr, uid, docs, context=context)
+ return True
+
+ def reapply_sla(self, cr, uid, ids, context=None):
+ """ Reapply SLAs button action """
+ return self._reapply_sla(cr, uid, ids, context=context)
diff --git a/project_sla/analytic_account_view.xml b/project_sla/analytic_account_view.xml
new file mode 100644
index 00000000..5b74a43c
--- /dev/null
+++ b/project_sla/analytic_account_view.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+ view_account_analytic_account_form_sla
+ account.analytic.account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project_sla/i18n/project_sla.pot b/project_sla/i18n/project_sla.pot
new file mode 100644
index 00000000..3f727476
--- /dev/null
+++ b/project_sla/i18n/project_sla.pot
@@ -0,0 +1,296 @@
+# Translation of OpenERP Server.
+# This file contains the translation of the following modules:
+# * project_sla
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: OpenERP Server 7.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2013-12-19 10:28+0000\n"
+"PO-Revision-Date: 2013-12-19 10:28+0000\n"
+"Last-Translator: <>\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: project_sla
+#: model:project.sla,name:project_sla.sla_response
+msgid "Standard Response Time"
+msgstr ""
+
+#. module: project_sla
+#: help:project.sla,control_field_id:0
+msgid "Date field used to check if the SLA was achieved."
+msgstr ""
+
+#. module: project_sla
+#: model:ir.model,name:project_sla.model_project_issue
+msgid "Project Issue"
+msgstr ""
+
+#. module: project_sla
+#: model:ir.model,name:project_sla.model_project_sla_control
+msgid "SLA Control Registry"
+msgstr ""
+
+#. module: project_sla
+#: view:project.sla:0
+msgid "Reapply SLA on Contracts"
+msgstr ""
+
+#. module: project_sla
+#: view:project.project:0
+msgid "Administration"
+msgstr ""
+
+#. module: project_sla
+#: view:project.issue:0
+msgid "Priority"
+msgstr ""
+
+#. module: project_sla
+#: selection:project.issue,sla_state:0
+#: selection:project.sla.control,sla_state:0
+#: selection:project.sla.controlled,sla_state:0
+msgid "Failed"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,sla_warn_date:0
+msgid "Warning Date"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.line,warn_qty:0
+msgid "Hours to Warn"
+msgstr ""
+
+#. module: project_sla
+#: view:project.sla.control:0
+msgid "Service Level"
+msgstr ""
+
+#. module: project_sla
+#: selection:project.issue,sla_state:0
+#: selection:project.sla.control,sla_state:0
+#: selection:project.sla.controlled,sla_state:0
+msgid "Watching"
+msgstr ""
+
+#. module: project_sla
+#: view:project.sla:0
+#: field:project.sla,analytic_ids:0
+msgid "Contracts"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla,name:0
+#: field:project.sla.line,name:0
+msgid "Title"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla,active:0
+msgid "Active"
+msgstr ""
+
+#. module: project_sla
+#: field:project.issue,sla_control_ids:0
+#: field:project.sla.controlled,sla_control_ids:0
+msgid "SLA Control"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,sla_achieved:0
+msgid "Achieved?"
+msgstr ""
+
+#. module: project_sla
+#: view:project.issue:0
+#: field:project.issue,sla_state:0
+#: field:project.sla.control,sla_state:0
+#: field:project.sla.controlled,sla_state:0
+msgid "SLA Status"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.line,condition:0
+msgid "Condition"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla,control_model:0
+msgid "For documents"
+msgstr ""
+
+#. module: project_sla
+#: view:project.sla:0
+#: field:project.sla.line,sla_id:0
+msgid "SLA Definition"
+msgstr ""
+
+#. module: project_sla
+#: model:project.sla.line,name:project_sla.sla_response_rule2
+msgid "Response in two business days"
+msgstr ""
+
+#. module: project_sla
+#: selection:project.issue,sla_state:0
+#: selection:project.sla.control,sla_state:0
+#: selection:project.sla.controlled,sla_state:0
+msgid "Will Fail"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,sla_line_id:0
+msgid "Service Agreement"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,doc_id:0
+msgid "Document ID"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,locked:0
+msgid "Recalculation disabled"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,sla_limit_date:0
+msgid "Limit Date"
+msgstr ""
+
+#. module: project_sla
+#: help:project.sla.line,condition:0
+msgid "Apply only if this expression is evaluated to True. The document fields can be accessed using either o, obj or object. Example: obj.priority <= '2'"
+msgstr ""
+
+#. module: project_sla
+#: model:project.sla.line,name:project_sla.sla_resolution_rule1
+msgid "Resolution in two business days"
+msgstr ""
+
+#. module: project_sla
+#: view:account.analytic.account:0
+msgid "Reapply"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.line,limit_qty:0
+msgid "Hours to Limit"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla,sla_line_ids:0
+#: view:project.sla.line:0
+msgid "Definitions"
+msgstr ""
+
+#. module: project_sla
+#: model:project.sla.line,name:project_sla.sla_resolution_rule2
+msgid "Resolution in three business days"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,sla_close_date:0
+msgid "Close Date"
+msgstr ""
+
+#. module: project_sla
+#: model:ir.model,name:project_sla.model_project_sla
+msgid "project.sla"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla,control_field_id:0
+msgid "Control Date"
+msgstr ""
+
+#. module: project_sla
+#: model:project.sla.line,name:project_sla.sla_response_rule1
+msgid "Response in one business day"
+msgstr ""
+
+#. module: project_sla
+#: selection:project.issue,sla_state:0
+#: selection:project.sla.control,sla_state:0
+#: selection:project.sla.controlled,sla_state:0
+msgid "Achieved"
+msgstr ""
+
+#. module: project_sla
+#: view:account.analytic.account:0
+#: field:account.analytic.account,sla_ids:0
+msgid "Service Level Agreement"
+msgstr ""
+
+#. module: project_sla
+#: model:project.sla,name:project_sla.sla_resolution
+msgid "Standard Resolution Time"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.line,sequence:0
+msgid "Sequence"
+msgstr ""
+
+#. module: project_sla
+#: view:project.issue:0
+msgid "Service Levels"
+msgstr ""
+
+#. module: project_sla
+#: model:ir.model,name:project_sla.model_account_analytic_account
+msgid "Analytic Account"
+msgstr ""
+
+#. module: project_sla
+#: view:project.sla:0
+msgid "Rules"
+msgstr ""
+
+#. module: project_sla
+#: help:project.sla.control,locked:0
+msgid "Safeguard manual changes from future automatic recomputations."
+msgstr ""
+
+#. module: project_sla
+#: selection:project.issue,sla_state:0
+#: selection:project.sla.control,sla_state:0
+#: selection:project.sla.controlled,sla_state:0
+msgid "Warning"
+msgstr ""
+
+#. module: project_sla
+#: view:project.issue:0
+msgid "Extra Info"
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,doc_model:0
+msgid "Document Model"
+msgstr ""
+
+#. module: project_sla
+#: model:ir.model,name:project_sla.model_project_sla_line
+msgid "project.sla.line"
+msgstr ""
+
+#. module: project_sla
+#: view:account.analytic.account:0
+msgid "Reapply the SLAs to all Contract's documents."
+msgstr ""
+
+#. module: project_sla
+#: field:project.sla.control,sla_start_date:0
+msgid "Start Date"
+msgstr ""
+
+#. module: project_sla
+#: model:ir.model,name:project_sla.model_project_sla_controlled
+msgid "SLA Controlled Document"
+msgstr ""
+
diff --git a/project_sla/images/10_sla_contract.png b/project_sla/images/10_sla_contract.png
new file mode 100644
index 00000000..e46f8b55
Binary files /dev/null and b/project_sla/images/10_sla_contract.png differ
diff --git a/project_sla/images/20_sla_definition.png b/project_sla/images/20_sla_definition.png
new file mode 100644
index 00000000..7f05f2a8
Binary files /dev/null and b/project_sla/images/20_sla_definition.png differ
diff --git a/project_sla/images/30_sla_controlled.png b/project_sla/images/30_sla_controlled.png
new file mode 100644
index 00000000..6ebf5dd3
Binary files /dev/null and b/project_sla/images/30_sla_controlled.png differ
diff --git a/project_sla/m2m.py b/project_sla/m2m.py
new file mode 100644
index 00000000..39fe1e2c
--- /dev/null
+++ b/project_sla/m2m.py
@@ -0,0 +1,75 @@
+"""
+Wrapper for OpenERP's cryptic write conventions for x2many fields.
+
+Example usage:
+
+ import m2m
+ browse_rec.write({'many_ids: m2m.clear())
+ browse_rec.write({'many_ids: m2m.link(99))
+ browse_rec.write({'many_ids: m2m.add({'name': 'Monty'}))
+ browse_rec.write({'many_ids: m2m.replace([98, 99]))
+
+Since returned values are lists, the can be joined using the plus operator:
+
+ browse_rec.write({'many_ids: m2m.clear() + m2m.link(99))
+
+(Source: https://github.com/dreispt/openerp-write2many)
+"""
+
+
+def create(values):
+ """ Create a referenced record """
+ assert isinstance(values, dict)
+ return [(0, 0, values)]
+
+
+def add(values):
+ """ Intuitive alias for create() """
+ return create(values)
+
+
+def write(id, values):
+ """ Write on referenced record """
+ assert isinstance(id, int)
+ assert isinstance(values, dict)
+ return [(1, id, values)]
+
+
+def remove(id):
+ """ Unlink and delete referenced record """
+ assert isinstance(id, int)
+ return [(2, id)]
+
+
+def unlink(id):
+ """ Unlink but do not delete the referenced record """
+ assert isinstance(id, int)
+ return [(3, id)]
+
+
+def link(id):
+ """ Link but do not delete the referenced record """
+ assert isinstance(id, int)
+ return [(4, id)]
+
+
+def clear():
+ """ Unlink all referenced records (doesn't delete them) """
+ return [(5, 0)]
+
+
+def replace(ids):
+ """ Unlink all current records and replace them with a new list """
+ assert isinstance(ids, list)
+ return [(6, 0, ids)]
+
+
+if __name__ == "__main__":
+ # Tests:
+ assert create({'name': 'Monty'}) == [(0, 0, {'name': 'Monty'})]
+ assert write(99, {'name': 'Monty'}) == [(1, 99, {'name': 'Monty'})]
+ assert remove(99) == [(2, 99)]
+ assert unlink(99) == [(3, 99)]
+ assert clear() == [(5, 0)]
+ assert replace([97, 98, 99]) == [(6, 0, [97, 98, 99])]
+ print("Done!")
diff --git a/project_sla/project_issue.py b/project_sla/project_issue.py
new file mode 100644
index 00000000..9800f47a
--- /dev/null
+++ b/project_sla/project_issue.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (C) 2013 Daniel Reis
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from openerp.osv import orm
+
+
+class ProjectIssue(orm.Model):
+ """
+ Extend Project Issues to be SLA Controlled
+ """
+ _name = 'project.issue'
+ _inherit = ['project.issue', 'project.sla.controlled']
diff --git a/project_sla/project_issue_view.xml b/project_sla/project_issue_view.xml
new file mode 100644
index 00000000..eba74066
--- /dev/null
+++ b/project_sla/project_issue_view.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+ project_issue_form_view_sla
+ project.issue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ project_issue_tree_view_sla
+ project.issue
+
+
+
+
+
+
+
+
+
+
+
+
+
+ view_project_issue_filter_sdesk
+ project.issue
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project_sla/project_sla.py b/project_sla/project_sla.py
new file mode 100644
index 00000000..78978bb3
--- /dev/null
+++ b/project_sla/project_sla.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (C) 2013 Daniel Reis
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from openerp.osv import fields, orm
+
+
+class SLADefinition(orm.Model):
+ """
+ SLA Definition
+ """
+ _name = 'project.sla'
+ _description = 'SLA Definition'
+ _columns = {
+ 'name': fields.char('Title', size=64, required=True, translate=True),
+ 'active': fields.boolean('Active'),
+ 'control_model': fields.char('For documents', size=128, required=True),
+ 'control_field_id': fields.many2one(
+ 'ir.model.fields', 'Control Date', required=True,
+ domain="[('model_id.model', '=', control_model),"
+ " ('ttype', 'in', ['date', 'datetime'])]",
+ help="Date field used to check if the SLA was achieved."),
+ 'sla_line_ids': fields.one2many(
+ 'project.sla.line', 'sla_id', 'Definitions'),
+ 'analytic_ids': fields.many2many(
+ 'account.analytic.account', string='Contracts'),
+ }
+ _defaults = {
+ 'active': True,
+ }
+
+ def _reapply_slas(self, cr, uid, ids, recalc_closed=False, context=None):
+ """
+ Force SLA recalculation on all _open_ Contracts for the selected SLAs.
+ To use upon SLA Definition modifications.
+ """
+ contract_obj = self.pool.get('account.analytic.account')
+ for sla in self.browse(cr, uid, ids, context=context):
+ contr_ids = [x.id for x in sla.analytic_ids if x.state == 'open']
+ contract_obj._reapply_sla(
+ cr, uid, contr_ids, recalc_closed=recalc_closed,
+ context=context)
+ return True
+
+ def reapply_slas(self, cr, uid, ids, context=None):
+ """ Reapply SLAs button action """
+ return self._reapply_slas(cr, uid, ids, context=context)
+
+
+class SLARules(orm.Model):
+ """
+ SLA Definition Rule Lines
+ """
+ _name = 'project.sla.line'
+ _definition = 'SLA Definition Rule Lines'
+ _order = 'sla_id,sequence'
+ _columns = {
+ 'sla_id': fields.many2one('project.sla', 'SLA Definition'),
+ 'sequence': fields.integer('Sequence'),
+ 'name': fields.char('Title', size=64, required=True, translate=True),
+ 'condition': fields.char(
+ 'Condition', size=256, help="Apply only if this expression is "
+ "evaluated to True. The document fields can be accessed using "
+ "either o, obj or object. Example: obj.priority <= '2'"),
+ 'limit_qty': fields.integer('Hours to Limit'),
+ 'warn_qty': fields.integer('Hours to Warn'),
+ }
+ _defaults = {
+ 'sequence': 10,
+ }
diff --git a/project_sla/project_sla_control.py b/project_sla/project_sla_control.py
new file mode 100644
index 00000000..d807a36b
--- /dev/null
+++ b/project_sla/project_sla_control.py
@@ -0,0 +1,322 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Copyright (C) 2013 Daniel Reis
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from openerp.osv import fields, orm
+from openerp.tools.safe_eval import safe_eval
+from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT as DT_FMT
+from openerp import SUPERUSER_ID
+from datetime import timedelta
+from datetime import datetime as dt
+import m2m
+
+import logging
+_logger = logging.getLogger(__name__)
+
+
+SLA_STATES = [('5', 'Failed'), ('4', 'Will Fail'), ('3', 'Warning'),
+ ('2', 'Watching'), ('1', 'Achieved')]
+
+
+def safe_getattr(obj, dotattr, default=False):
+ """
+ Follow an object attribute dot-notation chain to find the leaf value.
+ If any attribute doesn't exist or has no value, just return False.
+ Checks hasattr ahead, to avoid ORM Browse log warnings.
+ """
+ attrs = dotattr.split('.')
+ while attrs:
+ attr = attrs.pop(0)
+ if attr in obj._model._columns:
+ try:
+ obj = getattr(obj, attr)
+ except AttributeError:
+ return default
+ if not obj:
+ return default
+ else:
+ return default
+ return obj
+
+
+class SLAControl(orm.Model):
+ """
+ SLA Control Registry
+ Each controlled document (Issue, Claim, ...) will have a record here.
+ This model concentrates all the logic for Service Level calculation.
+ """
+ _name = 'project.sla.control'
+ _description = 'SLA Control Registry'
+
+ _columns = {
+ 'doc_id': fields.integer('Document ID', readonly=True),
+ 'doc_model': fields.char('Document Model', size=128, readonly=True),
+ 'sla_line_id': fields.many2one(
+ 'project.sla.line', 'Service Agreement'),
+ 'sla_warn_date': fields.datetime('Warning Date'),
+ 'sla_limit_date': fields.datetime('Limit Date'),
+ 'sla_start_date': fields.datetime('Start Date'),
+ 'sla_close_date': fields.datetime('Close Date'),
+ 'sla_achieved': fields.integer('Achieved?'),
+ 'sla_state': fields.selection(SLA_STATES, string="SLA Status"),
+ 'locked': fields.boolean(
+ 'Recalculation disabled',
+ help="Safeguard manual changes from future automatic "
+ "recomputations."),
+ # Future: perfect SLA manual handling
+ }
+
+ def write(self, cr, uid, ids, vals, context=None):
+ """
+ Update the related Document's SLA State when any of the SLA Control
+ lines changes state
+ """
+ res = super(SLAControl, self).write(
+ cr, uid, ids, vals, context=context)
+ new_state = vals.get('sla_state')
+ if new_state:
+ # just update sla_state without recomputing the whole thing
+ context = context or {}
+ context['__sla_stored__'] = 1
+ for sla in self.browse(cr, uid, ids, context=context):
+ doc = self.pool.get(sla.doc_model).browse(
+ cr, uid, sla.doc_id, context=context)
+ if doc.sla_state < new_state:
+ doc.write({'sla_state': new_state})
+ return res
+
+ def update_sla_states(self, cr, uid, context=None):
+ """
+ Updates SLA States, given the current datetime:
+ Only works on "open" sla states (watching, warning and will fail):
+ - exceeded limit date are set to "will fail"
+ - exceeded warning dates are set to "warning"
+ To be used by a scheduled job.
+ """
+ now = dt.now().strftime(DT_FMT)
+ # SLAs to mark as "will fail"
+ control_ids = self.search(
+ cr, uid,
+ [('sla_state', 'in', ['2', '3']), ('sla_limit_date', '<', now)],
+ context=context)
+ self.write(cr, uid, control_ids, {'sla_state': '4'}, context=context)
+ # SLAs to mark as "warning"
+ control_ids = self.search(
+ cr, uid,
+ [('sla_state', 'in', ['2']), ('sla_warn_date', '<', now)],
+ context=context)
+ self.write(cr, uid, control_ids, {'sla_state': '3'}, context=context)
+ return True
+
+ def _compute_sla_date(self, cr, uid, working_hours, res_uid,
+ start_date, hours, context=None):
+ """
+ Return a limit datetime by adding hours to a start_date, honoring
+ a working_time calendar and a resource's (res_uid) timezone and
+ availability (leaves)
+
+ Currently implemented using a binary search using
+ _interval_hours_get() from resource.calendar. This is
+ resource.calendar agnostic, but could be more efficient if
+ implemented based on it's logic.
+
+ Known issue: the end date can be a non-working time; it would be
+ best for it to be the latest working time possible. Example:
+ if working time is 08:00 - 16:00 and start_date is 19:00, the +8h
+ end date will be 19:00 of the next day, and it should rather be
+ 16:00 of the next day.
+ """
+ assert isinstance(start_date, dt)
+ assert isinstance(hours, int) and hours >= 0
+
+ cal_obj = self.pool.get('resource.calendar')
+ target, step = hours * 3600, 16 * 3600
+ lo, hi = start_date, start_date
+ while target > 0 and step > 60:
+ hi = lo + timedelta(seconds=step)
+ check = int(3600 * cal_obj._interval_hours_get(
+ cr, uid, working_hours, lo, hi,
+ timezone_from_uid=res_uid, exclude_leaves=False,
+ context=context))
+ if check <= target:
+ target -= check
+ lo = hi
+ else:
+ step = int(step / 4.0)
+ return hi
+
+ def _get_computed_slas(self, cr, uid, doc, context=None):
+ """
+ Returns a dict with the computed data for SLAs, given a browse record
+ for the target document.
+
+ * The SLA used is either from a related analytic_account_id or
+ project_id, whatever is found first.
+ * The work calendar is taken from the Project's definitions ("Other
+ Info" tab -> Working Time).
+ * The timezone used for the working time calculations are from the
+ document's responsible User (user_id) or from the current User (uid).
+
+ For the SLA Achieved calculation:
+
+ * Creation date is used to start counting time
+ * Control date, used to calculate SLA achievement, is defined in the
+ SLA Definition rules.
+ """
+ def datetime2str(dt_value, fmt): # tolerant datetime to string
+ return dt_value and dt.strftime(dt_value, fmt) or None
+
+ res = []
+ sla_ids = (safe_getattr(doc, 'analytic_account_id.sla_ids') or
+ safe_getattr(doc, 'project_id.analytic_account_id.sla_ids'))
+ if not sla_ids:
+ return res
+
+ for sla in sla_ids:
+ if sla.control_model != doc._table_name:
+ continue # SLA not for this model; skip
+
+ for l in sla.sla_line_ids:
+ eval_context = {'o': doc, 'obj': doc, 'object': doc}
+ if not l.condition or safe_eval(l.condition, eval_context):
+ start_date = dt.strptime(doc.create_date, DT_FMT)
+ res_uid = doc.user_id.id or uid
+ cal = safe_getattr(
+ doc, 'project_id.resource_calendar_id.id')
+ warn_date = self._compute_sla_date(
+ cr, uid, cal, res_uid, start_date, l.warn_qty,
+ context=context)
+ lim_date = self._compute_sla_date(
+ cr, uid, cal, res_uid, warn_date,
+ l.limit_qty - l.warn_qty,
+ context=context)
+ # evaluate sla state
+ control_val = getattr(doc, sla.control_field_id.name)
+ if control_val:
+ control_date = dt.strptime(control_val, DT_FMT)
+ if control_date > lim_date:
+ sla_val, sla_state = 0, '5' # failed
+ else:
+ sla_val, sla_state = 1, '1' # achieved
+ else:
+ control_date = None
+ now = dt.now()
+ if now > lim_date:
+ sla_val, sla_state = 0, '4' # will fail
+ elif now > warn_date:
+ sla_val, sla_state = 0, '3' # warning
+ else:
+ sla_val, sla_state = 0, '2' # watching
+
+ res.append(
+ {'sla_line_id': l.id,
+ 'sla_achieved': sla_val,
+ 'sla_state': sla_state,
+ 'sla_warn_date': datetime2str(warn_date, DT_FMT),
+ 'sla_limit_date': datetime2str(lim_date, DT_FMT),
+ 'sla_start_date': datetime2str(start_date, DT_FMT),
+ 'sla_close_date': datetime2str(control_date, DT_FMT),
+ 'doc_id': doc.id,
+ 'doc_model': sla.control_model})
+ break
+
+ if sla_ids and not res:
+ _logger.warning("No valid SLA rule found for %d, SLA Ids %s"
+ % (doc.id, repr([x.id for x in sla_ids])))
+ return res
+
+ def store_sla_control(self, cr, uid, docs, context=None):
+ """
+ Used by controlled documents to ask for SLA calculation and storage.
+ ``docs`` is a Browse object
+ """
+ # context flag to avoid infinite loops on further writes
+ context = context or {}
+ if '__sla_stored__' in context:
+ return False
+ else:
+ context['__sla_stored__'] = 1
+
+ res = []
+ for ix, doc in enumerate(docs):
+ if ix and ix % 50 == 0:
+ _logger.info('...%d SLAs recomputed for %s' % (ix, doc._name))
+ control = {x.sla_line_id.id: x
+ for x in doc.sla_control_ids}
+ sla_recs = self._get_computed_slas(cr, uid, doc, context=context)
+ # calc sla control lines
+ if sla_recs:
+ slas = []
+ for sla_rec in sla_recs:
+ sla_line_id = sla_rec.get('sla_line_id')
+ if sla_line_id in control:
+ control_rec = control.get(sla_line_id)
+ if not control_rec.locked:
+ slas += m2m.write(control_rec.id, sla_rec)
+ else:
+ slas += m2m.add(sla_rec)
+ else:
+ slas = m2m.clear()
+ # calc sla control summary
+ vals = {'sla_state': None, 'sla_control_ids': slas}
+ if sla_recs and doc.sla_control_ids:
+ vals['sla_state'] = max(
+ x.sla_state for x in doc.sla_control_ids)
+ # store sla
+ doc._model.write( # regular users can't write on SLA Control
+ cr, SUPERUSER_ID, [doc.id], vals, context=context)
+ return res
+
+
+class SLAControlled(orm.AbstractModel):
+ """
+ SLA Controlled documents: AbstractModel to apply SLA control on Models
+ """
+ _name = 'project.sla.controlled'
+ _description = 'SLA Controlled Document'
+ _columns = {
+ 'sla_control_ids': fields.many2many(
+ 'project.sla.control', string="SLA Control", ondelete='cascade'),
+ 'sla_state': fields.selection(
+ SLA_STATES, string="SLA Status", readonly=True),
+ }
+
+ def create(self, cr, uid, vals, context=None):
+ res = super(SLAControlled, self).create(cr, uid, vals, context=context)
+ docs = self.browse(cr, uid, [res], context=context)
+ self.pool.get('project.sla.control').store_sla_control(
+ cr, uid, docs, context=context)
+ return res
+
+ def write(self, cr, uid, ids, vals, context=None):
+ res = super(SLAControlled, self).write(
+ cr, uid, ids, vals, context=context)
+ docs = [x for x in self.browse(cr, uid, ids, context=context)
+ if (x.state != 'cancelled') and
+ (x.state != 'done' or x.sla_state not in ['1', '5'])]
+ self.pool.get('project.sla.control').store_sla_control(
+ cr, uid, docs, context=context)
+ return res
+
+ def unlink(self, cr, uid, ids, context=None):
+ # Unlink and delete all related Control records
+ for doc in self.browse(cr, uid, ids, context=context):
+ vals = [m2m.remove(x.id)[0] for x in doc.sla_control_ids]
+ doc.write({'sla_control_ids': vals})
+ return super(SLAControlled, self).unlink(cr, uid, ids, context=context)
diff --git a/project_sla/project_sla_control_data.xml b/project_sla/project_sla_control_data.xml
new file mode 100644
index 00000000..edff4832
--- /dev/null
+++ b/project_sla/project_sla_control_data.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Update SLA States
+
+ 1
+ hours
+ -1
+
+ project.sla.control
+ update_sla_states
+ ()
+
+
+
+
diff --git a/project_sla/project_sla_control_view.xml b/project_sla/project_sla_control_view.xml
new file mode 100644
index 00000000..cca647f5
--- /dev/null
+++ b/project_sla/project_sla_control_view.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ view_sla_control_tree
+ project.sla.control
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project_sla/project_sla_demo.xml b/project_sla/project_sla_demo.xml
new file mode 100644
index 00000000..331c53d2
--- /dev/null
+++ b/project_sla/project_sla_demo.xml
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+ Working Days 09-13 14-18
+
+
+ 0
+ Monday Morning
+ 9
+ 13
+
+
+
+ 0
+ Monday Afternoon
+ 14
+ 18
+
+
+
+ 1
+ Tuesday Morning
+ 9
+ 13
+
+
+
+ 1
+ Tuesday Afternoon
+ 14
+ 18
+
+
+
+ 2
+ Wednesday Morning
+ 9
+ 13
+
+
+
+ 2
+ Wednesday Afternoon
+ 14
+ 18
+
+
+
+ 3
+ Thursday Morning
+ 9
+ 13
+
+
+
+ 3
+ Thursday Afternoon
+ 14
+ 18
+
+
+
+ 4
+ Friday Morning
+ 9
+ 13
+
+
+
+ 4
+ Friday Afternoon
+ 14
+ 18
+
+
+
+
+
+
+
+
+
+
+ Standard Resolution Time
+ project.issue
+
+
+
+
+ 10
+ Resolution in two business days
+ obj.priority <= '2'
+ 16
+ 8
+
+
+
+ 20
+ Resolution in three business days
+
+ 24
+ 16
+
+
+
+ Standard Response Time
+ project.issue
+
+
+
+
+ 10
+ Response in one business day
+ obj.priority <= '2'
+ 8
+ 4
+
+
+
+ 20
+ Response in two business days
+
+ 16
+ 8
+
+
+
+
+
+
+
+
+
diff --git a/project_sla/project_sla_view.xml b/project_sla/project_sla_view.xml
new file mode 100644
index 00000000..0381ff4f
--- /dev/null
+++ b/project_sla/project_sla_view.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+ view_sla_lines_tree
+ project.sla.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ view_sla_form
+ project.sla
+
+
+
+
+
+
+
+
+
diff --git a/project_sla/project_view.xml b/project_sla/project_view.xml
new file mode 100644
index 00000000..bc40c9d4
--- /dev/null
+++ b/project_sla/project_view.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+ edit_project_sla
+ project.project
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/project_sla/security/ir.model.access.csv b/project_sla/security/ir.model.access.csv
new file mode 100644
index 00000000..55d41a65
--- /dev/null
+++ b/project_sla/security/ir.model.access.csv
@@ -0,0 +1,8 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_sla_manager,access_sla_manager,model_project_sla,project.group_project_manager,1,1,1,1
+access_sla_user,access_sla_user,model_project_sla,base.group_user,1,0,0,0
+access_sla_lines_manager,access_sla_lines_manager,model_project_sla_line,project.group_project_manager,1,1,1,1
+access_sla_lines_user,access_sla_lines_user,model_project_sla_line,base.group_user,1,0,0,0
+access_sla_control_manager,access_sla_control_manager,model_project_sla_control,project.group_project_manager,1,1,0,0
+access_sla_control_user,access_sla_control_user,model_project_sla_control,base.group_user,1,0,0,0
+access_sla_controlled_manager,access_sla_controlled_manager,model_project_sla_controlled,project.group_project_manager,1,1,1,1
diff --git a/project_sla/static/src/img/icon.png b/project_sla/static/src/img/icon.png
new file mode 100644
index 00000000..30df47a2
Binary files /dev/null and b/project_sla/static/src/img/icon.png differ
diff --git a/project_sla/test/project_sla.yml b/project_sla/test/project_sla.yml
new file mode 100644
index 00000000..57231535
--- /dev/null
+++ b/project_sla/test/project_sla.yml
@@ -0,0 +1,66 @@
+-
+ Cleanup previous test run
+-
+ !python {model: project.issue}: |
+ res = self.search(cr, uid, [('name', '=', 'My monitor is flickering')])
+ self.unlink(cr, uid, res)
+-
+ Create a new Issue
+-
+ !record {model: project.issue, id: issue1, view: False}:
+ name: "My monitor is flickering"
+ project_id: project.project_project_1
+ priority: "3"
+ user_id: base.user_root
+ partner_id: base.res_partner_2
+ email_from: agr@agrolait.com
+ categ_ids:
+ - project_issue.project_issue_category_01
+-
+ Close the Issue
+-
+ !python {model: project.issue}: |
+ self.case_close(cr, uid, [ref("issue1")])
+-
+ Force the Issue's Create Date and Close Date
+ Created friday before opening hour, closed on next monday near closing hour
+-
+ !python {model: project.issue}: |
+ import time
+ self.write(cr, uid, [ref("issue1"),], {
+ 'create_date': time.strftime('2013-11-22 06:15:00'),
+ 'date_closed': time.strftime('2013-11-25 16:45:00'),
+ })
+-
+ There should be Service Level info generated on the Issue
+-
+ !assert {model: project.issue, id: issue1, string: Issue should have calculated service levels}:
+ - len(sla_control_ids) == 2
+-
+ Assign an additional "Response SLA" to the Contract
+-
+ !python {model: account.analytic.account}: |
+ self.write(cr, uid, [ref('project.project_project_1_account_analytic_account')],
+ {'sla_ids': [(4, ref('sla_response'))]})
+-
+ Button to Reapply the SLA Definition
+-
+ !python {model: project.sla}: |
+ self._reapply_slas(cr, uid, [ref('sla_resolution')], recalc_closed=True)
+-
+ There should be two Service Level lines generated on the Issue
+-
+ !assert {model: project.issue, id: issue1, string: Issue should have two calculated service levels}:
+ - len(sla_control_ids) == 2
+-
+ The Issue's Resolution SLA should be "3 business days"
+-
+ !python {model: project.issue}: |
+ issue = self.browse(cr, uid, ref('issue1'))
+ for x in issue.sla_control_ids:
+ print x.sla_line_id.name
+ if x.sla_line_id.id == ref("sla_resolution_rule2"):
+ assert x.sla_achieved == 1, "Issue resolution SLA should be achieved"
+ break
+ else:
+ assert False, 'Issue Resolution SLA should be "3 business days"'