diff --git a/locale/de.po b/locale/de.po index de361b3..480d2ae 100644 --- a/locale/de.po +++ b/locale/de.po @@ -58,6 +58,14 @@ msgctxt "model:cashbook.planner,name:" msgid "Scheduled Booking" 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:" msgid "Company" msgstr "Unternehmen" @@ -86,6 +94,94 @@ msgctxt "field:cashbook.planner,end_date:" msgid "End Date" 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 # diff --git a/locale/en.po b/locale/en.po index ecc234c..10a9846 100644 --- a/locale/en.po +++ b/locale/en.po @@ -2,17 +2,9 @@ msgid "" msgstr "Content-Type: text/plain; charset=utf-8\n" -msgctxt "model:res.group,name:group_planner_read" -msgid "Cashbook - Scheduled Bookings (read)" -msgstr "Cashbook - Scheduled Bookings (read)" - -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:res.group,name:group_planner" +msgid "Cashbook - Scheduled Bookings" +msgstr "Cashbook - Scheduled Bookings" msgctxt "model:ir.ui.menu,name:menu_planner" msgid "Scheduled Bookings" @@ -42,3 +34,135 @@ msgctxt "model:ir.rule.group,name:rg_planner_companies" msgid "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" + diff --git a/planner.py b/planner.py index e7d25d7..6d46fad 100644 --- a/planner.py +++ b/planner.py @@ -3,12 +3,26 @@ # The COPYRIGHT file at the top level of this repository contains the # 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.transaction import Transaction 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 +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): @@ -33,6 +47,45 @@ class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView): 'OR', ('end_date', '>', Eval('start_date')), ('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 def __setup__(cls): @@ -51,6 +104,164 @@ class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView): 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 def default_company(): return Transaction().context.get('company') or None diff --git a/setup.py b/setup.py index 4b06d71..528754a 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ with open(path.join(here, 'versiondep.txt'), encoding='utf-8') as f: major_version = 7 minor_version = 0 -requires = [] +requires = ['python-dateutil'] for dep in info.get('depends', []): if not re.match(r'(ir|res|webdav)(\W|$)', dep): if dep in modversion.keys(): diff --git a/tests/planner.py b/tests/planner.py index 33e0f78..0041e69 100644 --- a/tests/planner.py +++ b/tests/planner.py @@ -7,12 +7,107 @@ from trytond.tests.test_tryton import with_transaction from trytond.pool import Pool from trytond.transaction import Transaction from trytond.exceptions import UserError + from datetime import date -from decimal import Decimal class PlannerTestCase(object): """ 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 diff --git a/view/planner_form.xml b/view/planner_form.xml index 2eb2129..e9d4245 100644 --- a/view/planner_form.xml +++ b/view/planner_form.xml @@ -2,23 +2,42 @@ -