You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

322 lines
13 KiB

11 years ago
11 years ago
11 years ago
  1. # -*- coding: utf-8 -*-
  2. ##############################################################################
  3. #
  4. # Copyright (C) 2013 Daniel Reis
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. ##############################################################################
  20. from openerp.osv import fields, orm
  21. from openerp.tools.safe_eval import safe_eval
  22. from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT as DT_FMT
  23. from openerp import SUPERUSER_ID
  24. from datetime import timedelta
  25. from datetime import datetime as dt
  26. import m2m
  27. import logging
  28. _logger = logging.getLogger(__name__)
  29. SLA_STATES = [('5', 'Failed'), ('4', 'Will Fail'), ('3', 'Warning'),
  30. ('2', 'Watching'), ('1', 'Achieved')]
  31. def safe_getattr(obj, dotattr, default=False):
  32. """
  33. Follow an object attribute dot-notation chain to find the leaf value.
  34. If any attribute doesn't exist or has no value, just return False.
  35. Checks hasattr ahead, to avoid ORM Browse log warnings.
  36. """
  37. attrs = dotattr.split('.')
  38. while attrs:
  39. attr = attrs.pop(0)
  40. if attr in obj._model._columns:
  41. try:
  42. obj = getattr(obj, attr)
  43. except AttributeError:
  44. return default
  45. if not obj:
  46. return default
  47. else:
  48. return default
  49. return obj
  50. class SLAControl(orm.Model):
  51. """
  52. SLA Control Registry
  53. Each controlled document (Issue, Claim, ...) will have a record here.
  54. This model concentrates all the logic for Service Level calculation.
  55. """
  56. _name = 'project.sla.control'
  57. _description = 'SLA Control Registry'
  58. _columns = {
  59. 'doc_id': fields.integer('Document ID', readonly=True),
  60. 'doc_model': fields.char('Document Model', size=128, readonly=True),
  61. 'sla_line_id': fields.many2one(
  62. 'project.sla.line', 'Service Agreement'),
  63. 'sla_warn_date': fields.datetime('Warning Date'),
  64. 'sla_limit_date': fields.datetime('Limit Date'),
  65. 'sla_start_date': fields.datetime('Start Date'),
  66. 'sla_close_date': fields.datetime('Close Date'),
  67. 'sla_achieved': fields.integer('Achieved?'),
  68. 'sla_state': fields.selection(SLA_STATES, string="SLA Status"),
  69. 'locked': fields.boolean(
  70. 'Recalculation disabled',
  71. help="Safeguard manual changes from future automatic "
  72. "recomputations."),
  73. # Future: perfect SLA manual handling
  74. }
  75. def write(self, cr, uid, ids, vals, context=None):
  76. """
  77. Update the related Document's SLA State when any of the SLA Control
  78. lines changes state
  79. """
  80. res = super(SLAControl, self).write(
  81. cr, uid, ids, vals, context=context)
  82. new_state = vals.get('sla_state')
  83. if new_state:
  84. # just update sla_state without recomputing the whole thing
  85. context = context or {}
  86. context['__sla_stored__'] = 1
  87. for sla in self.browse(cr, uid, ids, context=context):
  88. doc = self.pool.get(sla.doc_model).browse(
  89. cr, uid, sla.doc_id, context=context)
  90. if doc.sla_state < new_state:
  91. doc.write({'sla_state': new_state})
  92. return res
  93. def update_sla_states(self, cr, uid, context=None):
  94. """
  95. Updates SLA States, given the current datetime:
  96. Only works on "open" sla states (watching, warning and will fail):
  97. - exceeded limit date are set to "will fail"
  98. - exceeded warning dates are set to "warning"
  99. To be used by a scheduled job.
  100. """
  101. now = dt.now().strftime(DT_FMT)
  102. # SLAs to mark as "will fail"
  103. control_ids = self.search(
  104. cr, uid,
  105. [('sla_state', 'in', ['2', '3']), ('sla_limit_date', '<', now)],
  106. context=context)
  107. self.write(cr, uid, control_ids, {'sla_state': '4'}, context=context)
  108. # SLAs to mark as "warning"
  109. control_ids = self.search(
  110. cr, uid,
  111. [('sla_state', 'in', ['2']), ('sla_warn_date', '<', now)],
  112. context=context)
  113. self.write(cr, uid, control_ids, {'sla_state': '3'}, context=context)
  114. return True
  115. def _compute_sla_date(self, cr, uid, working_hours, res_uid,
  116. start_date, hours, context=None):
  117. """
  118. Return a limit datetime by adding hours to a start_date, honoring
  119. a working_time calendar and a resource's (res_uid) timezone and
  120. availability (leaves)
  121. Currently implemented using a binary search using
  122. _interval_hours_get() from resource.calendar. This is
  123. resource.calendar agnostic, but could be more efficient if
  124. implemented based on it's logic.
  125. Known issue: the end date can be a non-working time; it would be
  126. best for it to be the latest working time possible. Example:
  127. if working time is 08:00 - 16:00 and start_date is 19:00, the +8h
  128. end date will be 19:00 of the next day, and it should rather be
  129. 16:00 of the next day.
  130. """
  131. assert isinstance(start_date, dt)
  132. assert isinstance(hours, int) and hours >= 0
  133. cal_obj = self.pool.get('resource.calendar')
  134. target, step = hours * 3600, 16 * 3600
  135. lo, hi = start_date, start_date
  136. while target > 0 and step > 60:
  137. hi = lo + timedelta(seconds=step)
  138. check = int(3600 * cal_obj._interval_hours_get(
  139. cr, uid, working_hours, lo, hi,
  140. timezone_from_uid=res_uid, exclude_leaves=False,
  141. context=context))
  142. if check <= target:
  143. target -= check
  144. lo = hi
  145. else:
  146. step = int(step / 4.0)
  147. return hi
  148. def _get_computed_slas(self, cr, uid, doc, context=None):
  149. """
  150. Returns a dict with the computed data for SLAs, given a browse record
  151. for the target document.
  152. * The SLA used is either from a related analytic_account_id or
  153. project_id, whatever is found first.
  154. * The work calendar is taken from the Project's definitions ("Other
  155. Info" tab -> Working Time).
  156. * The timezone used for the working time calculations are from the
  157. document's responsible User (user_id) or from the current User (uid).
  158. For the SLA Achieved calculation:
  159. * Creation date is used to start counting time
  160. * Control date, used to calculate SLA achievement, is defined in the
  161. SLA Definition rules.
  162. """
  163. def datetime2str(dt_value, fmt): # tolerant datetime to string
  164. return dt_value and dt.strftime(dt_value, fmt) or None
  165. res = []
  166. sla_ids = (safe_getattr(doc, 'analytic_account_id.sla_ids') or
  167. safe_getattr(doc, 'project_id.analytic_account_id.sla_ids'))
  168. if not sla_ids:
  169. return res
  170. for sla in sla_ids:
  171. if sla.control_model != doc._table_name:
  172. continue # SLA not for this model; skip
  173. for l in sla.sla_line_ids:
  174. eval_context = {'o': doc, 'obj': doc, 'object': doc}
  175. if not l.condition or safe_eval(l.condition, eval_context):
  176. start_date = dt.strptime(doc.create_date, DT_FMT)
  177. res_uid = doc.user_id.id or uid
  178. cal = safe_getattr(
  179. doc, 'project_id.resource_calendar_id.id')
  180. warn_date = self._compute_sla_date(
  181. cr, uid, cal, res_uid, start_date, l.warn_qty,
  182. context=context)
  183. lim_date = self._compute_sla_date(
  184. cr, uid, cal, res_uid, warn_date,
  185. l.limit_qty - l.warn_qty,
  186. context=context)
  187. # evaluate sla state
  188. control_val = getattr(doc, sla.control_field_id.name)
  189. if control_val:
  190. control_date = dt.strptime(control_val, DT_FMT)
  191. if control_date > lim_date:
  192. sla_val, sla_state = 0, '5' # failed
  193. else:
  194. sla_val, sla_state = 1, '1' # achieved
  195. else:
  196. control_date = None
  197. now = dt.now()
  198. if now > lim_date:
  199. sla_val, sla_state = 0, '4' # will fail
  200. elif now > warn_date:
  201. sla_val, sla_state = 0, '3' # warning
  202. else:
  203. sla_val, sla_state = 0, '2' # watching
  204. res.append(
  205. {'sla_line_id': l.id,
  206. 'sla_achieved': sla_val,
  207. 'sla_state': sla_state,
  208. 'sla_warn_date': datetime2str(warn_date, DT_FMT),
  209. 'sla_limit_date': datetime2str(lim_date, DT_FMT),
  210. 'sla_start_date': datetime2str(start_date, DT_FMT),
  211. 'sla_close_date': datetime2str(control_date, DT_FMT),
  212. 'doc_id': doc.id,
  213. 'doc_model': sla.control_model})
  214. break
  215. if sla_ids and not res:
  216. _logger.warning("No valid SLA rule found for %d, SLA Ids %s"
  217. % (doc.id, repr([x.id for x in sla_ids])))
  218. return res
  219. def store_sla_control(self, cr, uid, docs, context=None):
  220. """
  221. Used by controlled documents to ask for SLA calculation and storage.
  222. ``docs`` is a Browse object
  223. """
  224. # context flag to avoid infinite loops on further writes
  225. context = context or {}
  226. if '__sla_stored__' in context:
  227. return False
  228. else:
  229. context['__sla_stored__'] = 1
  230. res = []
  231. for ix, doc in enumerate(docs):
  232. if ix and ix % 50 == 0:
  233. _logger.info('...%d SLAs recomputed for %s' % (ix, doc._name))
  234. control = {x.sla_line_id.id: x
  235. for x in doc.sla_control_ids}
  236. sla_recs = self._get_computed_slas(cr, uid, doc, context=context)
  237. # calc sla control lines
  238. if sla_recs:
  239. slas = []
  240. for sla_rec in sla_recs:
  241. sla_line_id = sla_rec.get('sla_line_id')
  242. if sla_line_id in control:
  243. control_rec = control.get(sla_line_id)
  244. if not control_rec.locked:
  245. slas += m2m.write(control_rec.id, sla_rec)
  246. else:
  247. slas += m2m.add(sla_rec)
  248. else:
  249. slas = m2m.clear()
  250. # calc sla control summary
  251. vals = {'sla_state': None, 'sla_control_ids': slas}
  252. if sla_recs and doc.sla_control_ids:
  253. vals['sla_state'] = max(
  254. x.sla_state for x in doc.sla_control_ids)
  255. # store sla
  256. doc._model.write( # regular users can't write on SLA Control
  257. cr, SUPERUSER_ID, [doc.id], vals, context=context)
  258. return res
  259. class SLAControlled(orm.AbstractModel):
  260. """
  261. SLA Controlled documents: AbstractModel to apply SLA control on Models
  262. """
  263. _name = 'project.sla.controlled'
  264. _description = 'SLA Controlled Document'
  265. _columns = {
  266. 'sla_control_ids': fields.many2many(
  267. 'project.sla.control', string="SLA Control", ondelete='cascade'),
  268. 'sla_state': fields.selection(
  269. SLA_STATES, string="SLA Status", readonly=True),
  270. }
  271. def create(self, cr, uid, vals, context=None):
  272. res = super(SLAControlled, self).create(cr, uid, vals, context=context)
  273. docs = self.browse(cr, uid, [res], context=context)
  274. self.pool.get('project.sla.control').store_sla_control(
  275. cr, uid, docs, context=context)
  276. return res
  277. def write(self, cr, uid, ids, vals, context=None):
  278. res = super(SLAControlled, self).write(
  279. cr, uid, ids, vals, context=context)
  280. docs = [x for x in self.browse(cr, uid, ids, context=context)
  281. if (x.state != 'cancelled') and
  282. (x.state != 'done' or x.sla_state not in ['1', '5'])]
  283. self.pool.get('project.sla.control').store_sla_control(
  284. cr, uid, docs, context=context)
  285. return res
  286. def unlink(self, cr, uid, ids, context=None):
  287. # Unlink and delete all related Control records
  288. for doc in self.browse(cr, uid, ids, context=context):
  289. vals = [m2m.remove(x.id)[0] for x in doc.sla_control_ids]
  290. doc.write({'sla_control_ids': vals})
  291. return super(SLAControlled, self).unlink(cr, uid, ids, context=context)