Recurrence rule + tests

This commit is contained in:
Frederik Jaeckel 2024-02-25 23:10:40 +01:00
parent ebb1efe7a8
commit ed350ba3e2
6 changed files with 564 additions and 19 deletions

View file

@ -58,6 +58,14 @@ msgctxt "model:cashbook.planner,name:"
msgid "Scheduled Booking" msgid "Scheduled Booking"
msgstr "geplante Buchung" msgstr "geplante Buchung"
msgctxt "view:cashbook.planner:"
msgid "Recurrence Rule"
msgstr "Wiederholregel"
msgctxt "view:cashbook.planner:"
msgid "Result of the recurrence rule"
msgstr "Ergebnis der Wiederholregel"
msgctxt "field:cashbook.planner,company:" msgctxt "field:cashbook.planner,company:"
msgid "Company" msgid "Company"
msgstr "Unternehmen" msgstr "Unternehmen"
@ -86,6 +94,94 @@ msgctxt "field:cashbook.planner,end_date:"
msgid "End Date" msgid "End Date"
msgstr "Endedatum" msgstr "Endedatum"
msgctxt "field:cashbook.planner,frequ:"
msgid "Frequency"
msgstr "Frequenz"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Yearly"
msgstr "Jährlich"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Monthly"
msgstr "Monatlich"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Weekly"
msgstr "Wöchentlich"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Daily"
msgstr "Täglich"
msgctxt "field:cashbook.planner,weekday:"
msgid "Weekday"
msgstr "Wochentag"
msgctxt "help:cashbook.planner,weekday:"
msgid "Select a day of the week if you want the rule to run on that day."
msgstr "Wählen Sie einen Wochentag aus, wenn die Regel an diesem Tag ausgeführt werden soll."
msgctxt "selection:cashbook.planner,weekday:"
msgid "Monday"
msgstr "Montag"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Tuesday"
msgstr "Dienstag"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Wednesday"
msgstr "Mittwoch"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Thursday"
msgstr "Donnerstag"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Friday"
msgstr "Freitag"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Saturday"
msgstr "Samstag"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Sunday"
msgstr "Sonntag"
msgctxt "field:cashbook.planner,monthday:"
msgid "Day of month"
msgstr "Tag des Monats"
msgctxt "help:cashbook.planner,monthday:"
msgid "If you want the rule to run on a specific day of the month, select the day here."
msgstr "Wenn die Regel an einem bestimmten Tag im Monat ausgeführt soll, wählen Siehier den Tag."
msgctxt "field:cashbook.planner,interval:"
msgid "Interval"
msgstr "Intervall"
msgctxt "help:cashbook.planner,interval:"
msgid "Select an interval to run the rule on every n-th date."
msgstr "Wählen Sie einen Intervall um die Regel an jedem n-ten Datum auszuführen."
msgctxt "field:cashbook.planner,nextdates:"
msgid "Next Dates"
msgstr "nächste Termine"
msgctxt "help:cashbook.planner,nextdates:"
msgid "the next 5 appointments based on the configured rule"
msgstr "die nächsten 5 Termine anhand der konfigurierten Regel"
msgctxt "field:cashbook.planner,setpos:"
msgid "Occurrence"
msgstr "Ereignis"
msgctxt "help:cashbook.planner,setpos:"
msgid "For example, if you want to run the rule on the second Wednesday of the month, enter 2 here."
msgstr "Wenn Sie die Regel z.B. am zweiten Mittwoch im Monat ausführen möchten, tragen Sie hier 2 ein."
################# #################
# cashbook.book # # cashbook.book #

View file

