Генерация файлов .ics с расписанием занятий для студентов ИТМО программы Искусственный интеллект.
import logging


class Logger(logging.Formatter):
    """
    Add colors and formatter for logger
    """

    yellow = "\x1b[33m"
    red = "\x1b[31m"
    white = "\x1b[37m"
    blue = "\x1b[34m"
    reset = "\x1b[0m"
    _format = "%(asctime)s - %(levelname)s - (%(filename)s:%(lineno)d) %(message)s"
    formats = {
        logging.DEBUG: white + _format + reset,
        logging.WARNING: yellow + _format + reset,
        logging.INFO: blue + _format + reset,
        logging.ERROR: red + _format + reset,
        logging.CRITICAL: red + _format + reset,
    }

    def format(self, record: logging.LogRecord) -> str:
        log_fmt = self.formats.get(record.levelno)
        formatter = logging.Formatter(log_fmt)
        return formatter.format(record)


def get_logger(name: str, level: int = logging.DEBUG) -> logging.Logger:
    logger = logging.getLogger(name)
    logger.setLevel(level)
    logger.propagate = False

    console_handler = logging.StreamHandler()
    console_handler.setLevel(level)
    console_handler.setFormatter(Logger())
    logger.addHandler(console_handler)
    return logger

src/logger.py
Samoed/itmo-ai-timetable-ics
import argparse
import os

from src.logger import get_logger
from src.timetable_file import TimetableFile
from src.transform_ics import export_ics

logger = get_logger(__name__)


def parse_arguments() -> tuple[str, str, int]:
    parser = argparse.ArgumentParser(description="Обработка excel в ics")
    parser.add_argument("--file", help="Путь к файлу excel", default="../Расписание 1 курс 23_24.xlsx", type=str)
    parser.add_argument("--output", help="Папка для экспорта ics", default="../ics", type=str)
    parser.add_argument("--sheet", help="Страница с расписанием в excel файле", default=1, type=int)
    args = parser.parse_args()
    return args.file, args.output, args.sheet


def main() -> None:
    logger.info("Start")
    filepath, output_path, sheet_num = parse_arguments()
    timetable = TimetableFile(filepath, sheet_num)
    df = timetable.parse()
    if not os.path.exists(output_path):
        os.mkdir(output_path)
    export_ics(df, output_path)
    logger.info(f"Files exported to {output_path}")


if __name__ == "__main__":
    main()

src/main.py
Samoed/itmo-ai-timetable-ics
from datetime import datetime

import openpyxl
import pytz
from openpyxl.cell.cell import Cell, MergedCell
from openpyxl.worksheet.merge import MergedCellRange

from src.logger import get_logger

logger = get_logger(__name__)
moscow_tz = pytz.timezone("Europe/Moscow")


class TimetableFile:
    DAYS_COLUMN = 2  # Column B
    TIMETABLE_OFFSET = 3  # Offset between date and timetable
    TIMETABLE_LEN = 5  # Length of timetable

    def __init__(self, path: str, sheet: int):
        logger.info(f"Open file {path}")
        wb = openpyxl.load_workbook(path)
        self.sheet = wb.worksheets[sheet]

    def _get_days(self) -> list[MergedCellRange]:
        days_cells = []

        for merged_cell_range in self.sheet.merged_cells.ranges:
            if merged_cell_range.min_col == self.DAYS_COLUMN and merged_cell_range.max_col == self.DAYS_COLUMN:
                days_cells.append(merged_cell_range)
        return days_cells

    def find_mergedcell_mergerange(self, merged_cell: MergedCell) -> MergedCellRange:
        for merged_range in self.sheet.merged_cells.ranges:
            if (
                merged_range.min_col <= merged_cell.column <= merged_range.max_col
                and merged_range.min_row <= merged_cell.row <= merged_range.max_row
            ):
                return merged_range

    def get_first_cell_from_mergedcell_range(self, cell_range: MergedCellRange) -> Cell:
        for cell in cell_range.cells:
            return self.sheet.cell(row=cell[0], column=cell[1])

    def find_mergedcell_bounds(self, merged_cell: MergedCell) -> tuple[int, int, int, int]:
        merged_range = self.find_mergedcell_mergerange(merged_cell)
        return (
            merged_range.min_row,
            merged_range.min_col,
            merged_range.max_row,
            merged_range.max_col,
        )

    def pair_time(self, cell: Cell, day: datetime) -> tuple[datetime, datetime]:
        time = cell.value
        start_time, end_time = time.split("-")
        start_hour, start_minute = map(int, start_time.split(":"))
        end_hour, end_minute = map(int, end_time.split(":"))
        pair_start = day.replace(hour=start_hour, minute=start_minute).astimezone(pytz.timezone("Europe/Moscow"))
        pair_end = day.replace(hour=end_hour, minute=end_minute).astimezone(pytz.timezone("Europe/Moscow"))
        return pair_start, pair_end

    def process_cell(self, cell: Cell | MergedCell) -> tuple[str, str | None]:
        if cell.value is not None:
            link = None if cell.hyperlink is None else cell.hyperlink.target
            # Иностранный язык \nУровни А1/А2 breaking export
            return cell.value.replace("/", "\\"), link

        if type(cell) is MergedCell:
            merge_range = self.find_mergedcell_mergerange(cell)
            first_cell = self.get_first_cell_from_mergedcell_range(merge_range)
            link = None if first_cell.hyperlink is None else first_cell.hyperlink.target
            return first_cell.value.replace("/", "\\"), link

        return "", None

    def parse(self) -> list[tuple[datetime, datetime, str, str | None]]:
        logger.info("Start parse")
        df = []
        pair_start = datetime.now()
        pair_end = datetime.now()
        for day_cell in self._get_days():
            day = self.get_first_cell_from_mergedcell_range(day_cell).value

            for row in self.sheet.iter_rows(
                min_row=day_cell.min_row,
                max_row=day_cell.max_row,
                min_col=day_cell.min_col + self.TIMETABLE_OFFSET,
                max_col=day_cell.max_col + self.TIMETABLE_OFFSET + self.TIMETABLE_LEN,
            ):
                for cell_num, cell in enumerate(row):
                    if cell_num == 0:  # get time for row
                        pair_start, pair_end = self.pair_time(cell, day)
                        continue
                    title, link = self.process_cell(cell)
                    if title != "":
                        df.append((pair_start, pair_end, title, link))
        logger.info("End parse")
        return df

src/timetable_file.py
Samoed/itmo-ai-timetable-ics
from datetime import datetime

from ics import Calendar, Event  # type: ignore[attr-defined]


def export_ics(df: list[tuple[datetime, datetime, str, str | None]], path: str) -> None:
    unique_courses = {name for _, _, name, _ in df if not name.endswith(" ПА") and not name.endswith(" ПА\n")}
    for course in unique_courses:
        c = Calendar()

        for start, end, name, link in df:
            if not (name == course or name[:-3] == course or name[:-4] == course):
                continue

            e = Event(
                name=name,
                begin=start,
                end=end,
                url=link,
                description=link,
            )
            c.events.add(e)

        with open(f"{path}/{course}.ics", "w") as f:
            f.writelines(c.serialize())

src/transform_ics.py
Samoed/itmo-ai-timetable-ics