add edoxid calendar sync dag

Sync jadwal sidang eDOXID ke Google Calendar "Jadwal Sidang" tiap 5 menit
(read-only ke doxid2022), plus reminder email H-1/1-jam via Gmail API
karena reminder Calendar bawaan tidak ikut ke kalender pribadi attendee.
This commit is contained in:
Power BI Dev
2026-07-27 21:59:27 +07:00
parent c6edee7aeb
commit 33f4666bad
9 changed files with 658 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from scripts.edoxid_calendar_config import GoogleConfig
JAKARTA_TZ = ZoneInfo("Asia/Jakarta")
TYPE_LABELS = {
1: "Seminar Proposal",
2: "Seminar Hasil",
3: "Sidang Akhir",
}
DAY_NAMES = {
"Monday": "Senin", "Tuesday": "Selasa", "Wednesday": "Rabu", "Thursday": "Kamis",
"Friday": "Jumat", "Saturday": "Sabtu", "Sunday": "Minggu",
}
MONTH_NAMES = {
"January": "Januari", "February": "Februari", "March": "Maret", "April": "April",
"May": "Mei", "June": "Juni", "July": "Juli", "August": "Agustus",
"September": "September", "October": "Oktober", "November": "November", "December": "Desember",
}
# --- Fungsi format, dipakai bareng oleh event Calendar & email reminder (edoxid_calendar_gmail_reminder.py) ---
def get_schedule_datetime(submission: dict) -> datetime:
schedule = submission["schedule"]
if isinstance(schedule, str):
schedule = datetime.fromisoformat(schedule)
return schedule.replace(tzinfo=JAKARTA_TZ)
def get_type_label(submission: dict) -> str:
return TYPE_LABELS.get(submission["type_id"], "Sidang")
def format_indonesian_date(date: datetime) -> str:
day = DAY_NAMES.get(date.strftime("%A"), date.strftime("%A"))
month = MONTH_NAMES.get(date.strftime("%B"), date.strftime("%B"))
return f"{day}, {date.strftime('%d')} {month} {date.strftime('%Y')}"
def build_description(submission: dict) -> str:
schedule = get_schedule_datetime(submission)
type_label = get_type_label(submission)
pembimbing = [v for v in submission["verificators"] if v["type"] == "Pembimbing"]
penguji = [v for v in submission["verificators"] if v["type"] == "Penguji"]
lines = [
f"UNDANGAN {type_label.upper()} SECARA LURING",
"JURUSAN INFORMATIKA",
"",
"Mengundang Bapak dan Ibu dosen tim pembimbing dan penguji untuk hadir pada "
f"{type_label.lower()} mahasiswa yang dilaksanakan secara Luring.",
"",
f"👨‍🎓 {submission['student_name']} - {submission['student_nim']}",
"",
f"📖 {submission['title']}",
"",
f"📅 {format_indonesian_date(schedule)}",
f"🕑 {schedule.strftime('%H:%M')} WIB",
"",
]
for i, v in enumerate(pembimbing, start=1):
lines.append(f"📕 Pembimbing {i} : {v['name']}")
for i, v in enumerate(penguji, start=1):
lines.append(f"📒 Penguji {i} : {v['name']}")
lines += [
"",
"Tempat:",
submission.get("room") or "-",
"",
"Terimakasih.",
"ttd",
"Kajur Informatika",
]
return "\n".join(lines)
def format_submission_summary(submission: dict) -> str:
"""Ringkasan 1-baris untuk log Airflow — supaya kelihatan jelas di task log
siapa yang dapat undangan/reminder tanpa perlu buka Calendar/email manual."""
schedule = get_schedule_datetime(submission)
pembimbing = [v["name"] for v in submission["verificators"] if v["type"] == "Pembimbing"]
penguji = [v["name"] for v in submission["verificators"] if v["type"] == "Penguji"]
return (
f"{submission['student_name']} ({submission['student_nim']}) — "
f"{get_type_label(submission)}\"{submission['title']}\""
f"{format_indonesian_date(schedule)} {schedule.strftime('%H:%M')} WIB "
f"@ {submission.get('room') or '-'} | "
f"Pembimbing: {', '.join(pembimbing) or '-'} | "
f"Penguji: {', '.join(penguji) or '-'}"
)
def build_attendee_emails(submission: dict) -> list[str]:
emails = [submission["student_email"]] + [v["email"] for v in submission["verificators"]]
seen = set()
deduped = []
for email in emails:
if email and email not in seen:
seen.add(email)
deduped.append(email)
return deduped
class GoogleCalendarService:
"""Auth via OAuth refresh token sebagai tu@informatika.untan.ac.id LANGSUNG
(bukan service account) — service account tidak bisa invite attendee tanpa
Domain-Wide Delegation of Authority (dicoba & ditolak Google 2026-07-27)."""
def __init__(self, config: GoogleConfig):
self._calendar_id = config.calendar_id
creds = Credentials(
token=None,
refresh_token=config.refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=config.client_id,
client_secret=config.client_secret,
scopes=["https://www.googleapis.com/auth/calendar"],
)
creds.refresh(Request())
self._service = build("calendar", "v3", credentials=creds, cache_discovery=False)
def create_event(self, submission: dict) -> str:
event = self._build_event(submission)
result = (
self._service.events()
.insert(calendarId=self._calendar_id, body=event, sendUpdates="all")
.execute()
)
return result["id"]
def update_event(self, event_id: str, submission: dict) -> None:
event = self._build_event(submission)
self._service.events().update(
calendarId=self._calendar_id, eventId=event_id, body=event, sendUpdates="all"
).execute()
def delete_event(self, event_id: str) -> None:
try:
self._service.events().delete(
calendarId=self._calendar_id, eventId=event_id, sendUpdates="all"
).execute()
except HttpError as e:
# 404/410 berarti event sudah tidak ada (mis. dihapus manual) — aman diabaikan
if e.resp.status not in (404, 410):
raise
def _build_event(self, submission: dict) -> dict:
schedule = get_schedule_datetime(submission)
end = schedule + timedelta(hours=1)
return {
"summary": f"{get_type_label(submission)}{submission['student_name']}",
"description": build_description(submission),
"location": submission.get("room") or "",
"start": {"dateTime": schedule.isoformat(), "timeZone": "Asia/Jakarta"},
"end": {"dateTime": end.isoformat(), "timeZone": "Asia/Jakarta"},
"attendees": [{"email": email} for email in build_attendee_emails(submission)],
"reminders": {"useDefault": False, "overrides": self._build_reminders(schedule)},
}
@staticmethod
def _build_reminders(schedule: datetime) -> list[dict]:
h1 = schedule.replace(hour=7, minute=0, second=0, microsecond=0) - timedelta(days=1)
minutes_h1 = max(0, int((schedule - h1).total_seconds() // 60))
return [
{"method": "popup", "minutes": minutes_h1},
{"method": "popup", "minutes": 60},
]