@ -2,17 +2,9 @@
msgid "" msgid ""
msgstr "Content-Type: text/plain; charset=utf-8\n" msgstr "Content-Type: text/plain; charset=utf-8\n"
msgctxt "model:res.group,name:group_planner_read" msgctxt "model:res.group,name:group_planner"
msgid "Cashbook - Scheduled Bookings (read)" msgid "Cashbook - Scheduled Bookings"
msgstr "Cashbook - Scheduled Bookings (read)" msgstr "Cashbook - Scheduled Bookings"
msgctxt "model:res.group,name:group_planner_write"
msgid "Cashbook - Scheduled Bookings (write)"
msgstr "Cashbook - Scheduled Bookings (write)"
msgctxt "model:res.group,name:group_planner_admin"
msgid "Cashbook - Scheduled Bookings (admin)"
msgstr "Cashbook - Scheduled Bookings (admin)"
msgctxt "model:ir.ui.menu,name:menu_planner" msgctxt "model:ir.ui.menu,name:menu_planner"
msgid "Scheduled Bookings" msgid "Scheduled Bookings"
@ -42,3 +34,135 @@ msgctxt "model:ir.rule.group,name:rg_planner_companies"
msgid "User in companies" msgid "User in companies"
msgstr "User in companies" msgstr "User in companies"
msgctxt "model:cashbook.planner,name:"
msgid "Scheduled Booking"
msgstr "Scheduled Booking"
msgctxt "view:cashbook.planner:"
msgid "Recurrence Rule"
msgstr "Recurrence Rule"
msgctxt "view:cashbook.planner:"
msgid "Result of the recurrence rule"
msgstr "Result of the recurrence rule"
msgctxt "field:cashbook.planner,company:"
msgid "Company"
msgstr "Company"
msgctxt "field:cashbook.planner,cashbook:"
msgid "Cashbook"
msgstr "Cashbook"
msgctxt "help:cashbook.planner,cashbook:"
msgid "Cash book for which the planned posting is to be executed."
msgstr "Cash book for which the planned posting is to be executed."
msgctxt "field:cashbook.planner,name:"
msgid "Name"
msgstr "Name"
msgctxt "field:cashbook.planner,description:"
msgid "Description"
msgstr "Description"
msgctxt "field:cashbook.planner,start_date:"
msgid "Start Date"
msgstr "Start Date"
msgctxt "field:cashbook.planner,end_date:"
msgid "End Date"
msgstr "End Date"
msgctxt "field:cashbook.planner,frequ:"
msgid "Frequency"
msgstr "Frequency"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Yearly"
msgstr "Yearly"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Monthly"
msgstr "Monthly"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Weekly"
msgstr "Weekly"
msgctxt "selection:cashbook.planner,frequ:"
msgid "Daily"
msgstr "Daily"
msgctxt "field:cashbook.planner,weekday:"
msgid "Weekday"
msgstr "Weekday"
msgctxt "help:cashbook.planner,weekday:"
msgid "Select a day of the week if you want the rule to run on that day."
msgstr "Select a day of the week if you want the rule to run on that day."
msgctxt "selection:cashbook.planner,weekday:"
msgid "Monday"
msgstr "Monday"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Tuesday"
msgstr "Tuesday"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Wednesday"
msgstr "Wednesday"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Thursday"
msgstr "Thursday"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Friday"
msgstr "Friday"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Saturday"
msgstr "Saturday"
msgctxt "selection:cashbook.planner,weekday:"
msgid "Sunday"
msgstr "Sunday"
msgctxt "field:cashbook.planner,monthday:"
msgid "Day of month"
msgstr "Day of month"
msgctxt "help:cashbook.planner,monthday:"
msgid "If you want the rule to run on a specific day of the month, select the day here."
msgstr "If you want the rule to run on a specific day of the month, select the day here."
msgctxt "field:cashbook.planner,interval:"
msgid "Interval"
msgstr "Interval"
msgctxt "help:cashbook.planner,interval:"
msgid "Select an interval to run the rule on every n-th date."
msgstr "Select an interval to run the rule on every n-th date."
msgctxt "field:cashbook.planner,nextdates:"
msgid "Next Dates"
msgstr "Next Dates"
msgctxt "help:cashbook.planner,nextdates:"
msgid "the next 5 appointments based on the configured rule"
msgstr "the next 5 appointments based on the configured rule"
msgctxt "field:cashbook.planner,setpos:"
msgid "Occurrence"
msgstr "Occurrence"
msgctxt "help:cashbook.planner,setpos:"
msgid "For example, if you want to run the rule on the second Wednesday of the month, enter 2 here."
msgstr "For example, if you want to run the rule on the second Wednesday of the month, enter 2 here."
msgctxt "view:cashbook.book:"
msgid "Scheduled Bookings"
msgstr "Scheduled Bookings"

View file

