Add recurring scheduled messages (daily, weekly, monthly)
Extend scheduled messages with a recurrence field. After sending a recurring message, the scheduler automatically creates the next pending occurrence. Frontend adds repeat toggle (Once/Daily/Weekly/Monthly) and displays recurrence in the messages table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
||||
from quart import Blueprint, request, jsonify
|
||||
|
||||
from blueprints.users.decorators import admin_required
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||
|
||||
scheduled_messages_blueprint = Blueprint(
|
||||
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
|
||||
@@ -22,6 +22,7 @@ def _serialize(msg: ScheduledMessage) -> dict:
|
||||
"subject": msg.subject,
|
||||
"scheduled_at": msg.scheduled_at.isoformat(),
|
||||
"status": msg.status.value,
|
||||
"recurrence": msg.recurrence.value,
|
||||
"error_message": msg.error_message,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"updated_at": msg.updated_at.isoformat(),
|
||||
@@ -48,6 +49,8 @@ async def create_message():
|
||||
subject = (data.get("subject") or "").strip() or None
|
||||
scheduled_at_str = data.get("scheduled_at")
|
||||
|
||||
recurrence_str = data.get("recurrence", "none")
|
||||
|
||||
if not recipient or not channel or not content or not scheduled_at_str:
|
||||
return jsonify(
|
||||
{"error": "recipient, channel, content, and scheduled_at are required"}
|
||||
@@ -60,6 +63,15 @@ async def create_message():
|
||||
{"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"}
|
||||
), 400
|
||||
|
||||
try:
|
||||
recurrence_enum = Recurrence(recurrence_str)
|
||||
except ValueError:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Invalid recurrence: {recurrence_str}. Must be 'none', 'daily', 'weekly', or 'monthly'"
|
||||
}
|
||||
), 400
|
||||
|
||||
if channel_enum == MessageChannel.EMAIL and not subject:
|
||||
return jsonify({"error": "subject is required for email messages"}), 400
|
||||
|
||||
@@ -83,6 +95,7 @@ async def create_message():
|
||||
content=content,
|
||||
subject=subject,
|
||||
scheduled_at=scheduled_at,
|
||||
recurrence=recurrence_enum,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
return jsonify(_serialize(msg)), 201
|
||||
@@ -113,6 +126,11 @@ async def update_message(msg_id: str):
|
||||
msg.content = data["content"].strip()
|
||||
if "subject" in data:
|
||||
msg.subject = data["subject"].strip() or None
|
||||
if "recurrence" in data:
|
||||
try:
|
||||
msg.recurrence = Recurrence(data["recurrence"])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid recurrence: {data['recurrence']}"}), 400
|
||||
if "scheduled_at" in data:
|
||||
try:
|
||||
scheduled_at = datetime.fromisoformat(data["scheduled_at"])
|
||||
|
||||
@@ -16,6 +16,13 @@ class MessageStatus(enum.Enum):
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Recurrence(enum.Enum):
|
||||
NONE = "none"
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
class ScheduledMessage(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
recipient = fields.CharField(max_length=255)
|
||||
@@ -26,6 +33,9 @@ class ScheduledMessage(Model):
|
||||
status = fields.CharEnumField(
|
||||
enum_type=MessageStatus, max_length=10, default=MessageStatus.PENDING
|
||||
)
|
||||
recurrence = fields.CharEnumField(
|
||||
enum_type=Recurrence, max_length=10, default=Recurrence.NONE
|
||||
)
|
||||
error_message = fields.TextField(null=True)
|
||||
created_by = fields.ForeignKeyField(
|
||||
"models.User", related_name="scheduled_messages"
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL = 15
|
||||
|
||||
RECURRENCE_DELTAS = {
|
||||
Recurrence.DAILY: relativedelta(days=1),
|
||||
Recurrence.WEEKLY: relativedelta(weeks=1),
|
||||
Recurrence.MONTHLY: relativedelta(months=1),
|
||||
}
|
||||
|
||||
|
||||
async def _schedule_next_occurrence(msg: ScheduledMessage):
|
||||
"""Create the next pending occurrence for a recurring message."""
|
||||
delta = RECURRENCE_DELTAS.get(msg.recurrence)
|
||||
if not delta:
|
||||
return
|
||||
|
||||
next_at = msg.scheduled_at + delta
|
||||
# If we missed several intervals, advance until we're in the future
|
||||
now = datetime.now(timezone.utc)
|
||||
while next_at <= now:
|
||||
next_at += delta
|
||||
|
||||
await ScheduledMessage.create(
|
||||
recipient=msg.recipient,
|
||||
channel=msg.channel,
|
||||
content=msg.content,
|
||||
subject=msg.subject,
|
||||
scheduled_at=next_at,
|
||||
recurrence=msg.recurrence,
|
||||
created_by_id=msg.created_by_id,
|
||||
)
|
||||
logger.info(
|
||||
f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}"
|
||||
)
|
||||
|
||||
|
||||
async def scheduled_messages_loop():
|
||||
"""Background loop that polls for and sends due scheduled messages."""
|
||||
@@ -45,6 +78,10 @@ async def scheduled_messages_loop():
|
||||
f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}"
|
||||
)
|
||||
|
||||
# Schedule next occurrence for recurring messages
|
||||
if msg.recurrence != Recurrence.NONE:
|
||||
await _schedule_next_occurrence(msg)
|
||||
|
||||
except Exception as e:
|
||||
msg.status = MessageStatus.FAILED
|
||||
msg.error_message = str(e)
|
||||
|
||||
Reference in New Issue
Block a user