Загрузка данных


Для объединения интерактивного интерфейса с выбором файлов, динамической базы регистрационных полей (из Ответа 1) и исправленной логики работы с API МТС Линк (из Ответа 2) ниже представлен единый, полностью готовый скрипт.

Этот код оформлен так, чтобы его можно было скопировать в **одну ячейку** Jupyter Notebook. Он предоставляет удобную кнопку для загрузки любого Excel-файла (будь то расписание или список спикеров) прямо из интерфейса, исключая необходимость вручную прописывать пути к файлам.

### Полный код для ячейки Jupyter Notebook

```python
import io
import os
from datetime import datetime
import ipywidgets as widgets
import pandas as pd
import requests
from IPython.display import clear_output, display

# ================= 1. НАСТРОЙКИ API МТС ЛИНК =================
TOKEN = "ef12b978e5d96436c73932abcedf76ea"
API_URL = "https://userapi.mts-link.ru/v3/events"

# Передаем только токен авторизации. Content-Type указывать не нужно,
# requests автоматически сформирует application/x-www-form-urlencoded при передаче словаря в data.
HEADERS = {"x-auth-token": TOKEN}

COMMON_SETTINGS = {
    "access": "1",  # Свободный доступ (архивный, но валидный параметр)
    "type": "webinar",
}

SPECIFIC_SETTINGS = {8: {"description": "Проект: Финансовая грамотность"}}

# ================= 2. БАЗА ВОПРОСОВ И ОТВЕТОВ =================
QUESTION_BANK = {
    "Выберите регион": {
        "type": "radio",
        "values": [
            "Алтайский край",
            "Амурская область",
            "Архангельская область",
            "Астраханская область",
            "Белгородская область",
            "Брянская область",
            "Владимирская область",
            "Волгоградская область",
            "Вологодская область",
            "Воронежская область",
            "Еврейская автономная область",
            "Забайкальский край",
            "Ивановская область",
            "Иркутская область",
            "Кабардино-Балкарская Республика",
            "Калининградская область",
            "Калужская область",
            "Камчатский край",
            "Карачаево-Черкесская Республика",
            "Кемеровская область - Кузбасс",
            "Кировская область",
            "Костромская область",
            "Краснодарский край",
            "Красноярский край",
            "Курганская область",
            "Курская область",
            "Ленинградская область",
            "Липецкая область",
            "Магаданская область",
            "Москва",
            "Московская область",
            "Мурманская область",
            "Ненецкий автономный округ",
            "Нижегородская область",
            "Новгородская область",
            "Новосибирская область",
            "Омская область",
            "Оренбургская область",
            "Орловская область",
            "Пензенская область",
            "Пермский край",
            "Приморский край",
            "Псковская область",
            "Республика Адыгея",
            "Республика Алтай",
            "Республика Башкортостан",
            "Республика Бурятия",
            "Республика Дагестан",
            "Республика Ингушетия",
            "Республика Калмыкия",
            "Республика Карелия",
            "Республика Коми",
            "Республика Крым",
            "Севастополь",
            "Республика Марий Эл",
            "Республика Мордовия",
            "Республика Саха (Якутия)",
            "Республика Северная Осетия - Алания",
            "Республика Татарстан",
            "Республика Тыва",
            "Республика Хакасия",
            "Ростовская область",
            "Рязанская область",
            "Самарская область",
            "Санкт-Петербург",
            "Саратовская область",
            "Сахалинская область",
            "Свердловская область",
            "Смоленская область",
            "Ставропольский край",
            "Тамбовская область",
            "Тверская область",
            "Томская область",
            "Тульская область",
            "Тюменская область",
            "Удмуртская Республика",
            "Ульяновская область",
            "Хабаровский край",
            "Ханты-Мансийский автономный округ",
            "Челябинская область",
            "Чеченская Республика",
            "Чувашская Республика",
            "Чукотский автономный округ",
            "Ямало-Ненецкий автономный округ",
            "Ярославская область",
            "Иное",
            "Донецкая Народная Республика (ДНР)",
            "Запорожская область",
            "Луганская Народная Республика (ЛНР)",
            "Херсонская область",
        ],
    },
    "Выберите тип населенного пункта": {
        "type": "radio",
        "values": [
            "Городские населенные пункты (города, поселки городского типа)",
            "Сельские населенные пункты и населенные пункты на отдаленных и малонаселенных территориях",
        ],
    },
    "Отчество": {"type": "text"},
    "Количество участников (индивидуально поставить 1)": {
        "type": "radio",
        "values": [str(i) for i in range(1, 41)],
    },
    "Формат подключения": {"type": "radio", "values": ["индивидуально", "группа"]},
    "Возраст (при участии группы, укажите средний возраст слушателей)": {
        "type": "radio",
        "values": [
            "до 18",
            "18-24",
            "25-34",
            "35-44",
            "45-54",
            "55-64",
            "старше 65",
        ],
    },
    "Название организации для группы, остальные пишем НЕТ": {"type": "text"},
    "e-mail организации (индивидуально напишите НЕТ)": {"type": "text"},
    "Город (организации или участника)": {"type": "text"},
    "Район": {"type": "text"},
    "Принимали участие в онлайн-занятиях в текущую сессию?": {
        "type": "radio",
        "values": ["Да", "Нет"],
    },
    "ФИО для сертификата": {"type": "text"},
    "Индекс (организации или участника)": {"type": "text"},
    "Телефон": {"type": "text"},
    "Улица, дом организации для группы, остальные пишем НЕТ": {"type": "text"},
    "Вид образовательной организации": {
        "type": "radio",
        "values": [
            "школа",
            "профессиональная образовательная организация ПОО (техникум, колледж)",
            "учреждение для детей-сирот и детей без попеч. родит.",
            "организация доп. образования",
            "детский сад",
            "библиотека",
            "центр социального обслуживания (помощи) населения",
            "иное",
            "высшее учебное заведение",
        ],
    },
    "Номер и буква класса/группы": {"type": "text"},
    "Вид организации (ИНОЕ, если участвуете индивидуально)": {
        "type": "radio",
        "values": [
            "центр социального обслуживания (помощи) населения",
            "дом (интернат) для престарелых и инвалидов",
            "совет (клуб) ветеранов",
            "школа",
            "общественная организация",
            "коммерческая организация",
            "библиотека",
            "иное",
            "высшее учебное заведение",
            "профессиональная образовательная организация ПОО (техникум, колледж)",
        ],
    },
    "Название организации": {"type": "text"},
}

COMMON_NEW_QUESTIONS = [
    "Выберите регион",
    "Выберите тип населенного пункта",
    "Отчество",
    "Количество участников (индивидуально поставить 1)",
    "Название организации для группы, остальные пишем НЕТ",
    "e-mail организации (индивидуально напишите НЕТ)",
    "Город (организации или участника)",
    "Район",
    "Принимали участие в онлайн-занятиях в текущую сессию?",
    "ФИО для сертификата",
    "Формат подключения",
    "Индекс (организации или участника)",
    "Телефон",
    "Улица, дом организации для группы, остальные пишем НЕТ",
]


def generate_additional_fields_payload(project_code):
    """Автоматически генерирует структуру плоского словаря дополнительных полей для requests"""
    if project_code == 11:
        questions = COMMON_NEW_QUESTIONS + [
            "Вид образовательной организации",
            "Номер и буква класса/группы",
        ]
    elif project_code == 12:
        questions = COMMON_NEW_QUESTIONS + [
            "Возраст (при участии группы, укажите средний возраст слушателей)",
            "Вид организации (ИНОЕ, если участвуете индивидуально)",
        ]
    elif project_code == 16:
        questions = COMMON_NEW_QUESTIONS + [
            "Номер и буква класса/группы",
            "Вид организации (ИНОЕ, если участвуете индивидуально)",
            "Возраст (при участии группы, укажите средний возраст слушателей)",
        ]
    elif project_code == 19:
        questions = COMMON_NEW_QUESTIONS + [
            "Возраст (при участии группы, укажите средний возраст слушателей)",
            "Вид организации (ИНОЕ, если участвуете индивидуально)",
        ]
    elif project_code == 8:
        questions = [
            "Выберите регион",
            "Выберите тип населенного пункта",
            "Отчество",
            "ФИО для сертификата",
            "Название организации",
        ]
    elif project_code == 7:
        questions = COMMON_NEW_QUESTIONS
    else:
        return {}

    payload = {}
    for idx, q_label in enumerate(questions):
        q_data = QUESTION_BANK.get(q_label, {"type": "text"})
        payload[f"additionalFields[{idx}][label]"] = q_label
        payload[f"additionalFields[{idx}][type]"] = q_data["type"]

        if q_data["type"] == "radio" and "values" in q_data:
            for v_idx, val in enumerate(q_data["values"]):
                payload[f"additionalFields[{idx}][values][{v_idx}]"] = val
    return payload


# ================= 3. ЛОГИКА СОЗДАНИЯ МЕРОПРИЯТИЯ =================
def create_event(row):
    event_name = row.get("Название мероприятия")

    # Безопасная проверка пустого названия
    if pd.isna(event_name) or str(event_name).strip() == "":
        return "Пропущено (нет названия)"

    # Приведение кода проекта к целому числу (защита от float-преобразований pandas)
    project_code = row.get("Код проекта")
    if pd.notna(project_code):
        try:
            project_code = int(float(project_code))
        except ValueError:
            project_code = None
    else:
        project_code = None

    # Валидация и парсинг даты/времени
    try:
        # dayfirst=False, так как формат дат в отчетах M/D/YY (например, 3/12 — это 12 марта)
        date_obj = pd.to_datetime(row["дата"], dayfirst=False)
        date_str = date_obj.strftime("%Y-%m-%d")
        time_str = str(row["время"]).strip()

        dt = pd.to_datetime(f"{date_str} {time_str}")

        if dt < datetime.now():
            print(
                f"[ПРОПУСК] '{event_name[:30]}...' — в прошлом ({dt.strftime('%d.%m.%Y %H:%M')})"
            )
            return "Пропущено (в прошлом)"

        starts_at_iso = dt.strftime("%Y-%m-%dT%H:%M:%S+03:00")
    except Exception as e:
        print(
            f"[ОШИБКА ДАТЫ] Не удалось обработать дату для '{event_name[:30]}...': {e}"
        )
        return "Ошибка даты"

    # Формирование параметров запроса
    payload = COMMON_SETTINGS.copy()

    if project_code in SPECIFIC_SETTINGS:
        payload.update(SPECIFIC_SETTINGS[project_code])

    payload["name"] = event_name
    payload["startsAtTimestamp"] = starts_at_iso

    # Обработка лимита участников
    max_participants = row.get("MAX кол Участников")
    if pd.notna(max_participants):
        try:
            payload["estimatedAttendees"] = int(float(max_participants))
        except ValueError:
            pass

    # Интеграция динамических дополнительных полей
    additional_fields = generate_additional_fields_payload(project_code)
    payload.update(additional_fields)

    # Отправка HTTP POST-запроса (в формате x-www-form-urlencoded)
    try:
        response = requests.post(API_URL, headers=HEADERS, data=payload)

        if response.status_code in (200, 201):
            result = response.json()
            event_id = result.get("eventId") or result.get("id")
            link = result.get("link", "")
            print(
                f"[УСПЕХ] Создано: '{event_name[:35]}...' | ID: {event_id} | Ссылка: {link}"
            )
            return event_id
        else:
            print(
                f"[ОШИБКА API] '{event_name[:35]}...': {response.status_code} - {response.text}"
            )
            return f"Ошибка API: {response.status_code}"

    except Exception as e:
        print(f"[СБОЙ СЕТИ] '{event_name[:35]}...': {e}")
        return "Сбой сети"


# ================= 4. ИНТЕРФЕЙС И UI ЭЛЕМЕНТЫ =================
file_upload = widgets.FileUpload(
    accept=".xlsx, .xls",
    multiple=False,
    description="Выбрать Excel",
    button_style="info",
)

run_button = widgets.Button(
    description="Запустить обработку", button_style="success", icon="play"
)

output = widgets.Output()


def process_uploaded_file(b):
    with output:
        clear_output()

        if not file_upload.value:
            print("Ошибка: Сначала выберите Excel-файл!")
            return

        print("Загрузка и анализ структуры файла...")
        try:
            # Получение метаданных и содержимого из виджета загрузки
            uploaded_file = file_upload.value[0]
            file_content = uploaded_file["content"]
            original_filename = uploaded_file["name"]

            # Первичная попытка чтения (ориентируясь на заголовок во 2-й строке)
            df = pd.read_excel(io.BytesIO(file_content), header=1)

            # Эвристическая проверка корректности строки заголовков
            if "Название мероприятия" not in df.columns:
                # Если колонка не найдена, пробуем прочитать со стандартной 0-й строки
                df = pd.read_excel(io.BytesIO(file_content), header=0)

            if "Название мероприятия" not in df.columns:
                print(
                    "Критическая ошибка: В таблице не обнаружена колонка 'Название мероприятия'."
                )
                print("Присутствующие колонки:", list(df.columns))
                return

            # Очистка датафрейма от пустых строк
            df = df.dropna(subset=["Название мероприятия"])
            print(f"Файл успешно загружен. Строк к обработке: {len(df)}\n")

        except Exception as e:
            print(f"Ошибка при обработке файла: {e}")
            return

        # Итерация по строкам и выполнение запросов
        results = []
        for index, row in df.iterrows():
            result = create_event(row)
            results.append(result)

        # Добавление результатов обратно в таблицу и сохранение локально
        df["MTS_Result_ID"] = results
        name, ext = os.path.splitext(original_filename)
        output_filename = f"{name}_создано{ext}"

        try:
            df.to_excel(output_filename, index=False)
            print(
                f"\n[ГОТОВО] Все операции завершены. Результаты сохранены в файл: {output_filename}"
            )
        except Exception as e:
            print(f"Ошибка при сохранении выходного Excel-файла: {e}")


# Назначение обработчика события клика на кнопку
run_button.on_click(process_uploaded_file)

# Отрисовка интерфейса
display(widgets.VBox([file_upload, run_button, output]))

```