@ -3,12 +3,26 @@
# The COPYRIGHT file at the top level of this repository contains the # The COPYRIGHT file at the top level of this repository contains the
# full copyright notices and license terms. # full copyright notices and license terms.
from datetime import date
from dateutil.rrule import (
rrule, YEARLY, MONTHLY, WEEKLY, DAILY, MO, TU, WE, TH, FR, SA, SU)
from trytond.model import ModelSQL, ModelView, fields, Index, DeactivableMixin from trytond.model import ModelSQL, ModelView, fields, Index, DeactivableMixin
from trytond.transaction import Transaction from trytond.transaction import Transaction
from trytond.pool import Pool from trytond.pool import Pool
from trytond.pyson import Eval, Bool from trytond.report import Report
from trytond.pyson import Eval, Bool, If, And
DEF_NONE = None DEF_NONE = None
SEL_FREQU = [
('year', 'Yearly'),
('month', 'Monthly'),
('week', 'Weekly'),
('day', 'Daily')]
SEL_WEEKDAY = [
('99', '-'),
('0', 'Monday'), ('1', 'Tuesday'), ('2', 'Wednesday'),
('3', 'Thursday'), ('4', 'Friday'), ('5', 'Saturday'),
('6', 'Sunday')]
class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView): class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView):
@ -33,6 +47,45 @@ class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView):
'OR', 'OR',
('end_date', '>', Eval('start_date')), ('end_date', '>', Eval('start_date')),
('end_date', '=', DEF_NONE)]) ('end_date', '=', DEF_NONE)])
frequ = fields.Selection(
string='Frequency', required=True, selection=SEL_FREQU, sort=False)
weekday = fields.Selection(
string='Weekday', required=True, selection=SEL_WEEKDAY, sort=False,
help='Select a day of the week if you want the rule to ' +
'run on that day.',
depends=['frequ'],
states={'invisible': Eval('frequ') != 'month'})
COND_SETPOS = And(Eval('weekday', '') != '99', Eval('frequ') == 'month')
setpos = fields.Integer(
string='Occurrence', depends=['weekday', 'frequ'],
domain=[
If(COND_SETPOS,
[('setpos', '<=', 4), ('setpos', '>=', 1)],
('setpos', '=', None))],
help='For example, if you want to run the rule on the second ' +
'Wednesday of the month, enter 2 here.',
states={'required': COND_SETPOS, 'invisible': ~COND_SETPOS})
COND_MONTHDAY = And(Eval('weekday', '') == '99', Eval('frequ') == 'month')
monthday = fields.Integer(
string='Day of month',
help='If you want the rule to run on a specific day of the month, ' +
'select the day here.',
domain=[
If(COND_MONTHDAY,
[('monthday', '>=', 1), ('monthday', '<=', 31)],
('monthday', '=', None))],
depends=['weekday', 'frequ'],
states={'required': COND_MONTHDAY, 'invisible': ~COND_MONTHDAY})
interval = fields.Integer(
string='Interval', required=True,
help='Select an interval to run the rule on every n-th date.',
domain=[('interval', '>=', 1), ('interval', '<=', 10)])
nextdates = fields.Function(fields.Char(
string='Next Dates', readonly=True,
help='the next 5 appointments based on the configured rule'),
'on_change_with_nextdates')
# rrule: frequ, dtstart, until, bymonthday
@classmethod @classmethod
def __setup__(cls): def __setup__(cls):
@ -51,6 +104,164 @@ class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView):
where=t.end_date != DEF_NONE), where=t.end_date != DEF_NONE),
}) })
def _compute_dates_by_rrule(self, start_date=None, count=5, params={}):
""" run rrule with values from record or from 'params'
Args:
start_date (date, optional): Start date as a filter for
recurrences. Defaults to None.
count (int, optional): number of recurrences in result.
Defaults to 5. max value = 100
params (dict, optional): Values in the dictionary are
used instead of the stored values, Defaults to {},
allowed: frequ, weekday, start_date,
end_date (preferred over 'count'),
monthday, interval, setpos
Returns:
list: date values, result of rrlue
"""
pfrequ = {
'year': YEARLY, 'month': MONTHLY, 'week': WEEKLY, 'day': DAILY}
pweekday = {
'0': MO, '1': TU, '2': WE, '3': TH, '4': FR, '5': SA, '6': SU,
'99': None}
if (count is None) or (count > 100):
count = 100
if count < 1:
count = 1
end_date = params.get('end_date', self.end_date)
frequ = pfrequ[params.get('frequ', self.frequ)]
setpos = params.get('setpos', self.setpos)
if setpos is not None:
setpos = 1 if setpos < 1 else 4 if setpos > 4 else setpos
monthday = params.get('monthday', self.monthday)
if monthday is not None:
monthday = 1 if monthday < 1 else 31 if monthday > 31 else monthday
interval = params.get('interval', self.interval)
interval = 1 if interval < 1 else 10 if interval > 10 else interval
dtrule = rrule(
freq=frequ,
byweekday=pweekday[params.get('weekday', self.weekday)],
dtstart=params.get('start_date', self.start_date),
until=end_date,
bysetpos=setpos if frequ == MONTHLY else None,
bymonthday=monthday, interval=interval)
result = []
for x in dtrule:
if (start_date and (x.date() >= start_date)) or \
(start_date is None):
result.append(x.date())
if len(result) >= count:
break
return result
@fields.depends(
'start_date', 'end_date', 'frequ', 'weekday', 'monthday',
'interval', 'setpos')
def on_change_with_nextdates(self, name=None):
""" Calculates the next 5 appointments based on the configured rule,
returns a formatted date list
Args:
name (string, optional): name of field. Defaults to None.
context:
start_date (date, optional): start date for dates in result,
defaults to today if not set or None
Returns:
string: formatted list of dates
"""
IrDate = Pool().get('ir.date')
context = Transaction().context
start_date = context.get('start_date', None)
if not isinstance(start_date, date):
start_date = IrDate.today()
return ' | '.join([
Report.format_date(x)
for x in self._compute_dates_by_rrule(
start_date=start_date,
params={
'start_date': self.start_date,
'end_date': self.end_date,
'frequ': self.frequ,
'weekday': self.weekday,
'monthday': self.monthday,
'interval': self.interval,
'setpos': self.setpos}
)])
@fields.depends('frequ', 'setpos', 'weekday', 'monthday')
def on_change_frequ(self):
""" update fields
"""
if self.frequ and self.frequ == 'month':
if self.weekday:
if self.weekday == '99':
if self.monthday is None:
self.monthday = 1
self.setpos = None
else:
if self.setpos is None:
self.setpos = 1
self.monthday = None
else:
self.setpos = None
self.monthday = None
self.weekday = '99'
@fields.depends('frequ', 'setpos', 'weekday', 'monthday')
def on_change_weekday(self):
""" clear day-of-month if weekday is used
"""
self.on_change_frequ()
@classmethod
def default_interval(cls):
""" get default for interval
Returns:
int: 1 = each occurence
"""
return 1
@classmethod
def default_weekday(cls):
""" get default for weekday-rule
Returns:
string: '99' = not set
"""
return '99'
@classmethod
def default_monthday(cls):
""" get default for day-of-month
Returns:
int: 1
"""
return 1
@classmethod
def default_frequ(cls):
""" get default for frequency
Returns:
string: 'month'
"""
return 'month'
@staticmethod @staticmethod
def default_company(): def default_company():
return Transaction().context.get('company') or None return Transaction().context.get('company') or None

