diff --git a/crm_salesperson_planner/models/crm_salesperson_planner_visit_template.py b/crm_salesperson_planner/models/crm_salesperson_planner_visit_template.py
index dbddc433f0d..9771d7e57d2 100644
--- a/crm_salesperson_planner/models/crm_salesperson_planner_visit_template.py
+++ b/crm_salesperson_planner/models/crm_salesperson_planner_visit_template.py
@@ -8,21 +8,51 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
+from odoo.addons.base.models.res_partner import _tz_get
+from odoo.addons.calendar.models.calendar_recurrence import (
+ BYDAY_SELECTION,
+ END_TYPE_SELECTION,
+ MONTH_BY_SELECTION,
+ RRULE_TYPE_SELECTION,
+ WEEKDAY_SELECTION,
+)
+
class CrmSalespersonPlannerVisitTemplate(models.Model):
_name = "crm.salesperson.planner.visit.template"
_description = "Crm Salesperson Planner Visit Template"
- _inherit = "calendar.event"
+ _inherit = ["mail.thread"]
+ # We cannot inherit from calendar.event for several reasons:
+ # 1- There are many compute recursion fields that would not allow to change them.
+ # 2- Recurrence is only created correctly if the model is calendar.event
+ # 3- We want to generate visits ("events") manually when we want and only the ones
+ # we want.
name = fields.Char(
string="Visit Template Number",
default="/",
readonly=True,
copy=False,
)
+ description = fields.Html()
+ user_id = fields.Many2one(
+ comodel_name="res.users",
+ string="Salesperson",
+ tracking=True,
+ default=lambda self: self.env.user,
+ domain=lambda self: [
+ ("groups_id", "in", self.env.ref("sales_team.group_sale_salesman").id)
+ ],
+ )
+ partner_id = fields.Many2one(
+ comodel_name="res.partner",
+ string="Scheduled by",
+ related="user_id.partner_id",
+ readonly=True,
+ )
partner_ids = fields.Many2many(
+ comodel_name="res.partner",
string="Customer",
- relation="salesperson_planner_res_partner_rel",
default=False,
required=True,
)
@@ -35,18 +65,13 @@ class CrmSalespersonPlannerVisitTemplate(models.Model):
string="Company",
default=lambda self: self.env.company,
)
- user_id = fields.Many2one(
- string="Salesperson",
- tracking=True,
- default=lambda self: self.env.user,
- domain=lambda self: [
- ("groups_id", "in", self.env.ref("sales_team.group_sale_salesman").id)
- ],
- )
- categ_ids = fields.Many2many(
- relation="visit_category_rel",
+ categ_ids = fields.Many2many(comodel_name="calendar.event.type", string="Tags")
+ alarm_ids = fields.Many2many(
+ comodel_name="calendar.alarm",
+ string="Reminders",
+ ondelete="restrict",
+ help="Notifications sent to all attendees to remind of the meeting.",
)
- alarm_ids = fields.Many2many(relation="visit_calendar_event_rel")
state = fields.Selection(
string="Status",
required=True,
@@ -71,29 +96,62 @@ class CrmSalespersonPlannerVisitTemplate(models.Model):
auto_validate = fields.Boolean(default=True)
last_visit_date = fields.Date(compute="_compute_last_visit_date", store=True)
final_date = fields.Date(string="Repeat Until")
- allday = fields.Boolean(default=True)
- # Set all compute=_compute_recurrence fields of calendar.event as store=True.
- # We want to manage the value of the fields manually and we don't want to depend
- # on recurrence_id field (only possible with calendar.event).
- # We don't use the recurrency field either because it is unnecessary.
- rrule = fields.Char(store=True)
- rrule_type = fields.Selection(store=True, default="daily", required=True)
- event_tz = fields.Selection(store=True)
- end_type = fields.Selection(store=True)
- interval = fields.Integer(store=True)
- count = fields.Integer(store=True)
- mon = fields.Boolean(store=True)
- tue = fields.Boolean(store=True)
- wed = fields.Boolean(store=True)
- thu = fields.Boolean(store=True)
- fri = fields.Boolean(store=True)
- sat = fields.Boolean(store=True)
- sun = fields.Boolean(store=True)
- month_by = fields.Selection(store=True)
- day = fields.Integer(store=True)
- weekday = fields.Selection(store=True)
- byday = fields.Selection(store=True)
- until = fields.Date(store=True)
+ start = fields.Datetime(
+ required=True,
+ tracking=True,
+ default=fields.Date.today,
+ help="Start date of an event, without time for full days events",
+ )
+ stop = fields.Datetime(
+ required=True,
+ tracking=True,
+ default=lambda self: fields.Datetime.today() + timedelta(hours=1),
+ compute="_compute_stop",
+ readonly=False,
+ store=True,
+ help="Stop date of an event, without time for full days events",
+ )
+ allday = fields.Boolean(string="All Day", default=True)
+ start_date = fields.Date(
+ store=True,
+ tracking=True,
+ compute="_compute_dates",
+ inverse="_inverse_dates",
+ )
+ stop_date = fields.Date(
+ string="End Date",
+ store=True,
+ tracking=True,
+ compute="_compute_dates",
+ inverse="_inverse_dates",
+ )
+ duration = fields.Float(compute="_compute_duration", store=True, readonly=False)
+ rrule = fields.Char(string="Recurrent Rule")
+ rrule_type = fields.Selection(
+ RRULE_TYPE_SELECTION,
+ string="Recurrence",
+ help="Let the event automatically repeat at that interval",
+ default="daily",
+ required=True,
+ )
+ event_tz = fields.Selection(_tz_get, string="Timezone")
+ end_type = fields.Selection(END_TYPE_SELECTION, string="Recurrence Termination")
+ interval = fields.Integer(
+ string="Repeat Every", help="Repeat every (Days/Week/Month/Year)"
+ )
+ count = fields.Integer(string="Repeat", help="Repeat x times")
+ mon = fields.Boolean()
+ tue = fields.Boolean()
+ wed = fields.Boolean()
+ thu = fields.Boolean()
+ fri = fields.Boolean()
+ sat = fields.Boolean()
+ sun = fields.Boolean()
+ month_by = fields.Selection(MONTH_BY_SELECTION, string="Option")
+ day = fields.Integer(string="Date of month")
+ weekday = fields.Selection(WEEKDAY_SELECTION)
+ byday = fields.Selection(BYDAY_SELECTION)
+ until = fields.Date()
_sql_constraints = [
(
@@ -120,6 +178,55 @@ def _compute_last_visit_date(self):
for sel in self.filtered(lambda x: x.visit_ids):
sel.last_visit_date = sel.visit_ids.sorted(lambda x: x.date)[-1].date
+ @api.depends("start", "duration")
+ def _compute_stop(self):
+ """Same method as in calendar.event."""
+ for item in self:
+ item.stop = item.start and item.start + timedelta(
+ minutes=round((item.duration or 1.0) * 60)
+ )
+ if item.allday:
+ item.stop -= timedelta(seconds=1)
+
+ @api.depends("allday", "start", "stop")
+ def _compute_dates(self):
+ """Same method as in calendar.event."""
+ for item in self:
+ if item.allday and item.start and item.stop:
+ item.start_date = item.start.date()
+ item.stop_date = item.stop.date()
+ else:
+ item.start_date = False
+ item.stop_date = False
+
+ @api.depends("stop", "start")
+ def _compute_duration(self):
+ """Same method as in calendar.event."""
+ for item in self:
+ item.duration = self._get_duration(item.start, item.stop)
+
+ def _get_duration(self, start, stop):
+ """Same method as in calendar.event."""
+ if not start or not stop:
+ return 0
+ duration = (stop - start).total_seconds() / 3600
+ return round(duration, 2)
+
+ def _inverse_dates(self):
+ """Same method as in calendar.event."""
+ for item in self:
+ if item.allday:
+ enddate = fields.Datetime.from_string(item.stop_date)
+ enddate = enddate.replace(hour=18)
+ startdate = fields.Datetime.from_string(item.start_date)
+ startdate = startdate.replace(hour=8)
+ item.write(
+ {
+ "start": startdate.replace(tzinfo=None),
+ "stop": enddate.replace(tzinfo=None),
+ }
+ )
+
@api.constrains("partner_ids")
def _constrains_partner_ids(self):
for item in self:
@@ -146,12 +253,6 @@ def create(self, vals_list):
)
return super().create(vals_list)
- # overwrite
- # Calling _update_cron from default write funciont is not
- # necessary in this case
- def write(self, vals):
- return super(models.Model, self).write(vals)
-
def action_view_salesperson_planner_visit(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"crm_salesperson_planner.all_crm_salesperson_planner_visit_action"
@@ -189,29 +290,51 @@ def _prepare_crm_salesperson_planner_visit_vals(self, dates):
for date in dates
]
+ # Get the date range from calendar.recurrence, that way the values obtained will
+ # be correct (except for incompatible cases).
+ def _get_start_range_dates(self):
+ """Method to get all dates (sorted) in the range."""
+ duration = self.stop - self.start
+ ranges = (
+ self.env["calendar.recurrence"]
+ .new(
+ {
+ "rrule_type": self.rrule_type,
+ "interval": self.interval,
+ "month_by": self.month_by,
+ "weekday": self.weekday,
+ "byday": self.byday,
+ "count": self.count,
+ "end_type": self.end_type,
+ "until": self.until,
+ "mon": self.mon,
+ "tue": self.tue,
+ "wed": self.wed,
+ "thu": self.thu,
+ "fri": self.fri,
+ "sat": self.sat,
+ "sun": self.sun,
+ }
+ )
+ ._range_calculation(self, duration)
+ )
+ start_dates = []
+ for start, _stop in ranges:
+ start_dates.append(start.date())
+ return sorted(start_dates)
+
def _get_max_date(self):
- return self.until or self._increase_date(self.start_date, self.count)
-
- def _increase_date(self, date, value):
- if self.rrule_type == "daily":
- date += timedelta(days=value)
- elif self.rrule_type == "weekly":
- date += timedelta(weeks=value)
- elif self.rrule_type == "monthly":
- date += timedelta(months=value)
- elif self.rrule_type == "yearly":
- date += timedelta(years=value)
- return date
+ """The maximum date will be the last of the range."""
+ return self._get_start_range_dates()[-1]
def _get_recurrence_dates(self, items):
+ """For the n items, get only those that are not already generated."""
+ start_dates = self._get_start_range_dates()
dates = []
- max_date = self._get_max_date()
- from_date = self._increase_date(self.last_visit_date or self.start_date, 1)
- if max_date > from_date:
- for _x in range(items):
- if from_date <= max_date:
- dates.append(from_date)
- from_date = self._increase_date(from_date, 1)
+ visit_dates = self.visit_ids.mapped("date")
+ for _date in start_dates[:items]:
+ if _date not in visit_dates:
+ dates.append(_date)
return dates
def _create_visits(self, days=7):
diff --git a/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit.py b/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit.py
index e252429a1cc..3d1a8381e8c 100644
--- a/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit.py
+++ b/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit.py
@@ -5,12 +5,23 @@
from odoo import fields
from odoo.tests import common
+from odoo.tools import mute_logger
class TestCrmSalespersonPlannerVisitBase(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
+ cls.env = cls.env(
+ context=dict(
+ cls.env.context,
+ mail_create_nolog=True,
+ mail_create_nosubscribe=True,
+ mail_notrack=True,
+ no_reset_password=True,
+ tracking_disable=True,
+ )
+ )
cls.visit_model = cls.env["crm.salesperson.planner.visit"]
cls.partner_model = cls.env["res.partner"]
cls.close_model = cls.env["crm.salesperson.planner.visit.close.reason"]
@@ -94,6 +105,7 @@ def config_close_wiz(self, att_close_type, vals):
)
close_wiz.action_close_reason_apply()
+ @mute_logger("odoo.models.unlink")
def test_crm_salesperson_close_wiz_cancel(self):
self.visit1.action_confirm()
self.assertEqual(self.visit1.state, "confirm")
@@ -108,6 +120,7 @@ def test_crm_salesperson_close_wiz_cancel(self):
2,
)
+ @mute_logger("odoo.models.unlink")
def test_crm_salesperson_close_wiz_cancel_resch(self):
self.visit1.action_confirm()
self.assertEqual(self.visit1.state, "confirm")
@@ -132,6 +145,7 @@ def test_crm_salesperson_close_wiz_cancel_resch(self):
1,
)
+ @mute_logger("odoo.models.unlink")
def test_crm_salesperson_close_wiz_cancel_img(self):
self.visit1.action_confirm()
self.assertEqual(self.visit1.state, "confirm")
diff --git a/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit_template.py b/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit_template.py
index 4cf6add1e07..2df099e56d6 100644
--- a/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit_template.py
+++ b/crm_salesperson_planner/tests/test_crm_salesperson_planner_visit_template.py
@@ -1,17 +1,28 @@
# Copyright 2021 Sygel - Valentin Vinagre
# Copyright 2021 Sygel - Manuel Regidor
+# Copyright 2024 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html)
-
from datetime import timedelta
from odoo import exceptions, fields
from odoo.tests import common
+from odoo.tools import mute_logger
class TestCrmSalespersonPlannerVisitTemplate(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
+ cls.env = cls.env(
+ context=dict(
+ cls.env.context,
+ mail_create_nolog=True,
+ mail_create_nosubscribe=True,
+ mail_notrack=True,
+ no_reset_password=True,
+ tracking_disable=True,
+ )
+ )
cls.visit_template_model = cls.env["crm.salesperson.planner.visit.template"]
cls.partner_model = cls.env["res.partner"]
cls.close_reason_mode = cls.env["crm.salesperson.planner.visit.close.reason"]
@@ -47,7 +58,7 @@ def test_01_repeat_days(self):
)
self.visit_template_base.action_validate()
self.visit_template_base.create_visits(days=4)
- self.assertEqual(self.visit_template_base.visit_ids_count, 4)
+ self.assertEqual(len(self.visit_template_base.visit_ids), 4)
self.assertEqual(
len(
self.visit_template_base.visit_ids.filtered(
@@ -65,9 +76,8 @@ def test_01_repeat_days(self):
0,
)
self.assertEqual(self.visit_template_base.state, "in-progress")
- self.visit_template_base.create_visits(days=9)
- self.visit_template_base._compute_visit_ids_count()
- self.assertEqual(self.visit_template_base.visit_ids_count, 10)
+ self.visit_template_base.create_visits(days=10)
+ self.assertEqual(len(self.visit_template_base.visit_ids), 10)
self.assertEqual(
len(
self.visit_template_base.visit_ids.filtered(
@@ -98,7 +108,7 @@ def test_02_repeat_days_autovalidate(self):
)
self.visit_template_base.action_validate()
self.visit_template_base.create_visits(days=4)
- self.assertEqual(self.visit_template_base.visit_ids_count, 4)
+ self.assertEqual(len(self.visit_template_base.visit_ids), 4)
self.assertEqual(
len(
self.visit_template_base.visit_ids.filtered(
@@ -116,9 +126,8 @@ def test_02_repeat_days_autovalidate(self):
4,
)
self.assertEqual(self.visit_template_base.state, "in-progress")
- self.visit_template_base.create_visits(days=9)
- self.visit_template_base._compute_visit_ids_count()
- self.assertEqual(self.visit_template_base.visit_ids_count, 10)
+ self.visit_template_base.create_visits(days=10)
+ self.assertEqual(len(self.visit_template_base.visit_ids), 10)
self.assertEqual(
len(
self.visit_template_base.visit_ids.filtered(
@@ -162,6 +171,7 @@ def test_03_change_visit_date(self):
)
self.assertEqual(visit_0.date, fields.Date.today() + timedelta(days=14))
+ @mute_logger("odoo.models.unlink")
def test_04_cancel_visit(self):
visit_template = self.visit_template_base.copy()
visit_template.write(
@@ -183,3 +193,114 @@ def test_04_cancel_visit(self):
self.assertFalse(first_visit.calendar_event_id)
first_visit.unlink()
self.assertEqual(len(visit_template.visit_ids), 9)
+
+ def test_05_repeat_weeks(self):
+ self.visit_template_base.write(
+ {
+ "start_date": "2024-03-08",
+ "interval": 1,
+ "rrule_type": "weekly",
+ "tue": True,
+ "end_type": "end_date",
+ "until": "2024-07-02",
+ }
+ )
+ self.visit_template_base.action_validate()
+ self.assertFalse(self.visit_template_base.visit_ids)
+ create_model = self.env["crm.salesperson.planner.visit.template.create"]
+ create_item = create_model.with_context(
+ active_id=self.visit_template_base.id
+ ).create({"date_to": "2024-07-02"})
+ create_item.create_visits()
+ self.assertEqual(self.visit_template_base.state, "done")
+ visit_dates = self.visit_template_base.visit_ids.mapped("date")
+ self.assertIn(fields.Date.from_string("2024-03-19"), visit_dates)
+ self.assertEqual(
+ self.visit_template_base.last_visit_date,
+ fields.Date.from_string("2024-07-02"),
+ )
+
+ def test_06_repeat_months_count_01(self):
+ self.visit_template_base.write(
+ {
+ "start_date": "2024-03-08",
+ "interval": 1,
+ "rrule_type": "monthly",
+ "end_type": "count",
+ "count": 2,
+ "month_by": "date",
+ "day": 1,
+ }
+ )
+ self.visit_template_base.action_validate()
+ self.assertFalse(self.visit_template_base.visit_ids)
+ create_model = self.env["crm.salesperson.planner.visit.template.create"]
+ create_item = create_model.with_context(
+ active_id=self.visit_template_base.id
+ ).create({"date_to": "2024-12-13"})
+ create_item.create_visits()
+ self.assertEqual(self.visit_template_base.state, "done")
+ self.assertEqual(len(self.visit_template_base.visit_ids), 2)
+ visit_dates = self.visit_template_base.visit_ids.mapped("date")
+ self.assertIn(fields.Date.from_string("2024-04-01"), visit_dates)
+ self.assertEqual(
+ self.visit_template_base.last_visit_date,
+ fields.Date.from_string("2024-05-01"),
+ )
+
+ def test_06_repeat_months_count_02(self):
+ self.visit_template_base.write(
+ {
+ "start_date": "2024-03-08",
+ "interval": 1,
+ "rrule_type": "monthly",
+ "end_type": "count",
+ "count": 2,
+ "month_by": "date",
+ "day": 1,
+ }
+ )
+ self.visit_template_base.action_validate()
+ self.assertFalse(self.visit_template_base.visit_ids)
+ create_model = self.env["crm.salesperson.planner.visit.template.create"]
+ create_item = create_model.with_context(
+ active_id=self.visit_template_base.id
+ ).create({"date_to": "2024-12-13"})
+ create_item.create_visits()
+ self.assertEqual(self.visit_template_base.state, "done")
+ self.assertEqual(len(self.visit_template_base.visit_ids), 2)
+ visit_dates = self.visit_template_base.visit_ids.mapped("date")
+ self.assertIn(fields.Date.from_string("2024-04-01"), visit_dates)
+ self.assertEqual(
+ self.visit_template_base.last_visit_date,
+ fields.Date.from_string("2024-05-01"),
+ )
+
+ def test_06_repeat_months_count_03(self):
+ self.visit_template_base.write(
+ {
+ "start_date": "2024-03-08",
+ "interval": 1,
+ "rrule_type": "monthly",
+ "end_type": "count",
+ "count": 2,
+ "month_by": "day",
+ "byday": "1",
+ "weekday": "MON",
+ }
+ )
+ self.visit_template_base.action_validate()
+ self.assertFalse(self.visit_template_base.visit_ids)
+ create_model = self.env["crm.salesperson.planner.visit.template.create"]
+ create_item = create_model.with_context(
+ active_id=self.visit_template_base.id
+ ).create({"date_to": "2024-12-13"})
+ create_item.create_visits()
+ self.assertEqual(self.visit_template_base.state, "done")
+ self.assertEqual(len(self.visit_template_base.visit_ids), 2)
+ visit_dates = self.visit_template_base.visit_ids.mapped("date")
+ self.assertIn(fields.Date.from_string("2024-04-01"), visit_dates)
+ self.assertEqual(
+ self.visit_template_base.last_visit_date,
+ fields.Date.from_string("2024-05-06"),
+ )
diff --git a/crm_salesperson_planner/views/crm_salesperson_planner_visit_template_views.xml b/crm_salesperson_planner/views/crm_salesperson_planner_visit_template_views.xml
index 503d466f96d..875bc0e0e7f 100644
--- a/crm_salesperson_planner/views/crm_salesperson_planner_visit_template_views.xml
+++ b/crm_salesperson_planner/views/crm_salesperson_planner_visit_template_views.xml
@@ -91,8 +91,6 @@
/>
-
-
diff --git a/crm_salesperson_planner/wizards/crm_salesperson_planner_visit_template_create.py b/crm_salesperson_planner/wizards/crm_salesperson_planner_visit_template_create.py
index 1746757241a..ea278d38382 100644
--- a/crm_salesperson_planner/wizards/crm_salesperson_planner_visit_template_create.py
+++ b/crm_salesperson_planner/wizards/crm_salesperson_planner_visit_template_create.py
@@ -29,9 +29,6 @@ def create_visits(self):
days = (self.date_to - fields.Date.context_today(self)).days
if days < 0:
raise ValidationError(_("The date can't be earlier than today"))
- visits = self.env["crm.salesperson.planner.visit"].create(
- template._create_visits(days=days)
- )
- if visits and template.auto_validate:
- visits.action_confirm()
+ # Create visits + auto-confirm + auto-done
+ template.create_visits(days=days)
return {"type": "ir.actions.act_window_close"}