Skip to content

Commit

Permalink
✨ Night owl service for completing daily tasks after midnight
Browse files Browse the repository at this point in the history
  • Loading branch information
Kristian Tashkov committed Sep 5, 2023
1 parent e2b1c8e commit 84bf974
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 24 deletions.
17 changes: 17 additions & 0 deletions tools_for_todoist/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@
from tools_for_todoist.models.google_calendar import GoogleCalendar
from tools_for_todoist.models.todoist import Todoist
from tools_for_todoist.services.calendar_to_todoist import CalendarToTodoistService
from tools_for_todoist.services.night_owl_enabler import NightOwlEnabler
from tools_for_todoist.storage import set_storage
from tools_for_todoist.storage.storage import LocalKeyValueStorage, PostgresKeyValueStorage

DEFAULT_STORAGE = os.path.join(os.path.dirname(__file__), 'storage', 'store.json')


def setup_storage() -> None:
database_config = os.environ.get('DATABASE_URL', None)
if database_config is not None:
storage = PostgresKeyValueStorage(database_config)
else:
storage = LocalKeyValueStorage(os.environ.get('FILE_STORE', DEFAULT_STORAGE))
set_storage(storage)


def setup_logger(logging_level=logging.DEBUG):
Expand All @@ -51,6 +65,7 @@ def run_sync_service(logger):
todoist = Todoist()
google_calendar = GoogleCalendar()
calendar_service = CalendarToTodoistService(todoist, google_calendar)
night_owl_enabler = NightOwlEnabler(todoist, google_calendar)
logger.info('Started syncing service.')

while True:
Expand All @@ -61,10 +76,12 @@ def run_sync_service(logger):
while should_keep_syncing:
todoist_sync_result = todoist.sync()
should_keep_syncing = calendar_service.on_todoist_sync(todoist_sync_result)
should_keep_syncing |= night_owl_enabler.on_todoist_sync(todoist_sync_result)
time.sleep(10)


def main():
setup_storage()
logger = setup_logger(os.environ.get('LOGGING_LEVEL', logging.DEBUG))
retry_count = 0
max_retries = 5
Expand Down
2 changes: 1 addition & 1 deletion tools_for_todoist/models/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def raw(self):
return self._raw

@staticmethod
def from_raw(todoist, raw):
def from_raw(todoist, raw) -> 'TodoistItem':
item = TodoistItem(todoist, raw['content'], raw['project_id'])
item.id = raw['id']
item.update_from_raw(raw)
Expand Down
12 changes: 10 additions & 2 deletions tools_for_todoist/models/todoist.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import logging
from contextlib import ExitStack
from tempfile import TemporaryDirectory
from typing import Optional

from requests import Session
from todoist.api import SyncError, TodoistAPI
Expand All @@ -32,6 +35,7 @@

class Todoist:
def __init__(self):
self._exit_stack: Optional[ExitStack] = None
self._recreate_api()
self.api.reset_state()
self._items = {}
Expand All @@ -40,11 +44,15 @@ def __init__(self):
self._initial_sync()

def _recreate_api(self):
if self._exit_stack is not None:
self._exit_stack.close()
self._exit_stack = ExitStack()
token = get_storage().get_value(TODOIST_API_KEY)
headered_session = Session()
headered_session.headers['Authorization'] = f'Bearer {token}'

self.api = TodoistAPI(token, session=headered_session, api_version='v9')
new_temp_dir = self._exit_stack.enter_context(TemporaryDirectory())
self.api = TodoistAPI(token, session=headered_session, api_version='v9', cache=new_temp_dir)

def _activity_sync(self, offset=0, limit=100):
def activity_get_func():
Expand Down Expand Up @@ -124,7 +132,7 @@ def _update_items(self, raw_updated_items):
updated_items.append((old_item, item_model))
return {'deleted': deleted_items, 'created': new_items, 'updated': updated_items}

def get_item_by_id(self, item_id):
def get_item_by_id(self, item_id: str) -> TodoistItem:
return self._items.get(item_id)

def get_project_by_name(self, name):
Expand Down
55 changes: 55 additions & 0 deletions tools_for_todoist/services/night_owl_enabler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Copyright (C) 2020-2023 Kristian Tashkov <[email protected]>
This file is part of "Tools for Todoist".
"Tools for Todoist" is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by the
Free Software Foundation, either version 3 of the License, or (at your
option) any later version.
"Tools for Todoist" is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from datetime import datetime
from typing import Any, Dict

