from __future__ import annotations from datetime import datetime, timedelta import pytz 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 = pytz.timezone("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) if schedule.tzinfo is not None: return schedule return JAKARTA_TZ.localize(schedule) 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["name"] for v in submission["verificators"] if v["type"] == "Pembimbing"] penguji = [v["name"] 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, name in enumerate(pembimbing, start=1): lines.append(f"📕 Pembimbing {i} : {name}") for i, name in enumerate(penguji, start=1): lines.append(f"📒 Penguji {i} : {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 # Sengaja TIDAK set `scopes=` — lihat catatan di GmailService.__init__ # (edoxid_calendar_gmail.py) soal `invalid_scope` yang muncul di # container Airflow (google-auth versi lain) saat scope refresh # diminta lebih sempit dari yang tercakup refresh_token aslinya. 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, ) 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}, ]