View file

@ -45,7 +45,7 @@ with open(path.join(here, 'versiondep.txt'), encoding='utf-8') as f:
major_version = 7 major_version = 7
minor_version = 0 minor_version = 0
requires = [] requires = ['python-dateutil']
for dep in info.get('depends', []): for dep in info.get('depends', []):
if not re.match(r'(ir|res|webdav)(\W|$)', dep): if not re.match(r'(ir|res|webdav)(\W|$)', dep):
if dep in modversion.keys(): if dep in modversion.keys():

View file

@ -7,12 +7,107 @@ from trytond.tests.test_tryton import with_transaction
from trytond.pool import Pool from trytond.pool import Pool
from trytond.transaction import Transaction from trytond.transaction import Transaction
from trytond.exceptions import UserError from trytond.exceptions import UserError
from datetime import date from datetime import date
from decimal import Decimal
class PlannerTestCase(object): class PlannerTestCase(object):
""" test planner """ test planner
""" """
def prep_create_job(self, name='Job 1'):
pool = Pool()
Book = pool.get('cashbook.book')
Planner = pool.get('cashbook.planner')
types = self.prep_type()
company = self.prep_company()
job = None
with Transaction().set_context({
'company': company.id,
'start_date': date(2022, 5, 1)}):
book, = Book.create([{
'name': 'Book 1',
'btype': types.id,
'company': company.id,
'currency': company.currency.id,
'number_sequ': self.prep_sequence().id,
'start_date': date(2022, 5, 1),
}])
self.assertEqual(book.rec_name, 'Book 1 | 0.00 usd | Open')
job, = Planner.create([{
'cashbook': book.id,
'name': name,
'start_date': date(2022, 5, 1)}])
# check applied defaults
self.assertEqual(job.rec_name, 'Job 1')
self.assertEqual(job.start_date, date(2022, 5, 1))
self.assertEqual(job.end_date, None)
self.assertEqual(job.frequ, 'month')
self.assertEqual(job.weekday, '99')
self.assertEqual(job.monthday, 1)
self.assertEqual(job.interval, 1)
self.assertEqual(job.setpos, None)
self.assertEqual(
job.nextdates,
'05/01/2022 | 06/01/2022 | 07/01/2022 | 08/01/2022 |' +
' 09/01/2022')
return job
@with_transaction()
def test_planner_create_job(self):
""" create job, check rule
"""
Planner = Pool().get('cashbook.planner')
job = self.prep_create_job()
self.assertEqual(
job._compute_dates_by_rrule(
start_date=date(2022, 5, 1), count=5), [
date(2022, 5, 1), date(2022, 6, 1),
date(2022, 7, 1), date(2022, 8, 1),
date(2022, 9, 1)])
self.assertRaisesRegex(
UserError,
r'The value "2022-05-01" for field "End Date" in "Job 1" of ' +
r'"Scheduled Booking" is not valid according to its domain\.',
Planner.write,
*[[job], {'end_date': date(2022, 5, 1)}])
Planner.write(*[[job], {
'end_date': date(2022, 9, 15), 'monthday': 3}])
self.assertEqual(
job._compute_dates_by_rrule(start_date=date(2022, 5, 1)), [
date(2022, 5, 3), date(2022, 6, 3),
date(2022, 7, 3), date(2022, 8, 3),
date(2022, 9, 3)])
Planner.write(*[[job], {
'end_date': date(2022, 9, 15), 'monthday': 3, 'interval': 2}])
self.assertEqual(
job._compute_dates_by_rrule(start_date=date(2022, 5, 1)), [
date(2022, 5, 3), date(2022, 7, 3),
date(2022, 9, 3)])
# 3rd of each 2nd month
Planner.write(*[[job], {
'end_date': None, 'monthday': 1, 'interval': 1}])
self.assertEqual(
job._compute_dates_by_rrule(
start_date=date(2022, 5, 1),
params={
'end_date': date(2022, 9, 15), 'monthday': 3,
'interval': 2}),
[date(2022, 5, 3), date(2022, 7, 3), date(2022, 9, 3)])
# 1st wednesday of each 2nd month
self.assertEqual(
job._compute_dates_by_rrule(
start_date=date(2022, 5, 1),
params={
'end_date': date(2022, 9, 15), 'weekday': '2',
'interval': 2, 'setpos': 1}),
[date(2022, 5, 4), date(2022, 7, 6), date(2022, 9, 7)])
# end PlannerTestCase # end PlannerTestCase

