Skip to content

Commit 58e06f6

Browse files
authored
Merge pull request #13 from erwindouna/scheduler-concept
Scheduler concept
2 parents 852ade0 + 57c6584 commit 58e06f6

File tree

5 files changed

+227
-3
lines changed

5 files changed

+227
-3
lines changed

poetry.lock

Lines changed: 61 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ jinja2 = "^3.1.6"
2424
python-multipart = "^0.0.20"
2525
itsdangerous = "^2.2.0"
2626
pyjwt = "^2.10.1"
27+
apscheduler = "^3.11.0"
2728

2829
[tool.poetry.group.dev.dependencies]
2930
aresponses = "3.0.0"

scheduler.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Class to handle the scheduler workflow."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
import logging
7+
from typing import Any
8+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
9+
from apscheduler.triggers.cron import CronTrigger
10+
import asyncio
11+
12+
from clients.firefly import FireflyClient
13+
from clients.truelayer import TrueLayerClient
14+
from importer2firefly import Import2Firefly
15+
16+
from config import Config
17+
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
21+
class Scheduler:
22+
"""Class to handle the scheduler workflow."""
23+
24+
def __init__(self, schedule: str | None = None) -> None:
25+
"""Initialize the Scheduler class."""
26+
self._config: Config = Config()
27+
self._scheduler: AsyncIOScheduler = AsyncIOScheduler()
28+
self._import_job = None
29+
self._schedule: str | None = schedule or self._config.get("import_schedule")
30+
31+
def start(
32+
self, truelayer_client: TrueLayerClient, firefly_client: FireflyClient
33+
) -> None:
34+
"""Start the scheduler."""
35+
_LOGGER.info("Starting the scheduler, with schedule: %s", self._schedule)
36+
if self._schedule is None:
37+
_LOGGER.warning("No schedule set, not starting the scheduler")
38+
return
39+
40+
if self._import_job:
41+
self._scheduler.remove_job(self._import_job.id)
42+
43+
loop = asyncio.get_event_loop()
44+
45+
def run_import() -> None:
46+
"""Run the import job."""
47+
start_time = datetime.now()
48+
_LOGGER.info("Running import job, started at %s", start_time)
49+
importer = Import2Firefly(truelayer_client, firefly_client)
50+
asyncio.run_coroutine_threadsafe(importer.start_import().__anext__(), loop)
51+
end_time = datetime.now()
52+
elapsed_time = end_time - start_time
53+
_LOGGER.info("Import job completed elapsed time: %s", elapsed_time)
54+
55+
self._import_job = self._scheduler.add_job(
56+
run_import,
57+
trigger=CronTrigger.from_crontab(self._schedule),
58+
id="import_job",
59+
replace_existing=True,
60+
)
61+
self._scheduler.start()
62+
_LOGGER.info("Scheduler started")
63+
64+
def set_schedule(self, schedule: str) -> None:
65+
"""Set the schedule for the import job."""
66+
self._schedule = schedule
67+
_LOGGER.info("Scheduler schedule set to: %s", self._schedule)
68+
69+
if not schedule:
70+
_LOGGER.info("Disabling the scheduler")
71+
if self._import_job:
72+
self._scheduler.remove_job(self._import_job.id)
73+
_LOGGER.info("Scheduler job removed")
74+
else:
75+
_LOGGER.warning("No import job to remove")
76+
self._import_job = None
77+
return
78+
79+
if self._import_job:
80+
self._scheduler.reschedule_job(
81+
self._import_job.id,
82+
trigger=CronTrigger.from_crontab(self._schedule),
83+
)
84+
_LOGGER.info("Scheduler job rescheduled to: %s", self._schedule)
85+
86+
def stop(self) -> None:
87+
"""Stop the scheduler."""
88+
if not self._scheduler.running:
89+
_LOGGER.warning("Scheduler is not running")
90+
return
91+
92+
self._scheduler.shutdown()
93+
_LOGGER.info("Scheduler stopped")

templates/configuration.html

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ <h5 class="modal-title" id="resetModalLabel">Confirm Reset</h5>
3232
</div>
3333
</div>
3434

