From 442118fdc7c67429438aa986c56a7c9f0c0d6158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Krop=C3=A1=C4=8Dek?= Date: Mon, 3 Mar 2025 21:26:27 +0100 Subject: [PATCH] scheduler WiP --- facturio/management/commands/run_scheduler.py | 102 ++++++++++++++++++ facturio/settings/base.py | 1 + invoices/tasks.py | 2 +- pyproject.toml | 1 + uv.lock | 39 +++++++ 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 facturio/management/commands/run_scheduler.py diff --git a/facturio/management/commands/run_scheduler.py b/facturio/management/commands/run_scheduler.py new file mode 100644 index 0000000..183a999 --- /dev/null +++ b/facturio/management/commands/run_scheduler.py @@ -0,0 +1,102 @@ +import logging +import signal +from copy import deepcopy + +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger +from django.conf import settings +from django.core.management.base import BaseCommand +from django_apscheduler import util +from django_apscheduler.models import DjangoJobExecution +from django_apscheduler.models import DjangoJobStore +from django_dramatiq.models import Task + +logger = logging.getLogger(__name__) + +DEFAULT_JOB_KWARGS = { + "max_instances": 1, + "replace_existing": True, +} + +# For cron definitions use https://crontab.guru/ + +daily_cron = CronTrigger.from_crontab("0 0 * * *") +weekly_cron = CronTrigger.from_crontab("0 0 * * 0") +monthly_cron = CronTrigger.from_crontab("30 3 1 * *") +daily_export_cron = CronTrigger.from_crontab("0 5 * * *") + +PERIODIC_JOBS = [ + { + "task": "not_working.yet", + "trigger": daily_cron, + }, + +] + + +# The `close_old_connections` decorator ensures that database connections that have become +# unusable or are obsolete are closed before and after your job has run. +# You should use it to wrap any jobs that you schedule that access the Django database in any way. +@util.close_old_connections +def delete_old_job_executions(max_age: int = 604_800) -> None: + """ + This job deletes APScheduler job execution entries older than `max_age` from the database. + It helps to prevent the database from filling up with old historical records that are no + longer useful. + + :param max_age: The maximum length of time to retain historical job execution records. + Defaults to 7 days. + """ + DjangoJobExecution.objects.delete_old_job_executions(max_age) + Task.tasks.delete_old_tasks(max_age) + + +class Command(BaseCommand): + help = "Runs APScheduler." # noqa: A003 + scheduler = None + + def prepare_scheduler(self): + self.stdout.write(self.style.NOTICE("Preparing scheduler")) + self.scheduler = BlockingScheduler(timezone=settings.TIME_ZONE) + self.scheduler.add_jobstore(DjangoJobStore(), "default") + + def add_jobs(self): + self.scheduler.add_job( + delete_old_job_executions, + trigger=CronTrigger( + day_of_week="mon", hour="00", minute="00", + ), # Midnight on Monday, before the start of the next work week. + id="delete_old_job_executions", + max_instances=1, + replace_existing=True, + ) + self.stdout.write("Added weekly job: 'delete_old_job_executions'.") + + for job in PERIODIC_JOBS: + kwargs = DEFAULT_JOB_KWARGS | deepcopy(job) + task = kwargs.pop("task") + + if "id" not in kwargs: + kwargs["id"] = task + + self.scheduler.add_job(task, **kwargs) + + self.stdout.write(f"Added job: {task}") + + def handle_shutdown(self, *args, **kwargs): # noqa: ARG001 + self.stdout.write(self.style.NOTICE("Stopping scheduler...")) + self.scheduler.shutdown() + self.stdout.write(self.style.NOTICE("Scheduler shut down successfully!")) + + def handle(self, *args, **options): + self.prepare_scheduler() + self.add_jobs() + + # signal.signal(signal.SIGINT, self.handle_shutdown) + signal.signal(signal.SIGTERM, self.handle_shutdown) + + try: + self.stdout.write(self.style.NOTICE("Starting scheduler...")) + self.scheduler.start() + except KeyboardInterrupt: + self.handle_shutdown() diff --git a/facturio/settings/base.py b/facturio/settings/base.py index c4b243d..ed820ad 100644 --- a/facturio/settings/base.py +++ b/facturio/settings/base.py @@ -54,6 +54,7 @@ THIRD_PARTY_APPS = [ 'crispy_forms', 'crispy_bootstrap5', 'post_office', + 'django_apscheduler', ] INSTALLED_APPS = [ diff --git a/invoices/tasks.py b/invoices/tasks.py index db17b3f..aa7c103 100644 --- a/invoices/tasks.py +++ b/invoices/tasks.py @@ -10,7 +10,7 @@ from invoices.models import Invoice @dramatiq.actor() def generate_pdf(invoice_id: int): invoice = Invoice.objects.get(pk=invoice_id) - print_url = settings.INTERNAL_API_URL + reverse('invoices:print_invoice', kwargs=dict(invoice_id=invoice.id)) + print_url = f"{settings.INTERNAL_API_URL}{reverse('invoices:print_invoice', kwargs=dict(invoice_id=invoice.id))}" invoice_file = invoice.files.create() diff --git a/pyproject.toml b/pyproject.toml index 9ca9c27..93cc1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "ares-util>=0.3.0", "crispy-bootstrap5>=2024.10", "django>=5.1.6", + "django-apscheduler>=0.7.0", "django-crispy-forms>=2.3", "django-dramatiq>=0.13.0", "django-environ>=0.12.0", diff --git a/uv.lock b/uv.lock index 1455b32..7582e2e 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004 }, +] + [[package]] name = "ares-util" version = "0.3.0" @@ -164,6 +176,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/6f/d2c216d00975e2604b10940937b0ba6b2c2d9b3cc0cc633e414ae3f14b2e/Django-5.1.6-py3-none-any.whl", hash = "sha256:8d203400bc2952fbfb287c2bbda630297d654920c72a73cc82a9ad7926feaad5", size = 8277066 }, ] +[[package]] +name = "django-apscheduler" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apscheduler" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/6b/873899c2da113187b74f0cccdf4c16660e07bfbbcae72621c4758e0958bf/django_apscheduler-0.7.0.tar.gz", hash = "sha256:30d61a2ba98615922fc2c9782f84bba342ec0c5ed63384d686d71ea90a1a4318", size = 473051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/19/c3d2dea21a6afdc93689b9f769ff3694cac810e4a09c24ab423dd1613e6c/django_apscheduler-0.7.0-py3-none-any.whl", hash = "sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce", size = 24690 }, +] + [[package]] name = "django-crispy-forms" version = "2.3" @@ -236,6 +261,7 @@ dependencies = [ { name = "ares-util" }, { name = "crispy-bootstrap5" }, { name = "django" }, + { name = "django-apscheduler" }, { name = "django-crispy-forms" }, { name = "django-dramatiq" }, { name = "django-environ" }, @@ -257,6 +283,7 @@ requires-dist = [ { name = "ares-util", specifier = ">=0.3.0" }, { name = "crispy-bootstrap5", specifier = ">=2024.10" }, { name = "django", specifier = ">=5.1.6" }, + { name = "django-apscheduler", specifier = ">=0.7.0" }, { name = "django-crispy-forms", specifier = ">=2.3" }, { name = "django-dramatiq", specifier = ">=0.13.0" }, { name = "django-environ", specifier = ">=0.12.0" }, @@ -579,6 +606,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, ] +[[package]] +name = "tzlocal" +version = "5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/cc/11360404b20a6340b9b4ed39a3338c4af47bc63f87f6cea94dbcbde07029/tzlocal-5.3.tar.gz", hash = "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2", size = 30480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/9f/1c0b69d3abf4c65acac051ad696b8aea55afbb746dea8017baab53febb5e/tzlocal-5.3-py3-none-any.whl", hash = "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c", size = 17920 }, +] + [[package]] name = "urllib3" version = "2.3.0"