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