mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 06:40:53 -05:00
bad397fbdb
- Improve invoice model with enhanced prefix handling and validation - Enhance expense routes with better error handling and validation - Refactor PDF generator with improved template support and formatting - Update Google Calendar integration with improved error handling - Enhance scheduled tasks with better logging and reliability - Update admin routes with improved permission checks - Improve email utility with better template handling
772 lines
30 KiB
Python
772 lines
30 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 (
|
|
Invoice,
|
|
User,
|
|
TimeEntry,
|
|
Project,
|
|
BudgetAlert,
|
|
RecurringInvoice,
|
|
Quote,
|
|
ReportEmailSchedule,
|
|
Integration,
|
|
)
|
|
from app.utils.email import send_overdue_invoice_notification, send_weekly_summary, send_quote_expired_notification
|
|
from app.utils.budget_forecasting import check_budget_alerts
|
|
from app.services.scheduled_report_service import ScheduledReportService
|
|
from app.services.integration_service import IntegrationService
|
|
|
|
|
|
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 app.services.unpaid_hours_service import UnpaidHoursService
|
|
from app.models import SalesmanEmailMapping
|
|
from app.utils.email import send_email
|
|
from datetime import datetime, timedelta
|
|
|
|
# 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")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error registering scheduled tasks: {e}")
|
|
|
|
|
|
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 app.utils.timezone import local_now
|
|
from datetime import timedelta
|
|
from app.utils.email import send_quote_expiring_reminder
|
|
|
|
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')}"
|
|
)
|
|
|
|
db.session.commit()
|
|
|
|
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)
|
|
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)]}
|