diff --git a/backend/config/settings.py b/backend/config/settings.py index bf70160..e3b46d2 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -25,7 +25,6 @@ env_file = os.path.join(BASE_DIR.parent, ".env") if os.path.exists(env_file): environ.Env.read_env(env_file, overwrite=False) - SSL_ENABLED = env("SSL_ENABLED") == "true" FRONTEND_URL = f"https://{env('FRONTEND_DOMAIN')}" if SSL_ENABLED else f"http://{env('FRONTEND_DOMAIN')}" if env("FRONTEND_PORT"): @@ -42,10 +41,10 @@ DEBUG = env("DEBUG") ALLOWED_HOSTS = [env("FRONTEND_DOMAIN")] - # Application definition INSTALLED_APPS = [ + "django_apscheduler", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", @@ -73,7 +72,6 @@ ROOT_URLCONF = "config.urls" WSGI_APPLICATION = "config.wsgi.application" - # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases @@ -125,7 +123,6 @@ EMAIL_PORT = env("EMAIL_PORT") EMAIL_USE_TLS = not DEBUG FROM_EMAIL = env("FROM_EMAIL") - # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators @@ -144,7 +141,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - # Internationalization # https://docs.djangoproject.com/en/6.0/topics/i18n/ @@ -156,10 +152,15 @@ USE_I18N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = "static/" MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" + +LOGGING = { + "version": 1, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "loggers": {"letters": {"handlers": ["console"], "level": "INFO"}}, +} diff --git a/backend/letters/apps.py b/backend/letters/apps.py index 2af728f..d420deb 100644 --- a/backend/letters/apps.py +++ b/backend/letters/apps.py @@ -1,5 +1,19 @@ +import os + from django.apps import AppConfig class LettersConfig(AppConfig): name = "letters" + + def ready(self): + """ + Start the scheduler only when the server is starting. + NOTE: If we don't check for RUN_MAIN, the scheduler triggers for all django operations (migration, test etc.) + """ + + if not (os.environ.get("RUN_MAIN") == "true" or os.environ.get("WERKZEUG_RUN_MAIN") == "true"): + return + from .tasks import start_scheduler + + start_scheduler() diff --git a/backend/letters/tasks.py b/backend/letters/tasks.py index 2d233f3..f3805f4 100644 --- a/backend/letters/tasks.py +++ b/backend/letters/tasks.py @@ -1,6 +1,7 @@ import logging from datetime import UTC, datetime +from apscheduler.schedulers.background import BackgroundScheduler from django.core.mail import send_mail from config import settings @@ -13,8 +14,7 @@ def get_vault_letters_to_notify(): """ Identifies the vault letters that have been recently unlocked and not notified """ - letters = Letter.objects.filter(unlock_at__lt=datetime.now(UTC), notified_at=None) - return letters + return Letter.objects.filter(unlock_at__lt=datetime.now(UTC), notified_at=None) def notify_unlocked_letter(letter): @@ -31,8 +31,25 @@ def notify_unlocked_letter(letter): def vault_unlock_notification_polling_scheduler(): - logger.info("Starting vault_unlock_notification_polling_scheduler") + """ + Orchestrates the vault polling logic. + """ letters_to_notify = get_vault_letters_to_notify() - print("letters_to_notify", letters_to_notify) for letter in letters_to_notify: notify_unlocked_letter(letter) + + +def start_scheduler(): + """ + Starts the background scheduler for polling and notifying vault letters. + """ + logger.info("Starting vault polling scheduler...") + scheduler = BackgroundScheduler() + scheduler.add_job( + vault_unlock_notification_polling_scheduler, + trigger="interval", + minutes=1, + id="letter_polling", + replace_existing=True, + ) + scheduler.start() diff --git a/backend/letters/tests.py b/backend/letters/tests.py index 7fa5844..431095f 100644 --- a/backend/letters/tests.py +++ b/backend/letters/tests.py @@ -327,7 +327,7 @@ class LetterTaskTest(TestCase): letter_to_notify1 = Letter.objects.create( user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None ) - with patch("tasks.send_mail") as mock_send_mail: + with patch("letters.tasks.send_mail") as mock_send_mail: notify_unlocked_letter(letter_to_notify1) mock_send_mail.assert_called_with( @@ -342,7 +342,7 @@ class LetterTaskTest(TestCase): letter_to_notify2 = Letter.objects.create( user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None ) - with patch("tasks.send_mail") as mock_send_mail: + with patch("letters.tasks.send_mail") as mock_send_mail: mock_send_mail.side_effect = Exception() notify_unlocked_letter(letter_to_notify2) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7e39043..4dfa6e5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,12 +5,15 @@ description = "Django Rest Framework for handling requests for Pi Ku app" readme = "README.md" requires-python = ">=3.14" dependencies = [ + "apscheduler>=3.11.2", "django>=6.0.4", + "django-apscheduler>=0.7.0", "django-cors-headers>=4.9.0", "django-environ>=0.13.0", "django-extensions>=4.1", "djangorestframework>=3.17.1", "djangorestframework-simplejwt>=5.5.1", + "djangorestframework-stubs>=3.16.9", "freezegun>=1.5.5", "psycopg2-binary>=2.9.11", "pyopenssl>=26.0.0", diff --git a/backend/uv.lock b/backend/uv.lock index 96f4680..5304b96 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -111,6 +123,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, ] +[[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, upload-time = "2024-09-28T04:54:09.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/19/c3d2dea21a6afdc93689b9f769ff3694cac810e4a09c24ab423dd1613e6c/django_apscheduler-0.7.0-py3-none-any.whl", hash = "sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce", size = 24690, upload-time = "2024-09-28T04:54:06.884Z" }, +] + [[package]] name = "django-cors-headers" version = "4.9.0" @@ -145,6 +170,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, ] +[[package]] +name = "django-stubs" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/b2/f0214d86180f937c8e3358ff831b20f0634d95bd77436b18861c647e15bc/django_stubs-6.0.2.tar.gz", hash = "sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd", size = 274742, upload-time = "2026-04-01T08:27:35.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e7/8f2aaa22eac7fa18db3aca0e7b651ccf5ac79a2021bf67e75a16934a7076/django_stubs-6.0.2-py3-none-any.whl", hash = "sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4", size = 538234, upload-time = "2026-04-01T08:27:33.411Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/e0/f2e6caf627d176a51fba1ca9c34082c7ea10d3f521ff2c828532ca99fa91/django_stubs_ext-6.0.2.tar.gz", hash = "sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c", size = 6751, upload-time = "2026-04-01T08:27:01.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/d2/9cb93cd1ef94ddc97c26c902ff75a859f5f154051fec98cf8242649b26ce/django_stubs_ext-6.0.2-py3-none-any.whl", hash = "sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b", size = 10446, upload-time = "2026-04-01T08:27:00.847Z" }, +] + [[package]] name = "djangorestframework" version = "3.17.1" @@ -171,6 +224,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, ] +[[package]] +name = "djangorestframework-stubs" +version = "3.16.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django-stubs" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/84/fa0e31f763ee35152a418c2a456efdd8047a9da0f5909110147b70382191/djangorestframework_stubs-3.16.9.tar.gz", hash = "sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697", size = 32798, upload-time = "2026-03-31T22:40:23.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/be/e53e3b89eaa30c21e036ae4d2ee88a92ef8cb43678400901748ddad870c5/djangorestframework_stubs-3.16.9-py3-none-any.whl", hash = "sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958", size = 57239, upload-time = "2026-03-31T22:40:22.314Z" }, +] + [[package]] name = "freezegun" version = "1.5.5" @@ -218,12 +285,15 @@ name = "piku-backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "apscheduler" }, { name = "django" }, + { name = "django-apscheduler" }, { name = "django-cors-headers" }, { name = "django-environ" }, { name = "django-extensions" }, { name = "djangorestframework" }, { name = "djangorestframework-simplejwt" }, + { name = "djangorestframework-stubs" }, { name = "freezegun" }, { name = "psycopg2-binary" }, { name = "pyopenssl" }, @@ -233,12 +303,15 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "apscheduler", specifier = ">=3.11.2" }, { name = "django", specifier = ">=6.0.4" }, + { name = "django-apscheduler", specifier = ">=0.7.0" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, { name = "django-environ", specifier = ">=0.13.0" }, { name = "django-extensions", specifier = ">=4.1" }, { name = "djangorestframework", specifier = ">=3.17.1" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, + { name = "djangorestframework-stubs", specifier = ">=3.16.9" }, { name = "freezegun", specifier = ">=1.5.5" }, { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "pyopenssl", specifier = ">=26.0.0" }, @@ -350,6 +423,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + [[package]] name = "tzdata" version = "2026.1" @@ -359,6 +450,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8"