139 lines
5.5 KiB
Python
139 lines
5.5 KiB
Python
import argparse
|
|||
|
|
import shutil
|
||
|
|
import sqlite3
|
||
|
|
from datetime import date, datetime, timedelta
|
||
|
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import openpyxl
|
||
|
|
|
||
|
|
|
||
|
|
GREEN = "FFC2F1C8"
|
||
|
|
YELLOW = "FFFFFF00"
|
||
|
|
ORANGE_THEME = 5
|
||
|
|
MONTH_HEADER_ROWS = (2, 11, 20)
|
||
|
|
MONTH_START_COLUMNS = (2, 10, 18, 26)
|
||
|
|
|
||
|
|
|
||
|
|
def cell_color(cell):
|
||
|
|
color = cell.fill.fgColor
|
||
|
|
if cell.fill.fill_type != "solid":
|
||
|
|
return None
|
||
|
|
if color.type == "rgb":
|
||
|
|
return color.rgb
|
||
|
|
if color.type == "theme" and color.theme == ORANGE_THEME:
|
||
|
|
return "orange"
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def calendar_date(year, month, week_index, weekday_index):
|
||
|
|
first = date(year, month, 1)
|
||
|
|
first_sunday = first - timedelta(days=(first.weekday() + 1) % 7)
|
||
|
|
result = first_sunday + timedelta(days=week_index * 7 + weekday_index)
|
||
|
|
return result if result.month == month else None
|
||
|
|
|
||
|
|
|
||
|
|
def amount_cents(value, coordinate):
|
||
|
|
try:
|
||
|
|
amount = Decimal(str(value).strip())
|
||
|
|
if not amount.is_finite() or amount < 0:
|
||
|
|
raise InvalidOperation
|
||
|
|
return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||
|
|
except (InvalidOperation, ValueError):
|
||
|
|
raise ValueError(f"Invalid amount in {coordinate}: {value!r}") from None
|
||
|
|
|
||
|
|
|
||
|
|
def read_workbook(path):
|
||
|
|
workbook = openpyxl.load_workbook(path, data_only=True, read_only=False)
|
||
|
|
amounts = {}
|
||
|
|
statuses = {}
|
||
|
|
|
||
|
|
for sheet in workbook.worksheets:
|
||
|
|
try:
|
||
|
|
year = int(sheet.title)
|
||
|
|
except ValueError:
|
||
|
|
continue
|
||
|
|
for quarter, header_row in enumerate(MONTH_HEADER_ROWS):
|
||
|
|
for position, start_column in enumerate(MONTH_START_COLUMNS):
|
||
|
|
month = quarter * 4 + position + 1
|
||
|
|
for week_index in range(6):
|
||
|
|
row = header_row + 2 + week_index
|
||
|
|
for weekday_index in range(7):
|
||
|
|
cell = sheet.cell(row=row, column=start_column + weekday_index)
|
||
|
|
selected_date = calendar_date(year, month, week_index, weekday_index)
|
||
|
|
if selected_date is None:
|
||
|
|
continue
|
||
|
|
color = cell_color(cell)
|
||
|
|
key = selected_date.isoformat()
|
||
|
|
if color == GREEN:
|
||
|
|
# A green blank means worked, but supplies no amount to import.
|
||
|
|
if cell.value is not None and str(cell.value).strip():
|
||
|
|
amounts[key] = amount_cents(cell.value, f"{sheet.title}!{cell.coordinate}")
|
||
|
|
elif color == YELLOW:
|
||
|
|
statuses[key] = "sick"
|
||
|
|
elif color == "orange":
|
||
|
|
statuses[key] = "vacation"
|
||
|
|
return amounts, statuses
|
||
|
|
|
||
|
|
|
||
|
|
def existing_conflicts(database_path, amounts, statuses):
|
||
|
|
if not database_path.exists():
|
||
|
|
return 0, 0
|
||
|
|
with sqlite3.connect(database_path) as database:
|
||
|
|
existing_amounts = dict(database.execute("SELECT choice_date, amount_cents FROM date_choices"))
|
||
|
|
existing_statuses = dict(database.execute("SELECT status_date, day_status FROM date_statuses"))
|
||
|
|
amount_conflicts = sum(key in existing_amounts and existing_amounts[key] != value for key, value in amounts.items())
|
||
|
|
status_conflicts = sum(key in existing_statuses and existing_statuses[key] != value for key, value in statuses.items())
|
||
|
|
return amount_conflicts, status_conflicts
|
||
|
|
|
||
|
|
|
||
|
|
def import_data(database_path, amounts, statuses):
|
||
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
|
|
backup_path = database_path.with_name(f"{database_path.name}.backup-{timestamp}")
|
||
|
|
if database_path.exists():
|
||
|
|
shutil.copy2(database_path, backup_path)
|
||
|
|
|
||
|
|
with sqlite3.connect(database_path) as database:
|
||
|
|
database.executemany(
|
||
|
|
"""
|
||
|
|
INSERT INTO date_choices (choice_date, amount_cents) VALUES (?, ?)
|
||
|
|
ON CONFLICT(choice_date) DO UPDATE SET
|
||
|
|
amount_cents = excluded.amount_cents,
|
||
|
|
updated_at = CURRENT_TIMESTAMP
|
||
|
|
""",
|
||
|
|
amounts.items(),
|
||
|
|
)
|
||
|
|
database.executemany(
|
||
|
|
"""
|
||
|
|
INSERT INTO date_statuses (status_date, day_status) VALUES (?, ?)
|
||
|
|
ON CONFLICT(status_date) DO UPDATE SET
|
||
|
|
day_status = excluded.day_status,
|
||
|
|
updated_at = CURRENT_TIMESTAMP
|
||
|
|
""",
|
||
|
|
statuses.items(),
|
||
|
|
)
|
||
|
|
return backup_path if backup_path.exists() else None
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description="Import Fatima calendar data from Excel.")
|
||
|
|
parser.add_argument("workbook", type=Path)
|
||
|
|
parser.add_argument("--database", type=Path, default=Path("instance/calendar.sqlite"))
|
||
|
|
parser.add_argument("--apply", action="store_true", help="Write the data; otherwise perform a dry run.")
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
amounts, statuses = read_workbook(args.workbook)
|
||
|
|
amount_conflicts, status_conflicts = existing_conflicts(args.database, amounts, statuses)
|
||
|
|
print(f"Found {len(amounts)} amounts, {sum(v == 'vacation' for v in statuses.values())} vacation days, and {sum(v == 'sick' for v in statuses.values())} sick days.")
|
||
|
|
print(f"Existing differing values to overwrite: {amount_conflicts} amounts, {status_conflicts} statuses.")
|
||
|
|
if not args.apply:
|
||
|
|
print("Dry run only; no database changes made.")
|
||
|
|
return
|
||
|
|
|
||
|
|
backup = import_data(args.database, amounts, statuses)
|
||
|
|
print(f"Import complete. Backup: {backup}" if backup else "Import complete; no previous database existed.")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|