Files
airflow-coolify/scripts/edoxid_calendar_sync.py
T
Power BI Dev a357e0622e throttle polling outside business hours
Single DAG, single 30-min schedule instead of 5-min or two separate
DAGs. run_sync() itself decides whether to do real work: always during
business hours (default 07:00-17:00 WIB), otherwise only once per
EDOXID_OFF_HOURS_INTERVAL_MINUTES (default 120) tracked via a
last_full_sync timestamp in the sqlite state. Off-hours skips return
immediately without touching DB/Calendar/Gmail at all.
2026-07-27 22:33:04 +07:00

163 lines
6.0 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime, timedelta
import pytz
from scripts.edoxid_calendar_config import (
get_business_hours,
get_db_config,
get_google_config,
get_off_hours_interval_minutes,
get_state_sqlite_path,
get_sync_min_date,
)
from scripts.edoxid_calendar_database import Database
from scripts.edoxid_calendar_gmail import GmailService
from scripts.edoxid_calendar_google import (
GoogleCalendarService,
build_attendee_emails,
build_description,
format_submission_summary,
get_schedule_datetime,
get_type_label,
)
from scripts.edoxid_calendar_state_store import StateStore
logger = logging.getLogger(__name__)
JAKARTA_TZ = pytz.timezone("Asia/Jakarta")
def _should_run_now(now: datetime, state: StateStore) -> bool:
"""DAG tetap trigger tiap 30 menit sepanjang hari (1 DAG, 1 schedule —
lebih sederhana dari 2 DAG terpisah), tapi kerja BENERAN (baca DB, panggil
Calendar/Gmail API) cuma jalan penuh saat jam kerja, atau di luar jam kerja
kalau sudah >= EDOXID_OFF_HOURS_INTERVAL_MINUTES sejak sync terakhir."""
start, end = get_business_hours()
if start <= now.hour < end:
return True
last = state.get_last_full_sync()
if last is None:
return True
if last.tzinfo is None:
last = JAKARTA_TZ.localize(last)
return now - last >= timedelta(minutes=get_off_hours_interval_minutes())
def _check_and_send_reminders(submissions: list[dict], state: StateStore, gmail: GmailService) -> int:
"""Kirim email reminder H-1 (07:00) & 1 jam sebelum ke semua attendee —
terpisah dari reminders.overrides Calendar (yang cuma berlaku untuk
calendar TU sendiri, tidak ikut ke kalender pribadi tiap attendee)."""
# Toleransi jendela harus >= jarak terbesar antar-run BENERAN (di luar jam
# kerja), supaya H-1/1-jam tidak "kelewat" gara-gara run di antaranya di-skip.
reminder_window = timedelta(minutes=get_off_hours_interval_minutes() + 5)
now = datetime.now(JAKARTA_TZ)
sent = 0
for submission in submissions:
schedule = get_schedule_datetime(submission)
h1_time = schedule.replace(hour=7, minute=0, second=0, microsecond=0) - timedelta(days=1)
one_hour_time = schedule - timedelta(hours=1)
emails = build_attendee_emails(submission)
if not emails:
continue
if h1_time <= now < h1_time + reminder_window and not state.is_notified(submission["id"], "h1"):
subject = f"[PENGINGAT SEBELUM H-1] {get_type_label(submission)}{submission['student_name']}"
gmail.send(emails, subject, build_description(submission))
state.mark_notified(submission["id"], "h1")
sent += 1
logger.info(
"REMINDER H-1 terkirim | submission #%s | %s | penerima: %s",
submission["id"], format_submission_summary(submission), ", ".join(emails),
)
if one_hour_time <= now < one_hour_time + reminder_window and not state.is_notified(
submission["id"], "1h"
):
subject = f"[PENGINGAT 1 JAM SEBELUM] {get_type_label(submission)}{submission['student_name']}"
gmail.send(emails, subject, build_description(submission))
state.mark_notified(submission["id"], "1h")
sent += 1
logger.info(
"REMINDER 1-JAM terkirim | submission #%s | %s | penerima: %s",
submission["id"], format_submission_summary(submission), ", ".join(emails),
)
return sent
def run_sync() -> dict:
state = StateStore(get_state_sqlite_path())
now = datetime.now(JAKARTA_TZ)
if not _should_run_now(now, state):
start, end = get_business_hours()
logger.info(
"Di luar jam kerja (%02d:00-%02d:00 WIB) dan belum %s menit sejak sync terakhir — skip run ini.",
start, end, get_off_hours_interval_minutes(),
)
state.close()
return {"skipped_off_hours": True}
state.set_last_full_sync(now)
db = Database(get_db_config())
google_config = get_google_config()
calendar = GoogleCalendarService(google_config)
gmail = GmailService(google_config)
created = updated = deleted = skipped = 0
try:
submissions = db.get_scheduled_submissions(get_sync_min_date())
for submission in submissions:
content_hash = state.compute_hash(submission)
existing = state.find(submission["id"])
if existing is None:
event_id = calendar.create_event(submission)
state.save(submission["id"], event_id, content_hash)
created += 1
logger.info(
"CREATE event %s | submission #%s | %s",
event_id, submission["id"], format_submission_summary(submission),
)
elif existing["content_hash"] != content_hash:
calendar.update_event(existing["event_id"], submission)
state.update_hash(submission["id"], content_hash)
updated += 1
logger.info(
"UPDATE event %s | submission #%s | %s",
existing["event_id"], submission["id"], format_submission_summary(submission),
)
else:
skipped += 1
active_ids = db.get_ids_with_active_schedule()
for submission_id, event_id in state.find_orphaned(active_ids).items():
calendar.delete_event(event_id)
state.remove(submission_id)
deleted += 1
logger.info("DELETE submission #%s -> event %s", submission_id, event_id)
reminders_sent = _check_and_send_reminders(submissions, state, gmail)
finally:
db.close()
state.close()
summary = {
"created": created,
"updated": updated,
"deleted": deleted,
"skipped": skipped,
"reminders_sent": reminders_sent,
}
logger.info("Sync selesai: %s", summary)
return summary