Merge lp:~will-q/openobject-server/trunk_bugfix_1190112 into lp:openobject-server

Proposed by Will Stokes
Status: Needs review
Proposed branch: lp:~will-q/openobject-server/trunk_bugfix_1190112
Merge into: lp:openobject-server
Diff against target: 101 lines (+59/-2)
1 file modified
openerp/addons/base/ir/ir_cron.py (+59/-2)
To merge this branch: bzr merge lp:~will-q/openobject-server/trunk_bugfix_1190112
Reviewer Review Type Date Requested Status
OpenERP Core Team Pending
Review via email: mp+168840@code.launchpad.net

Description of the change

Adds a monthsend interval_type and special handling which it requires.

This change also fixes existing subtle bug that could affect monthly crons. In explaining it, hopefully it will help clarify why the new method works the way it does.

The issue is easiest to see by example:

Take user who has timezone Pacific/Auckland (NZST +12 or NZDT +13)
If they set up a monthly job to run from the 31st of May, they will then see the next run scheduled for 1st July (NZST) instead of 30th June.

To show how that happens -
UI (NZST) -> DB (UTC) -> add month DB (UTC) -> UI (NZST)
31/05 10am -> 30/05 10pm -> 30/06 10pm -> 01/07 10am

Since this issue also affects monthsend calculations the fix adds a new method to perform date calculation in the timezone of the user running the job (if they have one set). So the flow is instead:

UI (NZST) -> DB (UTC) -> Local -> add month Local -> DB (UTC) -> UI (NZST)
31/05 10am -> 30/05 10pm -> 31/05 10am -> 30/06 10am -> 29/06 10pm -> 30/06 10am

With this fix, the additional monthsend interval type is handled as a special case of month interval type. It localizes the datetime as above but then has to move that to be the first of the month so that date calculation is thereby a simple add extra month and subtract a day.

A followup change to openobject-addons/subscription/subscription.py will be required for change to be used by users setting up recurring events. It is only:
- 'interval_type': fields.selection([('days', 'Days'), ('weeks', 'Weeks'), ('months', 'Months')], 'Interval Unit'),
+ 'interval_type': fields.selection([('days', 'Days'), ('weeks', 'Weeks'), ('months', 'Months'), ('monthsend', 'End of Months')], 'Interval Unit'),

To post a comment you must log in.

Unmerged revisions

4898. By Will Stokes

[FIX] 1190112: Add monthend cron handling.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'openerp/addons/base/ir/ir_cron.py'
2--- openerp/addons/base/ir/ir_cron.py 2013-04-22 09:36:55 +0000
3+++ openerp/addons/base/ir/ir_cron.py 2013-06-12 03:47:34 +0000
4@@ -24,6 +24,8 @@
5 import psycopg2
6 from datetime import datetime
7 from dateutil.relativedelta import relativedelta
8+from pytz import timezone
9+import pytz
10
11 import openerp
12 from openerp import netsvc
13@@ -47,8 +49,47 @@
14 'weeks': lambda interval: relativedelta(days=7*interval),
15 'months': lambda interval: relativedelta(months=interval),
16 'minutes': lambda interval: relativedelta(minutes=interval),
17+ 'monthsend': lambda interval: relativedelta(months=interval+1, days=-1), # requires special handling
18 }
19
20+def increment_nextcall_in_localtz(from_dt, interval_type, reldelta, tz):
21+ """ Add interval to datetime in local timezone
22+
23+ :param from_dt: naive datetime in UTC
24+ :param interval_type: months or monthsend (which is handled specially)
25+ :param tz: timezone (string) eg 'Pacific/Auckland'
26+ :param reldelta: size of interval to add eg relativedelta(months=3)
27+ """
28+ if (not tz) or tz == 'UTC':
29+ return from_dt + reldelta
30+
31+ from_dt_utc = pytz.utc.localize(from_dt)
32+
33+ local_tz = timezone(tz)
34+ local_dt = local_tz.normalize(from_dt_utc)
35+
36+ if interval_type == 'monthsend':
37+ local_dt = datetime(
38+ local_dt.year, local_dt.month, 1,
39+ local_dt.hour, local_dt.minute, local_dt.second, local_dt.microsecond
40+ )
41+ local_dt = local_tz.localize(local_dt)
42+
43+ fmt = '%Y-%m-%d %H:%M:%S %Z%z'
44+ _logger.debug("ir_cron.py (increment_nextcall_in_localtz); from:%s, local_dt:%s" % (from_dt.strftime(fmt), local_dt.strftime(fmt)))
45+ local_dt = local_tz.normalize(local_dt + reldelta)
46+
47+ utc_dt = pytz.utc.normalize(local_dt.astimezone(pytz.utc))
48+
49+ naive_dt_utc = datetime(
50+ utc_dt.year, utc_dt.month, utc_dt.day,
51+ utc_dt.hour, utc_dt.minute, utc_dt.second, utc_dt.microsecond
52+ )
53+
54+ _logger.debug("ir_cron.py (increment_nextcall_in_localtz); delta:%s, local_dt:%s, next:%s" % (str(reldelta), local_dt.strftime(fmt), naive_dt_utc.strftime(fmt)))
55+
56+ return naive_dt_utc
57+
58 class ir_cron(osv.osv):
59 """ Model describing cron jobs (also called actions or tasks).
60 """
61@@ -66,7 +107,7 @@
62 'active': fields.boolean('Active'),
63 'interval_number': fields.integer('Interval Number',help="Repeat every x."),
64 'interval_type': fields.selection( [('minutes', 'Minutes'),
65- ('hours', 'Hours'), ('work_days','Work Days'), ('days', 'Days'),('weeks', 'Weeks'), ('months', 'Months')], 'Interval Unit'),
66+ ('hours', 'Hours'), ('work_days','Work Days'), ('days', 'Days'),('weeks', 'Weeks'), ('months', 'Months'), ('monthsend', 'End of Month')], 'Interval Unit'),
67 'numbercall': fields.integer('Number of Calls', help='How many times the method is called,\na negative number indicates no limit.'),
68 'doall' : fields.boolean('Repeat Missed', help="Specify if missed occurrences should be executed when the server restarts."),
69 'nextcall' : fields.datetime('Next Execution Date', required=True, help="Next planned execution date for this job."),
70@@ -99,6 +140,14 @@
71 (_check_args, 'Invalid arguments', ['args']),
72 ]
73
74+ def _get_local_tz_for_job_user(self, cr, user_id):
75+ try:
76+ tz = self.pool.get('res.users').browse(cr, user_id, user_id, context=None).tz
77+ except:
78+ tz = None
79+
80+ return tz
81+
82 def _handle_callback_exception(self, cr, uid, model_name, method_name, args, job_id, job_exception):
83 """ Method called when an exception is raised by a job.
84
85@@ -169,7 +218,15 @@
86 if not ok or job['doall']:
87 self._callback(job_cr, job['user_id'], job['model'], job['function'], job['args'], job['id'])
88 if numbercall:
89- nextcall += _intervalTypes[job['interval_type']](job['interval_number'])
90+ # get the timezone of the user set to run cron job for calculating nextcall
91+ tz = self._get_local_tz_for_job_user(job_cr, job['user_id'])
92+
93+ if job['interval_type'] == 'months' or job['interval_type'] == 'monthsend':
94+ nextcall = increment_nextcall_in_localtz(
95+ nextcall, job['interval_type'], _intervalTypes[job['interval_type']](job['interval_number']), tz
96+ )
97+ else:
98+ nextcall += _intervalTypes[job['interval_type']](job['interval_number'])
99 ok = True
100 addsql = ''
101 if not numbercall: