Загрузка данных
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Скрипт для автоматической сборки «Формы сбора данных для анкетирования»
из выгрузки расписания по кафедрам (43 файла Excel).
"""
from __future__ import annotations
import argparse
import logging
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
import pandas as pd
CURRENT_ACADEMIC_YEAR_SHORT = 26
ONLY_OCHNAYA = True
EXCLUDE_GRADUATING_BACHELORS_AND_MASTERS = False
DEDUPLICATE_MULTI_DEPT_TEACHERS = True
FV_DISCIPLINE_KEYWORDS = (
"физической культуре",
"физическая культура",
"физкультура",
"прикладная физическая культура",
)
EXCLUDED_DISCIPLINE_EXACT = {
"проектная деятельность",
}
EXCLUDED_DISCIPLINE_KEYWORDS = (
"учебная практика",
"производственная практика",
"преддипломная практика",
"научно-исследовательская работа",
"государственная итоговая аттестация",
"подготовка к процедуре защиты",
)
GROUP_SUFFIX_TO_FACULTY: Dict[str, str] = {
"10": "ТФ",
"0": "ФИДИЖ",
"1": "ТФ",
"2": "ФМ",
"3": "ФИТ",
"4": "ФУиГХ",
"5": "ФХТиБ",
"6": "ФЭиУ",
"7": "ПФ",
"8": "ИГРИК",
"9": "ПИШ ТЛ",
}
SPECIALIST_SUFFIX_PREFIXES: Tuple[str, ...] = (
"11", "37", "41", "45", "55", "81",
)
SPECIALIST_EXACT_GROUPS: Set[str] = {
"211-231", "211-331", "211-451", "211-521",
"211-811", "211-812", "221-331", "221-451",
}
DEPARTMENT_RULES: List[Tuple[Tuple[str, ...], str, str]] = [
(("передовая инженерная школа", "пиш тл", "fdr"), "ПИШ ТЛ", "ПИШ ТЛ"),
(("физическое воспитание", "физической культуры", "кафедра фв", "фв"), "ФВ", "ФБК"),
(("центр проектной деятельности", "цпд"), "ЦПД", "ЦПД"),
(("иллюстрация и эстамп", "ииэ"), "ИиЭ", "ИГРИК"),
(("рисунок и живопись", "риж"), "РиЖ", "ИГРИК"),
(("художественно-техническое оформление", "хтопп"), "ХТОПП", "ИГРИК"),
(("инновационные материалы принтмедиа", "имп"), "ИМП", "ПФ"),
(("полиграфические системы", "кафедра пс"), "ПС", "ПФ"),
(("технологии и управление качеством в полиграф", "тиуквпиуп"), "ТиУКвПиУП", "ПФ"),
(("динамика, прочность машин", "динамика прочность", "дпмисм"), "ДПМиСМ", "ТФ"),
(("наземные транспортные средства", "нтс"), "НТС", "ТФ"),
(("энергоустановки для транспорта", "эдтимэ"), "ЭдТиМЭ", "ТФ"),
(("дизайн",), "Дизайн", "ТФ"),
(("гуманитарные дисциплины", "кафедра гд"), "ГД", "ФБК"),
(("иностранные языки", "иностранных языков", "кафедра ия"), "ИЯ", "ФБК"),
(("высшая математика", "математика", "математики"), "Математика", "ФБК"),
(("теория механизмов", "техническая механика", "тмикм"), "ТМиКМ", "ФБК"),
(("физика", "физики"), "Физика", "ФБК"),
(("журналистика и массовые коммуникации", "жимк"), "ЖиМК", "ФИДИЖ"),
(("издательское дело и книговедение", "идик"), "ИДиК", "ФИДИЖ"),
(("русский язык и литература", "русский язык и история", "ряил"), "РЯиЛ", "ФИДИЖ"),
(("информационная безопасность", "кафедра иб"), "ИБ", "ФИТ"),
(("инфокогнитивные технологии", "икт"), "ИКТ", "ФИТ"),
(("информатика и информационные технологии", "ииит"), "ИиИТ", "ФИТ"),
(("прикладная информатика", "кафедра пи"), "ПИ", "ФИТ"),
(("смарт-технологии", "смарт технологии", "смарт"), "СМАРТ", "ФИТ"),
(("автоматика и управление", "аиу"), "АиУ", "ФМ"),
(("материаловедение",), "Материаловедение", "ФМ"),
(("машины и технология литейного", "митлп"), "МиТЛП", "ФМ"),
(("обработка материалов давлением", "омдиат"), "ОМДиАТ", "ФМ"),
(("оборудование и технологии сварочного", "отисп"), "ОТиСП", "ФМ"),
(("стандартизация, метрология и сертификация", "стандартизация метрология", "смис"), "СМиС", "ФМ"),
(("технологии и оборудование машиностроения", "технология и оборудование машиностроения", "тиом"), "ТиОМ", "ФМ"),
(("промышленное, гражданское и подземное", "промышленное гражданское", "пгпс"), "ПГПС", "ФУиГХ"),
(("промышленная теплоэнергетика", "кафедра пт"), "ПТ", "ФУиГХ"),
(("электрооборудование и промышленная электроника", "эипэ"), "ЭиПЭ", "ФУиГХ"),
(("аппаратурное оформление и автоматизация", "аоиатп"), "АОиАТП", "ФХТиБ"),
(("процессы и аппараты химической", "пахт"), "ПАХТ", "ФХТиБ"),
(("техника низких температур", "тнт"), "ТНТ", "ФХТиБ"),
(("химическая и биологическая", "химия и биотехнология", "хбт"), "ХБТ", "ФХТиБ"),
(("экологическая безопасность технических", "эбтс"), "ЭБТС", "ФХТиБ"),
(("менеджмент",), "Менеджмент", "ФЭиУ"),
(("реклама и связи с общественностью", "рисом"), "РиСОМ", "ФЭиУ"),
(("управление персоналом", "кафедра уп"), "УП", "ФЭиУ"),
(("экономика и организация", "эио"), "ЭиО", "ФЭиУ"),
]
KAFEDRA_TO_FACULTY: Dict[str, str] = {
"ИиЭ": "ИГРИК", "РиЖ": "ИГРИК", "ХТОПП": "ИГРИК", "ПИШ ТЛ": "ПИШ ТЛ",
"ИМП": "ПФ", "ПС": "ПФ", "ТиУКвПиУП": "ПФ", "ДПМиСМ": "ТФ",
"Дизайн": "ТФ", "НТС": "ТФ", "ЭдТиМЭ": "ТФ", "ГД": "ФБК",
"ИЯ": "ФБК", "Математика": "ФБК", "ТМиКМ": "ФБК", "Физика": "ФБК",
"ФВ": "ФБК", "ЖиМК": "ФИДИЖ", "ИДиК": "ФИДИЖ", "РЯиЛ": "ФИДИЖ",
"ИБ": "ФИТ", "ИКТ": "ФИТ", "ИиИТ": "ФИТ", "ПИ": "ФИТ",
"СМАРТ": "ФИТ", "АиУ": "ФМ", "Материаловедение": "ФМ", "МиТЛП": "ФМ",
"ОМДиАТ": "ФМ", "ОТиСП": "ФМ", "СМиС": "ФМ", "ТиОМ": "ФМ",
"ПГПС": "ФУиГХ", "ПТ": "ФУиГХ", "ЭиПЭ": "ФУиГХ", "АОиАТП": "ФХТиБ",
"ПАХТ": "ФХТиБ", "ТНТ": "ФХТиБ", "ХБТ": "ФХТиБ", "ЭБТС": "ФХТиБ",
"Менеджмент": "ФЭиУ", "РиСОМ": "ФЭиУ", "УП": "ФЭиУ", "ЭиО": "ФЭиУ",
"ЦПД": "ЦПД",
}
def clean_text(val: object) -> str:
if val is None or (isinstance(val, float) and pd.isna(val)):
return ""
s = str(val).replace("\xa0", " ").replace("\t", " ").replace("\r", " ").replace("\n", " ")
return re.sub(r"\s+", " ", s).strip()
def strip_kafedra_from_teacher(teacher: str) -> str:
return re.sub(r"\s*\([^)]*\)\s*$", "", clean_text(teacher)).strip()
def infer_kafedra_from_filename(filepath: Path) -> Tuple[str, str]:
stem = clean_text(filepath.stem)
stem_no_num = re.sub(r"^\d+[\.\-\s_]+", "", stem).strip()
stem_lower = stem_no_num.lower()
for code, fac in KAFEDRA_TO_FACULTY.items():
if stem_no_num.lower() == code.lower():
return code, fac
for keywords, code, fac in DEPARTMENT_RULES:
for kw in keywords:
if kw in stem_lower:
return code, fac
m = re.search(r"\(([^)]+)\)\s*$", stem_no_num)
if m:
abbr = m.group(1).strip()
if abbr in KAFEDRA_TO_FACULTY:
return abbr, KAFEDRA_TO_FACULTY[abbr]
logging.warning(f"Кафедра для файла '{filepath.name}' определена как '{stem_no_num}'")
return stem_no_num, "НЕ ОПРЕДЕЛЕН"
def parse_group_info(
group_code: str,
academic_year_short: int = CURRENT_ACADEMIC_YEAR_SHORT,
historical_group_map: Optional[Dict[str, Tuple[str, str, str]]] = None,
) -> Optional[Tuple[str, str, str, int]]:
g = clean_text(group_code)
m = re.match(r"^(\d{2})(\d)-(\d{3,4}[а-яА-Яa-zA-Z]*)$", g)
if not m:
return None
adm_year = int(m.group(1))
form_digit = m.group(2)
suffix = m.group(3)
course = academic_year_short - adm_year + 1
faculty = None
for prefix_len in (2, 1):
cand = suffix[:prefix_len]
if cand in GROUP_SUFFIX_TO_FACULTY:
faculty = GROUP_SUFFIX_TO_FACULTY[cand]
break
if not faculty:
faculty = "НЕ ОПРЕДЕЛЕН"
if form_digit in ("1", "4"):
form_edu = "Очная"
elif form_digit in ("2", "5"):
form_edu = "Очно-заочная"
elif form_digit in ("3", "6"):
form_edu = "Заочная"
else:
form_edu = f"Код {form_digit}"
if form_digit in ("4", "5", "6"):
level_edu = "Магистратура"
else:
if historical_group_map and g in historical_group_map:
_, _, hist_level = historical_group_map[g]
level_edu = hist_level
elif g in SPECIALIST_EXACT_GROUPS or suffix.startswith(SPECIALIST_SUFFIX_PREFIXES) or course >= 5:
level_edu = "Специалитет"
else:
level_edu = "Бакалавриат"
return faculty, form_edu, level_edu, course
def is_fv_discipline(discipline: str, kafedra: str) -> bool:
if kafedra == "ФВ":
return True
return any(kw in discipline.lower() for kw in FV_DISCIPLINE_KEYWORDS)
def is_excluded_discipline(discipline: str) -> bool:
d_low = discipline.lower().strip()
if d_low in EXCLUDED_DISCIPLINE_EXACT:
return True
return any(kw in d_low for kw in EXCLUDED_DISCIPLINE_KEYWORDS)
def load_historical_reference(ref_path: Optional[Path]) -> Tuple[
Dict[str, Tuple[str, str, str]],
Dict[Tuple[str, str], Tuple[str, str]],
Dict[str, Set[str]],
]:
group_map: Dict[str, Tuple[str, str, str]] = {}
teacher_disc_map: Dict[Tuple[str, str], Tuple[str, str]] = {}
teacher_kaf_map: Dict[str, Set[str]] = {}
if not ref_path or not ref_path.exists():
return group_map, teacher_disc_map, teacher_kaf_map
try:
df_ref = pd.read_excel(ref_path, sheet_name=0)
for _, row in df_ref.iterrows():
grp = clean_text(row.get("Группа"))
fac_grp = clean_text(row.get("Факультет/институт группы"))
form_edu = clean_text(row.get("Форма обучения"))
lvl_edu = clean_text(row.get("Уровень образования"))
disc = clean_text(row.get("Наименование дисциплины"))
fio = strip_kafedra_from_teacher(clean_text(row.get("Преподаватель")))
kaf = clean_text(row.get("Кафедра ") or row.get("Кафедра"))
fac_pps = clean_text(row.get("Факультет/институт ППС"))
if grp and fac_grp and lvl_edu:
group_map[grp] = (fac_grp, form_edu, lvl_edu)
if fio and disc and kaf and fac_pps:
teacher_disc_map[(fio, disc)] = (kaf, fac_pps)
if fio and kaf:
teacher_kaf_map.setdefault(fio, set()).add(kaf)
except Exception as e:
logging.warning(f"Не удалось прочитать '{ref_path}': {e}")
return group_map, teacher_disc_map, teacher_kaf_map
def read_department_file(filepath: Path) -> pd.DataFrame:
df = pd.read_excel(filepath, sheet_name=0, header=None)
if df.empty:
return pd.DataFrame(columns=["Преподаватель", "Группы", "Дисциплина", "Тип занятий"])
header_row_idx = 0
for r_idx in range(min(10, len(df))):
row_values = [clean_text(v).lower() for v in df.iloc[r_idx].tolist()]
if "преподаватель" in row_values or "группы" in row_values or "дисциплина" in row_values:
header_row_idx = r_idx
break
headers = [clean_text(v) for v in df.iloc[header_row_idx].tolist()]
data = df.iloc[header_row_idx + 1 :].copy()
data.columns = headers
col_map: Dict[str, str] = {}
for col in data.columns:
c_low = col.lower()
if "преподават" in c_low and "Преподаватель" not in col_map:
col_map["Преподаватель"] = col
elif "групп" in c_low and "Группы" not in col_map:
col_map["Группы"] = col
elif "дисциплин" in c_low and "Дисциплина" not in col_map:
col_map["Дисциплина"] = col
elif "тип занят" in c_low and "Тип занятий" not in col_map:
col_map["Тип занятий"] = col
if "Преподаватель" not in col_map and data.shape[1] > 1:
col_map["Преподаватель"] = data.columns[1]
if "Группы" not in col_map and data.shape[1] > 3:
col_map["Группы"] = data.columns[3]
if "Дисциплина" not in col_map and data.shape[1] > 8:
col_map["Дисциплина"] = data.columns[8]
if "Тип занятий" not in col_map and data.shape[1] > 7:
col_map["Тип занятий"] = data.columns[7]
out = pd.DataFrame()
out["Преподаватель"] = data[col_map["Преподаватель"]].map(clean_text) if "Преподаватель" in col_map else ""
out["Группы"] = data[col_map["Группы"]].map(clean_text) if "Группы" in col_map else ""
out["Дисциплина"] = data[col_map["Дисциплина"]].map(clean_text) if "Дисциплина" in col_map else ""
out["Тип занятий"] = data[col_map["Тип занятий"]].map(clean_text) if "Тип занятий" in col_map else ""
return out
def process_all_files(
input_dir: Path,
ref_path: Optional[Path] = None,
cpd_path: Optional[Path] = None,
academic_year_short: int = CURRENT_ACADEMIC_YEAR_SHORT,
only_ochnaya: bool = ONLY_OCHNAYA,
exclude_graduating: bool = EXCLUDE_GRADUATING_BACHELORS_AND_MASTERS,
deduplicate_multi_dept: bool = DEDUPLICATE_MULTI_DEPT_TEACHERS,
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
hist_group_map, hist_teacher_disc_map, hist_teacher_kaf_map = load_historical_reference(ref_path)
excel_files = sorted(
[
f for f in input_dir.iterdir()
if f.is_file()
and f.suffix.lower() in (".xlsx", ".xls")
and not f.name.startswith("~$")
and "форма сбора данных" not in f.name.lower()
]
)
if not excel_files:
raise FileNotFoundError(f"В папке '{input_dir.resolve()}' не найдено файлов расписания (.xlsx / .xls).")
logging.info(f"Найдено файлов кафедр для обработки: {len(excel_files)}")
raw_main_rows: List[Dict[str, str]] = []
fv_groups_set: Set[Tuple[str, str, str]] = set()
fv_teachers_set: Set[str] = set()
cpd_rows_from_schedule: List[Tuple[str, str, str]] = []
for fpath in excel_files:
kafedra, fac_pps = infer_kafedra_from_filename(fpath)
logging.info(f"Обработка: {fpath.name} -> {kafedra} ({fac_pps})")
df_raw = read_department_file(fpath)
for _, row in df_raw.iterrows():
teacher_fio = strip_kafedra_from_teacher(row["Преподаватель"])
groups_str = row["Группы"]
discipline = row["Дисциплина"]
if not discipline and not groups_str and not teacher_fio:
continue
if kafedra == "ФВ" and teacher_fio and "вакансия" not in teacher_fio.lower():
fv_teachers_set.add(teacher_fio)
if not groups_str or not discipline:
continue
group_list = [g.strip() for g in re.split(r"[|,;\s]+", groups_str) if g.strip()]
for grp in group_list:
parsed = parse_group_info(grp, academic_year_short, hist_group_map)
if not parsed:
continue
fac_group, form_edu, level_edu, course = parsed
if only_ochnaya and form_edu != "Очная":
continue
if exclude_graduating:
if level_edu == "Бакалавриат" and course >= 4:
continue
if level_edu == "Магистратура" and course >= 2:
continue
if is_fv_discipline(discipline, kafedra):
if level_edu != "Магистратура":
fv_groups_set.add((fac_group, grp, "Элективные дисциплины по физической культуре и спорту"))
if teacher_fio and "вакансия" not in teacher_fio.lower():
fv_teachers_set.add(teacher_fio)
continue
if discipline.lower().strip() == "проектная деятельность":
if teacher_fio and "вакансия" not in teacher_fio.lower():
cpd_rows_from_schedule.append((grp, "Проектная деятельность", teacher_fio))
continue
if is_excluded_discipline(discipline) or not teacher_fio or "вакансия" in teacher_fio.lower():
continue
raw_main_rows.append(
{
"Факультет/институт группы": fac_group,
"Группа": grp,
"Форма обучения": form_edu,
"Уровень образования": level_edu,
"Наименование дисциплины": discipline,
"ФИО": teacher_fio,
"Преподаватель": f"{teacher_fio} ({kafedra})",
"Кафедра ": kafedra,
"Факультет/институт ППС": fac_pps,
"Примечание": "",
}
)
df_main = pd.DataFrame(raw_main_rows)
if not df_main.empty:
df_main = df_main.drop_duplicates(
subset=["Группа", "Наименование дисциплины", "ФИО", "Кафедра "]
).copy()
if deduplicate_multi_dept:
df_main = _resolve_multi_department_duplicates(
df_main, hist_teacher_disc_map, hist_teacher_kaf_map
)
df_main = df_main.sort_values(
by=["Группа", "Кафедра ", "Наименование дисциплины", "Преподаватель"],
kind="mergesort",
).reset_index(drop=True)
df_main = df_main[
[
"Факультет/институт группы",
"Группа",
"Форма обучения",
"Уровень образования",
"Наименование дисциплины",
"Преподаватель",
"Кафедра ",
"Факультет/институт ППС",
"Примечание",
]
]
else:
df_main = pd.DataFrame(
columns=[
"Факультет/институт группы", "Группа", "Форма обучения",
"Уровень образования", "Наименование дисциплины",
"Преподаватель", "Кафедра ", "Факультет/институт ППС", "Примечание",
]
)
if not df_main.empty and not fv_groups_set:
for _, r in df_main[["Факультет/институт группы", "Группа", "Уровень образования"]].drop_duplicates().iterrows():
if r["Уровень образования"] != "Магистратура":
parsed = parse_group_info(r["Группа"], academic_year_short, hist_group_map)
if parsed and (parsed[3] <= 3 or r["Факультет/институт группы"] == "ИГРИК"):
fv_groups_set.add(
(r["Факультет/институт группы"], r["Группа"], "Элективные дисциплины по физической культуре и спорту")
)
df_fv_groups = pd.DataFrame(
sorted(list(fv_groups_set), key=lambda x: (x[0], x[1])),
columns=["Факультет", "Группа", "Дисциплина"],
)
df_fv_groups["Проверено"] = ""
df_fv_teachers = pd.DataFrame(
sorted(list(fv_teachers_set)),
columns=["Преподаватель"],
)
df_fv_teachers["Проверено "] = ""
df_cpd = _load_or_build_cpd(cpd_path, ref_path, cpd_rows_from_schedule)
return df_main, df_fv_groups, df_fv_teachers, df_cpd
def _resolve_multi_department_duplicates(
df: pd.DataFrame,
hist_teacher_disc_map: Dict[Tuple[str, str], Tuple[str, str]],
hist_teacher_kaf_map: Dict[str, Set[str]],
) -> pd.DataFrame:
dup_mask = df.duplicated(subset=["Группа", "Наименование дисциплины", "ФИО"], keep=False)
if not dup_mask.any():
return df
df_unique = df[~dup_mask].copy()
df_dups = df[dup_mask].copy()
resolved_rows = []
for (grp, disc, fio), group_df in df_dups.groupby(["Группа", "Наименование дисциплины", "ФИО"], sort=False):
chosen_row = None
if (fio, disc) in hist_teacher_disc_map:
hist_kaf, _ = hist_teacher_disc_map[(fio, disc)]
m_hist = group_df[group_df["Кафедра "] == hist_kaf]
if len(m_hist) == 1:
chosen_row = m_hist.iloc[0].to_dict()
if chosen_row is None:
fac_grp = group_df.iloc[0]["Факультет/институт группы"]
m_fac = group_df[group_df["Факультет/институт ППС"] == fac_grp]
if len(m_fac) == 1:
chosen_row = m_fac.iloc[0].to_dict()
elif len(m_fac) == 0 and fac_grp != "ПИШ ТЛ" and "ПИШ ТЛ" in group_df["Кафедра "].values:
non_pish = group_df[group_df["Кафедра "] != "ПИШ ТЛ"]
if len(non_pish) == 1:
chosen_row = non_pish.iloc[0].to_dict()
if chosen_row is None:
chosen_row = group_df.iloc[0].to_dict()
all_kafs = ", ".join(group_df["Кафедра "].tolist())
chosen_row["Примечание"] = f"Проверить кафедру ({all_kafs})"
resolved_rows.append(chosen_row)
return pd.concat([df_unique, pd.DataFrame(resolved_rows)], ignore_index=True)
def _load_or_build_cpd(
cpd_path: Optional[Path],
ref_path: Optional[Path],
cpd_rows_from_schedule: List[Tuple[str, str, str]],
) -> pd.DataFrame:
if cpd_path and cpd_path.exists():
df_cpd = pd.read_excel(cpd_path, sheet_name=0)
if len(df_cpd.columns) >= 3:
df_cpd = df_cpd.iloc[:, :3].copy()
df_cpd.columns = ["Учебная группа", "Название проекта", "Руководители проекта"]
return df_cpd.drop_duplicates().sort_values(by=["Учебная группа", "Название проекта"]).reset_index(drop=True)
if cpd_rows_from_schedule:
df_cpd = pd.DataFrame(
cpd_rows_from_schedule,
columns=["Учебная группа", "Название проекта", "Руководители проекта"],
)
return df_cpd.drop_duplicates().sort_values(by=["Учебная группа", "Название проекта"]).reset_index(drop=True)
return pd.DataFrame(columns=["Учебная группа", "Название проекта", "Руководители проекта"])
def export_to_excel(
output_path: Path,
df_main: pd.DataFrame,
df_fv_groups: pd.DataFrame,
df_fv_teachers: pd.DataFrame,
df_cpd: pd.DataFrame,
) -> None:
wb = openpyxl.Workbook()
font_header_12 = Font(name="Calibri", size=12, bold=True, color="1F2937")
font_body_12 = Font(name="Calibri", size=12, bold=False, color="111827")
font_header_11 = Font(name="Calibri", size=11, bold=True, color="1F2937")
font_body_11 = Font(name="Calibri", size=11, bold=False, color="111827")
font_cpd_header = Font(name="Tahoma", size=9, bold=True, color="1F2937")
fill_header = PatternFill(start_color="E8EEF5", end_color="E8EEF5", fill_type="solid")
fill_sub = PatternFill(start_color="F3F6FA", end_color="F3F6FA", fill_type="solid")
fill_zebra = PatternFill(start_color="F9FAFB", end_color="F9FAFB", fill_type="solid")
fill_warn = PatternFill(start_color="FEF3C7", end_color="FEF3C7", fill_type="solid")
thin_side = Side(style="thin", color="D1D5DB")
med_bottom = Side(style="medium", color="9CA3AF")
border_data = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
border_header = Border(left=thin_side, right=thin_side, top=thin_side, bottom=med_bottom)
align_left = Alignment(horizontal="left", vertical="center", wrap_text=False)
align_center = Alignment(horizontal="center", vertical="center", wrap_text=False)
# ЛИСТ 1
ws1 = wb.active
ws1.title = "Общая (кроме ФВ, ЦПД)"
ws1.views.sheetView[0].showGridLines = True
headers_s1 = [
"Факультет/институт группы", "Группа", "Форма обучения",
"Уровень образования", "Наименование дисциплины",
"Преподаватель", "Кафедра ", "Факультет/институт ППС", "Примечание",
]
ws1.append(headers_s1)
ws1.row_dimensions[1].height = 24
for col_idx in range(1, len(headers_s1) + 1):
cell = ws1.cell(row=1, column=col_idx)
cell.font = font_header_12
cell.fill = fill_header
cell.border = border_header
cell.alignment = align_center if col_idx in (1, 2, 3, 4, 7, 8) else align_left
for row_idx, r in enumerate(df_main.itertuples(index=False), start=2):
ws1.append(list(r))
is_even = row_idx % 2 == 0
has_note = bool(clean_text(r[8]))
for col_idx in range(1, len(headers_s1) + 1):
c = ws1.cell(row=row_idx, column=col_idx)
c.font = font_body_12
c.border = border_data
c.alignment = align_center if col_idx in (1, 2, 3, 4, 7, 8) else align_left
if has_note:
c.fill = fill_warn
elif is_even:
c.fill = fill_zebra
ws1.auto_filter.ref = f"A1:I{max(2, ws1.max_row)}"
ws1.freeze_panes = "A2"
col_widths_s1 = {
"A": 25.0, "B": 14.0, "C": 16.0, "D": 21.0,
"E": 55.0, "F": 42.0, "G": 20.0, "H": 24.0, "I": 26.0,
}
for col_letter, width in col_widths_s1.items():
ws1.column_dimensions[col_letter].width = width
# ЛИСТ 2
ws2 = wb.create_sheet(title="Список групп и ППС ФВ")
ws2.views.sheetView[0].showGridLines = True
ws2.cell(row=2, column=1, value="Список групп ").font = font_header_11
ws2.cell(row=2, column=1).fill = fill_sub
ws2.cell(row=2, column=7, value="Список ППС каф. ФВ ").font = font_header_11
ws2.cell(row=2, column=7).fill = fill_sub
ws2.row_dimensions[2].height = 20
left_headers = ["Факультет", "Группа", "Дисциплина", "Проверено"]
for idx, text in enumerate(left_headers, start=1):
c = ws2.cell(row=3, column=idx, value=text)
c.font = font_header_12
c.fill = fill_header
c.border = border_header
c.alignment = align_center if idx in (1, 2, 4) else align_left
right_headers = ["Преподаватель", "Проверено "]
for idx, text in enumerate(right_headers, start=7):
c = ws2.cell(row=3, column=idx, value=text)
c.font = font_header_11
c.fill = fill_header
c.border = border_header
c.alignment = align_center if idx == 8 else align_left
ws2.row_dimensions[3].height = 22
for r_offset, r in enumerate(df_fv_groups.itertuples(index=False), start=4):
ws2.cell(row=r_offset, column=1, value=r[0])
ws2.cell(row=r_offset, column=2, value=r[1])
ws2.cell(row=r_offset, column=3, value=r[2])
ws2.cell(row=r_offset, column=4, value=r[3] if r[3] else None)
for c_idx in range(1, 5):
c = ws2.cell(row=r_offset, column=c_idx)
c.font = font_body_11 if c_idx == 3 else font_body_12
c.border = border_data
c.alignment = align_center if c_idx in (1, 2, 4) else align_left
for r_offset, r in enumerate(df_fv_teachers.itertuples(index=False), start=4):
ws2.cell(row=r_offset, column=7, value=r[0])
ws2.cell(row=r_offset, column=8, value=r[1] if r[1] else None)
for c_idx in (7, 8):
c = ws2.cell(row=r_offset, column=c_idx)
c.font = font_body_11
c.border = border_data
c.alignment = align_center if c_idx == 8 else align_left
ws2.auto_filter.ref = f"A3:D{max(3, len(df_fv_groups) + 3)}"
ws2.freeze_panes = "A4"
col_widths_s2 = {
"A": 14.0, "B": 14.0, "C": 70.0, "D": 14.0,
"E": 6.0, "F": 6.0, "G": 42.0, "H": 14.0,
}
for col_letter, width in col_widths_s2.items():
ws2.column_dimensions[col_letter].width = width
# ЛИСТ 3
ws3 = wb.create_sheet(title="ЦПД ")
ws3.views.sheetView[0].showGridLines = True
headers_s3 = ["Учебная группа", "Название проекта", "Руководители проекта"]
ws3.append(headers_s3)
ws3.row_dimensions[1].height = 22
for col_idx in range(1, 4):
c = ws3.cell(row=1, column=col_idx)
c.font = font_cpd_header
c.fill = fill_header
c.border = border_header
c.alignment = align_center if col_idx == 1 else align_left
for row_idx, r in enumerate(df_cpd.itertuples(index=False), start=2):
ws3.append(list(r))
for col_idx in range(1, 4):
c = ws3.cell(row=row_idx, column=col_idx)
c.font = font_body_11
c.border = border_data
c.alignment = align_center if col_idx == 1 else align_left
ws3.auto_filter.ref = f"A1:C{max(1, ws3.max_row)}"
ws3.freeze_panes = "A2"
col_widths_s3 = {"A": 16.0, "B": 52.0, "C": 52.0}
for col_letter, width in col_widths_s3.items():
ws3.column_dimensions[col_letter].width = width
output_path.parent.mkdir(parents=True, exist_ok=True)
wb.save(str(output_path))
logging.info(f"Файл успешно сохранён: {output_path.resolve()}")
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
datefmt="%H:%M:%S",
)
parser = argparse.ArgumentParser(
description="Сборка формы анкетирования из 43 файлов выгрузки расписания по кафедрам."
)
parser.add_argument(
"--input-dir",
"-i",
type=Path,
default=Path("./выгрузка"),
help="Папка с 43 файлами выгрузки по кафедрам",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=Path("Форма сбора данных для анкетирования 2026.xlsx"),
help="Путь к итоговому файлу Excel",
)
parser.add_argument(
"--ref",
"-r",
type=Path,
default=Path("Форма сбора данных для анкетирования ЗАПОЛНЕНО 2.0.xlsx"),
help="Прошлогодний файл для сверки (необязательно)",
)
parser.add_argument(
"--cpd",
type=Path,
default=None,
help="Отдельный файл выгрузки проектов ЦПД (необязательно)",
)
parser.add_argument(
"--year",
type=int,
default=CURRENT_ACADEMIC_YEAR_SHORT,
help="Две последние цифры учебного года (26 для 2026/2027)",
)
parser.add_argument(
"--all-forms",
action="store_true",
help="Включить все формы обучения (по умолчанию только Очная)",
)
parser.add_argument(
"--exclude-graduating",
action="store_true",
help="Исключить выпускные курсы",
)
args = parser.parse_args()
input_dir: Path = args.input_dir
if not input_dir.exists():
input_dir = Path(".")
ref_path = args.ref if args.ref and args.ref.exists() else None
df_main, df_fv_groups, df_fv_teachers, df_cpd = process_all_files(
input_dir=input_dir,
ref_path=ref_path,
cpd_path=args.cpd,
academic_year_short=args.year,
only_ochnaya=not args.all_forms,
exclude_graduating=args.exclude_graduating,
deduplicate_multi_dept=DEDUPLICATE_MULTI_DEPT_TEACHERS,
)
export_to_excel(
output_path=args.output,
df_main=df_main,
df_fv_groups=df_fv_groups,
df_fv_teachers=df_fv_teachers,
df_cpd=df_cpd,
)
logging.info(
f"\nУспешно собрано:\n"
f" - Лист 1: {len(df_main)} строк ({df_main['Группа'].nunique() if not df_main.empty else 0} групп)\n"
f" - Лист 2: {len(df_fv_groups)} групп, {len(df_fv_teachers)} преподавателей ФВ\n"
f" - Лист 3: {len(df_cpd)} строк"
)
if __name__ == "__main__":
main()