35+
<div class="row">
36+
<div class="col-md-12">
37+
<h3>Scheduler</h3>
38+
<p>Set a scheduler to automatically import your transactions.</p>
39+
<hr>
40+
<form action="/set-schedule" method="post">
41+
<div class="mb-3">
42+
<label for="schedule" class="form-label">Scheduler</label>
43+
<select class="form-select" id="schedule" name="schedule">
44+
<option value="">Disabled</option>
45+
<option value="*/1 * * * *">Every minute</option>
46+
<option value="*/5 * * * *">Every 5 minutes</option>
47+
<option value="*/10 * * * *">Every 10 minutes</option>
48+
<option value="*/15 * * * *">Every 15 minutes</option>
49+
<option value="*/30 * * * *">Every 30 minutes</option>
50+
<option value="0 * * * *">Every hour</option>
51+
<option value="0 0 * * *">Every day at midnight</option>
52+
<option value="0 0 * * 0">Every week at midnight</option>
53+
</select>
54+
</div>
55+
<button type="submit" class="btn btn-primary">Save Scheduler</button>
56+
</form>
57+
</div>
58+
</div>
59+
<hr>
60+
3561
<div class="row">
3662
<div class="col-md-6">
3763
<div class="card mb-4">
@@ -107,4 +133,4 @@ <h5 class="modal-title" id="resetModalLabel">Confirm Reset</h5>
107133
</div>
108134
</div>
109135

110-
{% endblock %}
136+
{% endblock %}

truelayer2firefly.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@
1717
import logging
1818
from contextlib import asynccontextmanager
1919
from starlette.middleware.sessions import SessionMiddleware
20+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
21+
from apscheduler.triggers.cron import CronTrigger
22+
2023

2124
from clients.firefly import FireflyClient
2225
from clients.truelayer import TrueLayerClient
26+
from scheduler import Scheduler
2327
from config import Config
2428
from exception_handlers import (
2529
truelayer_authorization_error_handler,
@@ -63,6 +67,14 @@ async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]:
6367
)
6468
_LOGGER.info("Firefly client initialized")
6569

70+
application.state.scheduler = Scheduler()
71+
_LOGGER.info("Scheduler initialized")
72+
73+
application.state.scheduler.start(
74+
application.state.truelayer_client, application.state.firefly_client
75+
)
76+
_LOGGER.info("Scheduling started")
77+
6678
yield
6779

6880
if client := application.state.truelayer_client:
@@ -73,6 +85,10 @@ async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]:
7385
await client.close()
7486
_LOGGER.info("Firefly client closed")
7587

88+
if scheduler := application.state.scheduler:
89+
scheduler.stop()
90+
_LOGGER.info("Scheduler stopped")
91+
7692
_LOGGER.info("Application shutdown complete")
7793

7894

@@ -97,6 +113,14 @@ async def get_firefly_client() -> FireflyClient:
97113
return client
98114

99115

116+
async def get_scheduler() -> Scheduler:
117+
"""Get the scheduler from the application state."""
118+
scheduler = app.state.scheduler
119+
if not scheduler:
120+
raise RuntimeError("Scheduler is not initialized.")
121+
return scheduler
122+
123+
100124
@app.get("/", response_class=HTMLResponse)
101125
async def index(request: Request):
102126
"""Render the index page."""
@@ -349,6 +373,27 @@ async def reset_configuration(request: Request):
349373
return RedirectResponse(str(request.url_for("index")), status_code=302)
350374

351375

376+
@app.post("/set-schedule")
377+
async def set_schedule(
378+
request: Request,
379+
schedule: str = Form(...),
380+
scheduler: Scheduler = Depends(get_scheduler),
381+
) -> RedirectResponse:
382+
"""Set the import schedule."""
383+
_LOGGER.info("Setting schedule to %s", schedule)
384+
config.set("import_schedule", schedule)
385+
386+
try:
387+
scheduler.set_schedule(schedule)
388+
except Exception as e:
389+
_LOGGER.error("Error setting schedule: %s", e)
390+
return JSONResponse(
391+
status_code=500,
392+
content={"error": "Error setting schedule", "details": str(e)},
393+
)
394+
return RedirectResponse(str(request.url_for("index")), status_code=302)
395+
396+
352397
app.add_exception_handler(
353398
TrueLayer2FireflyAuthorizationError, truelayer_authorization_error_handler
354399
)

0 commit comments

Comments
 (0)