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