View file

@ -2,23 +2,42 @@
<!-- This file is part of the cashbook-planner from m-ds for Tryton. <!-- This file is part of the cashbook-planner from m-ds for Tryton.
The COPYRIGHT file at the top level of this repository contains the The COPYRIGHT file at the top level of this repository contains the
full copyright notices and license terms. --> full copyright notices and license terms. -->
<form col="4"> <form col="6">
<label name="cashbook"/> <label name="cashbook"/>
<field name="cashbook" colspan="3"/> <field name="cashbook" colspan="3"/>
<newline/>
<label name="name"/> <label name="name"/>
<field name="name"/> <field name="name" colspan="3"/>
<label name="active"/> <newline/>
<field name="active"/>
<label name="start_date"/> <label name="start_date"/>
<field name="start_date"/> <field name="start_date"/>
<label name="end_date"/> <label name="end_date"/>
<field name="end_date"/> <field name="end_date"/>
<label name="active"/>
<field name="active"/>
<separator id="sep1" colspan="6" string="Recurrence Rule"/>
<label name="frequ"/>
<field name="frequ"/>
<label name="interval"/>
<field name="interval"/>
<label name="setpos"/>
<field name="setpos"/>
<label name="weekday"/>
<field name="weekday"/>
<label name="monthday"/>
<field name="monthday"/>
<newline/>
<separator id="sep2" colspan="6" string="Result of the recurrence rule"/>
<field name="nextdates" colspan="6"/>
<label name="description"/> <label name="description"/>
<newline/> <newline/>
<field name="description" colspan="4"/> <field name="description" colspan="6"/>
</form> </form>