mydocker/scripts/stefi_custody_calendar.py

564 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Stefi custody calendar generator.
Based on:
- Rozsudek 9 Nc 3731/2021-53 (prawomocny 01.06.2022):
Even ISO weeks: OJCIEC (Mon 8:00 - Mon 8:00)
Odd ISO weeks: MATKA (Mon 8:00 - Mon 8:00)
- Dohoda o uprave pomeru nezletile (effective 01.07.2023):
Vacation amendments override ISO week rule for specific holiday periods.
Usage:
python stefi_custody_calendar.py --year 2026 --credentials /path/to/gcp-service-account.json [--dry-run]
"""
import argparse
import json
import sys
from datetime import datetime, date, timedelta
from typing import Optional
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build
CALENDAR_ID = "ked0ao233h92ogj8l80b7e9a4g@group.calendar.google.com"
SCOPES = ["https://www.googleapis.com/auth/calendar"]
TZ = "Europe/Prague"
COLOR_ID = "9" # blue
EVENT_SUMMARY = "👧 Stefi z tatą"
TAG = "(custody-gen)"
OJCIEC = "OJCIEC"
MATKA = "MATKA"
# ---------------------------------------------------------------------------
# Czech school holiday dates per school year
# Source: MSMT (Ministry of Education) - update yearly
# Keys are the calendar year in which the holiday *starts*.
# ---------------------------------------------------------------------------
HOLIDAYS = {
2026: {
# Jarni prazdniny: depends on district. Velke Pritocno (Kladno) region.
# Update this each year from https://www.stredniskoly.cz/prazdniny.html
"jarni": {"start": date(2026, 2, 16), "end_school": date(2026, 2, 23)},
# Velikonoce 2026: Velky patek 3.4, Pondeli velikonocni 6.4
# Prazdniny: ctvrtek 2.4 - pondeli 6.4
"velikonoce": {
"start": date(2026, 4, 2),
"easter_monday": date(2026, 4, 6),
"end_school": date(2026, 4, 7), # utery po Vel. pondeli
},
# Letni prazdniny: always 1.7 - 31.8
"letni": {"start": date(2026, 7, 1), "end": date(2026, 8, 31)},
# First school day after summer
"first_school_sep": date(2026, 9, 1),
# Podzimni prazdniny 2026: 29-30.10
"podzimni": {"start": date(2026, 10, 29), "end_school": date(2026, 11, 2)},
# Vanocni prazdniny: 23.12 - 3.1
"vanocni": {"start": date(2026, 12, 23), "end_school": date(2027, 1, 4)},
},
2027: {
"jarni": {"start": date(2027, 2, 15), "end_school": date(2027, 2, 22)},
# Velikonoce 2027: Velky patek 26.3, Pondeli velikonocni 29.3
"velikonoce": {
"start": date(2027, 3, 25),
"easter_monday": date(2027, 3, 29),
"end_school": date(2027, 3, 30),
},
"letni": {"start": date(2027, 7, 1), "end": date(2027, 8, 31)},
"first_school_sep": date(2027, 9, 1),
"podzimni": {"start": date(2027, 10, 28), "end_school": date(2027, 11, 1)},
"vanocni": {"start": date(2027, 12, 23), "end_school": date(2028, 1, 3)},
},
2028: {
"jarni": {"start": date(2028, 2, 14), "end_school": date(2028, 2, 21)},
# Velikonoce 2028: Velky patek 14.4, Pondeli velikonocni 17.4
"velikonoce": {
"start": date(2028, 4, 13),
"easter_monday": date(2028, 4, 17),
"end_school": date(2028, 4, 18),
},
"letni": {"start": date(2028, 7, 1), "end": date(2028, 8, 31)},
"first_school_sep": date(2028, 9, 1),
"podzimni": {"start": date(2028, 10, 26), "end_school": date(2028, 10, 30)},
"vanocni": {"start": date(2028, 12, 23), "end_school": date(2029, 1, 3)},
},
}
def is_even_year(year: int) -> bool:
return year % 2 == 0
def iso_week_parent(dt: date) -> str:
"""Base rule from rozsudek: even ISO week = OJCIEC, odd = MATKA."""
iso_week = dt.isocalendar()[1]
return OJCIEC if iso_week % 2 == 0 else MATKA
def get_vacation_events(year: int) -> list[dict]:
"""
Generate vacation events per dohoda Clanek II.
Dohoda rules (calendar year parity):
a) Jarni prazdniny:
- sudy rok (even): OJCIEC, 9:00 first day - 8:00 first school day
- lichy rok (odd): MATKA
b) Velikonocni prazdniny:
- sudy rok (even): MATKA, 9:00 first day - 8:00 day after Easter Monday
- lichy rok (odd): OJCIEC
c) Letni prazdniny (always split into 4 blocks of ~15 days):
- sudy rok (even): OJCIEC 1.7-15.7, MATKA 15.7-1.8, OJCIEC 1.8-15.8, MATKA 15.8-first school day
- lichy rok (odd): MATKA 1.7-15.7, OJCIEC 15.7-1.8, MATKA 1.8-15.8, OJCIEC 15.8-first school day
All blocks: 9:00 start, 9:00 end (except last: 8:00 first school day)
d) Podzimni prazdniny:
- sudy rok (even): OJCIEC, 9:00 first day - 8:00 first school day
- lichy rok (odd): MATKA
e) Vanocni prazdniny:
- sudy rok (even): MATKA 23.12 9:00 - 26.12 10:00, OJCIEC 26.12 10:00 - 8:00 first school day
- lichy rok (odd): OJCIEC 23.12 9:00 - 26.12 10:00, MATKA 26.12 10:00 - 8:00 first school day
"""
if year not in HOLIDAYS:
print(f"WARNING: No holiday data for year {year}. Add it to HOLIDAYS dict.")
return []
h = HOLIDAYS[year]
even = is_even_year(year)
events = []
# a) Jarni prazdniny - sudy=OJCIEC, lichy=MATKA
jarni_parent = OJCIEC if even else MATKA
if jarni_parent == OJCIEC:
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Dohoda art.II.1.a | {jarni_parent} | Jarni prazdniny | "
f"{year} ({'sudy' if even else 'lichy'}) | {TAG}"
),
"start": {"dateTime": f"{h['jarni']['start']}T09:00:00", "timeZone": TZ},
"end": {"dateTime": f"{h['jarni']['end_school']}T08:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
# b) Velikonocni prazdniny - sudy=MATKA, lichy=OJCIEC (opposite to jarni!)
vel_parent = MATKA if even else OJCIEC
if vel_parent == OJCIEC:
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Dohoda art.II.1.b | {vel_parent} | Velikonocni prazdniny | "
f"{year} ({'sudy' if even else 'lichy'}) | {TAG}"
),
"start": {"dateTime": f"{h['velikonoce']['start']}T09:00:00", "timeZone": TZ},
"end": {"dateTime": f"{h['velikonoce']['end_school']}T08:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
# c) Letni prazdniny - 4 blocks
letni_start = h["letni"]["start"]
first_school = h["first_school_sep"]
blocks = [
(date(year, 7, 1), date(year, 7, 15), "9:00", "9:00"),
(date(year, 7, 15), date(year, 8, 1), "9:00", "9:00"),
(date(year, 8, 1), date(year, 8, 15), "9:00", "9:00"),
(date(year, 8, 15), first_school, "9:00", "8:00"),
]
if even:
block_parents = [OJCIEC, MATKA, OJCIEC, MATKA]
else:
block_parents = [MATKA, OJCIEC, MATKA, OJCIEC]
for i, ((bs, be, st, et), parent) in enumerate(zip(blocks, block_parents)):
if parent == OJCIEC:
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Dohoda art.II.1.c | {parent} | Letni prazdniny block {i+1} | "
f"{year} ({'sudy' if even else 'lichy'}) | {TAG}"
),
"start": {"dateTime": f"{bs}T{st}:00", "timeZone": TZ},
"end": {"dateTime": f"{be}T{et}:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
# d) Podzimni prazdniny - sudy=OJCIEC, lichy=MATKA
podz_parent = OJCIEC if even else MATKA
if podz_parent == OJCIEC:
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Dohoda art.II.1.d | {podz_parent} | Podzimni prazdniny | "
f"{year} ({'sudy' if even else 'lichy'}) | {TAG}"
),
"start": {"dateTime": f"{h['podzimni']['start']}T09:00:00", "timeZone": TZ},
"end": {"dateTime": f"{h['podzimni']['end_school']}T08:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
# e) Vanocni prazdniny - 2 blocks
vanocni_start = h["vanocni"]["start"]
vanocni_end_school = h["vanocni"]["end_school"]
xmas_split = date(year, 12, 26)
if even:
xmas_first, xmas_second = MATKA, OJCIEC
else:
xmas_first, xmas_second = OJCIEC, MATKA
if xmas_first == OJCIEC:
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Dohoda art.II.1.e | {xmas_first} | Vanocni prazdniny 1/2 | "
f"{year} ({'sudy' if even else 'lichy'}) | {TAG}"
),
"start": {"dateTime": f"{vanocni_start}T09:00:00", "timeZone": TZ},
"end": {"dateTime": f"{xmas_split}T10:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
if xmas_second == OJCIEC:
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Dohoda art.II.1.e | {xmas_second} | Vanocni prazdniny 2/2 | "
f"{year}-{year+1} ({'sudy' if even else 'lichy'}) | {TAG}"
),
"start": {"dateTime": f"{xmas_split}T10:00:00", "timeZone": TZ},
"end": {"dateTime": f"{vanocni_end_school}T08:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
return events
def date_in_vacation(dt: date, year: int) -> bool:
"""Check if a date falls within any vacation period for the given year."""
if year not in HOLIDAYS:
return False
h = HOLIDAYS[year]
# Jarni
if h["jarni"]["start"] <= dt < h["jarni"]["end_school"]:
return True
# Velikonoce
if h["velikonoce"]["start"] <= dt < h["velikonoce"]["end_school"]:
return True
# Letni
if h["letni"]["start"] <= dt <= h["letni"]["end"]:
return True
# Podzimni
if h["podzimni"]["start"] <= dt < h["podzimni"]["end_school"]:
return True
# Vanocni (spans year boundary)
if dt >= h["vanocni"]["start"]:
return True
# Also check if we're in the tail of PREVIOUS year's vanocni
prev = year - 1
if prev in HOLIDAYS:
hp = HOLIDAYS[prev]
if hp["vanocni"]["start"] <= dt < hp["vanocni"]["end_school"]:
return True
return False
def get_transition_events(year: int) -> list[dict]:
"""
Generate events for partial weeks at vacation boundaries.
When vacation starts mid-week, the days before it still belong to
the regular ISO week custody. Same when vacation ends mid-week.
Only creates events for OJCIEC (even ISO week) periods.
"""
if year not in HOLIDAYS:
return []
h = HOLIDAYS[year]
even = is_even_year(year)
events = []
# Each vacation: (start_date, start_time, end_date, end_time, label)
vacations = [
(h["jarni"]["start"], "09:00", h["jarni"]["end_school"], "08:00", "Jarni"),
(h["velikonoce"]["start"], "09:00", h["velikonoce"]["end_school"], "08:00", "Velikonoce"),
(h["letni"]["start"], "09:00", h["first_school_sep"], "08:00", "Letni"),
(h["podzimni"]["start"], "09:00", h["podzimni"]["end_school"], "08:00", "Podzimni"),
(h["vanocni"]["start"], "09:00", h["vanocni"]["end_school"], "08:00", "Vanocni"),
]
for vac_start, vac_stime, vac_end, vac_etime, label in vacations:
# Pre-stub: week_monday 8:00 -> vacation start
dow = vac_start.weekday()
if dow > 0:
week_monday = vac_start - timedelta(days=dow)
iso_week = week_monday.isocalendar()[1]
if iso_week % 2 == 0: # OJCIEC
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Rozsudek I./VI. | OJCIEC | ISO week {iso_week} "
f"(pred {label}) | {year} | {TAG}"
),
"start": {"dateTime": f"{week_monday}T08:00:00", "timeZone": TZ},
"end": {"dateTime": f"{vac_start}T{vac_stime}:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
# Post-stub: vacation end -> next Monday 8:00
dow = vac_end.weekday()
if dow > 0:
week_monday = vac_end - timedelta(days=dow)
next_monday = week_monday + timedelta(days=7)
iso_week = week_monday.isocalendar()[1]
if iso_week % 2 == 0: # OJCIEC
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Rozsudek I./VI. | OJCIEC | ISO week {iso_week} "
f"(po {label}) | {year} | {TAG}"
),
"start": {"dateTime": f"{vac_end}T{vac_etime}:00", "timeZone": TZ},
"end": {"dateTime": f"{next_monday}T08:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
return events
def get_school_week_events(year: int) -> list[dict]:
"""
Generate school week events based on rozsudek ISO week rule.
Skip weeks that overlap with vacation periods (handled by dohoda).
Each school week: single Mon 8:00 - Mon 8:00 event.
"""
events = []
# Start from first Monday of the year (or last Monday of prev year if week 1 starts there)
jan1 = date(year, 1, 1)
# Find first Monday on or after Jan 1
first_monday = jan1 + timedelta(days=(7 - jan1.weekday()) % 7)
if first_monday > date(year, 1, 7):
first_monday -= timedelta(days=7)
# Also handle: if previous year's vanocni extends into this year,
# start from the first Monday after vanocni ends
prev_year = year - 1
actual_start = first_monday
if prev_year in HOLIDAYS:
vanocni_end = HOLIDAYS[prev_year]["vanocni"]["end_school"]
if actual_start < vanocni_end:
# Move to first Monday on or after vanocni end
days_to_mon = (7 - vanocni_end.weekday()) % 7
actual_start = vanocni_end + timedelta(days=days_to_mon)
if actual_start.weekday() != 0:
actual_start += timedelta(days=(7 - actual_start.weekday()) % 7)
monday = actual_start
end_of_year = date(year, 12, 31)
while monday <= end_of_year:
next_monday = monday + timedelta(days=7)
# Check if this week is during a vacation
# We check the Monday itself - if it's in vacation, skip
check_year = monday.year
if date_in_vacation(monday, check_year):
monday = next_monday
continue
# Also skip if any day of the week falls entirely within a vacation
week_in_vacation = False
for d in range(7):
day = monday + timedelta(days=d)
if date_in_vacation(day, day.year):
week_in_vacation = True
break
if week_in_vacation:
# This week partially overlaps vacation.
# Vacation events from dohoda handle the vacation days.
# We still create a school week event for the non-vacation part,
# but for simplicity: if vacation starts mid-week, shorten the event.
# For now: skip entirely if ANY day is vacation (dohoda overrides).
monday = next_monday
continue
iso_week = monday.isocalendar()[1]
parent = OJCIEC if iso_week % 2 == 0 else MATKA
if parent == OJCIEC:
parity = "sudy"
events.append({
"summary": EVENT_SUMMARY,
"description": (
f"Rozsudek I./VI. | {parent} | ISO week {iso_week} ({parity}) | "
f"Skolni tyden 8:00-8:00 | {TAG}"
),
"start": {"dateTime": f"{monday}T08:00:00", "timeZone": TZ},
"end": {"dateTime": f"{next_monday}T08:00:00", "timeZone": TZ},
"colorId": COLOR_ID,
})
monday = next_monday
return events
def delete_generated_events(service, year: int, dry_run: bool = False) -> int:
"""
Delete all custody-gen tagged events for the given year.
Matches by (custody-gen) tag in description for reliable identification.
"""
import re
tag_re = re.compile(r"\(custody-gen\)")
time_min = f"{year}-01-01T00:00:00Z"
time_max = f"{year+1}-01-31T23:59:59Z" # include Jan of next year for vanocni
deleted = 0
page_token = None
while True:
result = service.events().list(
calendarId=CALENDAR_ID,
timeMin=time_min,
timeMax=time_max,
maxResults=250,
pageToken=page_token,
).execute()
for event in result.get("items", []):
desc = event.get("description", "") or ""
summary = event.get("summary", "") or ""
# Primary match: tag in description
# Backup match: our summary + Dohoda/Rozsudek in description
if tag_re.search(desc) or (summary == EVENT_SUMMARY and ("Dohoda" in desc or "Rozsudek" in desc)):
eid = event["id"]
if dry_run:
start = event.get("start", {}).get("dateTime", "?")
print(f" [DRY-RUN] Would delete: {start} | {desc[:60]}")
else:
service.events().delete(
calendarId=CALENDAR_ID, eventId=eid
).execute()
print(f" Deleted: {desc[:60]}")
deleted += 1
page_token = result.get("nextPageToken")
if not page_token:
break
return deleted
def create_events(service, events: list[dict], dry_run: bool = False) -> int:
"""Create events in Google Calendar."""
created = 0
for ev in events:
if dry_run:
desc = ev.get("description", "")
start = ev["start"].get("dateTime", ev["start"].get("date", ""))
end = ev["end"].get("dateTime", ev["end"].get("date", ""))
print(f" [DRY-RUN] {start} -> {end} | {desc}")
else:
service.events().insert(
calendarId=CALENDAR_ID, body=ev
).execute()
desc = ev.get("description", "")
print(f" Created: {desc[:80]}")
created += 1
return created
def main():
parser = argparse.ArgumentParser(
description="Generate Stefi custody calendar events based on rozsudek + dohoda"
)
parser.add_argument("--year", type=int, required=True, help="Calendar year (e.g. 2026)")
parser.add_argument(
"--credentials", type=str, required=True,
help="Path to GCP service account JSON"
)
parser.add_argument(
"--dry-run", action="store_true",
help="Print events without creating them"
)
parser.add_argument(
"--no-delete", action="store_true",
help="Skip deletion of old events before creating new ones"
)
parser.add_argument(
"--rollback", action="store_true",
help="Only delete generated events (by custody-gen tag), do not create new ones"
)
args = parser.parse_args()
year = args.year
# Authenticate
creds = Credentials.from_service_account_file(args.credentials, scopes=SCOPES)
service = build("calendar", "v3", credentials=creds)
# Rollback-only mode
if args.rollback:
print(f"=== ROLLBACK: Deleting generated custody events for {year} ===")
deleted = delete_generated_events(service, year, dry_run=args.dry_run)
print(f"Total deleted: {deleted}")
return
if year not in HOLIDAYS:
print(f"ERROR: Year {year} not configured in HOLIDAYS dict.")
print(f"Available years: {sorted(HOLIDAYS.keys())}")
sys.exit(1)
print(f"=== Stefi custody calendar for {year} ===")
print(f"Even year: {is_even_year(year)}")
print()
# Step 1: Delete old generated events
if not args.no_delete:
print(f"Deleting old generated events for {year}...")
deleted = delete_generated_events(service, year, dry_run=args.dry_run)
print(f" Total deleted: {deleted}")
print()
# Step 2: Generate vacation events (dohoda)
print("Generating vacation events (dohoda)...")
vacation_events = get_vacation_events(year)
print(f" {len(vacation_events)} vacation events")
# Step 3: Generate school week events (rozsudek)
print("Generating school week events (rozsudek)...")
school_events = get_school_week_events(year)
print(f" {len(school_events)} school week events")
# Step 4: Generate transition stubs (partial weeks at vacation boundaries)
print("Generating transition stubs...")
transition_events = get_transition_events(year)
print(f" {len(transition_events)} transition events")
all_events = vacation_events + school_events + transition_events
# Sort by start time
all_events.sort(key=lambda e: e["start"].get("dateTime", e["start"].get("date", "")))
print(f"\nTotal events to create: {len(all_events)}")
print()
# Step 5: Create events
print("Creating events...")
created = create_events(service, all_events, dry_run=args.dry_run)
print(f"\nDone! Created {created} events for {year}.")
# Summary
print("\n=== Summary ===")
ojciec_count = sum(1 for e in all_events if OJCIEC in e.get("description", ""))
matka_count = sum(1 for e in all_events if MATKA in e.get("description", ""))
print(f"OJCIEC events: {ojciec_count}")
print(f"MATKA events: {matka_count}")
if __name__ == "__main__":
main()