├── temporal ├── patches.txt ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── modules.txt ├── temporal_core │ ├── doctype │ │ ├── temporal_dates │ │ │ ├── __init__.py │ │ │ ├── temporal_dates.js │ │ │ ├── test_temporal_dates.py │ │ │ ├── temporal_dates.py │ │ │ └── temporal_dates.json │ │ ├── __init__.py │ │ └── temporal_manager │ │ │ ├── __init__.py │ │ │ ├── test_temporal_manager.py │ │ │ ├── rebuild_dates_table.sql │ │ │ ├── temporal_manager.js │ │ │ ├── temporal_manager.json │ │ │ └── temporal_manager.py │ ├── __init__.py │ ├── sql_statements │ │ └── populate_temporal_dates.sql │ └── workspace │ │ ├── temporal │ │ └── temporal.json │ │ └── development │ │ └── development.json ├── cron.py ├── hooks.py ├── helpers.py ├── core.py ├── redis.py ├── result.py └── __init__.py ├── docs ├── _config.yml ├── index.md └── why.md ├── .gitignore ├── run_pylint.sh ├── .pylintrc ├── pyproject.toml ├── README.md ├── DESIGN.md └── LICENSE.md /temporal/patches.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /temporal/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /temporal/modules.txt: -------------------------------------------------------------------------------- 1 | Temporal Core 2 | -------------------------------------------------------------------------------- /temporal/templates/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_dates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-dinky 2 | markdown: kramdown 3 | -------------------------------------------------------------------------------- /temporal/temporal_core/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py for temporal_core 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | temporal/docs/current -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py for module 'doctype' 2 | # Do not write code here. 3 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_manager/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py for the 'temporal_manager' module/DocType 2 | # Normally no code goes here. 3 | -------------------------------------------------------------------------------- /temporal/cron.py: -------------------------------------------------------------------------------- 1 | """ For backwards compatability with existing Apps. """ 2 | 3 | from temporal_lib.cron import date_and_time_to_cron_string # pylint: disable=unused-import 4 | -------------------------------------------------------------------------------- /run_pylint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo -e "Running Pylint on Temporal App..." 4 | 5 | find . -name "*.py" | xargs ../../env/bin/pylint --disable=missing-module-docstring,missing-class-docstring 6 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_dates/temporal_dates.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Datahenge LLC and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Temporal Dates', { 5 | 6 | }); 7 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_dates/test_temporal_dates.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Datahenge LLC and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | class TestTemporalDates(unittest.TestCase): 8 | pass 9 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | indent-string="\t" 3 | disable= 4 | missing-function-docstring, 5 | broad-except, 6 | import-outside-toplevel, 7 | line-too-long, 8 | too-few-public-methods, 9 | too-many-arguments 10 | 11 | [LOGGING] 12 | logging-format-style=new 13 | 14 | [FORMAT] 15 | good-names=ex 16 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_manager/test_temporal_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2021, Datahenge LLC and Contributors 3 | # See license.txt 4 | from __future__ import unicode_literals 5 | 6 | # import frappe 7 | import unittest 8 | 9 | class TestTemporalManager(unittest.TestCase): 10 | pass 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "temporal" 3 | authors = [ 4 | { name = "Datahenge LLC", email = "brian@datahenge.com"}, 5 | ] 6 | description = "Time after Time" 7 | dynamic = [ "version" ] 8 | readme = "README.md" 9 | requires-python = ">=3.10,<3.13" 10 | 11 | dependencies = [ 12 | "frappe >= 15,<16", 13 | "pytz-deprecation-shim", 14 | "temporal-lib == 0.1.2", 15 | ] 16 | 17 | [tool.setuptools.dynamic] 18 | version = {attr = "temporal.__version__"} 19 | 20 | -------------------------------------------------------------------------------- /temporal/hooks.py: -------------------------------------------------------------------------------- 1 | """ hooks.py for Temporal App """ 2 | 3 | from __future__ import unicode_literals 4 | from . import __version__ as app_version 5 | 6 | # pylint: disable=invalid-name 7 | app_name = "temporal" 8 | app_title = "Temporal" 9 | app_publisher = "Datahenge LLC" 10 | app_description = "Time after Time" 11 | app_icon = "octicon octicon-file-directory" 12 | # app_color = "grey" 13 | app_email = "brian@datahenge.com" 14 | app_license = "MIT" 15 | app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg" 16 | -------------------------------------------------------------------------------- /temporal/helpers.py: -------------------------------------------------------------------------------- 1 | """ temporal.helpers.py """ 2 | 3 | import copy 4 | from datetime import date as DateType 5 | from temporal import date_to_iso_string 6 | 7 | NoneType = type(None) 8 | 9 | def dict_to_dateless_dict(some_object, replace_nones=False): 10 | """ 11 | Given an common object, convert any Dates to ISO Strings. 12 | """ 13 | result = copy.deepcopy(some_object) # making a deep copy to be safe. 14 | 15 | # Scenario 1: Object is a Date 16 | if isinstance(result, DateType): 17 | return date_to_iso_string(some_object) 18 | 19 | # Scenario 1B: Object is a NoneType 20 | if replace_nones and isinstance(result, NoneType): 21 | return "" 22 | 23 | # Scenario 2: Object is a List 24 | if isinstance(some_object, list): 25 | return [dict_to_dateless_dict(v, replace_nones=replace_nones) for v in some_object] # recursive call to this function. 26 | 27 | # Scenario 3: Argument is a Dictionary 28 | if isinstance(some_object, dict): 29 | new_dict = {} 30 | for key, value in some_object.items(): 31 | new_dict[ key ] = dict_to_dateless_dict(value, replace_nones=replace_nones) # recursive call to this function. 32 | return new_dict 33 | 34 | # Scenario 4: Argument is something not covered above (e.g. Integers) 35 | return some_object 36 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_manager/rebuild_dates_table.sql: -------------------------------------------------------------------------------- 1 | -- Some special syntax that makes this work 2 | -- SET @StartDate := '2021-01-01'; 3 | -- SET @CutoffDate := '2070-12-31'; 4 | 5 | INSERT INTO "tabTemporal Dates" 6 | (name, creation, modified, modified_by, owner, docstatus, parent, parentfield, parenttype, idx, 7 | "_user_tags", "_comments", "_assign", "_liked_by", calendar_date, day_name, scalar_value) 8 | 9 | 10 | WITH RECURSIVE DateSequence(calendar_date) AS 11 | ( 12 | SELECT @StartDate::date AS calendar_date 13 | 14 | UNION ALL 15 | 16 | SELECT (calendar_date + INTERVAL '1 DAY')::date 17 | FROM DateSequence 18 | WHERE (calendar_date - @EndDate) < 0 19 | ) 20 | 21 | SELECT 22 | TO_CHAR(calendar_date, 'YYYY-MM-DD') AS "name", 23 | now() AS creation, 24 | now() AS modified, 25 | 'Administrator' AS modified_by, 26 | 'Administrator' AS owner, 27 | 0 AS docstatus, 28 | NULL AS parent, 29 | NULL AS parentfield, 30 | NULL AS parenttype, 31 | 0 AS idx, 32 | NULL AS "_user_tags", 33 | NULL AS "_comments", 34 | NULL AS "_assign", 35 | NULL AS "_liked_by", 36 | calendar_date, 37 | TO_CHAR(calendar_date, 'Day') AS day_name, 38 | ROW_NUMBER() OVER (ORDER BY calendar_date) AS scalar_value 39 | FROM 40 | DateSequence 41 | -------------------------------------------------------------------------------- /temporal/temporal_core/sql_statements/populate_temporal_dates.sql: -------------------------------------------------------------------------------- 1 | -- Some special syntax that makes this work 2 | 3 | -- TODO: PyPika? 4 | 5 | SET @StartDate := '2021-01-01'; 6 | SET @CutoffDate := '2071-12-31'; 7 | TRUNCATE TABLE `tabTemporal Dates`; 8 | 9 | INSERT INTO `tabTemporal Dates` 10 | (name, creation, modified, modified_by, owner, docstatus, parent, parentfield, parenttype, idx, 11 | `_user_tags`, `_comments`, `_assign`, `_liked_by`, calendar_date, day_name) 12 | 13 | 14 | WITH RECURSIVE DateSequence(calendar_date) AS 15 | ( 16 | SELECT @StartDate AS calendar_date 17 | 18 | UNION ALL 19 | 20 | SELECT DATE_ADD(calendar_date, INTERVAL 1 DAY) 21 | FROM DateSequence 22 | WHERE DATEDIFF(calendar_date, @CutoffDate) < 0 23 | ) 24 | 25 | SELECT 26 | LPAD( 27 | CAST(ROW_NUMBER() OVER (ORDER BY calendar_date) AS VARCHAR(10)) 28 | ,5,'0') AS name, 29 | now() AS creation, 30 | now() AS modified, 31 | 'Administrator' AS modified_by, 32 | 'Administrator' AS owner, 33 | 0 AS docstatus, 34 | NULL AS parent, 35 | NULL AS parentfield, 36 | NULL AS parenttype, 37 | 0 AS idx, 38 | NULL AS `_user_tags`, 39 | NULL AS `_comments`, 40 | NULL AS `_assign`, 41 | NULL AS `_liked_by`, 42 | calendar_date, 43 | DAYNAME(calendar_date) AS day_name 44 | FROM 45 | DateSequence 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Temporal: Time after Time 2 | 3 | An ERPNext [App](https://frappeframework.com/docs/user/en/basics/apps) that integrates with Redis to rapidly provide calendar information. 4 | 5 | ⚠️ The default branch `version-15` is now using PostgreSQL syntax. I've created an alternate branch, `version-15-mariadb` to support the original syntax. 6 | 7 | ### Documentation 8 | Most of my documentation [can be found here](https://datahenge.github.io/temporal/) using GitHub Pages. 9 | 10 | ### What is Temporal? 11 | Temporal does a few interesting things: 12 | 1. Integrates a useful *library* of Python functions from its sibling app, [temporal-lib](https://pypi.org/project/temporal-lib/), bringing these functions into Frappe and ERPNext Apps. 13 | 2. It creates a Redis dataset containing Calendar information. 14 | 3. It creates a DocType containing Calendar information. 15 | 16 | ### Installation 17 | 18 | #### Using Bench: 19 | ``` 20 | bench get-app https://github.com/Datahenge/temporal 21 | bench --site install-app temporal 22 | ``` 23 | 24 | #### Manual Installation 25 | If for some reason, don't want to use Bench for *downloading* the App: 26 | ``` 27 | cd 28 | source env/bin/activate 29 | cd apps 30 | git clone https://github.com/Datahenge/temporal 31 | cd temporal 32 | pip install -e . 33 | deactivate 34 | 35 | cd 36 | bench --site install-app temporal 37 | ``` 38 | 39 | #### License 40 | Lesser GNU Public License version 3. 41 | -------------------------------------------------------------------------------- /temporal/temporal_core/workspace/temporal/temporal.json: -------------------------------------------------------------------------------- 1 | { 2 | "category": "Modules", 3 | "charts": [], 4 | "creation": "2020-03-12 16:35:55.299820", 5 | "developer_mode_only": 0, 6 | "disable_user_customization": 0, 7 | "docstatus": 0, 8 | "doctype": "Workspace", 9 | "extends_another_page": 0, 10 | "hide_custom": 0, 11 | "icon": "check", 12 | "idx": 0, 13 | "is_default": 0, 14 | "is_standard": 1, 15 | "label": "Temporal", 16 | "links": [ 17 | { 18 | "hidden": 0, 19 | "is_query_report": 0, 20 | "label": "Basic", 21 | "onboard": 0, 22 | "type": "Card Break" 23 | }, 24 | { 25 | "dependencies": "", 26 | "hidden": 0, 27 | "is_query_report": 0, 28 | "label": "Temporal Dates", 29 | "link_to": "Temporal Dates", 30 | "link_type": "DocType", 31 | "onboard": 0, 32 | "type": "Link" 33 | }, 34 | { 35 | "dependencies": "", 36 | "hidden": 0, 37 | "is_query_report": 0, 38 | "label": "Temporal Manager", 39 | "link_to": "Temporal Manager", 40 | "link_type": "DocType", 41 | "onboard": 0, 42 | "type": "Link" 43 | } 44 | ], 45 | "modified": "2022-03-07 19:40:33.112261", 46 | "modified_by": "Administrator", 47 | "module": "Temporal Core", 48 | "name": "Temporal", 49 | "owner": "Administrator", 50 | "pin_to_bottom": 0, 51 | "pin_to_top": 0, 52 | "shortcuts": [ 53 | { 54 | "label": "Temporal Dates", 55 | "link_to": "Temporal Dates", 56 | "type": "DocType" 57 | }, 58 | { 59 | "label": "Temporal Manager", 60 | "link_to": "Temporal Manager", 61 | "type": "DocType" 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /temporal/temporal_core/workspace/development/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "cards_label": "Link Cards", 3 | "category": "Modules", 4 | "charts": [], 5 | "creation": "2021-06-19 22:25:32.791366", 6 | "developer_mode_only": 0, 7 | "disable_user_customization": 0, 8 | "docstatus": 0, 9 | "doctype": "Workspace", 10 | "extends_another_page": 0, 11 | "hide_custom": 0, 12 | "idx": 0, 13 | "is_default": 0, 14 | "is_standard": 1, 15 | "label": "Development", 16 | "links": [ 17 | { 18 | "hidden": 0, 19 | "is_query_report": 0, 20 | "label": "Email Queue", 21 | "link_to": "Email Queue", 22 | "link_type": "DocType", 23 | "onboard": 0, 24 | "type": "Link" 25 | }, 26 | { 27 | "hidden": 0, 28 | "is_query_report": 0, 29 | "label": "Notifications", 30 | "link_to": "Notification", 31 | "link_type": "DocType", 32 | "onboard": 0, 33 | "type": "Link" 34 | }, 35 | { 36 | "hidden": 0, 37 | "is_query_report": 0, 38 | "label": "Notification Log", 39 | "link_to": "Notification Log", 40 | "link_type": "DocType", 41 | "onboard": 0, 42 | "type": "Link" 43 | } 44 | ], 45 | "modified": "2022-01-17 18:05:41.504963", 46 | "modified_by": "Administrator", 47 | "module": "Temporal", 48 | "name": "Development", 49 | "owner": "Administrator", 50 | "pin_to_bottom": 0, 51 | "pin_to_top": 0, 52 | "shortcuts": [ 53 | { 54 | "doc_view": "List", 55 | "label": "DocTypes", 56 | "link_to": "DocType", 57 | "type": "DocType" 58 | }, 59 | { 60 | "doc_view": "List", 61 | "label": "Scheduled Job Log", 62 | "link_to": "Scheduled Job Log", 63 | "type": "DocType" 64 | } 65 | ] 66 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | * TOC 2 | {:toc} 3 | 4 | # Temporal 5 | [Why I created this App](/why.md) 6 | 7 | ## Functions 8 | 9 | ### The 'TDate' class 10 | TDate() is a wrapper around the standard Python `'datetime.date'` type. It's really helpful when you want a type that offers more built-in functions, versus standard dates. 11 | 12 | You can try these examples yourself, using `'bench console'` 13 | 14 | **Examples**: 15 | 16 | ```python 17 | from temporal import datestr_to_date, TDate 18 | regular_date = datestr_to_date("2022-05-25") # this is an ordinary Python datetime.date (Wednesday, May 25th 2022) 19 | my_tdate = TDate(regular_date) # this is a Temporal date of type TDate 20 | ``` 21 | 22 | Now that you have a TDate 'temporal_date', you can call useful functions! 23 | 24 | ```python 25 | my_tdate.day_of_week_int() # 4 (the fourth day of the week) 26 | my_tdate.day_of_week_shortname() # WED 27 | my_tdate.day_of_week_longname() # Wednesday 28 | my_tdate.day_of_month() # 25 29 | my_tdate.day_of_year() # 145 30 | 31 | my_tdate.month_of_year() # 5 32 | my_tdate.year() # 2022 33 | 34 | my_tdate.as_date() # datetime.date(2022, 5, 25) 35 | 36 | my_tdate.jan1() # creates a new TDate for January 1st of the same year. 37 | my_tdate.jan1().as_date() # datetime.date(2022, 1, 1) 38 | 39 | my_tdate.jan1_next_year().as_date() # datetime.date(2023, 1, 1) 40 | my_tdate.week_number() # 22 41 | ``` 42 | 43 | This helpful class function allows you to see if your TDate falls between 2 other dates: 44 | 45 | ```python 46 | from_date = datestr_to_date("01/01/2022") 47 | to_date = datestr_to_date("12/31/2022") 48 | 49 | my_tdate.is_between(from_date, to_date) # True 50 | ``` 51 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_dates/temporal_dates.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023, Datahenge LLC and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | 7 | from temporal import date_to_week_tuple 8 | 9 | class TemporalDates(Document): 10 | 11 | @staticmethod 12 | def get_document_by_calendar_date(calendar_date): 13 | """ 14 | Given a calendar date, return a Temporal Dates document. 15 | """ 16 | document_key = frappe.db.get_value("Temporal Dates", filters={"calendar_date": calendar_date}, fieldname="name", cache=True) 17 | return frappe.get_doc("Temporal Dates", document_key) 18 | 19 | def set_week_number(self, raise_on_exception=False): 20 | """ 21 | Set the week number for this Calendar Date. 22 | """ 23 | try: 24 | self.week_number = date_to_week_tuple(self.calendar_date)[1] # pylint: disable=attribute-defined-outside-init, no-member 25 | except Exception as ex: 26 | if raise_on_exception: 27 | raise ex 28 | print(ex) 29 | 30 | def populate_week_numbers(): 31 | """ 32 | Mass update, populating the Week Number for every calendar date in the systems. 33 | """ 34 | filters = { 35 | "calendar_date": ["<=", "2025-12-31"], 36 | "week_number": 0 37 | } 38 | 39 | date_keys = frappe.get_list("Temporal Dates", filters=filters) 40 | print(f"Found {len(date_keys)} Temporal Dates that are missing a value for 'week_number'") 41 | for index, date_key in enumerate(date_keys): 42 | if index % 500 == 0: 43 | print(f"Iteration number {index}") 44 | try: 45 | doc_temporal_date = frappe.get_doc("Temporal Dates", date_key) 46 | doc_temporal_date.set_week_number() 47 | doc_temporal_date.save() 48 | frappe.db.commit() 49 | except Exception as ex: 50 | print(f"populate_week_numbers() : {repr(ex)}") 51 | -------------------------------------------------------------------------------- /temporal/core.py: -------------------------------------------------------------------------------- 1 | """ temporal/core.py """ 2 | # pylint: disable=unused-import 3 | 4 | # No internal dependencies allowed here. 5 | import sys 6 | # from datetime import datetime 7 | 8 | # Third Party 9 | import temporal_lib 10 | from temporal_lib.core import ( 11 | is_datetime_naive, 12 | localize_datetime, 13 | make_datetime_naive, 14 | ) 15 | from temporal_lib.tlib_date import ( 16 | datetime_to_sql_datetime 17 | ) 18 | from temporal_lib.tlib_timezone import TimeZone 19 | 20 | # Frappe 21 | import frappe 22 | 23 | if sys.version_info.major != 3: 24 | raise RuntimeError("Temporal is only available for Python 3.") 25 | 26 | 27 | def get_system_timezone(): 28 | """ 29 | Returns the Time Zone of the Site. 30 | """ 31 | system_time_zone = frappe.db.get_system_setting('time_zone') 32 | if not system_time_zone: 33 | raise ValueError("Please configure a Time Zone under 'System Settings'.") 34 | return TimeZone(system_time_zone) 35 | 36 | 37 | def get_system_datetime_now(): 38 | """ 39 | Get the system datetime using the current time zone. 40 | """ 41 | return temporal_lib.core.get_system_datetime_now(get_system_timezone()) 42 | 43 | 44 | def get_system_date(): 45 | return get_system_datetime_now().date() 46 | 47 | 48 | def safeset(any_dict, key, value, as_value=False): 49 | """ 50 | This function is used for setting values on an existing Object, while respecting current keys. 51 | """ 52 | 53 | if not hasattr(any_dict, key): 54 | raise AttributeError(f"Cannot assign value to unknown attribute '{key}' in dictionary {any_dict}.") 55 | if isinstance(value, list) and not as_value: 56 | any_dict.__dict__[key] = [] 57 | any_dict.extend(key, value) 58 | else: 59 | any_dict.__dict__[key] = value 60 | 61 | 62 | def make_datetime_tz_aware(naive_datetime): 63 | """ 64 | Given a naive datetime, localize to the ERPNext system timezone. 65 | """ 66 | return localize_datetime(naive_datetime, get_system_timezone()) 67 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_manager/temporal_manager.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Datahenge LLC and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Temporal Manager', { 5 | refresh: function(frm) { 6 | }, 7 | 8 | btn_show_weeks: function(frm) { 9 | dialog_show_redis_weeks(); 10 | } 11 | }); 12 | 13 | 14 | function dialog_show_redis_weeks() { 15 | 16 | var mydialog = new frappe.ui.Dialog({ 17 | title: 'Display Weeks from the Temporal Redis database', 18 | width: 100, 19 | fields: [ 20 | { 21 | 'fieldtype': 'Int', 22 | 'label': __('From Week Year'), 23 | 'fieldname': 'from_year', 24 | 'default': moment(new Date()).year() 25 | }, 26 | { 27 | 'fieldtype': 'Int', 28 | 'label': __('From Week Number'), 29 | 'fieldname': 'from_week_num', 30 | 'default': 1 31 | }, 32 | { 33 | 'fieldtype': 'Int', 34 | 'label': __('To Week Year'), 35 | 'fieldname': 'to_year', 36 | 'default': moment(new Date()).year() 37 | }, 38 | { 39 | 'fieldtype': 'Int', 40 | 'label': __('To Week Number'), 41 | 'fieldname': 'to_week_num', 42 | 'default': 52 43 | } 44 | ] 45 | }); 46 | 47 | mydialog.set_primary_action(__('Show'), args => { 48 | let foo = frappe.call({ 49 | method: 'temporal.get_weeks_as_dict', 50 | // Arguments must precisely match the Python function declaration. 51 | args: { from_year: args.from_year, 52 | from_week_num: args.from_week_num, 53 | to_year: args.to_year, 54 | to_week_num: args.to_week_num 55 | }, 56 | callback: function(r) { 57 | if (r.message) { 58 | let message_object = JSON.parse(r.message); 59 | frappe.msgprint(message_object); 60 | } 61 | } 62 | }); 63 | mydialog.hide(); // After callback, close dialog regardless of result. 64 | }); 65 | 66 | // Now that we've defined it, show that dialog. 67 | mydialog.show(); 68 | }; 69 | 70 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_dates/temporal_dates.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:calendar_date", 4 | "creation": "2021-07-03 10:34:12.186271", 5 | "description": "Because sometimes you need a SQL table full of calendar dates.", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "scalar_value", 11 | "calendar_date", 12 | "week_number", 13 | "day_name" 14 | ], 15 | "fields": [ 16 | { 17 | "bold": 1, 18 | "fieldname": "calendar_date", 19 | "fieldtype": "Date", 20 | "in_list_view": 1, 21 | "in_standard_filter": 1, 22 | "label": "Calendar Date", 23 | "read_only": 1, 24 | "reqd": 1, 25 | "unique": 1 26 | }, 27 | { 28 | "fieldname": "day_name", 29 | "fieldtype": "Data", 30 | "in_list_view": 1, 31 | "label": "Day Name", 32 | "read_only": 1, 33 | "reqd": 1 34 | }, 35 | { 36 | "fieldname": "scalar_value", 37 | "fieldtype": "Int", 38 | "label": "Scalar Value", 39 | "non_negative": 1, 40 | "read_only": 1, 41 | "reqd": 1, 42 | "reqd_in_database": 1, 43 | "search_index": 1 44 | }, 45 | { 46 | "fieldname": "week_number", 47 | "fieldtype": "Int", 48 | "in_list_view": 1, 49 | "label": "Week Number", 50 | "non_negative": 1, 51 | "read_only": 1, 52 | "search_index": 1 53 | } 54 | ], 55 | "in_create": 1, 56 | "links": [], 57 | "modified": "2025-01-02 22:13:09.171817", 58 | "modified_by": "Administrator", 59 | "module": "Temporal Core", 60 | "name": "Temporal Dates", 61 | "owner": "Administrator", 62 | "permissions": [ 63 | { 64 | "create": 1, 65 | "delete": 1, 66 | "email": 1, 67 | "export": 1, 68 | "print": 1, 69 | "read": 1, 70 | "report": 1, 71 | "role": "System Manager", 72 | "share": 1, 73 | "write": 1 74 | } 75 | ], 76 | "sort_field": "calendar_date", 77 | "sort_order": "DESC", 78 | "states": [], 79 | "title_field": "calendar_date" 80 | } -------------------------------------------------------------------------------- /docs/why.md: -------------------------------------------------------------------------------- 1 | # Why I created this App? 2 | 3 | ## My initial reason. 4 | Initially, I created this App for -*performance*-. Consider the following: 5 | 6 | The Earth's temporal calendar (years, months, weeks, days) is static information. We already know that May 4th in year 2542 will be a Friday. It will be the 124th day of that year. 7 | 8 | ERP systems frequently need date-based information. How do they achieve this? 9 | 10 | #### Options 11 | 1: Call Python functions (e.g. from the standard [datetime](https://docs.python.org/3/library/datetime.html) library) and write calculations. However, it is inefficient to repeatedly call the same algorithms. This approach leads to unnecessary coding and wasted CPU activity. 12 | 13 | 2: Generate calendar data once, then store inside the SQL database. This is better. But this approach leads to frequent SQL queries and increases disk I/O activity. 14 | 15 | Temporal offers a 3rd possibility: 16 | 17 | 3: Load calendar data into the **Redis Cache** at startup. Including additional elements such as 'Week Number of Year', Week Dates, and more. 18 | 19 | Below is an example of a Temporal function that leverages the Redis cache: 20 | ```python 21 | from temporal import week_generator 22 | for each_week in week_generator("2022-01-01", "2022-01-31"): 23 | print(f"Week {each_week.week_number} starts on {each_week.date_start} and ends on {each_week.date_end}.") 24 | ``` 25 | 26 | Results: 27 | ``` 28 | Week 1 starts on 2021-12-26 and ends on 2022-01-01. 29 | Week 2 starts on 2022-01-02 and ends on 2022-01-08. 30 | Week 3 starts on 2022-01-09 and ends on 2022-01-15. 31 | Week 4 starts on 2022-01-16 and ends on 2022-01-22. 32 | Week 5 starts on 2022-01-23 and ends on 2022-01-29. 33 | Week 6 starts on 2022-01-30 and ends on 2022-02-05. 34 | ``` 35 | 36 | By leveraging the high-performance of Redis, ERPNext can rapidly fetch date-based information with minimal CPU and Disk activity. 37 | 38 | ## My later reasons. 39 | The more I used ERPNext, the more I discovered I needed reusable date and time functions. Functions that were not available in the Python standard library. 40 | -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_manager/temporal_manager.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_copy": 1, 4 | "creation": "2021-04-20 11:55:54.814929", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "heading_1", 10 | "debug_mode", 11 | "start_year", 12 | "end_year", 13 | "cb_1", 14 | "heading_2", 15 | "btn_show_weeks", 16 | "btn_rebuild_calendar_cache", 17 | "btn_rebuild_temporal_dates" 18 | ], 19 | "fields": [ 20 | { 21 | "fieldname": "btn_show_weeks", 22 | "fieldtype": "Button", 23 | "label": "Show Weeks" 24 | }, 25 | { 26 | "fieldname": "btn_rebuild_calendar_cache", 27 | "fieldtype": "Button", 28 | "label": "Rebuild Calendar Cache", 29 | "options": "button_rebuild_calendar_cache" 30 | }, 31 | { 32 | "fieldname": "end_year", 33 | "fieldtype": "Data", 34 | "label": "End Year" 35 | }, 36 | { 37 | "default": "2020", 38 | "fieldname": "start_year", 39 | "fieldtype": "Int", 40 | "label": "Start Year" 41 | }, 42 | { 43 | "default": "0", 44 | "description": "When enabled, additional error messages are displayed.", 45 | "fieldname": "debug_mode", 46 | "fieldtype": "Check", 47 | "label": "Debug Mode" 48 | }, 49 | { 50 | "description": "Repopulate the SQL table `tabTemporal Dates`", 51 | "fieldname": "btn_rebuild_temporal_dates", 52 | "fieldtype": "Button", 53 | "label": "Rebuild Dates Table", 54 | "options": "button_rebuild_temporal_dates" 55 | }, 56 | { 57 | "fieldname": "cb_1", 58 | "fieldtype": "Column Break" 59 | }, 60 | { 61 | "description": "The purpose of this DocType is to provide a type of \"dashboard\" for the Temporal App.", 62 | "fieldname": "heading_1", 63 | "fieldtype": "Heading", 64 | "label": "Temporal Manager" 65 | }, 66 | { 67 | "fieldname": "heading_2", 68 | "fieldtype": "Heading", 69 | "label": "Actions" 70 | } 71 | ], 72 | "hide_toolbar": 1, 73 | "in_create": 1, 74 | "issingle": 1, 75 | "links": [], 76 | "modified": "2023-04-05 02:09:29.917711", 77 | "modified_by": "Administrator", 78 | "module": "Temporal Core", 79 | "name": "Temporal Manager", 80 | "owner": "Administrator", 81 | "permissions": [ 82 | { 83 | "create": 1, 84 | "delete": 1, 85 | "email": 1, 86 | "print": 1, 87 | "read": 1, 88 | "role": "System Manager", 89 | "share": 1, 90 | "write": 1 91 | } 92 | ], 93 | "sort_field": "modified", 94 | "sort_order": "DESC" 95 | } -------------------------------------------------------------------------------- /temporal/temporal_core/doctype/temporal_manager/temporal_manager.py: -------------------------------------------------------------------------------- 1 | """ Code for DocType 'Temporal Manager' """ 2 | 3 | # -*- coding: utf-8 -*- 4 | # Copyright (c) 2023, Datahenge LLC and contributors 5 | # For license information, please see license.txt 6 | 7 | from __future__ import unicode_literals 8 | 9 | import datetime 10 | import pathlib 11 | 12 | import frappe 13 | from frappe import _ 14 | from frappe.model.document import Document 15 | 16 | from temporal import Builder 17 | 18 | # 19 | # Button Naming Convention: Python functions start with 'button', while DocField names are 'btn' 20 | # 21 | 22 | class TemporalManager(Document): 23 | """ 24 | This DocType just provides a mechanism for displaying buttons on the page. 25 | """ 26 | 27 | @frappe.whitelist() 28 | def button_show_weeks(self): 29 | frappe.msgprint(_("DEBUG: Calling frappe.publish_realtime. This should open a dialog, but it does not (known bug 4 June 2021)")) 30 | frappe.publish_realtime("Dialog Show Redis Weeks", user=frappe.session.user) 31 | 32 | @frappe.whitelist() 33 | def button_rebuild_calendar_cache(self): 34 | """ 35 | Create a calendar records in Redis. 36 | * Start and End Years will default from the DocType 'Temporal Manager' 37 | * If no values exist in 'Temporal Manager', there are hard-coded values in temporal.Builder() 38 | """ 39 | Builder.build_all() 40 | frappe.msgprint(_("Finished rebuilding Redis Calendar.")) 41 | 42 | @frappe.whitelist() 43 | def button_rebuild_temporal_dates(self): 44 | """ 45 | Open the .SQL file in the module, and execute to populate "tabTemporal Dates" 46 | """ 47 | print("Rebuilding the Temporal Dates table...") 48 | 49 | this_path = pathlib.Path(__file__) # path to this Python module 50 | query_path = this_path.parent / 'rebuild_dates_table.sql' 51 | if not query_path.exists(): 52 | raise FileNotFoundError(f"Cannot ready query file '{query_path}'") 53 | 54 | frappe.db.sql("""TRUNCATE TABLE "tabTemporal Dates";""") 55 | 56 | start_date = datetime.date(int(frappe.db.get_single_value("Temporal Manager", "start_year")), 1, 1) # January 1st of starting year. 57 | end_date = datetime.date(int(frappe.db.get_single_value("Temporal Manager", "end_year")), 12, 31) # December 31st of ending year. 58 | 59 | # frappe.db.sql("SET max_recursive_iterations = 20000;") # IMPORTANT: Overrides the default value of 1000, which limits result to < 3 years. 60 | with open(query_path, encoding="utf-8") as fstream: 61 | query = fstream.readlines() 62 | query = ''.join(query) 63 | query = query.replace('@StartDate', f"'{start_date}'") 64 | query = query.replace('@EndDate', f"'{end_date}'") 65 | frappe.db.sql(query) 66 | 67 | query = """SELECT count(*) FROM "tabTemporal Dates"; """ 68 | row_count = frappe.db.sql(query) 69 | if row_count: 70 | row_count = row_count[0][0] 71 | else: 72 | row_count = 0 73 | frappe.db.commit() 74 | 75 | frappe.msgprint("Calculating calendar week numbers...", to_console=True) 76 | # Next, need to assign Week Numbers. 77 | temporal_date_keys = frappe.get_list("Temporal Dates", pluck="name", order_by="calendar_date") 78 | for index, each_key in enumerate(temporal_date_keys): 79 | try: 80 | if index % 100 == 0: 81 | print(f" Iteration: {index} ...") 82 | doc_temporal_date = frappe.get_doc("Temporal Dates", each_key) 83 | doc_temporal_date.set_week_number(raise_on_exception=True) 84 | doc_temporal_date.save() 85 | frappe.db.commit() 86 | except Exception as ex: 87 | frappe.db.rollback() 88 | raise ex 89 | 90 | frappe.msgprint(f"Table successfully rebuilt and contains {row_count} rows of calendar dates.") 91 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | ## Regarding Calendars and other Temporal features. 2 | 3 | ### MySQL 4 | While creating queries to answer the question: "How much inventory will I have on FutureDate?" 5 | I needed to join On Hand inventory (`tabBin`) against future dates. However, you cannot write a Table 6 | Valued Function in MySQL. That feature doesn't exist. So I couldn't write a function to return the next N 7 | days. 8 | 9 | Luckily, this is not necessary. Future calendar dates are fixed datapoints; they aren't going to change. I 10 | can just permanently store them. So I created a new DocType `tabTemporal Dates` 11 | 12 | ## Testing 13 | ### Testing Redis 14 | I highly recommend downloading [Another Redis Desktop Manager](https://www.electronjs.org/apps/anotherredisdesktopmanager) 15 | 16 | Otherwise, you can always examine a Redis database using the `redis-cli` CLI application: 17 | ``` 18 | $ redis-cli -p 13003 19 | ``` 20 | 21 | If you already know your Redis command, you can `cat` and pipes too: 22 | ``` 23 | $ echo 'SMEMBERS v12testdb|temporal/calyears' | redis-cli -p 13003 24 | ``` 25 | 26 | ### MySQL Keywords and Reserved Words 27 | https://dev.mysql.com/doc/refman/8.0/en/keywords.html#keywords-8-0-detailed-D 28 | 29 | ### 1. Calendar Years 30 | These are straightforward. They are stored in Redis for performance reasons. 31 | 32 | #### calyears 33 | * Key: `v12testdb|temporal/calyears` 34 | * Value: `[ 2020, 2021, 2022 ]` 35 | * Redis CLI GET: `SMEMBERS v12testdb|temporal/calyears` 36 | * Pyhon GET: `temporal.cget_calendar_years()` 37 | 38 | #### calyear 39 | * Key: `v12testdb|temporal/calyear/2020` 40 | * Value: `{ year: 2020, startdate: "1/1/2020", enddate: "12/31/2020", length_in_days: 365}` 41 | * Redis CLI GET: `SMEMBERS v12testdb|temporal/calyear/2020` 42 | 43 | * Python: `get_calendar_year(year_number)` 44 | 45 | 46 | ### Traditional Calendar Weeks 47 | The following rules are observed when calculating a "Week Number" using Google Docs: 48 | 1. January 1st is always the beginning of Week #1. 49 | * This week probably has fewer than seven(7) days. 50 | 2. The first Sunday after January 1st is the beginning of Week #2. 51 | 3. Weeks 2 thru Week L-1 are seven(7) days in length. 52 | 3. Week L (final week) ends on December 31st. 53 | * This week probably has fewer than seven(7) days. 54 | 55 | ### Temporal Weeks 56 | 1. Every week contains 7 days, without exception. There is no such thing as a partial week. 57 | 2. Weeks begin on Sunday, so Wednesday is the middle day of a week. 58 | 3. Week #1 will always contain January 1st. 59 | 60 | For example. Each hypothetical week below would be considered "Week #1" 61 | ``` 62 | -------------------------------- 63 | S M T W T F S 64 | -------------------------------- 65 | 26 27 28 29 30 31 1 66 | 27 28 29 30 31 1 2 67 | 28 29 30 31 1 2 3 68 | 29 30 31 1 2 3 4 69 | 30 31 1 2 3 4 5 70 | 31 1 2 3 4 5 6 71 | 1 2 3 4 5 6 7 72 | ``` 73 | 74 | 3. Week #L (final week) contains December 25th. 75 | ``` 76 | -------------------------------- 77 | S M T W T F S 78 | -------------------------------- 79 | 19 20 21 22 23 24 25 80 | 20 21 22 23 24 25 26 81 | 21 22 23 24 25 26 27 82 | 22 23 24 25 26 27 28 83 | 23 24 25 26 27 28 29 84 | 24 25 26 27 28 29 30 85 | 25 26 27 28 29 30 31 86 | ``` 87 | 88 | ### Snippets 89 | * `datetime.strptime(date_string, format)` 90 | 91 | * Parsing a date from a string: 92 | ``` 93 | from dateutil.parser import parse 94 | now = parse("Sat Oct 11 17:13:46 UTC 2003") 95 | ``` 96 | 97 | ### Redis Articles 98 | * https://redis.io/topics/data-types 99 | * https://pythontic.com/database/redis/hash%20-%20add%20and%20remove%20elements 100 | * https://www.shellhacks.com/redis-get-all-keys-redis-cli/ 101 | 102 | ### Datetime Articles 103 | * datetime.date.isocalendar 104 | * https://pypi.org/project/isoweek/ 105 | * https://docs.python.org/3/library/calendar.html 106 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | GNU LESSER GENERAL PUBLIC LICENSE 3 | 4 | Version 3, 29 June 2007 5 | 6 | Copyright © 2007 Free Software Foundation, Inc. 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. 9 | 10 | This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 11 | 0. Additional Definitions. 12 | 13 | As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License. 14 | 15 | “The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. 16 | 17 | An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. 18 | 19 | A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”. 20 | 21 | The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. 22 | 23 | The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 24 | 1. Exception to Section 3 of the GNU GPL. 25 | 26 | You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 27 | 2. Conveying Modified Versions. 28 | 29 | If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: 30 | 31 | a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or 32 | b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 33 | 34 | 3. Object Code Incorporating Material from Library Header Files. 35 | 36 | The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: 37 | 38 | a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. 39 | b) Accompany the object code with a copy of the GNU GPL and this license document. 40 | 41 | 4. Combined Works. 42 | 43 | You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: 44 | 45 | a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. 46 | b) Accompany the Combined Work with a copy of the GNU GPL and this license document. 47 | c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. 48 | d) Do one of the following: 49 | 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 50 | 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. 51 | e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 52 | 53 | 5. Combined Libraries. 54 | 55 | You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: 56 | 57 | a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. 58 | b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 59 | 60 | 6. Revised Versions of the GNU Lesser General Public License. 61 | 62 | The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. 63 | 64 | Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. 65 | 66 | If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. 67 | -------------------------------------------------------------------------------- /temporal/redis.py: -------------------------------------------------------------------------------- 1 | """ temporal/temporal/redis.py """ 2 | 3 | # ---------------- 4 | # Redis Functions 5 | # ---------------- 6 | 7 | # Standard Library 8 | from pprint import pprint 9 | import datetime 10 | 11 | # Third Party 12 | from six import iteritems 13 | 14 | # Frappe 15 | import frappe 16 | from frappe import msgprint, safe_decode 17 | 18 | # Redis Data Model: 19 | # I'm choosing to uses forward slash (/) to build Compound Keys 20 | 21 | # temporal/calyears [ 2019, 2020, 2021] 22 | # temporal/calyear/2020 { } 23 | 24 | # calyear:2020:weeks [ 1, 2, 3, 4, 5, ... ] 25 | # calyear:2020:wk1 : { 'firstday': '4/1/2020', lastday: '4/7/2020' } 26 | # calyear:2020:12:26 : { 'week' : 34 , 'dayname': Monday } 27 | 28 | 29 | def redis_hash_to_dict(redis_hash, mandatory_arg=True): 30 | if not redis_hash: 31 | raise ValueError("Missing required argument 'redis_hash'") 32 | ret = {} 33 | for key, data in iteritems(redis_hash): 34 | key = safe_decode(key) 35 | ret[key] = data 36 | if mandatory_arg and (not ret): 37 | raise ValueError(f"Failed to create dictionary for Redis hash = {redis_hash}") 38 | return ret 39 | 40 | # --------------- 41 | # INTERNALS 42 | # --------------- 43 | 44 | def _year_to_yearkey(year): 45 | if not isinstance(year, int): 46 | raise TypeError("Argument 'year' should be a Python integer.") 47 | return f"temporal/year/{year}" 48 | 49 | def _date_to_daykey(date): 50 | # For rationality, key format will be YYYY-MM-DD 51 | if not isinstance(date, datetime.date): 52 | raise TypeError("Argument 'date' should be a Python datetime Date.") 53 | date_as_string = date.strftime("%Y-%m-%d") 54 | day_key = f"temporal/day/{date_as_string}" 55 | return day_key 56 | 57 | def _get_weekkey(year, week_number): 58 | """ Return a Redis weekkey """ 59 | if not isinstance(week_number, int): 60 | week_number = int(week_number) 61 | if week_number > 53: 62 | raise ValueError("Week number must be an integer between 1 and 53.") 63 | if week_number < 1: 64 | raise ValueError("Week number must be an integer between 1 and 53.") 65 | week_number_str = str(week_number).zfill(2) 66 | return f"temporal/week/{year}-{week_number_str}" 67 | 68 | # ------------ 69 | # WRITING TO REDIS 70 | # ------------ 71 | 72 | def write_years(years_tuple, verbose=False): 73 | """ Create Redis list of Calendar Years. """ 74 | if not isinstance(years_tuple, tuple): 75 | raise TypeError("Argument 'years_tuple' should be a Python Tuple.") 76 | frappe.cache().delete_key("temporal/years") 77 | for year in years_tuple: 78 | frappe.cache().sadd("temporal/years", year) # add to set. 79 | if verbose: 80 | msgprint(f"Temporal Years: {read_years()}") 81 | 82 | 83 | def write_single_year(year_dict, verbose=False): 84 | """ Store a year in Redis as a Hash. """ 85 | if not isinstance(year_dict, dict): 86 | raise TypeError("Argument 'year_dict' should be a Python Dictionary.") 87 | year_key = _year_to_yearkey(int(year_dict['year'])) 88 | frappe.cache().delete_key(year_key) 89 | for key,value in year_dict.items(): 90 | frappe.cache().hset(year_key, key, value) 91 | if verbose: 92 | print(f"\u2713 Created temporal year '{year_key}' in Redis.") 93 | 94 | 95 | def update_year(year, key, value, verbose=False): 96 | """ Update one of the hash values in the Redis Year. """ 97 | # Example: Update the 'last_week_number' key, once Weeks have been generated. 98 | if not isinstance(year, int): 99 | raise TypeError("Argument 'year' should be a Python integer.") 100 | year_key = _year_to_yearkey(year) 101 | frappe.cache().hset(year_key, key, value) 102 | if verbose: 103 | pass 104 | 105 | def write_weeks(weeks_tuple, verbose=False): 106 | """ Create Redis list of Weeks. """ 107 | if not isinstance(weeks_tuple, tuple): 108 | raise TypeError("Argument 'weeks_tuple' should be a Python Tuple.") 109 | frappe.cache().delete_key("temporal/weeks") 110 | for week in weeks_tuple: 111 | frappe.cache().sadd("temporal/weeks", week) # add to set. 112 | if verbose: 113 | msgprint(f"Temporal Weeks: {read_weeks()}") 114 | 115 | def write_single_week(week_dict, verbose=False): 116 | """ Store a Week in Redis as a hash. """ 117 | if not isinstance(week_dict, dict): 118 | raise TypeError("Argument 'week_dict' should be a Python Dictionary.") 119 | week_key = _get_weekkey(week_dict['year'], week_dict['week_number']) 120 | frappe.cache().delete_key(week_key) 121 | for key,value in week_dict.items(): 122 | frappe.cache().hset(week_key, key, value) 123 | if verbose: 124 | print("Created a Temporal Week '{week_key}' in Redis:\n") 125 | pprint(read_single_week(week_dict['year'], week_dict['week_number']), depth=6) 126 | 127 | def write_single_day(day_dict): 128 | """ 129 | Store a Day in Redis as a hash. 130 | """ 131 | if not isinstance(day_dict, dict): 132 | raise TypeError("Argument 'day_dict' should be a Python Dictionary.") 133 | 134 | hash_key = _date_to_daykey(day_dict['date']) 135 | frappe.cache().delete_key(hash_key) 136 | 137 | date_as_string = day_dict['date'].strftime("%Y-%m-%d") 138 | for key, value in day_dict.items(): 139 | if key == 'date': 140 | # No point storing datetime.date; just store a sortable date string: YYYY-MM-DD 141 | frappe.cache().hset(hash_key, key, date_as_string) 142 | else: 143 | frappe.cache().hset(hash_key, key, value) 144 | 145 | # ------------ 146 | # READING FROM REDIS 147 | # ------------ 148 | 149 | def read_years(): 150 | """ 151 | Returns a Python Tuple containing year integers. 152 | """ 153 | year_tuple = tuple( int(year) for year in frappe.cache().smembers('temporal/years') ) 154 | return sorted(year_tuple) # redis does not naturally store Sets as sorted. 155 | 156 | 157 | def read_single_year(year): 158 | """ 159 | Returns a Python Dictionary containing year-by-year data. 160 | """ 161 | year_key = _year_to_yearkey(year) 162 | redis_hash = frappe.cache().hgetall(year_key) 163 | if not redis_hash: 164 | if frappe.db.get_single_value('Temporal Manager', 'debug_mode'): 165 | raise KeyError(f"Temporal was unable to find Redis key with name = {year_key}") 166 | return None 167 | return redis_hash_to_dict(redis_hash) 168 | 169 | 170 | def read_days(): 171 | """ Returns a Python Tuple containing Day Keys. """ 172 | day_tuple = tuple( day_key for day_key in frappe.cache().smembers('temporal/days') ) 173 | return sorted(day_tuple) # Redis Sets are not stored in the Redis database. 174 | 175 | 176 | def read_single_day(day_key): 177 | """ Returns a Python Dictionary containing a Single Day. """ 178 | if not day_key.startswith('temporal'): 179 | raise ValueError("All Redis key arguments should begin with 'temporal'") 180 | redis_hash = frappe.cache().hgetall(day_key) 181 | if not redis_hash: 182 | if frappe.db.get_single_value('Temporal Manager', 'debug_mode'): 183 | raise KeyError(f"Temporal was unable to find Redis key with name = {day_key}") 184 | return None 185 | return redis_hash_to_dict(redis_hash) 186 | 187 | 188 | def read_weeks(): 189 | """ Returns a Python Tuple containing Week Keys. """ 190 | week_tuple = tuple( week for week in frappe.cache().smembers('temporal/weeks') ) 191 | return sorted(week_tuple) # redis does not naturally store Sets as sorted. 192 | 193 | 194 | def read_single_week(year, week_number): 195 | """ Reads Redis, and returns a Python Dictionary containing a single Week. """ 196 | week_key = _get_weekkey(year, week_number) 197 | redis_hash = frappe.cache().hgetall(week_key) 198 | if not redis_hash: 199 | if frappe.db.get_single_value('Temporal Manager', 'debug_mode'): 200 | raise KeyError(f"Temporal was unable to find Redis key with name = {week_key}") 201 | return None 202 | return redis_hash_to_dict(redis_hash) 203 | -------------------------------------------------------------------------------- /temporal/result.py: -------------------------------------------------------------------------------- 1 | """ temporal.result """ 2 | # Extending what Python can return from a function call. 3 | 4 | from enum import Enum 5 | import json 6 | from typing import NamedTuple 7 | 8 | import frappe # pylint: disable=unused-import 9 | 10 | from temporal import validate_datatype 11 | from temporal.helpers import dict_to_dateless_dict 12 | 13 | PERFORM_TYPE_CHECKING = True 14 | 15 | 16 | class FriendlyException(Exception): 17 | """ 18 | Python exceptions with optional, customer-facing messages. 19 | 20 | Usage: 21 | ex = FriendlyException("Friendly Message", "Internal-Only Message") 22 | """ 23 | def __init__(self, friendly_message, internal_message=None): 24 | 25 | self.friendly = friendly_message 26 | self.internal = internal_message or friendly_message 27 | super().__init__(self.internal) 28 | 29 | def __str__(self): 30 | return f"{self.args[0]}" 31 | 32 | def __friendly__(self): 33 | return self.friendly 34 | 35 | 36 | # ======== 37 | # Define a Message 38 | # ======== 39 | 40 | def validate_enum_value(any_value, enum_type, raise_on_errors=True): 41 | """ 42 | Ensure that an enumerated Value is a member of the specific enumerated data Type. 43 | """ 44 | 45 | # Validate the arguments -themselves- are of the correct types. 46 | if not isinstance(any_value, (str, Enum)): 47 | raise TypeError(f"Expected argument 'any_value' with value = '{any_value}' to be either String or Enum type.") 48 | if not issubclass(enum_type, Enum): 49 | raise TypeError(f"Argument '{enum_type}' must be a subclass of Enum.") 50 | 51 | # Next, if the first argument is a String, verify it will successfully coerce into an Enum variant. 52 | if isinstance(any_value, str): 53 | try: 54 | enum_type[any_value.upper()] 55 | except Exception as ex: 56 | message = f"Argument value '{any_value}' is not a type of Enum '{enum_type.__name__}'" 57 | print(message) 58 | if raise_on_errors: 59 | raise TypeError(message) from ex 60 | return False 61 | return True 62 | 63 | # NOTE: Should always define Enum classes as both (str, Enum), so they are JSON serializable. 64 | class MessageLevel(str, Enum): 65 | INFO = 'Info' 66 | WARNING = 'Warning' 67 | ERROR = 'Error' 68 | 69 | class MessageAudience(str, Enum): 70 | ALL = 'All' 71 | INTERNAL = 'Internal' 72 | EXTERNAL = 'External' 73 | 74 | class ResultMessage(NamedTuple): 75 | audience: MessageAudience 76 | message_level: MessageLevel 77 | message: str 78 | message_tags: list # just a flexible attribute, useful for things like taggging messages as 'header-error' or 'line-error' 79 | 80 | @staticmethod 81 | def new(audience: MessageAudience, level: MessageLevel, message_string: str, tags: list=None): 82 | """ 83 | Create a new ResultMessageString 84 | """ 85 | if isinstance(tags, str): 86 | tags = [ tags ] 87 | return ResultMessage( 88 | audience=audience, 89 | message_level=level, 90 | message=message_string, 91 | message_tags=tags or [] 92 | ) 93 | 94 | def has_tag(self, message_tag) -> bool: 95 | return message_tag in self.message_tags 96 | 97 | def __str__(self): 98 | return f"{self.message_level} : {self.message}" 99 | 100 | 101 | class OutcomeType(str, Enum): 102 | SUCCESS = 'Success' 103 | PARTIAL = 'Partial Success' # For example, success with Warnings, dropped Order Lines. 104 | ERROR = 'Error' 105 | INTERNAL_ERROR = 'Runtime Error' # unhandled Exceptions 106 | NONE = "None" # used when something hasn't happened yet 107 | 108 | 109 | class ResultBase(): # pylint: disable=too-many-instance-attributes 110 | """ 111 | Extensible class for operations with Results and Related Data 112 | Examples include is base class with specific ones (Change Order Date, Un-Skip, Un-Pause, Cart Merging, Anon Registration) 113 | """ 114 | def __init__(self, validate_types=True, validate_schemas=True): 115 | 116 | self.outcome: OutcomeType = OutcomeType.SUCCESS 117 | self._messages = [] 118 | self._data: dict = {} 119 | self._available_message_tags = [] 120 | self._should_raise_exceptions = False # should the consumer of this Result throw a Python Exception? 121 | self.runtime_exception = None 122 | self.validate_types = bool(validate_types) 123 | self.validate_schemas = bool(validate_schemas) 124 | 125 | def get_data(self, key=None): 126 | """ 127 | Return the data dictionary of the ResultBase class instance. 128 | """ 129 | if PERFORM_TYPE_CHECKING: 130 | validate_datatype("_data", self._data, dict, False) 131 | if not key: 132 | return self._data 133 | return self._data[key] 134 | 135 | def add_data(self, key, value): 136 | self._data[key] = value 137 | 138 | def append_data(self, key, value): 139 | if not isinstance(self._data[key], (list,set)): 140 | raise TypeError(f"ResultBase data element '{key}' is not a Python list or set.") 141 | self._data[key].append(value) 142 | 143 | def __bool__(self) -> bool: 144 | """ 145 | A useful overload. For example: 'if Result():' 146 | """ 147 | if self.runtime_exception: 148 | return False 149 | return (self.outcome not in {OutcomeType.ERROR, OutcomeType.INTERNAL_ERROR}) 150 | 151 | def as_dict(self) -> dict: 152 | return { 153 | "outcome": self.outcome, 154 | "data": dict_to_dateless_dict(self._data), 155 | "messages": dict_to_dateless_dict(self._messages) 156 | } 157 | 158 | def as_json(self): 159 | return json.dumps({ 160 | "outcome": self.outcome, 161 | "data": dict_to_dateless_dict(self._data), 162 | "messages": dict_to_dateless_dict(self._messages) 163 | }) 164 | 165 | def __str__(self): 166 | return self.as_json() 167 | 168 | def should_raise_exceptions(self) -> bool: 169 | if self.outcome in (OutcomeType.ERROR, OutcomeType.INTERNAL_ERROR): 170 | return True 171 | if self._should_raise_exceptions: 172 | return True 173 | if self.runtime_exception: 174 | return True 175 | return False 176 | 177 | # Message Functions 178 | def add_message(self, audience, message_level, message_string, tags=None): 179 | 180 | validate_enum_value(audience, MessageAudience) 181 | validate_enum_value(message_level, MessageLevel) 182 | 183 | # Validate the tags 184 | if tags: 185 | if isinstance(tags, str): 186 | tags = [ tags ] 187 | for each_tag in tags: 188 | if each_tag not in self._available_message_tags: 189 | raise ValueError(f"Invalid tag value '{each_tag}' passed to ResultBase.add_message()") 190 | new_message = ResultMessage.new(audience=audience, level=message_level, message_string=message_string, tags=tags) 191 | self._messages.append(new_message) 192 | # Error Message leads to Error Outcome 193 | if message_level == MessageLevel.ERROR: 194 | self.outcome: OutcomeType = OutcomeType.ERROR 195 | 196 | def get_all_messages(self): 197 | return self._messages 198 | 199 | def get_error_messages(self): 200 | return [ each for each in self._messages if each.message_level == 'Error'] # MessageLevel.ERROR 201 | 202 | def get_warning_messages(self): 203 | return [ each for each in self._messages if each.message_level == 'Warning'] # MessageLevel.WARNING 204 | 205 | def get_info_messages(self): 206 | return [ each for each in self._messages if each.message_level == 'Info'] # MessageLevel.INFO 207 | 208 | # Common Response Schema 209 | 210 | def add_result_to_crs(self, crs_instance): 211 | """ 212 | Add this result's data to a Common Response Schmea for the FTP Middleware. 213 | """ 214 | converted_dict = dict_to_dateless_dict(self.get_data()) # NOTE: function already handles a deepcopy 215 | 216 | for key, value in converted_dict.items(): 217 | crs_instance.add_data(key, value) # important to send as JSON, to convert things like Date and DateTime to string. 218 | 219 | for each_message in self.get_all_messages(): 220 | 221 | if each_message.audience in (MessageAudience.ALL, MessageAudience.INTERNAL): 222 | crs_instance.add_internal_message(str(each_message)) 223 | if each_message.audience in (MessageAudience.ALL, MessageAudience.EXTERNAL): 224 | crs_instance.add_customer_message(str(each_message)) 225 | -------------------------------------------------------------------------------- /temporal/__init__.py: -------------------------------------------------------------------------------- 1 | """ temporal.py """ 2 | 3 | # -*- coding: utf-8 -*- 4 | from __future__ import unicode_literals 5 | 6 | # Standard Library 7 | import datetime 8 | from datetime import timedelta 9 | from datetime import date as DateType, datetime as DateTimeType 10 | 11 | # Third Party 12 | import dateutil.parser # https://stackoverflow.com/questions/48632176/python-dateutil-attributeerror-module-dateutil-has-no-attribute-parse 13 | from dateutil.relativedelta import relativedelta 14 | from dateutil.rrule import SU, MO, TU, WE, TH, FR, SA # noqa F401 15 | 16 | # Temporal Lib 17 | import temporal_lib 18 | 19 | from temporal_lib.core import ( 20 | localize_datetime, 21 | calc_future_dates, 22 | date_generator_type_1, 23 | date_is_between, date_range, 24 | date_range_from_strdates, 25 | date_ranges_to_dates, 26 | get_earliest_date, 27 | get_latest_date 28 | ) 29 | 30 | from temporal_lib.tlib_types import ( 31 | any_to_iso_date_string, 32 | any_to_date, 33 | any_to_datetime, 34 | any_to_time, 35 | datestr_to_date, 36 | date_to_iso_string, 37 | date_to_datetime_midnight, 38 | datetime_to_iso_string, 39 | int_to_ordinal_string as make_ordinal, 40 | is_date_string_valid, 41 | timestr_to_time, 42 | validate_datatype 43 | ) 44 | from temporal_lib.tlib_date import TDate, date_to_week_tuple 45 | from temporal_lib.tlib_week import Week, week_generator 46 | from temporal_lib.tlib_weekday import ( 47 | next_weekday_after_date, 48 | WEEKDAYS_SUN0, 49 | WEEKDAYS_MON0, 50 | weekday_string_to_shortname, 51 | weekday_int_from_name 52 | ) 53 | 54 | # Frappe modules. 55 | import frappe 56 | from frappe import _, throw, msgprint, ValidationError # noqa F401 57 | 58 | # Temporal 59 | from temporal import core 60 | from temporal import redis as temporal_redis # alias to distinguish from Third Party module 61 | 62 | # Constants 63 | __version__ = '15.0.0' 64 | 65 | # Epoch is the range of 'business active' dates. 66 | EPOCH_START_YEAR = 2020 67 | EPOCH_END_YEAR = 2050 68 | EPOCH_START_DATE = DateType(EPOCH_START_YEAR, 1, 1) 69 | EPOCH_END_DATE = DateType(EPOCH_END_YEAR, 12, 31) 70 | 71 | # These should be considered true Min/Max for all other calculations. 72 | MIN_YEAR = 2000 73 | MAX_YEAR = 2201 74 | MIN_DATE = DateType(MIN_YEAR, 1, 1) 75 | MAX_DATE = DateType(MAX_YEAR, 12, 31) 76 | 77 | # Module Typing: https://docs.python.org/3.8/library/typing.html#module-typing 78 | 79 | class ArgumentMissing(ValidationError): 80 | http_status_code = 500 81 | 82 | class ArgumentType(ValidationError): 83 | http_status_code = 500 84 | 85 | 86 | class Builder(): 87 | """ 88 | This class is used to build the Temporal data (stored in Redis Cache) 89 | """ 90 | 91 | def __init__(self, epoch_year, end_year, start_of_week='SUN'): 92 | """ 93 | Initialize the Builder class. 94 | """ 95 | 96 | # This determines if we output additional Error Messages. 97 | self.debug_mode = frappe.db.get_single_value('Temporal Manager', 'debug_mode') 98 | 99 | if not isinstance(start_of_week, str): 100 | raise TypeError("Argument 'start_of_week' should be a Python String.") 101 | if start_of_week not in ('SUN', 'MON'): 102 | raise ValueError(f"Argument 'start of week' must be either 'SUN' or 'MON' (value passed was '{start_of_week}'") 103 | if start_of_week != 'SUN': 104 | raise NotImplementedError("Temporal is not-yet coded to handle weeks that begin with Monday.") 105 | 106 | # Starting and Ending Year 107 | if not epoch_year: 108 | gui_start_year = int(frappe.db.get_single_value('Temporal Manager', 'start_year') or 0) 109 | epoch_year = gui_start_year or EPOCH_START_YEAR 110 | if not end_year: 111 | gui_end_year = int(frappe.db.get_single_value('Temporal Manager', 'end_year') or 0) 112 | end_year = gui_end_year or EPOCH_END_YEAR 113 | if end_year < epoch_year: 114 | raise ValueError(f"Ending year {end_year} cannot be smaller than Starting year {epoch_year}") 115 | self.epoch_year = epoch_year 116 | self.end_year = end_year 117 | 118 | year_range = range(self.epoch_year, self.end_year + 1) # because Python ranges are not inclusive 119 | self.years = tuple(year_range) 120 | self.weekday_names = WEEKDAYS_SUN0 if start_of_week == 'SUN' else WEEKDAYS_MON0 121 | self.week_dicts = [] # this will get populated as we build. 122 | 123 | @staticmethod 124 | @frappe.whitelist() 125 | def build_all(epoch_year=None, end_year=None, start_of_week='SUN'): 126 | """ Rebuild all Temporal cache key-values. """ 127 | instance = Builder(epoch_year=epoch_year, 128 | end_year=end_year, 129 | start_of_week=start_of_week) 130 | 131 | instance.build_weeks() # must happen first, so we can build years more-easily. 132 | instance.build_years() 133 | instance.build_days() 134 | 135 | def build_years(self): 136 | """ 137 | Calculate years and write to Redis. 138 | """ 139 | temporal_redis.write_years(self.years, self.debug_mode) 140 | for year in self.years: 141 | self.build_year(year) 142 | 143 | def build_year(self, year): 144 | """ 145 | Create a dictionary of Year metadata and write to Redis. 146 | """ 147 | date_start = DateType(year, 1, 1) 148 | date_end = DateType(year, 12, 31) 149 | days_in_year = (date_end - date_start).days + 1 150 | jan_one_dayname = date_start.strftime("%a").upper() 151 | year_dict = {} 152 | year_dict['year'] = year 153 | year_dict['date_start'] = date_start.strftime("%m/%d/%Y") 154 | year_dict['date_end'] = date_end.strftime("%m/%d/%Y") 155 | year_dict['days_in_year'] = days_in_year 156 | # What day of the week is January 1st? 157 | year_dict['jan_one_dayname'] = jan_one_dayname 158 | try: 159 | weekday_short_names = tuple(weekday['name_short'] for weekday in self.weekday_names) 160 | year_dict['jan_one_weekpos'] = weekday_short_names.index(jan_one_dayname) + 1 # because zero-based indexing 161 | except ValueError as ex: 162 | raise ValueError(f"Could not find value '{jan_one_dayname}' in tuple 'self.weekday_names' = {self.weekday_names}") from ex 163 | # Get the maximum week number (52 or 53) 164 | max_week_number = max(week['week_number'] for week in self.week_dicts if week['year'] == year) 165 | year_dict['max_week_number'] = max_week_number 166 | 167 | temporal_redis.write_single_year(year_dict, self.debug_mode) 168 | 169 | def build_days(self): 170 | start_date = DateType(self.epoch_year, 1, 1) # could also do self.years[0] 171 | end_date = DateType(self.end_year, 12, 31) # could also do self.years[-1] 172 | 173 | count = 0 174 | for date_foo in date_range(start_date, end_date): 175 | day_dict = {} 176 | day_dict['date'] = date_foo 177 | day_dict['date_as_string'] = day_dict['date'].strftime("%Y-%m-%d") 178 | day_dict['weekday_name'] = date_foo.strftime("%A") 179 | day_dict['weekday_name_short'] = date_foo.strftime("%a") 180 | day_dict['day_of_month'] = date_foo.strftime("%d") 181 | day_dict['month_in_year_int'] = date_foo.strftime("%m") 182 | day_dict['month_in_year_str'] = date_foo.strftime("%B") 183 | day_dict['year'] = date_foo.year 184 | day_dict['day_of_year'] = date_foo.strftime("%j") 185 | # Calculate the week number: 186 | week_tuple = date_to_week_tuple(date_foo, verbose=False) # previously self.debug_mode 187 | day_dict['week_year'] = week_tuple[0] 188 | day_dict['week_number'] = week_tuple[1] 189 | day_dict['index_in_week'] = int(date_foo.strftime("%w")) + 1 # 1-based indexing 190 | # Write this dictionary in the Redis cache: 191 | temporal_redis.write_single_day(day_dict) 192 | count += 1 193 | if self.debug_mode: 194 | print(f"\u2713 Created {count} Temporal Day keys in Redis.") 195 | 196 | def build_weeks(self): 197 | """ 198 | Build all the weeks between Epoch Date and End Date 199 | """ 200 | # Begin on January 1st 201 | jan1_date = DateType(self.epoch_year, 1, 1) 202 | jan1_day_of_week = int(jan1_date.strftime("%w")) # day of week for January 1st 203 | 204 | week_start_date = jan1_date - timedelta(days=jan1_day_of_week) # if January 1st is not Sunday, back up. 205 | week_end_date = None 206 | week_number = None 207 | print(f"Temporal is building weeks, starting with {week_start_date}") 208 | 209 | if self.debug_mode: 210 | print(f"Processing weeks begining with calendar date: {week_start_date}") 211 | 212 | count = 0 213 | while True: 214 | # Stop once week_start_date's year exceeds the Maximum Year. 215 | if week_start_date.year > self.end_year: 216 | if self.debug_mode: 217 | print(f"Ending loop on {week_start_date}") 218 | break 219 | 220 | week_end_date = week_start_date + timedelta(days=6) 221 | if self.debug_mode: 222 | print(f"Week's end date = {week_end_date}") 223 | if (week_start_date.day == 1) and (week_start_date.month == 1): 224 | # Sunday is January 1st, it's a new year. 225 | week_number = 1 226 | elif week_end_date.year > week_start_date.year: 227 | # January 1st falls somewhere inside the week 228 | week_number = 1 229 | else: 230 | week_number += 1 231 | tuple_of_dates = tuple(list(date_range(week_start_date, week_end_date))) 232 | if self.debug_mode: 233 | print(f"Writing week number {week_number}") 234 | week_dict = {} 235 | week_dict['year'] = week_end_date.year 236 | week_dict['week_number'] = week_number 237 | week_dict['week_start'] = week_start_date 238 | week_dict['week_end'] = week_end_date 239 | week_dict['week_dates'] = tuple_of_dates 240 | temporal_redis.write_single_week(week_dict) 241 | self.week_dicts.append(week_dict) # internal object in Builder, for use later in build_years 242 | 243 | # Increment to the Next Week 244 | week_start_date = week_start_date + timedelta(days=7) 245 | count += 1 246 | 247 | # Loop complete. 248 | if self.debug_mode: 249 | print(f"\u2713 Created {count} Temporal Week keys in Redis.") 250 | 251 | 252 | def get_year_from_frappedate(frappe_date): 253 | return int(frappe_date[:4]) 254 | 255 | 256 | def date_to_datekey(any_date): 257 | """ 258 | Create a Redis key from any date value. 259 | """ 260 | if not isinstance(any_date, datetime.date): 261 | raise TypeError(f"Argument 'any_date' should have type 'datetime.date', not '{type(any_date)}'") 262 | date_as_string = any_date.strftime("%Y-%m-%d") 263 | return f"temporal/day/{date_as_string}" 264 | 265 | 266 | # ---------------- 267 | # Weeks 268 | # ---------------- 269 | 270 | def week_to_weekkey(year, week_number): 271 | """ 272 | Create a Redis key from any week tuple. 273 | """ 274 | if not isinstance(week_number, int): 275 | raise TypeError("Argument 'week_number' should be a Python integer.") 276 | week_as_string = str(week_number).zfill(2) 277 | return f"temporal/week/{year}-{week_as_string}" 278 | 279 | 280 | def get_week_by_weeknum(year, week_number): 281 | """ 282 | Returns a class Week. 283 | """ 284 | week_dict = temporal_redis.read_single_week(year, week_number, ) 285 | 286 | if not week_dict: 287 | frappe.msgprint(f"Warning: No value in Redis for year {year}, week number {week_number}. Rebuilding...", to_console=True) 288 | Builder.build_all() 289 | if (not week_dict) and frappe.db.get_single_value('Temporal Manager', 'debug_mode'): 290 | raise KeyError(f"WARNING: Unable to find Week in Redis for year {year}, week {week_number}.") 291 | return None 292 | result = Week((year, week_number)) 293 | if not result: 294 | raise RuntimeError(f"Unable to construct a Week for year {year}, week number {week_number}") 295 | return result 296 | 297 | 298 | @frappe.whitelist() 299 | def get_weeks_as_dict(from_year, from_week_num, to_year, to_week_num): 300 | """ Given a range of Week numbers, return a List of dictionaries. 301 | 302 | From terminal: bench execute --args "2021,15,20" temporal.get_weeks_as_dict 303 | 304 | """ 305 | return temporal_lib.tlib_week.get_weeks_as_dict(from_year, from_week_num, to_year, to_week_num) 306 | 307 | 308 | def date_to_scalar(any_date): 309 | """ 310 | It makes zero difference what particular Integers we use to represent calendar dates, so long as: 311 | 1. They are consistent throughout multiple calls/calculations. 312 | 2. There are no gaps between calendar days. 313 | 314 | Given all the calendar dates stored in a Table, a simple identity column would suffice. 315 | """ 316 | scalar_value = frappe.db.get_value("Temporal Dates", filters={"calendar_date": any_date}, fieldname="scalar_value", cache=True) 317 | return scalar_value 318 | 319 | 320 | def date_to_boolean(any_date) -> bool: 321 | """ 322 | In version 15, Frappe began substituting '1900-01-01' for empty or missing dates. 323 | This function helps handle this by representing '1900-01-01' as a NoneType. 324 | """ 325 | if not isinstance(any_date, DateType): 326 | raise TypeError("Argument 'any_date' should be a Python Date.") 327 | if not any_date: 328 | return False 329 | return any_date != datetime.datetime(1, 1, 1).date() 330 | 331 | 332 | def datetime_to_boolean(any_datetime) -> bool: 333 | """ 334 | In version 15, Frappe began substituting '1900-01-01' for empty or missing dates. 335 | This function helps handle this by representing '1900-01-01' as a NoneType. 336 | """ 337 | if not isinstance(any_datetime, DateTimeType): 338 | raise TypeError("Argument 'any_date' should be a Python DateTime.") 339 | if not any_datetime: 340 | return False 341 | return any_datetime != datetime.datetime(1, 1, 1) 342 | 343 | 344 | # TODO 345 | # Rust Option: Some(T), None 346 | # Rust Result: Ok(T), Err(E) 347 | --------------------------------------------------------------------------------