Browse Source
[ADD] partner_phonecall_schedule: Know partner's best phonecall time (#475)
pull/549/head
[ADD] partner_phonecall_schedule: Know partner's best phonecall time (#475)
pull/549/head
Jairo Llopis
7 years ago
10 changed files with 460 additions and 0 deletions
-
75partner_phonecall_schedule/README.rst
-
4partner_phonecall_schedule/__init__.py
-
20partner_phonecall_schedule/__openerp__.py
-
58partner_phonecall_schedule/i18n/es.po
-
4partner_phonecall_schedule/models/__init__.py
-
78partner_phonecall_schedule/models/res_partner.py
-
BINpartner_phonecall_schedule/static/description/icon.png
-
4partner_phonecall_schedule/tests/__init__.py
-
173partner_phonecall_schedule/tests/test_schedule.py
-
44partner_phonecall_schedule/views/res_partner_view.xml
@ -0,0 +1,75 @@ |
|||||
|
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg |
||||
|
:target: http://www.gnu.org/licenses/agpl |
||||
|
:alt: License: AGPL-3 |
||||
|
|
||||
|
=========================== |
||||
|
Partner phonecalls schedule |
||||
|
=========================== |
||||
|
|
||||
|
If you know the best moment to call your partners, use this addon to keep |
||||
|
track of it and be able to filter partners that you can comfortably call now. |
||||
|
|
||||
|
Usage |
||||
|
===== |
||||
|
|
||||
|
To use the phonecall schedules, you need to: |
||||
|
|
||||
|
#. Go to any partner form. |
||||
|
#. Go to the *Phone calls* tab. |
||||
|
#. Select any of the available schedules. |
||||
|
#. A readonly checkbox will tell you if it is a good time to call him/her. |
||||
|
#. A readonly aggregated schedule is visible too. |
||||
|
|
||||
|
To filter partners available to call right now: |
||||
|
|
||||
|
#. Go to the partners view. |
||||
|
#. Use the built-in filter *Available for phone calls now* |
||||
|
|
||||
|
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas |
||||
|
:alt: Try me on Runbot |
||||
|
:target: https://runbot.odoo-community.org/runbot/111/9.0 |
||||
|
|
||||
|
Bug Tracker |
||||
|
=========== |
||||
|
|
||||
|
Bugs are tracked on `GitHub Issues |
||||
|
<https://github.com/OCA/crm/issues>`_. In case of trouble, please |
||||
|
check there if your issue has already been reported. If you spotted it first, |
||||
|
help us smash it by providing detailed and welcomed feedback. |
||||
|
|
||||
|
Credits |
||||
|
======= |
||||
|
|
||||
|
Images |
||||
|
------ |
||||
|
|
||||
|
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_. |
||||
|
|
||||
|
Contributors |
||||
|
------------ |
||||
|
|
||||
|
* Jairo Llopis <jairo.llopis@tecnativa.com> |
||||
|
|
||||
|
Do not contact contributors directly about support or help with technical issues. |
||||
|
|
||||
|
Funders |
||||
|
------- |
||||
|
|
||||
|
The development of this module has been financially supported by: |
||||
|
|
||||
|
* `Tecnativa <https://www.tecnativa.com>`_ |
||||
|
|
||||
|
Maintainer |
||||
|
---------- |
||||
|
|
||||
|
.. image:: https://odoo-community.org/logo.png |
||||
|
:alt: Odoo Community Association |
||||
|
:target: https://odoo-community.org |
||||
|
|
||||
|
This module is maintained by the OCA. |
||||
|
|
||||
|
OCA, or the Odoo Community Association, is a nonprofit organization whose |
||||
|
mission is to support the collaborative development of Odoo features and |
||||
|
promote its widespread use. |
||||
|
|
||||
|
To contribute to this module, please visit https://odoo-community.org. |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
from . import models |
@ -0,0 +1,20 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 Jairo Llopis <jairo.llopis@tecnativa.com> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
||||
|
{ |
||||
|
"name": "Partner phonecalls schedule", |
||||
|
"summary": "Track the time and days your partners expect phone calls", |
||||
|
"version": "9.0.1.0.0", |
||||
|
"category": "Customer Relationship Management", |
||||
|
"website": "https://github.com/OCA/crm", |
||||
|
"author": "Tecnativa, Odoo Community Association (OCA)", |
||||
|
"license": "AGPL-3", |
||||
|
"application": False, |
||||
|
"installable": True, |
||||
|
"depends": [ |
||||
|
"resource", |
||||
|
], |
||||
|
"data": [ |
||||
|
"views/res_partner_view.xml", |
||||
|
], |
||||
|
} |
@ -0,0 +1,58 @@ |
|||||
|
# Translation of Odoo Server. |
||||
|
# This file contains the translation of the following modules: |
||||
|
# * partner_phonecall_schedule |
||||
|
# |
||||
|
msgid "" |
||||
|
msgstr "" |
||||
|
"Project-Id-Version: Odoo Server 9.0c\n" |
||||
|
"Report-Msgid-Bugs-To: \n" |
||||
|
"POT-Creation-Date: 2017-09-15 12:30+0000\n" |
||||
|
"PO-Revision-Date: 2017-09-15 14:32+0200\n" |
||||
|
"Language-Team: \n" |
||||
|
"MIME-Version: 1.0\n" |
||||
|
"Content-Type: text/plain; charset=UTF-8\n" |
||||
|
"Content-Transfer-Encoding: 8bit\n" |
||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n" |
||||
|
"X-Generator: Poedit 2.0.3\n" |
||||
|
"Last-Translator: Jairo Llopis <yajo.sk8@gmail.com>\n" |
||||
|
"Language: es_ES\n" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.model.fields,field_description:partner_phonecall_schedule.field_res_partner_phonecall_calendar_attendance_ids |
||||
|
msgid "Aggregated phonecall schedule" |
||||
|
msgstr "Horario agregado de llamadas telefónicas" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.ui.view,arch_db:partner_phonecall_schedule.view_res_partner_filter |
||||
|
msgid "Available for phone calls now" |
||||
|
msgstr "Disponible para llamar por teléfono ahora" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.model.fields,field_description:partner_phonecall_schedule.field_res_partner_phonecall_available |
||||
|
msgid "Available to call" |
||||
|
msgstr "Disponible para llamar" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.model.fields,help:partner_phonecall_schedule.field_res_partner_phonecall_calendar_ids |
||||
|
msgid "Best schedule when the contact expects to be called." |
||||
|
msgstr "El mejor horario para llamar por teléfono al contacto." |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.model.fields,help:partner_phonecall_schedule.field_res_partner_phonecall_available |
||||
|
msgid "Is it now a good time to call this partner?" |
||||
|
msgstr "¿Es un buen momento para llamar a este contacto?" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.model,name:partner_phonecall_schedule.model_res_partner |
||||
|
msgid "Partner" |
||||
|
msgstr "Contacto" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.ui.view,arch_db:partner_phonecall_schedule.view_partner_form |
||||
|
msgid "Phone calls" |
||||
|
msgstr "Llamadas telefónicas" |
||||
|
|
||||
|
#. module: partner_phonecall_schedule |
||||
|
#: model:ir.model.fields,field_description:partner_phonecall_schedule.field_res_partner_phonecall_calendar_ids |
||||
|
msgid "Phonecall schedule" |
||||
|
msgstr "Horario de llamadas telefónicas" |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
from . import res_partner |
@ -0,0 +1,78 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 Jairo Llopis <jairo.llopis@tecnativa.com> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
from __future__ import division |
||||
|
from datetime import datetime |
||||
|
from openerp import api, fields, models |
||||
|
|
||||
|
|
||||
|
class ResPartner(models.Model): |
||||
|
_inherit = "res.partner" |
||||
|
|
||||
|
phonecall_available = fields.Boolean( |
||||
|
"Available to call", |
||||
|
compute="_compute_phonecall_available", |
||||
|
search="_search_phonecall_available", |
||||
|
help="Is it now a good time to call this partner?", |
||||
|
) |
||||
|
phonecall_calendar_ids = fields.Many2many( |
||||
|
comodel_name="resource.calendar", |
||||
|
string="Phonecall schedule", |
||||
|
help="Best schedule when the contact expects to be called.", |
||||
|
) |
||||
|
phonecall_calendar_attendance_ids = fields.One2many( |
||||
|
comodel_name="resource.calendar.attendance", |
||||
|
string="Aggregated phonecall schedule", |
||||
|
compute="_compute_phonecall_calendar_ids", |
||||
|
help="Aggregation of all available phonecall schedules.", |
||||
|
) |
||||
|
|
||||
|
@api.depends("phonecall_calendar_ids", "phonecall_calendar_attendance_ids") |
||||
|
def _compute_phonecall_available(self): |
||||
|
"""Know if a partner is available to call right now.""" |
||||
|
Attendance = self.env["resource.calendar.attendance"] |
||||
|
for one in self: |
||||
|
domain = [ |
||||
|
("calendar_id", "in", one.phonecall_calendar_ids.ids) |
||||
|
] + one._phonecall_available_domain() |
||||
|
found = Attendance.search(domain, limit=1) |
||||
|
one.phonecall_available = bool(found) |
||||
|
|
||||
|
@api.depends("phonecall_calendar_ids") |
||||
|
def _compute_phonecall_calendar_ids(self): |
||||
|
"""Fill attendance aggregation.""" |
||||
|
for one in self: |
||||
|
one.phonecall_calendar_attendance_ids = one.mapped( |
||||
|
"phonecall_calendar_ids.attendance_ids") |
||||
|
|
||||
|
def _search_phonecall_available(self, operator, value): |
||||
|
"""Search quickly if partner is available to call right now.""" |
||||
|
Attendance = self.env["resource.calendar.attendance"] |
||||
|
available = Attendance.search( |
||||
|
self._phonecall_available_domain(), |
||||
|
) |
||||
|
if operator == "!=" or "not" in operator: |
||||
|
value = not value |
||||
|
operator = "in" if value else "not in" |
||||
|
return [("phonecall_calendar_ids.attendance_ids", |
||||
|
operator, available.ids)] |
||||
|
|
||||
|
def _phonecall_available_domain(self): |
||||
|
"""Get a domain to know if we are available to call a partner.""" |
||||
|
now = self.env.context.get("now", datetime.now()) |
||||
|
try: |
||||
|
now = fields.Datetime.from_string(now) |
||||
|
except TypeError: |
||||
|
# `now` is already a datetime object |
||||
|
pass |
||||
|
date = fields.Date.to_string(now) |
||||
|
now_tz = fields.Datetime.context_timestamp(self, now) |
||||
|
float_time = now_tz.hour + ((now_tz.minute / 60) + now_tz.second) / 60 |
||||
|
return [ |
||||
|
("dayofweek", "=", str(now.weekday())), |
||||
|
"|", ("date_from", "=", False), ("date_from", "<=", date), |
||||
|
"|", ("date_to", "=", False), ("date_to", ">=", date), |
||||
|
("hour_from", "<=", float_time), |
||||
|
("hour_to", ">=", float_time), |
||||
|
] |
After Width: 128 | Height: 128 | Size: 9.2 KiB |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
from . import test_schedule |
@ -0,0 +1,173 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2017 Jairo Llopis <jairo.llopis@tecnativa.com> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
from datetime import datetime, timedelta |
||||
|
from mock import patch |
||||
|
from openerp import fields |
||||
|
from openerp.tests.common import SavepointCase |
||||
|
|
||||
|
PATH = ("openerp.addons.partner_phonecall_schedule.models" |
||||
|
".res_partner.datetime") |
||||
|
|
||||
|
|
||||
|
class CanICallCase(SavepointCase): |
||||
|
@classmethod |
||||
|
def setUpClass(cls): |
||||
|
super(CanICallCase, cls).setUpClass() |
||||
|
cls.Calendar = cls.env["resource.calendar"].with_context(tz="UTC") |
||||
|
cls.Partner = cls.env["res.partner"].with_context(tz="UTC") |
||||
|
cls.some_mornings = cls.Calendar.create({ |
||||
|
"name": "Some mornings", |
||||
|
"attendance_ids": [ |
||||
|
(0, 0, { |
||||
|
"name": "Friday morning", |
||||
|
"dayofweek": "4", |
||||
|
"hour_from": 8, |
||||
|
"hour_to": 12, |
||||
|
}), |
||||
|
(0, 0, { |
||||
|
"name": "Next monday morning", |
||||
|
"dayofweek": "0", |
||||
|
"hour_from": 8, |
||||
|
"hour_to": 12, |
||||
|
"date_from": "2017-09-18", |
||||
|
"date_to": "2017-09-18", |
||||
|
}), |
||||
|
], |
||||
|
}) |
||||
|
cls.some_evenings = cls.Calendar.create({ |
||||
|
"name": "Some evenings", |
||||
|
"attendance_ids": [ |
||||
|
(0, 0, { |
||||
|
"name": "Friday evening", |
||||
|
"dayofweek": "4", |
||||
|
"hour_from": 15, |
||||
|
"hour_to": 19, |
||||
|
}), |
||||
|
(0, 0, { |
||||
|
"name": "Next monday evening", |
||||
|
"dayofweek": "0", |
||||
|
"hour_from": 15, |
||||
|
"hour_to": 19, |
||||
|
"date_from": "2017-09-18", |
||||
|
"date_to": "2017-09-18", |
||||
|
}), |
||||
|
], |
||||
|
}) |
||||
|
cls.dude = cls.Partner.create({ |
||||
|
"name": "Dude", |
||||
|
}) |
||||
|
cls.dude.phonecall_calendar_ids = cls.some_mornings |
||||
|
|
||||
|
def setUp(self): |
||||
|
super(CanICallCase, self).setUp() |
||||
|
# Now it is a friday morning |
||||
|
self.datetime = datetime(2017, 9, 15, 10, 53, 30) |
||||
|
|
||||
|
def _allowed(self, now=None): |
||||
|
dude, Partner = self.dude, self.Partner |
||||
|
if now: |
||||
|
dude = dude.with_context(now=now) |
||||
|
Partner = Partner.with_context(now=now) |
||||
|
self.assertTrue(dude.phonecall_available) |
||||
|
self.assertTrue(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "=", True), |
||||
|
])) |
||||
|
self.assertTrue(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "!=", False), |
||||
|
])) |
||||
|
self.assertFalse(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "=", False), |
||||
|
])) |
||||
|
self.assertFalse(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "!=", True), |
||||
|
])) |
||||
|
|
||||
|
def allowed(self): |
||||
|
# Test mocking datetime.now() |
||||
|
with patch(PATH) as mocked_dt: |
||||
|
mocked_dt.now.return_value = self.datetime |
||||
|
mocked_dt.date.return_value = self.datetime.date() |
||||
|
self._allowed() |
||||
|
# Test sending a datetime object in the context |
||||
|
self._allowed(self.datetime) |
||||
|
# Test sending a string in the context |
||||
|
self._allowed( |
||||
|
fields.Datetime.to_string(self.datetime)) |
||||
|
|
||||
|
def _disallowed(self, now=None): |
||||
|
dude, Partner = self.dude, self.Partner |
||||
|
if now: |
||||
|
dude = dude.with_context(now=now) |
||||
|
Partner = Partner.with_context(now=now) |
||||
|
self.assertFalse(dude.phonecall_available) |
||||
|
self.assertFalse(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "=", True), |
||||
|
])) |
||||
|
self.assertFalse(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "!=", False), |
||||
|
])) |
||||
|
self.assertTrue(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "=", False), |
||||
|
])) |
||||
|
self.assertTrue(Partner.search([ |
||||
|
("id", "=", dude.id), |
||||
|
("phonecall_available", "!=", True), |
||||
|
])) |
||||
|
|
||||
|
def disallowed(self): |
||||
|
# Test mocking datetime.now() |
||||
|
with patch(PATH) as mocked_dt: |
||||
|
mocked_dt.now.return_value = self.datetime |
||||
|
mocked_dt.date.return_value = self.datetime.date() |
||||
|
self._disallowed() |
||||
|
# Test sending a datetime object in the context |
||||
|
self._disallowed(self.datetime) |
||||
|
# Test sending a string in the context |
||||
|
self._disallowed( |
||||
|
fields.Datetime.to_string(self.datetime)) |
||||
|
|
||||
|
def test_friday_morning(self): |
||||
|
"""I can call dude this morning""" |
||||
|
self.allowed() |
||||
|
|
||||
|
def test_friday_evening(self): |
||||
|
"""I cannot call dude this evening""" |
||||
|
self.datetime += timedelta(hours=4) |
||||
|
self.disallowed() |
||||
|
|
||||
|
def test_saturday_morning(self): |
||||
|
"""I cannot call dude tomorrow morning""" |
||||
|
self.datetime += timedelta(days=1) |
||||
|
self.disallowed() |
||||
|
|
||||
|
def test_saturday_evening(self): |
||||
|
"""I cannot call dude tomorrow evening""" |
||||
|
self.datetime += timedelta(days=1, hours=4) |
||||
|
self.disallowed() |
||||
|
|
||||
|
def test_next_monday_morning(self): |
||||
|
"""I can call dude next monday morning""" |
||||
|
self.datetime += timedelta(days=3) |
||||
|
self.allowed() |
||||
|
|
||||
|
def test_second_next_monday_morning(self): |
||||
|
"""I cannot call dude second next monday morning""" |
||||
|
self.datetime += timedelta(days=10, hours=4) |
||||
|
self.disallowed() |
||||
|
|
||||
|
def test_aggregated_attendances(self): |
||||
|
"""I get aggregated schedules correctly.""" |
||||
|
self.dude.phonecall_calendar_ids |= self.some_evenings |
||||
|
all_attendances = (self.some_mornings | self.some_evenings).mapped( |
||||
|
"attendance_ids") |
||||
|
self.assertEqual( |
||||
|
self.dude.phonecall_calendar_attendance_ids, all_attendances) |
@ -0,0 +1,44 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<!-- Copyright 2017 Jairo Llopis <jairo.llopis@tecnativa.com> |
||||
|
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> |
||||
|
|
||||
|
<odoo> |
||||
|
|
||||
|
<record id="view_partner_form" model="ir.ui.view"> |
||||
|
<field name="name">Partner phonecall schedule</field> |
||||
|
<field name="model">res.partner</field> |
||||
|
<field name="inherit_id" ref="base.view_partner_form"/> |
||||
|
<field name="arch" type="xml"> |
||||
|
<xpath expr="//notebook"> |
||||
|
<!-- `many2many_tags` widget would not allow the user to click |
||||
|
and edit a calendar record as the list widget does; |
||||
|
management menus are under global settings, so only |
||||
|
admins could do it; we need plenty of space to display |
||||
|
the aggregated schedule. All that said, we have to use a |
||||
|
list widget inside a dedicated page. --> |
||||
|
<page string="Phone calls" name="phonecalls"> |
||||
|
<group> |
||||
|
<field name="phonecall_available"/> |
||||
|
<field name="phonecall_calendar_ids"/> |
||||
|
<field name="phonecall_calendar_attendance_ids"/> |
||||
|
</group> |
||||
|
</page> |
||||
|
</xpath> |
||||
|
</field> |
||||
|
</record> |
||||
|
|
||||
|
<record id="view_res_partner_filter" model="ir.ui.view"> |
||||
|
<field name="name">Partner phonecall availability</field> |
||||
|
<field name="model">res.partner</field> |
||||
|
<field name="inherit_id" ref="base.view_res_partner_filter"/> |
||||
|
<field name="arch" type="xml"> |
||||
|
<xpath expr="//separator[last()]" position="before"> |
||||
|
<filter |
||||
|
string="Available for phone calls now" |
||||
|
name="phonecall_available" |
||||
|
domain="[('phonecall_available', '=', True)]"/> |
||||
|
</xpath> |
||||
|
</field> |
||||
|
</record> |
||||
|
|
||||
|
</odoo> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue