diff --git a/config.py b/config.py index 226272c..66d5e41 100644 --- a/config.py +++ b/config.py @@ -4,14 +4,17 @@ # full copyright notices and license terms. +from datetime import date, timedelta +from dateutil.easter import ( + easter, EASTER_JULIAN, EASTER_ORTHODOX, EASTER_WESTERN) from trytond.pool import PoolMeta, Pool from trytond.model import fields holidays = fields.Char( - string='Holidays', help='Semicolon separate list of dates:' + - ' yyyy-mm-dd = single date, mm-dd = annual repetition, ' + - 'easter = Easter Sunday, ascension = Ascension Day, offset ' + - 'with +/-n e.g.: easter+1 = Easter Monday') + string='Holidays', help='Semicolon separate list of dates: ' + + 'yyyy-mm-dd = single date, mm-dd = annual repetition, ' + + 'easter[greg|jul|orth] = Easter Sunday, ascension = Ascension Day, ' + + 'whitsun = Whitsunday, offset with :+/-n e.g.: easter:+1 = Easter Monday') class Configuration(metaclass=PoolMeta): @@ -19,6 +22,137 @@ class Configuration(metaclass=PoolMeta): holidays = fields.MultiValue(holidays) + def holiday_dates(self, years=[]): + """ get list of dates for list of years + + Args: + years (list, optional): years to get holidays for. Defaults to []. + + Returns: + list of date: holidays for requestd years + """ + pool = Pool() + IrDate = pool.get('ir.date') + Config = pool.get('cashbook.configuration') + + if not years: + years = [IrDate.today().year] + + cfg1 = Config.get_singleton() + if not (cfg1 and cfg1.holidays and isinstance(cfg1.holidays, str)): + return [] + return Config.holiday_parseconfig(cfg1.holidays, years)['dates'] + + @classmethod + def holiday_parseconfig(cls, holiday_string, years=[]): + """ read holiday config, generate parsed list of defines + + Args: + holiday_string (str): holiday definition string + years (list of int): years to generate dates for + + Returns: + dict: {'definition': '', + 'dates': []} + """ + IrDate = Pool().get('ir.date') + + def parse_date_offet(offset_str): + """ parse offset string + + Args: + offset_str (str): '+n' or '-n' + + Returns: + tuple: (int(offset), 'offset-string') + """ + # decode ':+n' or ':-n' + offset_value = 0 + plus_sign = 1 + if offset_str: + plus_sign = -1 if offset_str.startswith('-') else 1 + date_offset = offset_str[1:] + if date_offset.isdigit(): + offset_value = int(date_offset) + return (offset_value * plus_sign, '%(sign)s%(amount)d' % { + 'sign': '+' if plus_sign >= 0 else '-', + 'amount': offset_value}) + + def parse_date_definition(date_str, years): + """ parse date definition string, generate list of + dates + + Args: + date_str (str): definition string + years (list of int): years to generate dates for + + Returns: + _type_: _description_ + """ + dates = [] + date_def = '' + easter_type = { + 'greg': EASTER_WESTERN, 'jul': EASTER_JULIAN, + 'orth': EASTER_ORTHODOX} + + date_str = date_str.lower() + # first parse easter-based dates + for dt_calc in [ + {'type': 'easter', 'days': 0}, + {'type': 'ascension', 'days': 39}, + {'type': 'whitsun', 'days': 49}]: + if date_str.startswith(dt_calc['type']): + e_meth = date_str[len(dt_calc['type']):] + easter_meth = easter_type.get(e_meth, EASTER_WESTERN) + dates.extend([ + easter(x, easter_meth) + + timedelta(days=dt_calc['days']) + for x in years]) + date_def = date_str + + # if not detected try date string + if not date_def: + date_fields = date_str.split('-') + try: + if len(date_fields) == 3: + dates.append(date.fromisoformat(date_str)) + date_def = date_str + elif len(date_fields) == 2: + for year in years: + dates.append(date.fromisoformat( + str(year) + '-' + date_str)) + date_def = date_str + except Exception: + pass + return (dates, date_def) + + if not (holiday_string and isinstance(holiday_string, str)): + return {'definition': '', 'dates': []} + + if not years: + years = [IrDate.today().year] + + parsed_str = [] + parsed_dates = [] + for datedef in holiday_string.split(';'): + if not datedef: + continue + + datedef = datedef.strip().split(':') + date_offset = datedef[1] if len(datedef) > 1 else '' + + (date_offset, offset_str) = parse_date_offet(date_offset) + (date_lst, date_def) = parse_date_definition(datedef[0], years) + + parsed_dates.extend([ + x + timedelta(days=date_offset) + for x in date_lst]) + if date_def: + if date_offset != 0: + date_def += ':' + offset_str + parsed_str.append(date_def) + return {'definition': ';'.join(parsed_str), 'dates': parsed_dates} + @classmethod def multivalue_model(cls, field): """ get model for value diff --git a/locale/de.po b/locale/de.po index 140a7d2..5d4d025 100644 --- a/locale/de.po +++ b/locale/de.po @@ -91,8 +91,8 @@ msgid "Holidays" msgstr "Feiertage" msgctxt "help:cashbook.configuration,holidays:" -msgid "Semicolon separate list of dates: yyyy-mm-dd = single date, mm-dd = annual repetition, easter = Easter Sunday, ascension = Ascension Day, offset with +/-n e.g.: easter+1 = Easter Monday" -msgstr "Semikolon getrennte Liste von Datumswerten: yyyy-mm-dd = Einzeldatum, mm-dd = jährliche Wiederholung, easter = Ostersonntag, ascension = Christi Himmelfahrt, Offset mit +/-n z.B: easter+1 = Ostermontag" +msgid "Semicolon separate list of dates: yyyy-mm-dd = single date, mm-dd = annual repetition, easter[greg|jul|orth] = Easter Sunday, ascension = Ascension Day, whitsun = Whitsunday, offset with :+/-n e.g.: easter:+1 = Easter Monday" +msgstr "Semikolon getrennte Liste von Datumswerten: yyyy-mm-dd = Einzeldatum, mm-dd = jährliche Wiederholung, easter[greg|jul|orth] = Ostersonntag, ascension = Christi Himmelfahrt, whitsun = Pfingstsonntag, Offset mit :+/-n z.B: easter:+1 = Ostermontag" #################### diff --git a/locale/en.po b/locale/en.po index abdc601..814fbea 100644 --- a/locale/en.po +++ b/locale/en.po @@ -67,8 +67,8 @@ msgid "Holidays" msgstr "Holidays" msgctxt "help:cashbook.configuration,holidays:" -msgid "Semicolon separate list of dates: yyyy-mm-dd = single date, mm-dd = annual repetition, easter = Easter Sunday, ascension = Ascension Day, offset with +/-n e.g.: easter+1 = Easter Monday" -msgstr "Semicolon separate list of dates: yyyy-mm-dd = single date, mm-dd = annual repetition, easter = Easter Sunday, ascension = Ascension Day, offset with +/-n e.g.: easter+1 = Easter Monday" +msgid "Semicolon separate list of dates: yyyy-mm-dd = single date, mm-dd = annual repetition, easter[greg|jul|orth] = Easter Sunday, ascension = Ascension Day, whitsun = Whitsunday, offset with :+/-n e.g.: easter:+1 = Easter Monday" +msgstr "Semicolon separate list of dates: yyyy-mm-dd = single date, mm-dd = annual repetition, easter[greg|jul|orth] = Easter Sunday, ascension = Ascension Day, whitsun = Whitsunday, offset with :+/-n e.g.: easter:+1 = Easter Monday" msgctxt "model:cashbook.planner,name:" msgid "Scheduled Booking" diff --git a/tests/planner.py b/tests/planner.py index d9ad9e7..a250efd 100644 --- a/tests/planner.py +++ b/tests/planner.py @@ -116,6 +116,52 @@ class PlannerTestCase(object): 'Depot | 0.00 usd | Open | 0.0000 u') return book + @with_transaction() + def test_func_holiday_parseconfig(self): + """ check function holiday_parseconfig() + """ + Config = Pool().get('cashbook.configuration') + + company = self.prep_company() + with Transaction().set_context({'company': company.id}): + # check valid data + result = Config.holiday_parseconfig( + '2022-05-01;12-25;12-25:+1;easter;easter:-2;easterjul;' + + 'ascension;whitsun;whitsun:+1;', + [2022, 2023, 2024]) + self.assertEqual(result, { + 'definition': '2022-05-01;12-25;12-25:+1;easter;easter:-2;' + + 'easterjul;ascension;whitsun;whitsun:+1', + 'dates': [ + date(2022, 5, 1), date(2022, 12, 25), date(2023, 12, 25), + date(2024, 12, 25), date(2022, 12, 26), date(2023, 12, 26), + date(2024, 12, 26), date(2022, 4, 17), date(2023, 4, 9), + date(2024, 3, 31), date(2022, 4, 15), date(2023, 4, 7), + date(2024, 3, 29), date(2022, 4, 11), date(2023, 4, 3), + date(2024, 4, 22), date(2022, 5, 26), date(2023, 5, 18), + date(2024, 5, 9), date(2022, 6, 5), date(2023, 5, 28), + date(2024, 5, 19), date(2022, 6, 6), date(2023, 5, 29), + date(2024, 5, 20)]}) + + # check invalid data + self.assertEqual( + Config.holiday_parseconfig('not-a-value;'), + {'definition': '', 'dates': []}) + + # check no data + self.assertEqual( + Config.holiday_parseconfig(''), + {'definition': '', 'dates': []}) + self.assertEqual( + Config.holiday_parseconfig(None), + {'definition': '', 'dates': []}) + + cfg1 = Config(holidays='2022-05-01;easter;whitsun') + cfg1.save() + self.assertEqual( + cfg1.holiday_dates([2022]), + [date(2022, 5, 1), date(2022, 4, 17), date(2022, 6, 5)]) + @with_transaction() def test_planner_create_job(self): """ create job, check rule + constraints