feat: add Celery task infrastructure for module system

Phase 4 of module migration plan:
- Add ScheduledTask dataclass for declaring Celery Beat tasks
- Add tasks_path and scheduled_tasks fields to ModuleDefinition
- Create ModuleTask base class with database session management
- Create task discovery utilities (discover_module_tasks, build_beat_schedule)
- Update celery_config.py to discover and register module tasks
- Maintain backward compatibility with legacy task modules

Modules can now define tasks in their tasks/ directory and scheduled
tasks in their definition. The infrastructure supports gradual migration
of existing tasks from app/tasks/ to their respective modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 22:52:01 +01:00
parent 7dbdbd4c7e
commit f1f91abe51
5 changed files with 556 additions and 11 deletions

View File

@@ -26,6 +26,8 @@ Self-Contained Module Structure:
├── exceptions.py # Module-specific exceptions (optional)
├── routes/ # FastAPI routers
├── services/ # Business logic
├── tasks/ # Celery background tasks (optional)
│ └── __init__.py # Task module discovery marker
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
├── migrations/ # Alembic migrations for this module
@@ -45,6 +47,41 @@ if TYPE_CHECKING:
from models.database.admin_menu_config import FrontendType
@dataclass
class ScheduledTask:
"""
Definition of a Celery Beat scheduled task.
Used in ModuleDefinition to declare scheduled tasks that should be
registered with Celery Beat when the module is loaded.
Attributes:
name: Unique name for the schedule entry (e.g., "billing.reset_counters")
task: Full Python path to the task (e.g., "app.modules.billing.tasks.subscription.reset_period_counters")
schedule: Cron expression string or crontab dict
- String format: "minute hour day_of_month month day_of_week" (e.g., "5 0 * * *")
- Dict format: {"minute": 5, "hour": 0} for crontab kwargs
args: Positional arguments to pass to the task
kwargs: Keyword arguments to pass to the task
options: Celery task options (e.g., {"queue": "scheduled"})
Example:
ScheduledTask(
name="billing.reset_period_counters",
task="app.modules.billing.tasks.subscription.reset_period_counters",
schedule="5 0 * * *", # Daily at 00:05
options={"queue": "scheduled"},
)
"""
name: str
task: str
schedule: str | dict[str, Any]
args: tuple = ()
kwargs: dict[str, Any] = field(default_factory=dict)
options: dict[str, Any] = field(default_factory=dict)
@dataclass
class ModuleDefinition:
"""
@@ -191,6 +228,12 @@ class ModuleDefinition:
locales_path: str | None = None # Relative to module directory
migrations_path: str | None = None # Relative to module directory, e.g., "migrations"
# =========================================================================
# Celery Tasks (optional)
# =========================================================================
tasks_path: str | None = None # Python import path, e.g., "app.modules.billing.tasks"
scheduled_tasks: list[ScheduledTask] = field(default_factory=list)
# =========================================================================
# Menu Item Methods
# =========================================================================
@@ -355,7 +398,7 @@ class ModuleDefinition:
Get the Python import path for a module component.
Args:
component: One of "services", "models", "schemas", "exceptions"
component: One of "services", "models", "schemas", "exceptions", "tasks"
Returns:
Import path string, or None if not configured
@@ -365,9 +408,51 @@ class ModuleDefinition:
"models": self.models_path,
"schemas": self.schemas_path,
"exceptions": self.exceptions_path,
"tasks": self.tasks_path,
}
return paths.get(component)
def get_tasks_module(self) -> str | None:
"""
Get the Python import path for this module's tasks.
Returns the explicitly configured tasks_path, or infers it from
the module code if the module is self-contained.
Returns:
Import path string (e.g., "app.modules.billing.tasks"), or None
"""
if self.tasks_path:
return self.tasks_path
if self.is_self_contained:
dir_name = self.code.replace("-", "_")
return f"app.modules.{dir_name}.tasks"
return None
def get_tasks_dir(self) -> Path | None:
"""
Get the filesystem path to this module's tasks directory.
Returns:
Path to tasks directory, or None if not configured
"""
tasks_module = self.get_tasks_module()
if not tasks_module:
return None
return self.get_module_dir() / "tasks"
def has_tasks(self) -> bool:
"""
Check if this module has a tasks directory.
Returns:
True if tasks directory exists and contains __init__.py
"""
tasks_dir = self.get_tasks_dir()
if not tasks_dir:
return False
return tasks_dir.exists() and (tasks_dir / "__init__.py").exists()
def validate_structure(self) -> list[str]:
"""
Validate that self-contained module has expected directory structure.