### Как с этим работать в Jupyter:

1. Вставьте этот код в пустую ячейку вашего ноутбука и нажмите `Shift + Enter`.
2. Появится кнопка **"Выбрать Excel"**. Нажмите её и выберите нужный файл со своего компьютера.
3. Нажмите зелёную кнопку **"Запустить обработку"**.
4. В поле вывода ниже начнут в реальном времени появляться логи: какие вебинары успешно созданы (со ссылками и ID), а какие пропущены (например, если дата осталась в прошлом).
5. По завершении работы скрипт создаст новый файл в той же директории, где запущен Ноутбук, добавив к названию суффикс `_создано`. Внутри этой таблицы появится новая колонка `MTS_Result_ID` с результатами выполнения для каждой строчки.

### Реализованные улучшения:

* **Автоматический выбор структуры (header):** Скрипт сначала пробует прочитать таблицу, предполагая, что заголовки находятся на второй строчке (индекс 1, как в вашем исходном расписании). Если колонка `"Название мероприятия"` не обнаруживается, он автоматически переключается на стандартную первую строку (индекс 0). Это позволяет обрабатывать файлы с разной версткой шапки.
* **Безопасное приведение типов:** Коды проектов и лимиты участников переводятся из формата `float` (который pandas часто присваивает столбцам с пропусками) строго в `int`.
* **Изоляция данных в памяти:** Файл считывается как массив байтов напрямую из виджета, что предотвращает ошибки блокировки файлов операционной системой.