from dateutil.tz import gettz

from tools_for_todoist.models.google_calendar import GoogleCalendar
from tools_for_todoist.models.todoist import Todoist
from tools_for_todoist.storage import get_storage

NIGHT_OWL_DAY_SWITCH_HOUR = 'night_owl.day_switch_hour'


class NightOwlEnabler:
def __init__(self, todoist: Todoist, calendar: GoogleCalendar) -> None:
self._todoist = todoist
self._google_calendar = calendar
self._day_switch_hour = int(get_storage().get_value(NIGHT_OWL_DAY_SWITCH_HOUR, 4))

def on_todoist_sync(self, sync_result: Dict[str, Any]) -> bool:
should_sync = False
for item_id in sync_result['completed']:
item = self._todoist.get_item_by_id(item_id)
if (
item is None
or item.get_due_string() is None
or 'every day' not in item.get_due_string()
):
continue
now = datetime.now(gettz(self._google_calendar.default_timezone))
seconds_from_midnight = (now - now.replace(hour=0, minute=0, second=0)).total_seconds()
if seconds_from_midnight / 3600 > self._day_switch_hour:
continue

next_due = item.next_due_date().replace(year=now.year, month=now.month, day=now.day)
item.set_due(next_date=next_due, due_string=item.get_due_string())
should_sync |= item.save()
return should_sync
28 changes: 8 additions & 20 deletions tools_for_todoist/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,19 @@
You should have received a copy of the GNU General Public License along
with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import os
from typing import Optional

from tools_for_todoist.storage.storage import LocalKeyValueStorage, PostgresKeyValueStorage
from tools_for_todoist.storage.storage import KeyValueStorage

DEFAULT_STORAGE = os.path.join(os.path.dirname(__file__), 'store.json')
_storage = None
_storage: Optional[KeyValueStorage] = None


def get_storage():
def set_storage(storage: KeyValueStorage) -> None:
global _storage
if _storage is not None:
return _storage
_storage = storage

database_config = os.environ.get('DATABASE_URL', None)
if database_config is not None:
_storage = PostgresKeyValueStorage(database_config)
else:
_storage = LocalKeyValueStorage(os.environ.get('FILE_STORE', DEFAULT_STORAGE))

return _storage


def reinitialize_storage():
def get_storage() -> KeyValueStorage:
global _storage
if _storage is not None:
_storage.close()
_storage = None
return get_storage()
assert _storage is not None
return _storage
62 changes: 62 additions & 0 deletions tools_for_todoist/tests/mocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Copyright (C) 2020-2023 Kristian Tashkov <[email protected]>
This file is part of "Tools for Todoist".
"Tools for Todoist" is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by the
Free Software Foundation, either version 3 of the License, or (at your
option) any later version.
"Tools for Todoist" is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from contextlib import ExitStack
from typing import Any, Dict, Optional
from unittest import TestCase
from unittest.mock import MagicMock

from tools_for_todoist.models.item import TodoistItem
from tools_for_todoist.storage import set_storage
from tools_for_todoist.storage.storage import KeyValueStorage


class ServicesTestCase(TestCase):
def setUp(self) -> None:
self._exit_stack = ExitStack()

self._todoist_items: Dict[str, TodoistItem] = {}
self._todoist_mock = MagicMock()
self._todoist_mock.get_item_by_id.side_effect = lambda item_id: self._todoist_items.get(
item_id
)

self._google_calendar_mock = MagicMock()
self._google_calendar_mock.default_timezone = 'Europe/Zurich'

self._storage = KeyValueStorage()
set_storage(self._storage)

def tearDown(self) -> None:
self._exit_stack.close()

def _create_todoist_item(self, due: Optional[Dict[str, Any]] = None) -> TodoistItem:
raw_item = {
'id': 'TEST_ITEM_ID',
'project_id': 'TEST_PROJECT_ID',
'content': 'Test item',
'priority': 1,
'description': 'Test item description',
'checked': False,
'duration': None,
'labels': [],
'due': due,
}
item = TodoistItem.from_raw(self._todoist_mock, raw_item)
self._todoist_items[item.id] = item
return item
Loading

0 comments on commit 84bf974

Please sign in to comment.