mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 21:00:15 -05:00
9449a46a42
- Add Linear import (GraphQL, personal API key, optional team key filter). - Centralize integration HTTP via integration_session and session_request. - Add integration_sync_context for project/task refs and custom_fields metadata. - Refactor Asana, GitHub, GitLab, Jira, Trello, ActivityWatch, and QuickBooks to use helpers. - Extend integration UI, settings, and scheduled sync behavior as needed.
934 lines
36 KiB
Python
934 lines
36 KiB
Python
"""Scheduled background tasks for the application"""
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
|
||
from flask import current_app
|
||
|
||
from app import db
|
||
from app.models import (
|
||
BudgetAlert,
|
||
Integration,
|
||
Invoice,
|
||
Project,
|
||
Quote,
|
||
RecurringInvoice,
|
||
ReportEmailSchedule,
|
||
TimeEntry,
|
||
User,
|
||
)
|
||
from app.services.integration_service import IntegrationService
|
||
from app.services.scheduled_report_service import ScheduledReportService
|
||
from app.utils.budget_forecasting import check_budget_alerts
|
||
from app.utils.email import (
|
||
send_overdue_invoice_notification,
|
||
send_quote_expired_notification,
|
||
send_remind_to_log_email,
|
||
send_weekly_summary,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def check_overdue_invoices():
|
||
"""Check for overdue invoices and send notifications
|
||
|
||
This task should be run daily to check for invoices that are past their due date
|
||
and send notifications to users who have overdue invoice notifications enabled.
|
||
"""
|
||
with current_app.app_context():
|
||
try:
|
||
logger.info("Checking for overdue invoices...")
|
||
|
||
# Get all invoices that are overdue and not paid/cancelled
|
||
today = datetime.utcnow().date()
|
||
overdue_invoices = Invoice.query.filter(
|
||
Invoice.due_date < today, Invoice.status.in_(["draft", "sent"])
|
||
).all()
|
||
|
||
logger.info(f"Found {len(overdue_invoices)} overdue invoices")
|
||
|
||
notifications_sent = 0
|
||
for invoice in overdue_invoices:
|
||
# Update invoice status to overdue if it's not already
|
||
if invoice.status != "overdue":
|
||
invoice.status = "overdue"
|
||
db.session.commit()
|
||
|
||
# Get users to notify (creator and admins)
|
||
users_to_notify = set()
|
||
|
||
# Add the invoice creator
|
||
if invoice.creator:
|
||
users_to_notify.add(invoice.creator)
|
||
|
||
# Add all admins
|
||
admins = User.query.filter_by(role="admin", is_active=True).all()
|
||
users_to_notify.update(admins)
|
||
|
||
# Send notifications
|
||
for user in users_to_notify:
|
||
if user.email and user.email_notifications and user.notification_overdue_invoices:
|
||
try:
|
||
send_overdue_invoice_notification(invoice, user)
|
||
notifications_sent += 1
|
||
logger.info(
|
||
f"Sent overdue notification for invoice {invoice.invoice_number} to {user.username}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to send notification to {user.username}: {e}")
|
||
|
||
logger.info(f"Sent {notifications_sent} overdue invoice notifications")
|
||
return notifications_sent
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking overdue invoices: {e}")
|
||
return 0
|
||
|
||
|
||
def send_weekly_summaries():
|
||
"""Send weekly time tracking summaries to users
|
||
|
||
This task should be run weekly (e.g., Sunday evening or Monday morning)
|
||
to send time tracking summaries to users who have opted in.
|
||
"""
|
||
with current_app.app_context():
|
||
try:
|
||
logger.info("Sending weekly summaries...")
|
||
|
||
# Get users who want weekly summaries
|
||
users = User.query.filter_by(
|
||
is_active=True, email_notifications=True, notification_weekly_summary=True
|
||
).all()
|
||
|
||
logger.info(f"Found {len(users)} users with weekly summaries enabled")
|
||
|
||
# Calculate date range (last 7 days)
|
||
end_date = datetime.utcnow().date()
|
||
start_date = end_date - timedelta(days=7)
|
||
|
||
summaries_sent = 0
|
||
for user in users:
|
||
if not user.email:
|
||
continue
|
||
|
||
try:
|
||
# Get time entries for this user in the past week
|
||
entries = TimeEntry.query.filter(
|
||
TimeEntry.user_id == user.id,
|
||
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
|
||
TimeEntry.start_time < datetime.combine(end_date + timedelta(days=1), datetime.min.time()),
|
||
TimeEntry.end_time.isnot(None),
|
||
).all()
|
||
|
||
if not entries:
|
||
logger.info(f"No entries for {user.username}, skipping")
|
||
continue
|
||
|
||
# Calculate hours worked
|
||
hours_worked = sum(e.duration_hours for e in entries)
|
||
|
||
# Group by project
|
||
projects_map = {}
|
||
for entry in entries:
|
||
if entry.project:
|
||
project_name = entry.project.name
|
||
if project_name not in projects_map:
|
||
projects_map[project_name] = {"name": project_name, "hours": 0}
|
||
projects_map[project_name]["hours"] += entry.duration_hours
|
||
|
||
projects_data = sorted(projects_map.values(), key=lambda x: x["hours"], reverse=True)
|
||
|
||
# Send email
|
||
send_weekly_summary(
|
||
user=user,
|
||
start_date=start_date.strftime("%Y-%m-%d"),
|
||
end_date=end_date.strftime("%Y-%m-%d"),
|
||
hours_worked=hours_worked,
|
||
projects_data=projects_data,
|
||
)
|
||
|
||
summaries_sent += 1
|
||
logger.info(f"Sent weekly summary to {user.username}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to send weekly summary to {user.username}: {e}")
|
||
|
||
logger.info(f"Sent {summaries_sent} weekly summaries")
|
||
return summaries_sent
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending weekly summaries: {e}")
|
||
return 0
|
||
|
||
|
||
def check_project_budget_alerts():
|
||
"""Check all active projects for budget alerts
|
||
|
||
This task should be run periodically (e.g., every 6 hours) to check
|
||
project budgets and create alerts when thresholds are exceeded.
|
||
"""
|
||
with current_app.app_context():
|
||
try:
|
||
logger.info("Checking project budget alerts...")
|
||
|
||
# Get all active projects with budgets
|
||
projects = Project.query.filter(Project.budget_amount.isnot(None), Project.status == "active").all()
|
||
|
||
logger.info(f"Found {len(projects)} active projects with budgets")
|
||
|
||
total_alerts_created = 0
|
||
for project in projects:
|
||
try:
|
||
# Check for budget alerts
|
||
alerts_to_create = check_budget_alerts(project.id)
|
||
|
||
# Create alerts
|
||
for alert_data in alerts_to_create:
|
||
alert = BudgetAlert.create_alert(
|
||
project_id=alert_data["project_id"],
|
||
alert_type=alert_data["type"],
|
||
budget_consumed_percent=alert_data["budget_consumed_percent"],
|
||
budget_amount=alert_data["budget_amount"],
|
||
consumed_amount=alert_data["consumed_amount"],
|
||
)
|
||
total_alerts_created += 1
|
||
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
|
||
|
||
logger.info(f"Created {total_alerts_created} budget alerts")
|
||
return total_alerts_created
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking project budget alerts: {e}")
|
||
return 0
|
||
|
||
|
||
def generate_recurring_invoices():
|
||
"""Generate invoices from active recurring invoice templates
|
||
|
||
This task should be run daily to check for recurring invoices that need to be generated.
|
||
|
||
Note: This function should be called within an app context.
|
||
Use generate_recurring_invoices_with_app() wrapper for scheduled tasks.
|
||
"""
|
||
try:
|
||
logger.info("Generating recurring invoices...")
|
||
|
||
# Get all active recurring invoices that should generate today
|
||
today = datetime.utcnow().date()
|
||
recurring_invoices = RecurringInvoice.query.filter(
|
||
RecurringInvoice.is_active == True, RecurringInvoice.next_run_date <= today
|
||
).all()
|
||
|
||
logger.info(f"Found {len(recurring_invoices)} recurring invoices to process")
|
||
|
||
invoices_generated = 0
|
||
emails_sent = 0
|
||
|
||
for recurring in recurring_invoices:
|
||
try:
|
||
# Check if we've reached the end date
|
||
if recurring.end_date and today > recurring.end_date:
|
||
logger.info(f"Recurring invoice {recurring.id} has reached end date, deactivating")
|
||
recurring.is_active = False
|
||
db.session.commit()
|
||
continue
|
||
|
||
# Generate invoice
|
||
invoice = recurring.generate_invoice()
|
||
if invoice:
|
||
db.session.commit()
|
||
invoices_generated += 1
|
||
logger.info(f"Generated invoice {invoice.invoice_number} from recurring template {recurring.name}")
|
||
|
||
# Auto-send if enabled
|
||
if recurring.auto_send and invoice.client_email:
|
||
try:
|
||
from app.utils.email import send_invoice_email
|
||
|
||
send_invoice_email(invoice, invoice.client_email, sender_user=recurring.creator)
|
||
emails_sent += 1
|
||
logger.info(f"Auto-sent invoice {invoice.invoice_number} to {invoice.client_email}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to auto-send invoice {invoice.invoice_number}: {e}")
|
||
else:
|
||
logger.warning(f"Failed to generate invoice from recurring template {recurring.id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing recurring invoice {recurring.id}: {e}")
|
||
db.session.rollback()
|
||
|
||
logger.info(f"Generated {invoices_generated} invoices, sent {emails_sent} emails")
|
||
return invoices_generated
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error generating recurring invoices: {e}")
|
||
return 0
|
||
|
||
|
||
def send_monthly_unpaid_hours_reports():
|
||
"""Send monthly unpaid hours reports split by salesman
|
||
|
||
This task runs on the first day of each month and generates
|
||
unpaid hours reports for each salesman based on their client assignments.
|
||
"""
|
||
with current_app.app_context():
|
||
try:
|
||
logger.info("Sending monthly unpaid hours reports by salesman...")
|
||
|
||
from datetime import datetime, timedelta
|
||
|
||
from app.models import SalesmanEmailMapping
|
||
from app.services.unpaid_hours_service import UnpaidHoursService
|
||
from app.utils.email import send_email
|
||
|
||
# Get last month's date range
|
||
now = datetime.now()
|
||
if now.month == 1:
|
||
last_month_start = datetime(now.year - 1, 12, 1)
|
||
last_month_end = datetime(now.year, 1, 1) - timedelta(seconds=1)
|
||
else:
|
||
last_month_start = datetime(now.year, now.month - 1, 1)
|
||
last_month_end = datetime(now.year, now.month, 1) - timedelta(seconds=1)
|
||
|
||
# Get unpaid hours grouped by salesman
|
||
unpaid_service = UnpaidHoursService()
|
||
salesman_reports = unpaid_service.get_unpaid_hours_by_salesman(
|
||
start_date=last_month_start,
|
||
end_date=last_month_end,
|
||
salesman_field_name="salesman",
|
||
)
|
||
|
||
sent_count = 0
|
||
for salesman_initial, report_data in salesman_reports.items():
|
||
if salesman_initial == "_UNASSIGNED_":
|
||
continue
|
||
|
||
# Get email for this salesman
|
||
email = SalesmanEmailMapping.get_email_for_initial(salesman_initial)
|
||
if not email:
|
||
logger.warning(f"No email mapping for salesman {salesman_initial}, skipping")
|
||
continue
|
||
|
||
# Format report data
|
||
formatted_data = {
|
||
"salesman_initial": salesman_initial,
|
||
"total_hours": report_data["total_hours"],
|
||
"total_entries": report_data["total_entries"],
|
||
"clients": report_data["clients"],
|
||
"projects": report_data["projects"],
|
||
"entries": [
|
||
{
|
||
"id": e.id,
|
||
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
|
||
"project": e.project.name if e.project else "",
|
||
# Project.client is a string property; relationship is Project.client_obj
|
||
"client": (
|
||
(
|
||
e.project.client_obj.name
|
||
if (e.project and getattr(e.project, "client_obj", None))
|
||
else (e.project.client if e.project else "")
|
||
)
|
||
or (e.client.name if e.client else "Unknown")
|
||
),
|
||
"user": e.user.username if e.user else "",
|
||
"duration": e.duration_hours,
|
||
"notes": e.notes or "",
|
||
}
|
||
for e in report_data["entries"]
|
||
],
|
||
}
|
||
|
||
try:
|
||
send_email(
|
||
to=email,
|
||
subject=f"Monthly Unpaid Hours Report - {salesman_initial} ({last_month_start.strftime('%Y-%m-%d')} to {last_month_end.strftime('%Y-%m-%d')})",
|
||
template="email/unpaid_hours_report.html",
|
||
salesman_initial=salesman_initial,
|
||
report_data=formatted_data,
|
||
start_date=last_month_start.strftime("%Y-%m-%d"),
|
||
end_date=last_month_end.strftime("%Y-%m-%d"),
|
||
)
|
||
sent_count += 1
|
||
logger.info(f"Sent monthly unpaid hours report to {email} for {salesman_initial}")
|
||
except Exception as e:
|
||
logger.error(f"Error sending report to {email} ({salesman_initial}): {e}")
|
||
|
||
logger.info(f"Sent {sent_count} monthly unpaid hours reports")
|
||
return sent_count
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending monthly unpaid hours reports: {e}")
|
||
return 0
|
||
|
||
|
||
def register_scheduled_tasks(scheduler, app=None):
|
||
"""Register all scheduled tasks with APScheduler
|
||
|
||
Args:
|
||
scheduler: APScheduler instance
|
||
app: Flask app instance (optional, will use current_app if not provided)
|
||
"""
|
||
try:
|
||
# Check overdue invoices daily at 9 AM
|
||
scheduler.add_job(
|
||
func=check_overdue_invoices,
|
||
trigger="cron",
|
||
hour=9,
|
||
minute=0,
|
||
id="check_overdue_invoices",
|
||
name="Check for overdue invoices",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered overdue invoices check task")
|
||
|
||
# Send weekly summaries every Monday at 8 AM
|
||
scheduler.add_job(
|
||
func=send_weekly_summaries,
|
||
trigger="cron",
|
||
day_of_week="mon",
|
||
hour=8,
|
||
minute=0,
|
||
id="send_weekly_summaries",
|
||
name="Send weekly time summaries",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered weekly summaries task")
|
||
|
||
# Check budget alerts every 6 hours
|
||
scheduler.add_job(
|
||
func=check_project_budget_alerts,
|
||
trigger="cron",
|
||
hour="*/6",
|
||
minute=0,
|
||
id="check_budget_alerts",
|
||
name="Check project budget alerts",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered budget alerts check task")
|
||
|
||
# Generate recurring invoices daily at 8 AM
|
||
# Create a closure that captures the app instance
|
||
def generate_recurring_invoices_with_app():
|
||
"""Wrapper that uses the captured app instance"""
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for recurring invoices generation")
|
||
return
|
||
|
||
with app_instance.app_context():
|
||
generate_recurring_invoices()
|
||
|
||
scheduler.add_job(
|
||
func=generate_recurring_invoices_with_app,
|
||
trigger="cron",
|
||
hour=8,
|
||
minute=0,
|
||
id="generate_recurring_invoices",
|
||
name="Generate recurring invoices",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered recurring invoices generation task")
|
||
logger.info("Registered recurring invoices generation task")
|
||
|
||
# Send monthly unpaid hours reports by salesman (first day of month at 9 AM)
|
||
def send_monthly_unpaid_hours_reports_with_app():
|
||
"""Wrapper that uses the captured app instance"""
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for monthly unpaid hours reports")
|
||
return
|
||
with app_instance.app_context():
|
||
send_monthly_unpaid_hours_reports()
|
||
|
||
scheduler.add_job(
|
||
func=send_monthly_unpaid_hours_reports_with_app,
|
||
trigger="cron",
|
||
day=1,
|
||
hour=9,
|
||
minute=0,
|
||
id="send_monthly_unpaid_hours_reports",
|
||
name="Send monthly unpaid hours reports by salesman",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered monthly unpaid hours reports task")
|
||
|
||
# Retry failed webhook deliveries every 5 minutes
|
||
# Create a closure that captures the app instance
|
||
if app is None:
|
||
try:
|
||
app = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.warning("Could not get app instance for webhook retry task")
|
||
app = None
|
||
|
||
def retry_failed_webhooks_with_app():
|
||
"""Wrapper that uses the captured app instance"""
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for webhook retry")
|
||
return
|
||
|
||
with app_instance.app_context():
|
||
retry_failed_webhooks()
|
||
|
||
scheduler.add_job(
|
||
func=retry_failed_webhooks_with_app,
|
||
trigger="cron",
|
||
minute="*/5",
|
||
id="retry_failed_webhooks",
|
||
name="Retry failed webhook deliveries",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered webhook retry task")
|
||
|
||
# Check for expiring quotes daily at 9:30 AM
|
||
# Create a closure that captures the app instance
|
||
def check_expiring_quotes_with_app():
|
||
"""Wrapper that uses the captured app instance"""
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for expiring quotes check")
|
||
return
|
||
|
||
with app_instance.app_context():
|
||
check_expiring_quotes()
|
||
|
||
scheduler.add_job(
|
||
func=check_expiring_quotes_with_app,
|
||
trigger="cron",
|
||
hour=9,
|
||
minute=30,
|
||
id="check_expiring_quotes",
|
||
name="Check for expiring quotes",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered expiring quotes check task")
|
||
|
||
# Sync integrations every hour
|
||
def sync_integrations_with_app():
|
||
"""Wrapper that uses the captured app instance"""
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for integration sync")
|
||
return
|
||
|
||
with app_instance.app_context():
|
||
sync_integrations()
|
||
|
||
scheduler.add_job(
|
||
func=sync_integrations_with_app,
|
||
trigger="cron",
|
||
minute=0, # Every hour at minute 0
|
||
id="sync_integrations",
|
||
name="Sync all active integrations",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered integration sync task")
|
||
|
||
# Process scheduled reports every hour
|
||
# Create a closure that captures the app instance
|
||
def process_scheduled_reports_with_app():
|
||
"""Wrapper that uses the captured app instance"""
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for scheduled reports processing")
|
||
return
|
||
|
||
with app_instance.app_context():
|
||
process_scheduled_reports()
|
||
|
||
scheduler.add_job(
|
||
func=process_scheduled_reports_with_app,
|
||
trigger="cron",
|
||
minute=0,
|
||
id="process_scheduled_reports",
|
||
name="Process scheduled reports",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered scheduled reports task")
|
||
|
||
# Remind to log time (end-of-day reminder) – every hour, check users whose local time matches their reminder time
|
||
def process_remind_to_log_with_app():
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
logger.error("No app instance available for remind-to-log processing")
|
||
return
|
||
with app_instance.app_context():
|
||
process_remind_to_log()
|
||
|
||
scheduler.add_job(
|
||
func=process_remind_to_log_with_app,
|
||
trigger="cron",
|
||
minute=0,
|
||
id="process_remind_to_log",
|
||
name="Process remind-to-log notifications",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered remind-to-log task")
|
||
|
||
# Base telemetry heartbeat (daily) – always-on minimal install footprint
|
||
def send_base_telemetry_heartbeat_with_app():
|
||
app_instance = app
|
||
if app_instance is None:
|
||
try:
|
||
app_instance = current_app._get_current_object()
|
||
except RuntimeError:
|
||
return
|
||
with app_instance.app_context():
|
||
try:
|
||
from app.telemetry.service import send_base_heartbeat
|
||
|
||
send_base_heartbeat()
|
||
except Exception:
|
||
pass
|
||
|
||
scheduler.add_job(
|
||
func=send_base_telemetry_heartbeat_with_app,
|
||
trigger="cron",
|
||
hour=3,
|
||
minute=0,
|
||
id="send_base_telemetry_heartbeat",
|
||
name="Base telemetry heartbeat",
|
||
replace_existing=True,
|
||
)
|
||
logger.info("Registered base telemetry heartbeat task")
|
||
|
||
try:
|
||
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||
|
||
from app.telemetry.otel_setup import record_background_job_outcome
|
||
|
||
def _otel_apscheduler_listener(event):
|
||
try:
|
||
if event.code == EVENT_JOB_ERROR:
|
||
record_background_job_outcome(event.job_id, False)
|
||
elif event.code == EVENT_JOB_EXECUTED:
|
||
record_background_job_outcome(event.job_id, True)
|
||
except Exception:
|
||
pass
|
||
|
||
scheduler.add_listener(_otel_apscheduler_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
|
||
logger.info("Registered OpenTelemetry APScheduler listener")
|
||
except Exception as listener_err:
|
||
logger.debug("OpenTelemetry APScheduler listener not registered: %s", listener_err)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error registering scheduled tasks: {e}")
|
||
|
||
|
||
def process_remind_to_log():
|
||
"""Send end-of-day reminder to log time to users who have it enabled and have not logged (or logged very little) today.
|
||
|
||
Runs every hour; for each user with notification_remind_to_log and reminder_to_log_time set,
|
||
if current time in user's timezone matches the reminder hour and they have < 0.5h logged today (user's local day), send email.
|
||
"""
|
||
from datetime import time as dt_time
|
||
from datetime import timezone as tz_utc
|
||
|
||
from app.utils.timezone import get_timezone_for_user, now_in_user_timezone
|
||
|
||
try:
|
||
users = User.query.filter(
|
||
User.is_active == True,
|
||
User.email_notifications == True,
|
||
User.notification_remind_to_log == True,
|
||
User.reminder_to_log_time.isnot(None),
|
||
User.reminder_to_log_time != "",
|
||
).all()
|
||
if not users:
|
||
return 0
|
||
sent = 0
|
||
for user in users:
|
||
try:
|
||
if not user.email:
|
||
continue
|
||
user_now = now_in_user_timezone(user)
|
||
reminder_time = (user.reminder_to_log_time or "").strip()
|
||
if not reminder_time or ":" not in reminder_time:
|
||
continue
|
||
parts = reminder_time.split(":", 1)
|
||
try:
|
||
reminder_hour = int(parts[0])
|
||
reminder_min = int(parts[1]) if len(parts) > 1 else 0
|
||
except (ValueError, IndexError):
|
||
continue
|
||
if not (user_now.hour == reminder_hour and user_now.minute < 30):
|
||
continue
|
||
user_tz = get_timezone_for_user(user)
|
||
user_today = user_now.date()
|
||
start_local = datetime.combine(user_today, dt_time.min).replace(tzinfo=user_tz)
|
||
end_local = start_local + timedelta(days=1)
|
||
start_utc = start_local.astimezone(tz_utc)
|
||
end_utc = end_local.astimezone(tz_utc)
|
||
from sqlalchemy import func
|
||
|
||
total_seconds = (
|
||
db.session.query(func.coalesce(func.sum(TimeEntry.duration_seconds), 0))
|
||
.filter(
|
||
TimeEntry.user_id == user.id,
|
||
TimeEntry.start_time >= start_utc,
|
||
TimeEntry.start_time < end_utc,
|
||
TimeEntry.end_time.isnot(None),
|
||
)
|
||
.scalar()
|
||
or 0
|
||
)
|
||
today_hours = total_seconds / 3600.0
|
||
if today_hours >= 0.5:
|
||
continue
|
||
send_remind_to_log_email(user)
|
||
sent += 1
|
||
logger.info("Sent remind-to-log email to %s", user.username)
|
||
except Exception as e:
|
||
logger.error("Failed to process remind-to-log for user %s: %s", getattr(user, "username", user.id), e)
|
||
return sent
|
||
except Exception as e:
|
||
logger.error("Error in process_remind_to_log: %s", e)
|
||
return 0
|
||
|
||
|
||
def process_scheduled_reports():
|
||
"""Process scheduled reports that are due
|
||
|
||
This task should be run periodically to check for scheduled reports
|
||
that are due and send them via email.
|
||
|
||
Note: This function should be called within an app context.
|
||
Use process_scheduled_reports_with_app() wrapper for scheduled tasks.
|
||
"""
|
||
try:
|
||
logger.info("Processing scheduled reports...")
|
||
|
||
now = datetime.utcnow()
|
||
due_schedules = ReportEmailSchedule.query.filter(
|
||
ReportEmailSchedule.active == True, ReportEmailSchedule.next_run_at <= now
|
||
).all()
|
||
|
||
logger.info(f"Found {len(due_schedules)} scheduled reports due")
|
||
|
||
service = ScheduledReportService()
|
||
processed = 0
|
||
|
||
for schedule in due_schedules:
|
||
try:
|
||
result = service.generate_and_send_report(schedule.id)
|
||
if result["success"]:
|
||
processed += 1
|
||
logger.info(f"Sent scheduled report {schedule.id} to {result['sent_count']} recipients")
|
||
else:
|
||
logger.error(f"Error sending scheduled report {schedule.id}: {result['message']}")
|
||
except Exception as e:
|
||
logger.error(f"Error processing scheduled report {schedule.id}: {e}")
|
||
|
||
logger.info(f"Processed {processed} scheduled reports")
|
||
return processed
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing scheduled reports: {e}")
|
||
return 0
|
||
|
||
|
||
def retry_failed_webhooks():
|
||
"""Retry failed webhook deliveries
|
||
|
||
This task should be run periodically to retry webhook deliveries
|
||
that have failed and are scheduled for retry.
|
||
|
||
Note: This function should be called within an app context.
|
||
Use retry_failed_webhooks_with_app() wrapper for scheduled tasks.
|
||
"""
|
||
try:
|
||
from app.utils.webhook_service import WebhookService
|
||
|
||
retried_count = WebhookService.retry_failed_deliveries(max_deliveries=100)
|
||
if retried_count > 0:
|
||
logger.info(f"Retried {retried_count} failed webhook deliveries")
|
||
except Exception as e:
|
||
logger.error(f"Error retrying failed webhooks: {e}")
|
||
|
||
|
||
def check_expiring_quotes():
|
||
"""Check for quotes expiring soon and send reminders
|
||
|
||
This task should be run daily to check for quotes that are expiring
|
||
within the next 7 days, 3 days, and 1 day, and send reminders.
|
||
|
||
Note: This function should be called within an app context.
|
||
Use check_expiring_quotes_with_app() wrapper for scheduled tasks.
|
||
"""
|
||
try:
|
||
from datetime import timedelta
|
||
|
||
from app.utils.email import send_quote_expiring_reminder
|
||
from app.utils.timezone import local_now
|
||
|
||
logger.info("Checking for expiring quotes...")
|
||
|
||
today = local_now().date()
|
||
seven_days = today + timedelta(days=7)
|
||
|
||
# Get quotes that are sent and expiring soon
|
||
expiring_quotes = Quote.query.filter(
|
||
Quote.status == "sent",
|
||
Quote.valid_until.isnot(None),
|
||
Quote.valid_until >= today,
|
||
Quote.valid_until <= seven_days,
|
||
).all()
|
||
|
||
logger.info(f"Found {len(expiring_quotes)} quotes expiring soon")
|
||
|
||
notifications_sent = 0
|
||
for quote in expiring_quotes:
|
||
if not quote.valid_until:
|
||
continue
|
||
|
||
days_until_expiry = (quote.valid_until - today).days
|
||
|
||
# Send reminders at 7 days, 3 days, and 1 day before expiration
|
||
if days_until_expiry not in [7, 3, 1]:
|
||
continue
|
||
|
||
# Get users to notify (creator and admins)
|
||
users_to_notify = set()
|
||
|
||
# Add the quote creator
|
||
if quote.creator:
|
||
users_to_notify.add(quote.creator)
|
||
|
||
# Add all admins
|
||
admins = User.query.filter_by(role="admin", is_active=True).all()
|
||
users_to_notify.update(admins)
|
||
|
||
# Send notifications
|
||
for user in users_to_notify:
|
||
if user.email and user.email_notifications:
|
||
try:
|
||
send_quote_expiring_reminder(quote, user, days_until_expiry)
|
||
notifications_sent += 1
|
||
logger.info(
|
||
f"Sent expiration reminder for quote {quote.quote_number} to {user.username} ({days_until_expiry} days remaining)"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to send reminder to {user.username}: {e}")
|
||
|
||
logger.info(f"Sent {notifications_sent} quote expiration reminders")
|
||
return notifications_sent
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking expiring quotes: {e}")
|
||
return 0
|
||
|
||
|
||
def sync_integrations():
|
||
"""Sync all active integrations
|
||
|
||
This task should be run periodically to sync data from all active integrations.
|
||
It will only sync integrations that have auto_sync enabled in their config.
|
||
"""
|
||
try:
|
||
logger.info("Starting integration sync...")
|
||
|
||
# Get all active integrations
|
||
active_integrations = Integration.query.filter_by(is_active=True).all()
|
||
|
||
logger.info(f"Found {len(active_integrations)} active integrations")
|
||
|
||
service = IntegrationService()
|
||
synced_count = 0
|
||
errors = []
|
||
|
||
for integration in active_integrations:
|
||
try:
|
||
# Check if auto_sync is enabled (default to True if not set)
|
||
config = integration.config or {}
|
||
auto_sync = config.get("auto_sync", True)
|
||
|
||
if not auto_sync:
|
||
logger.debug(f"Skipping integration {integration.id} ({integration.provider}): auto_sync disabled")
|
||
continue
|
||
|
||
# Get connector
|
||
connector = service.get_connector(integration)
|
||
if not connector:
|
||
logger.warning(f"Could not get connector for integration {integration.id} ({integration.provider})")
|
||
continue
|
||
|
||
# Perform sync
|
||
logger.info(f"Syncing integration {integration.id} ({integration.provider})...")
|
||
result = connector.sync_data(sync_type="incremental")
|
||
|
||
if result.get("success"):
|
||
synced_count += 1
|
||
# Update last sync time
|
||
integration.last_sync_at = datetime.utcnow()
|
||
integration.last_sync_status = "success"
|
||
integration.last_error = None
|
||
logger.info(
|
||
f"Successfully synced integration {integration.id} ({integration.provider}): {result.get('synced_items', 0)} items"
|
||
)
|
||
else:
|
||
errors.append(f"{integration.provider}: {result.get('message', 'Unknown error')}")
|
||
integration.last_sync_status = "error"
|
||
integration.last_error = result.get("message", "Unknown error")
|
||
logger.error(
|
||
f"Failed to sync integration {integration.id} ({integration.provider}): {result.get('message')}"
|
||
)
|
||
|
||
from app.utils.integration_sync_context import sync_result_item_count
|
||
|
||
_n = sync_result_item_count(result)
|
||
service._log_event(
|
||
integration.id,
|
||
"sync",
|
||
bool(result.get("success")),
|
||
result.get("message"),
|
||
({"synced_count": _n, "synced_items": _n, "trigger": "scheduler"} if _n or result.get("success") else {"trigger": "scheduler"}),
|
||
)
|
||
|
||
except Exception as e:
|
||
error_msg = f"Error syncing integration {integration.id} ({integration.provider}): {str(e)}"
|
||
errors.append(error_msg)
|
||
logger.error(error_msg, exc_info=True)
|
||
integration.last_sync_status = "error"
|
||
integration.last_error = str(e)
|
||
try:
|
||
service._log_event(integration.id, "sync", False, str(e), {"trigger": "scheduler"})
|
||
except Exception as log_err:
|
||
logger.warning("Could not log integration sync failure: %s", log_err)
|
||
db.session.commit()
|
||
|
||
logger.info(f"Integration sync completed. Synced {synced_count}/{len(active_integrations)} integrations")
|
||
if errors:
|
||
logger.warning(f"Integration sync errors: {', '.join(errors)}")
|
||
|
||
return {"synced": synced_count, "total": len(active_integrations), "errors": errors}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in integration sync task: {e}", exc_info=True)
|
||
return {"synced": 0, "total": 0, "errors": [str(e)]}
|