2026-01-27 08:24:22 +00:00
|
|
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
|
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
# See the LICENSE file for details.
|
|
|
|
|
|
2025-04-22 12:11:58 +00:00
|
|
|
# Python imports
|
2023-04-06 17:26:36 +00:00
|
|
|
import os
|
2025-04-22 12:11:58 +00:00
|
|
|
import logging
|
2026-05-28 13:04:27 +00:00
|
|
|
from datetime import timedelta
|
2025-04-22 12:11:58 +00:00
|
|
|
|
|
|
|
|
# Third party imports
|
2023-04-06 17:26:36 +00:00
|
|
|
from celery import Celery
|
fix: Add docker pytest runner and fix bugs the suite surfaced (#9138)
* chore(api): add docker compose test runner
Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.
Prereq: ./setup.sh (generates apps/api/.env).
Usage:
docker compose -f docker-compose-test.yml up --build \
--abort-on-container-exit --exit-code-from api-tests
docker compose -f docker-compose-test.yml down -v
* fix(api): correct bugs surfaced by the pytest suite
Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:
- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
required project_id in the request body, but the view only ever passes
it through the URL kwarg. Cycle create/update via the public API was
returning 400 "Project ID is required". Read project_id from
serializer context (passed by the view) in addition to body/instance.
- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
out is_service=True tokens, so a user could read and modify service
tokens through the user token endpoint. The list mode and delete
already filter is_service=False; aligned the other two.
- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
scheme, so file:///etc/passwd raised "No hostname found" instead of
the documented "Only HTTP and HTTPS" error. Swapped the order so the
scheme guard matches the docstring intent.
- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
so when both are configured to different hosts (the standard local
setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
allow-list. Redirects to APP_BASE_URL then had their next_path stripped
because the host wasn't allowed. Include every configured base URL.
* chore(api): align pytest tests with current behavior, clear warnings
Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.
Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
the Project model defaults the flag to False, so cycle create/update
always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
(validate_next_path rejects values without a leading slash and returns
empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
now returns description_json; the task unconditionally writes the value
back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
char 970+ on a single line, which contains_url truncates away as ReDoS
defense (500-char per-line cap). Restructured to keep test intent intact
while staying inside the per-line window.
Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
moved jsonlogger → json; update the import / formatter path.
2026-05-25 19:51:37 +00:00
|
|
|
from pythonjsonlogger.json import JsonFormatter
|
2025-04-22 12:11:58 +00:00
|
|
|
from celery.signals import after_setup_logger, after_setup_task_logger
|
2026-05-28 13:04:27 +00:00
|
|
|
from celery.schedules import crontab, schedule
|
2023-04-06 17:26:36 +00:00
|
|
|
|
2025-04-22 12:11:58 +00:00
|
|
|
# Module imports
|
|
|
|
|
from plane.settings.redis import redis_instance
|
|
|
|
|
|
2023-04-06 17:26:36 +00:00
|
|
|
# Set the default Django settings module for the 'celery' program.
|
|
|
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
|
|
|
|
|
2023-04-08 10:16:05 +00:00
|
|
|
ri = redis_instance()
|
|
|
|
|
|
2026-05-28 13:04:27 +00:00
|
|
|
# Configurable metrics push interval (in minutes)
|
|
|
|
|
# Default: 360 (6 hours), set to 5 for development/testing
|
|
|
|
|
def _get_metrics_push_interval_minutes() -> int:
|
|
|
|
|
raw = os.environ.get("METRICS_PUSH_INTERVAL_MINUTES", "360")
|
|
|
|
|
try:
|
|
|
|
|
value = int(raw)
|
|
|
|
|
# Cap at 10,000,000 minutes to prevent timedelta(minutes=...) OverflowError
|
|
|
|
|
# on arbitrarily large inputs while still allowing multi-year intervals.
|
|
|
|
|
return value if 0 < value <= 10_000_000 else 360
|
|
|
|
|
except (ValueError, OverflowError):
|
|
|
|
|
return 360
|
|
|
|
|
|
|
|
|
|
METRICS_PUSH_INTERVAL_MINUTES = _get_metrics_push_interval_minutes()
|
|
|
|
|
|
2023-04-06 17:26:36 +00:00
|
|
|
app = Celery("plane")
|
|
|
|
|
|
|
|
|
|
# Using a string here means the worker will not have to
|
|
|
|
|
# pickle the object when using Windows.
|
|
|
|
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
|
|
|
|
2023-07-11 09:05:20 +00:00
|
|
|
app.conf.beat_schedule = {
|
2025-03-20 08:32:55 +00:00
|
|
|
# Intra day recurring jobs
|
|
|
|
|
"check-every-five-minutes-to-send-email-notifications": {
|
|
|
|
|
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
|
|
|
|
|
"schedule": crontab(minute="*/5"), # Every 5 minutes
|
|
|
|
|
},
|
2026-05-28 13:04:27 +00:00
|
|
|
"push-instance-metrics": {
|
|
|
|
|
"task": "plane.license.bgtasks.telemetry_metrics.push_instance_metrics",
|
|
|
|
|
"schedule": schedule(run_every=timedelta(minutes=METRICS_PUSH_INTERVAL_MINUTES)),
|
2025-03-20 08:32:55 +00:00
|
|
|
},
|
|
|
|
|
# Occurs once every day
|
|
|
|
|
"check-every-day-to-delete-hard-delete": {
|
|
|
|
|
"task": "plane.bgtasks.deletion_task.hard_delete",
|
|
|
|
|
"schedule": crontab(hour=0, minute=0), # UTC 00:00
|
|
|
|
|
},
|
2023-07-11 09:05:20 +00:00
|
|
|
"check-every-day-to-archive-and-close": {
|
|
|
|
|
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
|
2025-03-20 08:32:55 +00:00
|
|
|
"schedule": crontab(hour=1, minute=0), # UTC 01:00
|
2023-07-11 09:05:20 +00:00
|
|
|
},
|
2023-08-11 14:39:52 +00:00
|
|
|
"check-every-day-to-delete_exporter_history": {
|
|
|
|
|
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
|
2025-03-20 08:32:55 +00:00
|
|
|
"schedule": crontab(hour=1, minute=30), # UTC 01:30
|
2023-08-11 14:39:52 +00:00
|
|
|
},
|
2023-11-20 09:23:06 +00:00
|
|
|
"check-every-day-to-delete-file-asset": {
|
2024-10-11 14:43:38 +00:00
|
|
|
"task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset",
|
2025-03-20 08:32:55 +00:00
|
|
|
"schedule": crontab(hour=2, minute=0), # UTC 02:00
|
2024-07-29 08:59:08 +00:00
|
|
|
},
|
2024-10-22 08:51:26 +00:00
|
|
|
"check-every-day-to-delete-api-logs": {
|
2025-08-24 09:43:49 +00:00
|
|
|
"task": "plane.bgtasks.cleanup_task.delete_api_logs",
|
2025-03-20 08:32:55 +00:00
|
|
|
"schedule": crontab(hour=2, minute=30), # UTC 02:30
|
2024-10-10 09:27:42 +00:00
|
|
|
},
|
2025-08-24 09:43:49 +00:00
|
|
|
"check-every-day-to-delete-email-notification-logs": {
|
|
|
|
|
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
|
2025-09-18 14:39:01 +00:00
|
|
|
"schedule": crontab(hour=2, minute=45), # UTC 02:45
|
2025-08-24 09:43:49 +00:00
|
|
|
},
|
|
|
|
|
"check-every-day-to-delete-page-versions": {
|
|
|
|
|
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
|
2025-09-18 14:39:01 +00:00
|
|
|
"schedule": crontab(hour=3, minute=0), # UTC 03:00
|
2025-08-24 09:43:49 +00:00
|
|
|
},
|
|
|
|
|
"check-every-day-to-delete-issue-description-versions": {
|
|
|
|
|
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
|
2025-09-18 14:39:01 +00:00
|
|
|
"schedule": crontab(hour=3, minute=15), # UTC 03:15
|
|
|
|
|
},
|
|
|
|
|
"check-every-day-to-delete-webhook-logs": {
|
|
|
|
|
"task": "plane.bgtasks.cleanup_task.delete_webhook_logs",
|
|
|
|
|
"schedule": crontab(hour=3, minute=30), # UTC 03:30
|
|
|
|
|
},
|
|
|
|
|
"check-every-day-to-delete-exporter-history": {
|
|
|
|
|
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
|
|
|
|
|
"schedule": crontab(hour=3, minute=45), # UTC 03:45
|
2025-08-24 09:43:49 +00:00
|
|
|
},
|
2023-07-11 09:05:20 +00:00
|
|
|
}
|
|
|
|
|
|
2025-04-22 12:11:58 +00:00
|
|
|
|
|
|
|
|
# Setup logging
|
|
|
|
|
@after_setup_logger.connect
|
|
|
|
|
def setup_loggers(logger, *args, **kwargs):
|
2025-09-29 13:45:32 +00:00
|
|
|
formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s')
|
2025-04-22 12:11:58 +00:00
|
|
|
handler = logging.StreamHandler()
|
|
|
|
|
handler.setFormatter(fmt=formatter)
|
|
|
|
|
logger.addHandler(handler)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@after_setup_task_logger.connect
|
|
|
|
|
def setup_task_loggers(logger, *args, **kwargs):
|
2025-09-29 13:45:32 +00:00
|
|
|
formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s')
|
2025-04-22 12:11:58 +00:00
|
|
|
handler = logging.StreamHandler()
|
|
|
|
|
handler.setFormatter(fmt=formatter)
|
|
|
|
|
logger.addHandler(handler)
|
|
|
|
|
|
|
|
|
|
|
2023-04-06 17:26:36 +00:00
|
|
|
# Load task modules from all registered Django app configs.
|
|
|
|
|
app.autodiscover_tasks()
|
2023-07-11 09:05:20 +00:00
|
|
|
|
2023-12-06 08:52:59 +00:00
|
|
|
app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler"
|