Compare commits

...

193 Commits

Author SHA1 Message Date
c759f34f0f обновил еще капитонов 2026-08-10 10:49:32 +03:00
b29fc06a4f поправил капитанов на расстановки 2026-08-10 10:30:08 +03:00
adea3a8bd6 добавил возможность администраторам создавать игроков, тренеров и судей вручную 2026-08-04 10:49:03 +03:00
2c2d9b9a7c исправлено передача синих логотипов для Зенит/Динамо и логотипы каналов не должны отображаться, когда у матча уже есть счет 2026-08-04 10:20:54 +03:00
e17c287d65 update 6 2026-07-29 11:52:22 +03:00
60ec71c73d update 5 2026-07-29 11:03:27 +03:00
77adc31a1a апдейт 4 2026-07-29 11:03:12 +03:00
8e38505f69 update 3.0 2026-07-29 10:29:35 +03:00
504cf77312 апдейт 2.0 2026-07-29 10:27:19 +03:00
00f8533236 обновил ссылки на tigra 2026-07-29 10:22:42 +03:00
87cd044c3f переименовал название картинки 2026-07-24 10:20:25 +03:00
6fe4eca0ba добавил картинку для info 2026-07-24 10:09:43 +03:00
c646691142 поправлена отдача данных в vMix с помощью agent.exe 2026-07-21 11:07:23 +03:00
2b3c882d25 замена url для текстового датасорса 2026-07-04 10:15:55 +03:00
c724c7e965 обновление 2 2026-07-03 14:59:23 +03:00
d689b853dc обновил инфо для Кубка России 2026-07-03 14:58:40 +03:00
3ec7f57d46 загрузил картинку по пенальти для Info 2026-07-03 14:50:25 +03:00
aeafdc9887 новый файл для скрипта создал 2026-07-02 17:42:48 +03:00
a215ba526b удилил архив 2026-07-02 17:00:31 +03:00
e7b215af5e все остальные апдейты на Кубок России 2026-07-02 17:00:15 +03:00
03a68ee8ca логотипы каналов, тест 2 2026-07-02 14:47:37 +03:00
e32fdcb74e поправил логотипы каналов для Кубка России 2026-07-02 14:09:29 +03:00
5b056550ed добавил экранирование \ для логотипов команд 2026-07-02 13:37:41 +03:00
1303b3b562 поправил вывод названия тура для кубка россии 2026-07-02 13:37:13 +03:00
61861085f8 поправил логотипы для заявочного титра, тест 1 2026-07-02 13:06:30 +03:00
e98d014243 подгрузка данных для кубка россии. тест 2 2026-07-02 12:56:24 +03:00
57907fd86b обновление для Кубка России
переделаны все парсеры на ссылки из базы
2026-07-02 12:46:29 +03:00
b0fe3ad5a4 Обновил добавление обновлений 2026-06-22 18:25:04 +03:00
24c10ea5ca обновил Обновления 2026-06-22 18:20:25 +03:00
425d1f981d поправка 2 2026-06-22 18:15:06 +03:00
3867579508 поправил инфо на сегодняший день 2026-06-22 18:12:56 +03:00
4580ff1d3e добавил при выборе тура отключение галочки Завершенные матчи 2026-06-22 18:04:54 +03:00
55cb965d9f 1. обновил инструкцию
2. добавил в админку Добавление обновлений, чтобы не через код
2026-06-22 17:58:20 +03:00
3e6f767a12 добавил в веб-интерфейс отображение рядом с номером игра фото, если фотографии у игрока нет 2026-06-22 15:52:47 +03:00
f63061a06d 1. добавил в админку сортировку игроков и Фото (если галочка стоит, то путь к фото генерируется автоматически)
2. фото обновляются прям в json
2026-06-22 15:31:07 +03:00
cd370cf04f условие канала, фикс 1 2026-06-18 11:26:44 +03:00
e314aa0ad2 поправил условие для отображение каналов 2026-06-18 11:22:57 +03:00
9df79f77df обновил инфо 4 2026-05-29 11:58:04 +03:00
a9d580795d обновил инфо 3 2026-05-29 11:55:27 +03:00
d34776a2d6 обновил инфо 2 2026-05-29 11:55:01 +03:00
59df51f6be обновил информацию 2026-05-29 11:41:14 +03:00
91eee7c08c дополнил инфо 2026-05-25 15:22:27 +03:00
81556cfd43 поправил инфо 2026-05-25 15:15:52 +03:00
96b257cff1 1. добавлена кнопка ? (переход на info)
2. добавлен пункт Обновления в Инфо
2026-05-25 15:13:14 +03:00
2de22915cf добавил кнопку инфо 2026-05-25 14:44:03 +03:00
d8127dccaf добавил защиту для отчистки событий матча, css стиль темный 2026-05-25 14:38:37 +03:00
4336ab19df добавил защиту для отчистки событий матча, тест 1 2026-05-25 14:36:36 +03:00
495121a8b7 синхронизация событий со всеми браузерами, тест 1 2026-05-25 14:32:27 +03:00
998774bf1c синхронизация текущего времени матча во всех браузерах, тест 1 2026-05-25 14:22:47 +03:00
64cd3891ea изменение текущего времени, тест 4 2026-05-25 14:13:06 +03:00
3ff7c95018 изменение текущего времени, тест 3 2026-05-25 14:08:43 +03:00
9db889ca4b изменение текущего времени, тест 2 2026-05-25 14:02:32 +03:00
508d209377 изменение текущего времени, тест 1 2026-05-25 13:49:40 +03:00
3738538d23 возможно нашел место, где неправильно формируется отправка данных в vMix (Фамилия Имя) 2026-05-25 12:55:24 +03:00
21e37a1097 мини защита для парсинга игры 2026-05-25 12:36:18 +03:00
801936518a мини защита от сброса данных в базе данных, если данных нет 2026-05-25 12:34:29 +03:00
18bac0d12d тест5 2026-05-25 12:26:42 +03:00
6c449ef7df тест4 2026-05-25 12:22:02 +03:00
23ecbc0f57 вроде как откатился на ошибки парсеров без веб интерфейс 2026-05-25 12:13:31 +03:00
3ff5d42045 Revert "каждый гол пишется отдельной строкой"
This reverts commit 50c4755b38.
2026-05-25 12:06:29 +03:00
28d7d01fdf тест 3 2026-05-25 11:42:51 +03:00
57f783e70d описание недоступности сайта, тест 2 2026-05-25 11:34:08 +03:00
9bbafe7c35 описание недоступности сайта 2026-05-25 11:28:41 +03:00
50c4755b38 каждый гол пишется отдельной строкой 2026-05-25 11:22:15 +03:00
04b1b8796e убрал точку из амплуа тренеров 2026-05-25 10:56:50 +03:00
2df9c97608 фикс5 2026-05-16 15:03:56 +03:00
685fcdabef фикс4 2026-05-16 13:52:53 +03:00
c970a9d755 фикс3 2026-05-16 13:16:08 +03:00
5d00fdeaf5 фикс2 2026-05-16 13:14:18 +03:00
214ab84fc6 фикс 1 2026-05-16 12:40:14 +03:00
2e3aba7c5a поправил ссесию на 4 часа 2026-05-15 15:36:16 +03:00
121095882c поправил вывод доп времени в голах на титр нижний счет 2026-05-15 15:09:28 +03:00
e671859f71 поправил отображение каналов на расписании тура, когда матч live, каналы не пишутся 2026-05-15 15:02:20 +03:00
79c679f35d тест 9 2026-05-15 14:35:11 +03:00
4e6806c553 тест 8 2026-05-15 14:28:14 +03:00
a8947556a5 тест 7 2026-05-15 14:23:17 +03:00
d8a31442c9 тест 6 2026-05-15 14:19:26 +03:00
761f46a932 в json выводиться канал 2026-05-15 14:08:08 +03:00
5110c402e8 логотипы команд в расписании тура 2026-05-15 14:05:05 +03:00
eda35ade7e добавил + в доп время 2026-05-15 13:55:52 +03:00
e3b612433c добавил отправку в vMix дополнительного времени 2026-05-15 13:51:07 +03:00
d7f13dcfe2 тест 5 2026-05-15 13:42:19 +03:00
007939a05e тест 4 2026-05-15 13:29:38 +03:00
48f752d512 тест 3 2026-05-15 13:23:21 +03:00
13b859a9fa поправил сортировку событий в отображении на веб странице 2026-05-15 13:15:20 +03:00
4f8e2f9b92 тест2 2026-05-15 13:10:41 +03:00
7f07f982d8 редактирование времени события. тест 2026-05-15 12:58:19 +03:00
a74aab3acd поправил ошибку для css и js 2026-05-15 12:52:52 +03:00
cfb746ebec автообновлении версии js и css 2026-05-15 12:50:18 +03:00
69e40984f2 убрал старые функции и сделал автообновление версий js и css 2026-05-15 12:45:16 +03:00
636a9a5e59 убрал дублированные функции 2026-05-15 12:22:05 +03:00
4f1dc64d5d поправил сброс инпутов на расстановках (убрал дублирование команд) 2026-05-15 12:00:03 +03:00
9a61796f06 поправил время сессии 2026-05-14 13:00:13 +03:00
647bf9f68c в расписании на онлайн матче, если матч статус live, и счет на сайте не запустили, ставлю автоматически 0-0 в счет 2026-05-14 10:49:07 +03:00
0ede5beb6b апдейт 2026-05-08 12:56:10 +03:00
5fb6189b2d фикс расписания онлайн 1 2026-04-25 10:09:11 +03:00
c4f2cb34da тест с финишем матчей 2026-04-24 21:40:10 +03:00
2c21bce9ed фиксики1 2026-04-24 19:56:24 +03:00
c9c6761303 фикс8 2026-04-24 17:27:08 +03:00
62301faf93 фикс7 2026-04-24 17:23:48 +03:00
03744d9c1a фикс6 2026-04-24 17:20:09 +03:00
c8decf6245 фикс5 2026-04-24 17:15:09 +03:00
1410b4a9ce фикс4 2026-04-24 17:12:05 +03:00
24bf2124b1 фикс3 2026-04-24 17:08:49 +03:00
054e44191a не матч id а external id для автоматического расписания 2026-04-24 17:05:50 +03:00
936b4c3a66 фикс 2 2026-04-24 14:58:35 +03:00
99c9aef1d0 фикс 1 2026-04-24 14:55:55 +03:00
6c50e7e2fe 1. вернул обратно в код создание учетки из базы, и прогон парсеров через базу 2026-04-24 14:45:39 +03:00
312a006312 еще 1 2026-04-23 20:55:27 +03:00
58e36ac55c добавил info
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 20:53:54 +03:00
5841ae9a2f гайд версия 1 2026-04-23 19:03:16 +03:00
a91e14dabc делаю гайд для операторов 2026-04-23 18:55:19 +03:00
2c7222260d фикс 14 2026-04-23 18:34:37 +03:00
25bd34d94f не туда положил файл 2026-04-23 18:18:33 +03:00
f2115d356e добавил иконки на веб страницы
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 18:16:01 +03:00
7d6c97ffc4 READMI Добавил 2026-04-23 18:11:30 +03:00
28d21c400f фикс 13 2026-04-23 17:58:34 +03:00
9e80041a09 отключил /docs, /redoc, /openapi.json 2026-04-23 17:56:38 +03:00
26621308fe фикс 12 2026-04-23 17:54:54 +03:00
7bc3fd977a фикс 11 2026-04-23 17:51:03 +03:00
32d1f8afa6 1. добавил scheduler для онлайн матчей и турнирки
2. добавил в расписание тура маски для счета и времени

Co-authored-by: Copilot <copilot@github.com>
2026-04-23 17:39:44 +03:00
44ec5cc69a фикс 10 2026-04-23 15:52:17 +03:00
f9e6c31b33 фикс 9 2026-04-23 15:43:45 +03:00
946e461fc0 фикс 8 2026-04-23 15:37:58 +03:00
655e9d0e27 фикс 7 2026-04-23 15:27:29 +03:00
80088f0ee5 фикс 6 2026-04-23 15:24:45 +03:00
80bcc27abe тест 4 2026-04-23 15:17:33 +03:00
5efabfb7ca тест 3 2026-04-23 15:07:24 +03:00
a13238dc6e тест 2 2026-04-23 14:56:40 +03:00
277cc83d0e тест 1 2026-04-23 14:46:29 +03:00
81bf26d8aa фикс 5 2026-04-23 14:41:13 +03:00
66644f420e фикс 4 2026-04-23 14:33:38 +03:00
c03b767979 переделал систему подключения агента к веб-сокету 2026-04-23 14:24:21 +03:00
bb1019f816 фикс 3 2026-04-23 12:29:45 +03:00
bb0d1111f0 фикс учетки 2 2026-04-23 12:11:35 +03:00
c5936f31d4 фикс создания учетки 2026-04-23 12:05:07 +03:00
42ce3587aa обновил создание аккаунтов 2026-04-23 11:59:52 +03:00
6974e3cd1a создание учетки в базе данных 2026-04-23 11:53:46 +03:00
ca9250ae86 обновил заставку загрузки матрицы 2026-04-23 11:39:24 +03:00
50de4ea68c загрузка матрицы на кнопки парсинга в редактировании базы 2026-04-23 11:32:05 +03:00
23300a4a37 добавил кнопки парсинга в редактировании базы для админов 2026-04-23 11:24:47 +03:00
a7597f943c кнопку базы сделал красивой 2026-04-23 11:13:07 +03:00
f7e7c09558 кнопка входа в редактирование базы для админов на странице выбора матча 2026-04-23 11:09:05 +03:00
7ff30c646e test17 2026-04-22 14:41:53 +03:00
49f76e746f test16 2026-04-22 14:39:02 +03:00
53861cd028 test15 2026-04-22 13:43:48 +03:00
a70609ec20 test14 2026-04-22 13:39:50 +03:00
5f63c15e95 test13 2026-04-22 13:33:42 +03:00
ca3a282cd3 test12 2026-04-22 13:24:42 +03:00
66ea8a7a80 test11 2026-04-22 13:21:03 +03:00
1640368a81 test10 2026-04-22 13:17:21 +03:00
0f11e17c70 test9 2026-04-22 13:15:17 +03:00
8d98decd62 test8 2026-04-22 13:10:35 +03:00
7d804050f7 test7 2026-04-22 13:02:49 +03:00
5fb43e9b80 test6 2026-04-22 12:57:28 +03:00
55e680fce8 попытка починить амлуа для передачи данных в vMix 2026-04-22 12:54:08 +03:00
92b1287485 поправка 5 2026-04-22 12:13:08 +03:00
4f83d95ed6 поправка 4 2026-04-22 12:09:09 +03:00
338335928b поправка 3 для иконки vMix 2026-04-22 11:49:25 +03:00
c0f9016d55 поправка для загрузки иконки vMix. версия 2 2026-04-22 11:42:57 +03:00
ade733bb3d нарисовал иконку vMix 2026-04-22 11:38:09 +03:00
2211d07562 исправлено автоскачивание проекта vMix на кнопку во вкладке игра 2026-04-22 11:28:24 +03:00
f2a49d9385 убрал лишний пробел в вводе логина и пароля 2026-04-21 18:05:57 +03:00
30fffb179a test17 2026-04-21 17:42:58 +03:00
e4721d823e чиню вызов титров переключая вкладки на веб морде 2026-04-21 17:31:00 +03:00
640211ff59 test16 2026-04-21 17:24:14 +03:00
82833afcc5 test15 2026-04-21 17:14:42 +03:00
d3fa490fc1 test14 2026-04-21 17:04:15 +03:00
f44792f2f2 test13 2026-04-21 16:57:17 +03:00
f62c48ccc3 test12 2026-04-21 16:49:12 +03:00
c4faace06a test11 2026-04-21 16:45:21 +03:00
150b400fc6 test10 2026-04-21 16:36:53 +03:00
6b1dfe9cb4 test9 2026-04-21 16:22:14 +03:00
fff2e909f8 test8 2026-04-21 16:08:33 +03:00
25b9bd183d test7 2026-04-21 15:56:34 +03:00
af17b088cf тест6 2026-04-21 15:51:08 +03:00
8c40687942 тест5 2026-04-21 15:36:46 +03:00
2795cd5a5c тест4 2026-04-21 15:22:59 +03:00
33b46792c4 тест3 2026-04-21 15:13:53 +03:00
84e49b2b39 тест2 2026-04-21 15:08:05 +03:00
b42449d42b тест. линки на обычных игроков 2026-04-21 15:02:28 +03:00
e8a7c5934b поправил кликабельность игроков 2026-04-21 14:31:22 +03:00
ac1904f538 тест простой подписи игроков и тренеров по клику из веб морды 2026-04-21 14:19:51 +03:00
de7c798837 пофиксил отправку запросов для превью vMix 2026-04-21 13:56:24 +03:00
90c39957a1 добавил еще и судей 2026-04-21 13:48:37 +03:00
d3f4bd1c8d добавил вызов Расписания тура и Турнирная таблица для vMix 2026-04-21 13:40:11 +03:00
c8effe0307 добавил автоматическую загрузку тренеров в состав матча из кнопки Загрузка данных с сайта 2026-04-21 13:35:07 +03:00
87442bdfdd Удалены файлы кэша Python из репозитория 2026-04-21 13:04:33 +03:00
bf1fd38ab0 gitignore try2 2026-04-21 13:02:35 +03:00
1f20a6c726 обновил gitignore 2026-04-21 12:59:24 +03:00
4ce5faed59 Добавил звук печатающей клавиатуры 2026-04-21 12:57:35 +03:00
a20f80453b добавил на страницы загрузок эффект матрицы 2026-04-21 12:37:22 +03:00
7baed89b6a 1. исправил agent.py
2. поменял явки и пароли для создания операторов
3. убрал из maches.html оператора, и добавил галочку завершенные матчи
2026-04-21 11:44:58 +03:00
123 changed files with 14063 additions and 4030 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
/__pycache__ /__pycache__
__pycache__/
*.env *.env
*.pyc
*.py[cod]
*$py.class
**/__pycache__/

771
README.md Normal file
View File

@@ -0,0 +1,771 @@
# ⚽ VMIX Football Broadcast System
<p align="center">
<img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.115+-009688?logo=fastapi&logoColor=white" alt="FastAPI">
<img src="https://img.shields.io/badge/PostgreSQL-15+-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/WebSocket-Realtime-7C3AED" alt="WebSocket">
<img src="https://img.shields.io/badge/vMix-Integration-FF6A00" alt="vMix">
<img src="https://img.shields.io/badge/status-active-success" alt="Status">
</p>
> Система управления футбольными трансляциями с интеграцией в **vMix**, админ-панелью, live-обновлением матчей, генерацией данных для графики и WebSocket-взаимодействием с операторскими клиентами.
---
## 📌 Что это за проект
Проект предназначен для подготовки и сопровождения футбольных трансляций. Он объединяет в одном приложении:
- администрирование матчей, команд, игроков, тренеров, судей и стадионов;
- работу с составами и формациями на матч;
- генерацию данных для эфирной графики в vMix;
- создание и выгрузку `.vmix`-проекта;
- приём и отправку команд в vMix-клиенты через WebSocket;
- фоновое отслеживание матчей и обновление live-данных;
- синхронизацию расписания, игроков, команд и турнирной таблицы.
По текущей структуре проект ориентирован на внутреннюю production/операторскую эксплуатацию, а не на публичный API-сервис.
---
## ✨ Основные возможности
### Админка и матч-центр
- авторизация администратора;
- список матчей и выбор матча для сессии;
- рабочее пространство матча;
- редактирование составов;
- редактирование формаций;
- управление судьями;
- управление событиями матча.
### База футбольных сущностей
- игроки;
- команды;
- тренеры;
- судьи;
- стадионы;
- турнирная таблица;
- составы на матч;
- матчевые сессии для операторов.
### Интеграция с vMix
- генерация `.vmix`-проекта;
- формирование имени файла проекта;
- генерация JSON-данных для графики;
- публикация команд в подключённые vMix-клиенты;
- получение динамических значений из vMix API.
### Live-логика
- планировщик, который отслеживает матчи дня;
- запуск watcher-потоков перед матчем;
- опрос live-данных каждые 60 секунд;
- обновление статуса матча и счёта;
- обновление турнирной таблицы.
### Парсинг и синхронизация
- парсинг матчей;
- парсинг игроков;
- парсинг расписания;
- парсинг таблицы;
- синхронизация сущностей в БД через сервисный слой.
---
## 🏗 Архитектура
Проект построен вокруг классической схемы:
```text
Parsers -> Services -> Repositories -> PostgreSQL
|
+-> vMix JSON / .vmix generation
+-> FastAPI admin UI
+-> WebSocket command bus
+-> Scheduler / live workers
```
### Слои проекта
#### `parsers/`
Получают данные из внешних источников. Используются для матчей, игроков, расписания, команд и таблиц.
#### `services/`
Содержат прикладную бизнес-логику: синхронизацию данных, авторизацию, подготовку JSON для vMix.
#### `repositories/`
Отвечают за доступ к PostgreSQL. Здесь собраны CRUD-операции и специализированные запросы.
#### `templates/` + `static/`
HTML-шаблоны Jinja2, CSS, JS и статические файлы интерфейса.
#### `vmix/`
Логика генерации vMix-проекта и служебные функции, связанные с эфирной интеграцией.
#### `scheduler.py`
Фоновый мониторинг матчей и обновление live-состояния.
#### `agent.py`
Отдельный клиент для связи между vMix и сервером через WebSocket.
---
## 📁 Структура проекта
```text
vmix/
├── app.py # Основной FastAPI сервер
├── agent.py # WebSocket/vMix агент
├── db.py # Подключение к PostgreSQL
├── scheduler.py # Фоновый live scheduler
├── main.py # Demo/seed сценарий
├── deploy.sh # Скрипт деплоя
├── requirements.txt # Python зависимости
├── wfl.sql # Основная схема БД
├── parsers/
│ ├── parser_game.py # Парсинг/обновление данных матча
│ ├── parser_players.py # Парсинг игроков
│ ├── parser_schedule.py # Парсинг расписания
│ ├── parser_standings.py # Парсинг таблицы
│ └── parser_teams.py # Парсинг команд
├── repositories/
│ ├── audit_log_repository.py
│ ├── auth_repository.py
│ ├── coach_repository.py
│ ├── match_clock_repository.py
│ ├── match_coach_repository.py
│ ├── match_event_repository.py
│ ├── match_formation_repository.py
│ ├── match_lineup_repository.py
│ ├── match_referee_repository.py
│ ├── match_repository.py
│ ├── match_session_repository.py
│ ├── match_view_repository.py
│ ├── player_repository.py
│ ├── referee_repository.py
│ ├── stadium_repository.py
│ ├── standings_repository.py
│ ├── team_coach_repository.py
│ ├── team_repository.py
│ └── team_squad_repository.py
├── services/
│ ├── auth_service.py
│ ├── game_service.py
│ ├── players_service.py
│ ├── schedule_service.py
│ ├── standings_service.py
│ ├── teams_service.py
│ └── vmix_json_service.py
├── sql/
│ └── 001_auth.sql # Таблицы админ-пользователей и сессий
├── static/
│ ├── script.js
│ ├── styles.css
│ ├── smith.ico
│ └── images/
│ └── vmix_icon.png
├── templates/
│ ├── login.html
│ ├── matches.html
│ ├── match_workspace.html
│ ├── download_vmix.html
│ ├── admin_db_index.html
│ ├── admin_db_players.html
│ ├── admin_db_player_edit.html
│ ├── admin_db_referees.html
│ ├── admin_db_referee_edit.html
│ ├── admin_db_coaches.html
│ ├── admin_db_coach_edit.html
│ ├── admin_db_stadiums.html
│ ├── admin_db_stadium_edit.html
│ ├── admin_db_teams.html
│ └── admin_db_team_edit.html
└── vmix/
├── __init__.py
└── vmix_service.py # Генерация .vmix файла
```
---
## 🧰 Технологии
| Категория | Технологии |
|---|---|
| Backend | FastAPI |
| Валидация | Pydantic |
| Шаблоны | Jinja2 |
| База данных | PostgreSQL + psycopg2 |
| Парсинг | requests + BeautifulSoup4 |
| Realtime | WebSocket |
| Интеграция с эфиром | vMix API + XML |
| Конфигурация | python-dotenv |
| Дополнительно | numpy |
### Зависимости из `requirements.txt`
```txt
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30,<1.0
pydantic>=2.0,<3.0
jinja2>=3.1,<4.0
python-multipart>=0.0.9,<1.0
requests>=2.31,<3.0
websockets>=12,<16
beautifulsoup4>=4.12,<5.0
psycopg2-binary>=2.9,<3.0
python-dotenv>=1.0,<2.0
numpy>=1.26,<3.0
```
Также в файле указан дополнительный приватный индекс и пакет `nasio`, поэтому для полноценной установки в целевой среде может понадобиться доступ к внутреннему package registry.
---
## 🚀 Быстрый старт
## 1. Клонирование репозитория
```bash
git clone <YOUR_REPOSITORY_URL>
cd vmix
```
## 2. Установка зависимостей
```bash
pip install -r requirements.txt
```
## 3. Настройка переменных окружения
Создай файл `.env` в корне проекта:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=wfl_db
DB_USER=postgres
DB_PASSWORD=your_password
```
`db.py` загружает эти параметры через `python-dotenv` и использует их для подключения к PostgreSQL.
## 4. Инициализация базы данных
Сначала разверни основную схему:
```bash
psql -U postgres -d wfl_db -f wfl.sql
```
Затем создай таблицы авторизации:
```bash
psql -U postgres -d wfl_db -f sql/001_auth.sql
```
## 5. Создание администратора
```bash
python scripts/create_admin.py
```
Скрипт запросит:
- `Username`
- `Password`
После этого создаст запись в таблице `admin_users`.
## 6. Запуск приложения
```bash
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
```
После запуска приложение будет доступно по адресу:
```text
http://localhost:8000
```
---
## 🔐 Авторизация
В проекте используется сессионная авторизация для админки.
### Что есть в коде
- таблица `admin_users`;
- таблица `auth_sessions`;
- проверка пароля в `services/auth_service.py`;
- cookie/session-based доступ к админским маршрутам;
- logout с отзывом сессии.
### SQL-таблицы авторизации
Файл `sql/001_auth.sql` создаёт:
- `admin_users`
- `auth_sessions`
- индексы по токену, пользователю и активности
### Скрипт создания пользователя
`python scripts/create_admin.py`
---
## 🖥 Интерфейс и рабочие зоны
### Основные страницы
- `/login` — вход администратора;
- `/` — редирект/входная точка;
- `/admin/matches` — список матчей для администратора;
- `/admin/session/{session_token}` — рабочее пространство матча;
- `/admin/session/{session_token}/download-vmix-page` — страница выгрузки проекта;
- `/admin/db` — индекс админского раздела БД.
### Разделы админ-БД
- игроки;
- судьи;
- тренеры;
- стадионы;
- команды.
Для каждой сущности есть минимум список и форма редактирования.
---
## 📡 HTTP и WebSocket API
Ниже — маршруты, которые реально определены в `app.py`.
### Аутентификация
| Метод | Маршрут | Назначение |
|---|---|---|
| GET | `/login` | страница входа |
| POST | `/login` | авторизация |
| POST | `/logout` | выход |
### Матчи и сессии
| Метод | Маршрут | Назначение |
|---|---|---|
| GET | `/admin/matches` | список матчей |
| GET | `/admin/matches/{match_id}/select` | выбор матча и создание/открытие сессии |
| GET | `/admin/session/{session_token}` | рабочее пространство матча |
| POST | `/admin/session/{session_token}/load-match-data` | загрузка/обновление данных матча |
| POST | `/admin/session/{session_token}/close` | закрытие сессии |
| GET | `/admin/session/{session_token}/download-vmix-page` | HTML-страница выгрузки vMix |
| GET | `/admin/session/{session_token}/download-vmix` | скачивание `.vmix` |
### Формации и составы
| Метод | Маршрут | Назначение |
|---|---|---|
| POST | `/admin/session/{session_token}/formations/apply` | применить preset формации к игрокам |
| POST | `/admin/session/{session_token}/formations/save` | сохранить формации |
| GET | `/admin/session/{session_token}/squad-editor-data` | получить данные редактора состава |
| POST | `/admin/session/{session_token}/squad-editor-save` | сохранить состав |
| POST | `/admin/session/{session_token}/referees/save` | сохранить судей на матч |
### События матча
| Метод | Маршрут | Назначение |
|---|---|---|
| GET | `/admin/session/{session_token}/events` | список событий |
| POST | `/admin/session/{session_token}/event` | создать событие |
| PUT | `/admin/session/{session_token}/event/{event_id}` | обновить событие |
| DELETE | `/admin/session/{session_token}/event/{event_id}` | удалить событие |
| DELETE | `/admin/session/{session_token}/events` | очистить все события |
### Справочники БД
| Метод | Маршрут | Назначение |
|---|---|---|
| GET | `/admin/db` | главная админ-БД |
| GET/POST | `/admin/db/players` / `/admin/db/players/{player_id}/edit` | список/редактирование игроков |
| GET/POST | `/admin/db/referees` / `/admin/db/referees/{referee_id}/edit` | список/редактирование судей |
| GET/POST | `/admin/db/coaches` / `/admin/db/coaches/{coach_id}/edit` | список/редактирование тренеров |
| GET/POST | `/admin/db/stadiums` / `/admin/db/stadiums/{stadium_id}/edit` | список/редактирование стадионов |
| GET/POST | `/admin/db/teams` / `/admin/db/teams/{team_id}/edit` | список/редактирование команд |
### vMix JSON endpoints
| Метод | Маршрут | Назначение |
|---|---|---|
| GET | `/vmix/session/{session_token}/home-lineup` | стартовый состав хозяев |
| GET | `/vmix/session/{session_token}/away-lineup` | стартовый состав гостей |
| GET | `/vmix/session/{session_token}/home-bench` | запасные хозяев |
| GET | `/vmix/session/{session_token}/away-bench` | запасные гостей |
| GET | `/vmix/session/{session_token}/info` | информация о матче |
| GET | `/vmix/session/{session_token}/standings` | турнирная таблица |
| GET | `/vmix/session/{session_token}/schedule` | расписание/тур |
| GET | `/vmix/session/{session_token}/home-formations` | формация хозяев |
| GET | `/vmix/session/{session_token}/away-formations` | формация гостей |
| GET | `/vmix/session/{session_token}/scoreboard` | данные для счётчика |
| GET | `/vmix/session/{session_token}/match-events` | данные по событиям матча |
### Команды для vMix-клиентов
| Метод | Маршрут | Назначение |
|---|---|---|
| POST | `/api/vmix/publish-command` | отправить команду в vMix-клиент(ы) |
| GET | `/api/vmix/clients` | список подключённых клиентов |
| WS | `/ws/vmix-client` | WebSocket-канал клиентов |
---
## 🎬 Интеграция с vMix
### Что делает сервер
Сервер:
- создаёт матчевые сессии;
- готовит JSON для графики;
- собирает и отдаёт `.vmix`-проект;
- отправляет команды в подключённые клиентские агенты.
### Что делает агент `agent.py`
Агент — это отдельное приложение, которое запускается рядом с vMix и выполняет роль моста между сервером и локальным vMix API.
#### В коде агента есть:
- `VMIX_API = "http://127.0.0.1:8088/api"`
- `WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"`
- чтение XML-ответа vMix;
- извлечение `dynamic/value1`, `dynamic/value2` и других значений;
- регистрация клиента в WebSocket;
- reconnect-логика.
### Как это работает в общем
1. Оператор открывает матч в админке.
2. Создаётся матчевая сессия с `session_token`.
3. Сервер отдаёт JSON и/или `.vmix`-файл.
4. Локальный агент подключается по WebSocket.
5. Сервер отправляет команды конкретному клиенту, группе или оператору.
6. Агент применяет команды в локальном vMix через HTTP API.
---
## 🧾 Генерация JSON для графики
Логика сосредоточена в `services/vmix_json_service.py`.
### По коду видно, что сервис формирует:
- стартовые составы;
- запасных;
- информацию о матче;
- турнирную таблицу;
- расписание тура;
- формации команд;
- scoreboard-информацию;
- события матча;
- данные для показа авторов голов.
### Особенности реализации
- строятся полные имена игроков;
- добавляются суффиксы капитана и вратаря;
- формируются пути к фотографиям игроков;
- используются логотипы команд и специальные варианты логотипов для некоторых клубов;
- матчевая информация собирается SQL-запросами по сессии.
---
## 📦 Генерация `.vmix` проекта
Файл `vmix/vmix_service.py` используется для:
- сборки бинарного содержимого vMix-проекта;
- генерации имени файла;
- выдачи файла пользователю через FastAPI.
В `app.py` для этого используются:
- `build_vmix_project_bytes`
- `build_vmix_filename`
Это означает, что оператор может открыть страницу сессии и скачать готовый `.vmix`-проект под конкретный матч.
---
## ⏱ Scheduler и live-мониторинг
`scheduler.py` — это фоновый процесс, который поднимается на старте приложения:
```python
@app.on_event("startup")
def start_scheduler():
import threading
thread = threading.Thread(target=run_scheduler, daemon=True)
thread.start()
```
### Ключевые интервалы
```python
MATCH_START_LEAD_MINUTES = 1
LIVE_MATCH_POLL_SECONDS = 60
MATCHES_LOOP_SECONDS = 60
STANDINGS_LOOP_SECONDS = 60
WORKER_ERROR_RETRY_SECONDS = 30
WORKER_MAX_ERRORS_IN_ROW = 20
```
### Что делает scheduler
- получает матчи на сегодня из БД;
- определяет, какие матчи уже пора мониторить;
- запускает отдельные worker-потоки;
- обновляет status/home_score/away_score в таблице матчей;
- вызывает обновление турнирной таблицы;
- ведёт реестр активных worker'ов.
### Источник live-данных
По текущему коду live-страница матча запрашивается с:
```text
https://wfl.rfs.ru/match/{match_id}
```
HTML разбирается через BeautifulSoup.
### Важное замечание
В `fetch_match_live_data()` есть незавершённая часть: функция частично реализована и в конце всё ещё содержит `NotImplementedError`. То есть scheduler уже встроен, но live-парсинг требует аккуратной доработки, чтобы считать его полностью production-ready.
---
## 🗄 Репозитории и доменные сущности
По составу `repositories/` проект работает со следующими сущностями:
### Пользователи и аудит
- `auth_repository.py`
- `audit_log_repository.py`
### Матчи
- `match_repository.py`
- `match_session_repository.py`
- `match_view_repository.py`
- `match_clock_repository.py`
- `match_event_repository.py`
### Составы и формации
- `match_lineup_repository.py`
- `match_formation_repository.py`
- `team_squad_repository.py`
### Официальные лица и стадионы
- `referee_repository.py`
- `match_referee_repository.py`
- `coach_repository.py`
- `match_coach_repository.py`
- `team_coach_repository.py`
- `stadium_repository.py`
### Команды, игроки, таблица
- `team_repository.py`
- `player_repository.py`
- `standings_repository.py`
Это хороший признак разделённой архитектуры: доступ к данным отделён от HTTP-слоя и от прикладной логики.
---
## 🔄 Сервисы синхронизации
В `services/` есть отдельные сервисы:
- `teams_service.py`
- `schedule_service.py`
- `players_service.py`
- `standings_service.py`
- `game_service.py`
- `auth_service.py`
- `vmix_json_service.py`
### Demo-режим
`main.py` показывает, как можно программно наполнить систему тестовыми данными:
- создаются 2 команды;
- создаётся матч;
- добавляются игроки;
- заполняется турнирная таблица.
Запуск:
```bash
python main.py
```
Это удобно для первичной проверки БД и шаблонного сценария разработки.
---
## 🧪 Полезные команды
### Проверка подключения к БД
```bash
python db.py
```
### Создание администратора
```bash
python scripts/create_admin.py
```
### Demo-наполнение
```bash
python main.py
```
### Запуск сервера
```bash
uvicorn app:app --reload
```
---
## 🖼 Скриншоты
Ниже заготовки под реальные скриншоты интерфейса. Когда будут реальные изображения, просто положи их в репозиторий и обнови пути.
```md
![Login](docs/screens/login.png)
![Matches](docs/screens/matches.png)
![Workspace](docs/screens/workspace.png)
![Database](docs/screens/database.png)
```
---
## ⚠️ Ограничения и важные замечания
### 1. Документация OpenAPI отключена
В `app.py` явно выключены:
- `/docs`
- `/redoc`
- `/openapi.json`
Это нормально для внутреннего production-режима, но неудобно для внешних интеграций.
### 2. Live scheduler требует доработки
`fetch_match_live_data()` ещё не завершена до конца и сейчас выглядит как заготовка с частичной логикой.
### 3. Есть Windows-специфичные элементы
В JSON-сервисе и агенте встречаются Windows-пути и логика работы с иконкой консоли. Это значит, что часть инфраструктуры ориентирована на Windows-машину оператора.
### 4. Есть внешняя зависимость от приватного Python registry
В `requirements.txt` указан `--extra-index-url`, поэтому разворачивание в другой среде может потребовать доступов.
### 5. Есть жёстко заданные интеграционные значения
Например:
- `http://127.0.0.1:8088/api`
- `wss://wfl.tvstart.ru/ws/vmix-client`
Для production лучше вынести их в `.env`.
---
## 🔮 Что можно улучшить
### Инфраструктура
- добавить Dockerfile и `docker-compose.yml`;
- вынести конфигурацию целиком в `.env`;
- добавить healthcheck и monitoring;
- настроить CI/CD.
### Backend
- включить структурированное логирование;
- покрыть ключевые места тестами;
- заменить часть threading-логики на более управляемый background orchestration;
- валидацию live-парсеров сделать устойчивее.
### Безопасность
- ограничить rate limit для логина;
- добавить CSRF-защиту, если нужна;
- вынести секреты и URL в окружение;
- расширить аудит действий администратора.
### API и DX
- вернуть внутреннюю OpenAPI-документацию под флагом окружения;
- добавить Postman/Insomnia collection;
- оформить контракт JSON-ответов для графики.
### UI/UX
- добавить больше навигации в админке;
- улучшить визуальный статус live-сессий;
- добавить быстрые действия в матч-центре.
---
## 🛠 Пример локального сценария запуска
```bash
# 1. установить зависимости
pip install -r requirements.txt
# 2. создать .env
cp .env.example .env # если появится шаблон
# 3. поднять БД и применить SQL
psql -U postgres -d wfl_db -f wfl.sql
psql -U postgres -d wfl_db -f sql/001_auth.sql
# 4. создать администратора
python scripts/create_admin.py
# 5. при необходимости заполнить демо-данными
python main.py
# 6. запустить сервер
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
```
---
## 🤝 Вклад в проект
Если проект будет развиваться дальше, рекомендуется принять минимальные правила contribution:
1. отдельная ветка на каждую фичу;
2. PR с описанием изменений;
3. проверка SQL-миграций перед merge;
4. ручная проверка vMix-сценария и матчевого workflow.
---
## 📄 Лицензия
В текущем архиве лицензия отдельно не обнаружена.
Если проект внутренний — можно оставить private/internal use only.
Если репозиторий будет публичным — добавь подходящую лицензию, например MIT, Apache-2.0 или другую нужную команде.
---
## 👨‍💻 Итог
Этот проект — это не просто CRUD-приложение, а полноценный операторский инструмент для футбольной трансляции:
- FastAPI-сервер с HTML-интерфейсом;
- PostgreSQL как основной источник данных;
- сессионная админ-авторизация;
- матчевые рабочие сессии;
- составы, формации, события и судьи;
- JSON и `.vmix` для эфирной графики;
- WebSocket-мост для операторских клиентов;
- scheduler для live-матчей и обновления данных.
Если нужно, следующим сообщением я могу так же сделать ещё и:
- `README_EN.md` на английском;
- `docs/API.md` с описанием всех маршрутов;
- `docker-compose.yml` для локального запуска;
- `.env.example`;
- более «маркетинговую» GitHub-версию с centered hero, anchors и navigation block.

View File

@@ -1,45 +0,0 @@
# WFL auth + idle session timeout
Этот набор добавляет:
- страницу `/login`
- `HttpOnly` cookie `auth_token`
- серверную проверку всех `/admin/*`
- автоматический logout после 2 часов бездействия
- rolling session timeout
- logout кнопку
- обработку `401` для AJAX/fetch
## Что заменить в проекте
Скопируй файлы в свой проект:
- `app.py` -> заменить текущий
- `repositories/auth_repository.py` -> новый файл
- `services/auth_service.py` -> новый файл
- `templates/login.html` -> новый файл
- `templates/matches.html` -> заменить
- `templates/match_workspace.html` -> заменить
## SQL
Выполни файл:
- `sql/001_auth.sql`
## Первый админ
1. Запусти:
`python scripts/create_admin.py`
2. Скопируй выведенный SQL
3. Выполни его в PostgreSQL
## Важно
В `app.py` cookie сейчас создаётся так:
- `secure=False`
Для production под HTTPS поменяй на:
- `secure=True`
## Что не трогалось
Твои `match_sessions` оставлены как рабочие сессии матча.
Пользовательская авторизация вынесена отдельно в `admin_users` и `auth_sessions`.

133
agent.py
View File

@@ -5,12 +5,15 @@ import socket
import uuid import uuid
from urllib.parse import urlencode from urllib.parse import urlencode
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import os
import sys
import ctypes
import requests import requests
import websockets import websockets
VMIX_API = "http://127.0.0.1:8088/api" VMIX_API = "http://127.0.0.1:8088/api"
# WS_BASE = "wss://hostname.tvstart.ru/ws/vmix-client" # WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client" WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}" CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
@@ -18,6 +21,72 @@ POLL_INTERVAL = 3
REQUEST_TIMEOUT = 3 REQUEST_TIMEOUT = 3
RECONNECT_DELAY = 5 RECONNECT_DELAY = 5
def resource_path(relative_path: str) -> str:
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
def setup_green_console():
if os.name != "nt":
return
kernel32 = ctypes.windll.kernel32
h_out = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_uint()
if kernel32.GetConsoleMode(h_out, ctypes.byref(mode)):
kernel32.SetConsoleMode(h_out, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
# зелёный текст по умолчанию
print("\033[92m", end="")
def set_console_icon():
if os.name != "nt":
return
ico_path = resource_path("app.ico")
if not os.path.exists(ico_path):
return
user32 = ctypes.windll.user32
# AppUserModelID помогает Windows корректнее показывать иконку приложения
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("my.agent.console")
hwnd = kernel32_get_console_window()
if not hwnd:
return
IMAGE_ICON = 1
LR_LOADFROMFILE = 0x00000010
WM_SETICON = 0x0080
ICON_SMALL = 0
ICON_BIG = 1
hicon = user32.LoadImageW(
None,
ico_path,
IMAGE_ICON,
0,
0,
LR_LOADFROMFILE
)
if hicon:
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon)
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon)
def kernel32_get_console_window():
try:
return ctypes.windll.kernel32.GetConsoleWindow()
except Exception:
return 0
def read_vmix_dynamic_values(): def read_vmix_dynamic_values():
try: try:
resp = requests.get(VMIX_API, timeout=REQUEST_TIMEOUT) resp = requests.get(VMIX_API, timeout=REQUEST_TIMEOUT)
@@ -36,12 +105,14 @@ def read_vmix_dynamic_values():
session_token = get_text("./dynamic/value1") session_token = get_text("./dynamic/value1")
match_id = get_text("./dynamic/value2") match_id = get_text("./dynamic/value2")
group_name = get_text("./dynamic/value3") group_name = get_text("./dynamic/value3")
operator_login = get_text("./dynamic/value4")
return { return {
"ok": True, "ok": True,
"session_token": session_token, "session_token": session_token,
"match_id": int(match_id) if match_id and match_id.isdigit() else None, "match_id": int(match_id) if match_id and match_id.isdigit() else None,
"group_name": group_name, "group_name": group_name,
"operator_login": operator_login,
} }
except Exception as e: except Exception as e:
return { return {
@@ -50,8 +121,10 @@ def read_vmix_dynamic_values():
"session_token": None, "session_token": None,
"match_id": None, "match_id": None,
"group_name": None, "group_name": None,
"operator_login": None,
} }
def execute_vmix_command(path: str): def execute_vmix_command(path: str):
path = str(path or "").strip() path = str(path or "").strip()
if not path.startswith("/api/"): if not path.startswith("/api/"):
@@ -73,46 +146,55 @@ def execute_vmix_command(path: str):
"error": str(e), "error": str(e),
} }
async def wait_for_session():
async def wait_for_routing():
while True: while True:
data = read_vmix_dynamic_values() data = read_vmix_dynamic_values()
if data["ok"] and data["session_token"]: if data["ok"] and data["match_id"] is not None and data["operator_login"]:
return data return data
print("[agent] waiting for vMix session...", data) print("[agent] waiting for match_id + operator_login...", data)
await asyncio.sleep(POLL_INTERVAL) await asyncio.sleep(POLL_INTERVAL)
def build_ws_url(session_token: str, match_id: int | None, group_name: str | None):
def build_ws_url(match_id: int, group_name: str | None, operator_login: str):
params = { params = {
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"session_token": session_token, "match_id": match_id,
"operator_name": operator_login,
} }
if match_id is not None:
params["match_id"] = match_id
if group_name: if group_name:
params["group_name"] = group_name params["group_name"] = group_name
return f"{WS_BASE}?{urlencode(params)}" return f"{WS_BASE}?{urlencode(params)}"
async def ping_loop(ws): async def ping_loop(ws):
while True: while True:
await asyncio.sleep(15) await asyncio.sleep(15)
await ws.send(json.dumps({"type": "ping"})) await ws.send(json.dumps({"type": "ping"}))
async def run_agent(): async def run_agent():
current_session = None current_match_id = None
current_group_name = None
current_operator_login = None
while True: while True:
vmix_data = await wait_for_session() vmix_data = await wait_for_routing()
session_token = vmix_data["session_token"]
match_id = vmix_data["match_id"] match_id = vmix_data["match_id"]
group_name = vmix_data["group_name"] group_name = vmix_data["group_name"]
operator_login = vmix_data["operator_login"]
if current_session != session_token: if current_match_id != match_id or current_operator_login != operator_login or current_group_name != group_name:
print(f"[agent] found session: {session_token}") print(
current_session = session_token f"[agent] routing: match_id={match_id}, operator_login={operator_login}, group_name={group_name}"
)
current_match_id = match_id
current_group_name = group_name
current_operator_login = operator_login
ws_url = build_ws_url(session_token, match_id, group_name) ws_url = build_ws_url(match_id, group_name, operator_login)
try: try:
async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws: async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws:
@@ -121,19 +203,26 @@ async def run_agent():
await ws.send(json.dumps({ await ws.send(json.dumps({
"type": "register", "type": "register",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"session_token": session_token,
"match_id": match_id, "match_id": match_id,
"group_name": group_name, "group_name": group_name,
"operator_name": operator_login,
})) }))
pinger = asyncio.create_task(ping_loop(ws)) pinger = asyncio.create_task(ping_loop(ws))
try: try:
while True: while True:
# следим, не сменился ли проект/session в vMix
latest = read_vmix_dynamic_values() latest = read_vmix_dynamic_values()
if latest["ok"] and latest["session_token"] and latest["session_token"] != current_session: if latest["ok"]:
print("[agent] session changed in vMix, reconnecting") latest_match_id = latest.get("match_id")
latest_group_name = latest.get("group_name")
latest_operator_login = latest.get("operator_login")
if (
latest_match_id != match_id
or latest_group_name != group_name
or latest_operator_login != operator_login
):
print("[agent] routing meta changed in vMix, reconnecting")
break break
try: try:
@@ -149,7 +238,8 @@ async def run_agent():
await ws.send(json.dumps({ await ws.send(json.dumps({
"type": "vmix_result", "type": "vmix_result",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"session_token": session_token, "match_id": match_id,
"operator_name": operator_login,
"results": results, "results": results,
"sent_at": time.time(), "sent_at": time.time(),
})) }))
@@ -161,5 +251,8 @@ async def run_agent():
await asyncio.sleep(RECONNECT_DELAY) await asyncio.sleep(RECONNECT_DELAY)
if __name__ == "__main__": if __name__ == "__main__":
setup_green_console()
set_console_icon()
asyncio.run(run_agent()) asyncio.run(run_agent())

1701
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,9 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from services.game_service import sync_match_page from services.game_service import sync_match_page
from source_config import DATA_MATCH_BASE_URL, match_url
BASE_MATCH_URL = "https://wfl.rfs.ru/match/" BASE_MATCH_URL = DATA_MATCH_BASE_URL
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -20,12 +21,37 @@ def extract_player_id_from_href(href: str) -> str:
def detect_captain(item) -> bool: def detect_captain(item) -> bool:
"""Определяет капитана по текстовой метке или отдельному HTML-маркеру сайта.
На странице протокола капитан может быть обозначен как ``(К)`` / ``(C)``,
а в некоторых версиях вёрстки — отдельным элементом/иконкой с captain в
class, title, aria-label или data-атрибуте. Если сайт вообще не указал
капитана, функция корректно возвращает False.
"""
if not item: if not item:
return False return False
text = item.get_text(" ", strip=True).lower() text = item.get_text(" ", strip=True).lower().replace("ё", "е")
if any(marker in text for marker in ("(к)", "(c)", "капитан", "captain")):
return True
return any(x in text for x in ["(к)", "(c)"]) # Поддержка отдельной иконки/элемента капитана, если буква не входит
# в видимый текст строки игрока. Не привязываемся к одной версии вёрстки.
for node in [item, *item.find_all(True)]:
classes = " ".join(node.get("class", [])).lower()
attrs_text = " ".join(
str(node.get(attr) or "")
for attr in ("title", "aria-label", "data-title", "data-role", "data-captain")
).lower().replace("ё", "е")
if "captain" in classes or "капитан" in classes:
return True
if "captain" in attrs_text or "капитан" in attrs_text:
return True
if str(node.get("data-captain") or "").strip().lower() in {"1", "true", "yes"}:
return True
return False
def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]: def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]:
home_starting = [] home_starting = []
@@ -197,10 +223,11 @@ def parse_game_page(html: str) -> dict:
def run_parser_game(match_external_id: str) -> None: def run_parser_game(match_external_id: str) -> None:
url = f"{BASE_MATCH_URL}{str(match_external_id).strip()}" url = match_url(match_external_id)
html = fetch_html(url) html = fetch_html(url)
data = parse_game_page(html) data = parse_game_page(html)
if data:
sync_match_page( sync_match_page(
match_external_id=str(match_external_id).strip(), match_external_id=str(match_external_id).strip(),
home_starting=data["home_starting"], home_starting=data["home_starting"],

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.players_service import sync_team_roster from services.players_service import sync_team_roster
from parsers.parser_sources import get_parser_source, source_absolute_url
URL_TEAMS = "https://wfl.rfs.ru/tournament/1061879/teams"
AMPLUA_FULL = { AMPLUA_FULL = {
"Пз.": "Полузащитник", "Пз.": "Полузащитник",
"Вр.": "Вратарь", "Вр.": "Вратарь",
@@ -23,19 +22,26 @@ def fetch_html(url: str) -> str:
return r.text return r.text
def get_links(html: str) -> list[dict]: def get_links(html: str, source: dict) -> list[dict]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
links: list[dict] = [] links: list[dict] = []
items = soup.find("ul", class_="teams__list").find_all("li") teams_list = soup.find("ul", class_="teams__list")
for i in items: if not teams_list:
href = i.find("a", class_="teams__link").get("href") return links
team_external_id = href.split("team_id=")[-1].strip()
items = teams_list.find_all("li")
for item in items:
link_el = item.find("a", class_="teams__link")
href = link_el.get("href") if link_el else ""
if not href:
continue
team_external_id = href.split("team_id=")[-1].strip() if "team_id=" in href else ""
links.append( links.append(
{ {
"team_external_id": team_external_id, "team_external_id": team_external_id,
"url": "https://wfl.rfs.ru" + href, "url": source_absolute_url(source, href),
} }
) )
@@ -92,13 +98,14 @@ def parse_team(html: str) -> dict:
full_player = name_p.get_text(strip=True) if name_p else "" full_player = name_p.get_text(strip=True) if name_p else ""
parts = full_player.split() parts = full_player.split()
pos_short = pos_td.get_text(strip=True) if pos_td else ""
players.append( players.append(
{ {
"player_id": player_id or "", "player_id": player_id or "",
"number": number_td.get_text(strip=True) if number_td else "", "number": number_td.get_text(strip=True) if number_td else "",
"pos": pos_td.get_text(strip=True) if pos_td else "", "pos": pos_short,
"amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""], "amplua": AMPLUA_FULL.get(pos_short, pos_short),
"player": full_player, "player": full_player,
"lastname": parts[0] if len(parts) >= 1 else "", "lastname": parts[0] if len(parts) >= 1 else "",
"name": parts[-1] if len(parts) >= 2 else "", "name": parts[-1] if len(parts) >= 2 else "",
@@ -133,7 +140,7 @@ def parse_team(html: str) -> dict:
"lastname": last_name, "lastname": last_name,
"player": f"{last_name} {first_name}".strip(), "player": f"{last_name} {first_name}".strip(),
"born": born.get_text(strip=True).replace(",", "") if born else "", "born": born.get_text(strip=True).replace(",", "") if born else "",
"amplua": amplua.get_text(strip=True) if amplua else "", "amplua": amplua.get_text(strip=True).replace(".", "") if amplua else "",
} }
) )
@@ -144,9 +151,13 @@ def parse_team(html: str) -> dict:
} }
def run_parser_players() -> None: def run_parser_players(source_key: str | None = None) -> None:
html = fetch_html(URL_TEAMS) source = get_parser_source(source_key)
links = get_links(html) print(f"[parser_players] Источник: {source['title']}")
print(f"[parser_players] URL: {source['teams_url']}")
html = fetch_html(source["teams_url"])
links = get_links(html, source)
with ThreadPoolExecutor(max_workers=8) as pool: with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(fetch_html, item["url"]): item for item in links} futures = {pool.submit(fetch_html, item["url"]): item for item in links}
@@ -169,6 +180,9 @@ def run_parser_players() -> None:
except Exception as e: except Exception as e:
print(f"[parser_players] error team={item['team_external_id']}: {e}") print(f"[parser_players] error team={item['team_external_id']}: {e}")
if not links:
print("[parser_players] Команды для парсинга игроков не найдены")
if __name__ == "__main__": if __name__ == "__main__":
run_parser_players() run_parser_players()

View File

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo
from services.schedule_service import sync_matches from services.schedule_service import sync_matches
from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id
from parsers.parser_sources import get_parser_source
MONTHS_RU = { MONTHS_RU = {
@@ -24,11 +25,6 @@ MONTHS_RU = {
TZ = ZoneInfo("Europe/Moscow") TZ = ZoneInfo("Europe/Moscow")
URL_SCHEDULE = (
"https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours"
)
SEASON = "2025/2026"
def parse_russian_date(date_str: str, year: int | None = None) -> datetime: def parse_russian_date(date_str: str, year: int | None = None) -> datetime:
@@ -76,7 +72,7 @@ def safe_int(value: str | None) -> int | None:
return int(value) if value.isdigit() else None return int(value) if value.isdigit() else None
def parse_schedule(html: str) -> list[dict]: def parse_schedule(html: str, season: str, source_key: str | None = None) -> list[dict]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
matches_data: list[dict] = [] matches_data: list[dict] = []
@@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]:
"home_score": home_score, "home_score": home_score,
"away_score": away_score, "away_score": away_score,
"tour": tour, "tour": tour,
"season": SEASON, "season": season,
"place": place, "place": place,
"date_raw": time_site, "date_raw": time_site,
"score_add": score_add, "score_add": score_add,
"source_key": source_key,
} }
) )
@@ -162,11 +159,18 @@ def parse_schedule(html: str) -> list[dict]:
return matches_data return matches_data
def run_parser_schedule() -> None: def run_parser_schedule(source_key: str | None = None) -> None:
html = fetch_html(URL_SCHEDULE) source = get_parser_source(source_key)
matches_data = parse_schedule(html) print(f"[parser_schedule] Источник: {source['title']}")
print(f"[parser_schedule] URL: {source['schedule_url']}")
html = fetch_html(source["schedule_url"])
matches_data = parse_schedule(html, source["season"], source["key"])
if matches_data:
sync_matches(matches_data) sync_matches(matches_data)
print(f"[parser_schedule] Matches synced: {len(matches_data)}") print(f"[parser_schedule] Matches synced: {len(matches_data)}")
else:
print("[parser_schedule] Матчи не найдены")
if __name__ == "__main__": if __name__ == "__main__":

291
parsers/parser_sources.py Normal file
View File

@@ -0,0 +1,291 @@
from __future__ import annotations
from urllib.parse import urljoin
from repositories.project_settings_repository import (
DEFAULT_APP_SETTINGS,
DEFAULT_PARSER_SOURCES,
ensure_project_settings_tables,
get_app_setting,
get_parser_source_from_db,
list_parser_sources_from_db,
)
def _fallback_sources() -> list[dict]:
return [dict(item) for item in DEFAULT_PARSER_SOURCES]
def _fallback_sources_dict() -> dict[str, dict]:
return {item["key"]: dict(item) for item in DEFAULT_PARSER_SOURCES}
def _fallback_default_source_key() -> str:
return DEFAULT_APP_SETTINGS["default_parser_source_key"]
def _fallback_base_url() -> str:
return DEFAULT_APP_SETTINGS["rfs_base_url"].rstrip("/")
def _get_base_url() -> str:
try:
ensure_project_settings_tables()
return get_app_setting("rfs_base_url", _fallback_base_url()).rstrip("/")
except Exception:
return _fallback_base_url()
RFS_BASE_URL = _get_base_url()
def get_rfs_base_url() -> str:
return _get_base_url()
def get_default_source_key() -> str:
try:
ensure_project_settings_tables()
value = get_app_setting("default_parser_source_key", _fallback_default_source_key())
value = (value or _fallback_default_source_key()).upper().strip()
known_keys = {source["key"] for source in list_parser_sources()}
return value if value in known_keys else _fallback_default_source_key()
except Exception:
return _fallback_default_source_key()
def get_parser_sources() -> dict[str, dict]:
try:
ensure_project_settings_tables()
sources = list_parser_sources_from_db(active_only=True)
if not sources:
return _fallback_sources_dict()
return {source["key"]: source for source in sources}
except Exception:
return _fallback_sources_dict()
def list_parser_sources() -> list[dict]:
sources = get_parser_sources()
return list(sources.values())
def get_parser_source(source_key: str | None = None) -> dict:
"""Возвращает настройки источника из БД. Если БД недоступна, используется fallback."""
source_key = (source_key or get_default_source_key() or "SUPERLEAGUE").upper().strip()
try:
ensure_project_settings_tables()
source = get_parser_source_from_db(source_key)
if source:
return source
except Exception:
pass
fallback = _fallback_sources_dict()
if source_key in fallback:
return fallback[source_key]
raise ValueError(f"Неизвестный источник парсинга: {source_key}")
def source_absolute_url(source: dict, path_or_url: str) -> str:
value = (path_or_url or "").strip()
if value.startswith(("http://", "https://")):
return value
base_url = (source.get("base_url") or get_rfs_base_url()).rstrip("/")
return urljoin(base_url + "/", value.lstrip("/"))
def source_match_url(source: dict, match_external_id: str | int) -> str:
match_base_url = (source.get("match_base_url") or f"{get_rfs_base_url()}/match/").rstrip("/") + "/"
return f"{match_base_url}{str(match_external_id).strip()}"
def extract_logo_filename(value: str | None) -> str:
"""Возвращает только имя файла логотипа из старого полного пути или нового значения."""
value = str(value or "").strip().strip('"').strip("'")
if not value:
return ""
normalized = value.replace("/", "\\")
filename = normalized.split("\\")[-1].strip()
return filename
def ensure_logo_extension(filename: str) -> str:
filename = extract_logo_filename(filename)
if not filename:
return ""
if "." not in filename.rsplit("\\", 1)[-1]:
return f"{filename}.png"
return filename
def get_logo_base_path(source_key: str | None = None) -> str:
source = get_parser_source(source_key)
return str(source.get("logo_base_path") or "").rstrip("\\/")
def build_logo_path(source_key: str | None, filename: str | None) -> str:
"""Собирает полный путь к логотипу для vMix по источнику турнира."""
logo_file = ensure_logo_extension(filename)
if not logo_file:
return ""
base_path = get_logo_base_path(source_key)
if not base_path:
return logo_file
return f"{base_path}\\{logo_file}"
def build_logo_variant_path(
source_key: str | None,
filename: str | None,
variant: str | None = None,
) -> str:
"""
Собирает путь к варианту логотипа.
variant="white" -> Динамоелый / Зенит_Белый
variant="blue" -> Динамо_Синий / Зенит_Синий
Остальные команды остаются без изменения.
"""
logo_file = ensure_logo_extension(filename)
if not logo_file:
return ""
suffix = ""
if variant == "white":
suffix = "елый"
elif variant == "blue":
suffix = "_Синий"
if suffix:
lower = logo_file.lower()
if "динамо" in lower or "зенит" in lower:
if "." in logo_file:
stem, ext = logo_file.rsplit(".", 1)
ext = "." + ext
else:
stem, ext = logo_file, ".png"
for old_suffix in ("елый", "_Синий"):
if stem.endswith(old_suffix):
stem = stem[: -len(old_suffix)]
logo_file = f"{stem}{suffix}{ext}"
return build_logo_path(source_key, logo_file)
def extract_photo_filename(value: str | None) -> str:
"""Возвращает имя файла/относительный путь фото из старого полного пути или нового значения."""
value = str(value or "").strip().strip('"').strip("'")
if not value:
return ""
normalized = value.replace("/", "\\")
lower = normalized.lower()
# Для старых полных путей пытаемся сохранить относительную часть от папки Photo.
marker = "\\photo\\"
if marker in lower:
idx = lower.rfind(marker)
return normalized[idx + len(marker):].strip("\\")
parts = [part for part in normalized.split("\\") if part]
if len(parts) >= 2 and ":" in parts[0]:
return parts[-1].strip()
return normalized.strip("\\")
def ensure_photo_extension(filename: str) -> str:
filename = extract_photo_filename(filename)
if not filename:
return ""
if "." not in filename.rsplit("\\", 1)[-1]:
return f"{filename}.png"
return filename
def get_photo_base_path(source_key: str | None = None) -> str:
source = get_parser_source(source_key)
return str(source.get("photo_base_path") or "").rstrip("\\/")
def build_photo_path(source_key: str | None, filename: str | None) -> str:
"""Собирает полный путь к фото игрока для vMix по источнику турнира."""
photo_file = ensure_photo_extension(filename)
if not photo_file:
return ""
base_path = get_photo_base_path(source_key)
if not base_path:
return photo_file
return f"{base_path}\\{photo_file}"
def build_empty_photo_path(source_key: str | None = None) -> str:
"""Путь к пустой картинке фото для выбранного источника."""
return build_photo_path(source_key, "EMPTY.png")
def extract_channel_logo_filename(value: str | None) -> str:
"""Возвращает имя файла логотипа телеканала из старого полного пути или нового значения."""
value = str(value or "").strip().strip('"').strip("'")
if not value:
return ""
normalized = value.replace("/", "\\")
filename = normalized.split("\\")[-1].strip()
return filename
def ensure_channel_logo_extension(filename: str) -> str:
filename = extract_channel_logo_filename(filename)
if not filename:
return ""
if "." not in filename.rsplit("\\", 1)[-1]:
return f"{filename}.png"
return filename
def get_channel_logo_base_path(source_key: str | None = None) -> str:
source = get_parser_source(source_key)
return str(source.get("channel_logo_base_path") or "").rstrip("\\/")
def build_channel_logo_path(source_key: str | None, filename: str | None) -> str:
"""Собирает полный путь к логотипу телеканала по источнику турнира."""
logo_file = ensure_channel_logo_extension(filename)
if not logo_file:
return ""
base_path = get_channel_logo_base_path(source_key)
if not base_path:
return logo_file
return f"{base_path}\\{logo_file}"
def build_empty_channel_logo_path(source_key: str | None = None) -> str:
return build_channel_logo_path(source_key, "EMPTY.png")
def build_schedule_channel_logo_path(
source_key: str | None,
channel: str | None,
has_score: bool,
) -> str:
"""Возвращает логотип канала для расписания vMix.
После появления счёта логотип канала должен быть скрыт, даже если канал
остался отмечен в расписании.
"""
if has_score:
return build_empty_channel_logo_path(source_key)
if str(channel or "").strip():
return build_channel_logo_path(source_key, channel)
return build_empty_channel_logo_path(source_key)

View File

@@ -2,9 +2,8 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from services.standings_service import sync_standings from services.standings_service import sync_standings
from parsers.parser_sources import get_parser_source
URL_STANDINGS = "https://wfl.rfs.ru/tournament/1061879/tables"
SEASON = "2025/2026"
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]:
return standings return standings
def run_parser_standings() -> None: def run_parser_standings(source_key: str | None = None) -> None:
html = fetch_html(URL_STANDINGS) source = get_parser_source(source_key)
standings_rows = parse_standings(html) print(f"[parser_standings] Источник: {source['title']}")
print(f"[parser_standings] URL: {source['standings_url']}")
html = fetch_html(source["standings_url"])
standings_rows = parse_standings(html)
if standings_rows:
sync_standings( sync_standings(
season=SEASON, season=source["season"],
standings_rows=standings_rows, standings_rows=standings_rows,
) )
print(f"[parser_standings] Synced rows: {len(standings_rows)}") print(f"[parser_standings] Synced rows: {len(standings_rows)}")
else:
print("[parser_standings] Строки турнирной таблицы не найдены")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -3,9 +3,7 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.teams_service import sync_teams from services.teams_service import sync_teams
from parsers.parser_sources import get_parser_source, source_absolute_url
TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams"
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -15,45 +13,51 @@ def fetch_html(url: str) -> str:
return response.text return response.text
def get_links(html) -> list: def get_links(html: str, source: dict) -> list[str]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
links: list[dict] = [] links: list[str] = []
items = soup.find("ul", class_="teams__list").find_all("li")
for i in items: teams_list = soup.find("ul", class_="teams__list")
links.append( if not teams_list:
"https://wfl.rfs.ru/team/" return links
+ i.find("a", class_="teams__link").get("href").split("team_id=")[-1]
) items = teams_list.find_all("li")
for item in items:
link_el = item.find("a", class_="teams__link")
href = link_el.get("href") if link_el else ""
if not href:
continue
if "team_id=" in href:
team_external_id = href.split("team_id=")[-1].strip()
links.append(source_absolute_url(source, "/team/" + team_external_id))
else:
links.append(source_absolute_url(source, href))
return links return links
def get_url_teams() -> list[dict]: def get_url_teams(source_key: str | None = None) -> list[dict]:
html = fetch_html(TEAMS_URL) source = get_parser_source(source_key)
links = get_links(html) html = fetch_html(source["teams_url"])
links = get_links(html, source)
teams_data: list[dict] = [] teams_data: list[dict] = []
with ThreadPoolExecutor() as pool: with ThreadPoolExecutor() as pool:
responses = [ responses = [pool.submit(fetch_html, link) for link in links]
pool.submit(
fetch_html,
link,
)
for link in links
]
for result in responses: for result in responses:
try: try:
html = result.result() html = result.result()
team_data = parse_teams_html(html) team_data = parse_teams_html(html)
teams_data.append(team_data) teams_data.append(team_data)
except Exception as e: except Exception as e:
print(f"Error fetching team data: {e}") print(f"[parser_teams] Error fetching team data: {e}")
return teams_data return teams_data
def parse_teams_html(html: str) -> dict: def parse_teams_html(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
teams_data: dict = {}
name = soup.find("a", class_="team-promo__team-name").text.strip() name = soup.find("a", class_="team-promo__team-name").text.strip()
external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1] external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1]
stat_info = soup.find("ul", class_="stats-info").find_all( stat_info = soup.find("ul", class_="stats-info").find_all(
@@ -65,7 +69,7 @@ def parse_teams_html(html: str) -> dict:
goals = stat_info[2].text.strip() goals = stat_info[2].text.strip()
tournaments = stat_info[3].text.strip() tournaments = stat_info[3].text.strip()
teams_data = { return {
"external_id": str(external_id), "external_id": str(external_id),
"name": name, "name": name,
"logo_url": logo_url, "logo_url": logo_url,
@@ -75,13 +79,18 @@ def parse_teams_html(html: str) -> dict:
"tournaments": tournaments, "tournaments": tournaments,
} }
return teams_data
def run_parser_teams(source_key: str | None = None) -> None:
source = get_parser_source(source_key)
print(f"[parser_teams] Источник: {source['title']}")
print(f"[parser_teams] URL: {source['teams_url']}")
def run_parser_teams() -> None: teams_data = get_url_teams(source_key)
teams_data = get_url_teams() if teams_data:
sync_teams(teams_data) sync_teams(teams_data)
print(f"Teams synced: {len(teams_data)}") print(f"[parser_teams] Teams synced: {len(teams_data)}")
else:
print("[parser_teams] Команды не найдены")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -122,6 +122,82 @@ def get_coach_id_by_external_id(external_id: str) -> int | None:
def _split_coach_name_for_autocreate(full_name: str) -> tuple[str, str]:
parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()]
if not parts:
return "", ""
if len(parts) == 1:
return "", parts[0]
first_name = parts[0]
last_name = " ".join(parts[1:])
return first_name, last_name
def create_coach_from_lineup(
team_id: int,
external_id: str = "",
full_name: str = "",
role: str = "",
) -> int | None:
"""Создаёт минимальную карточку тренера из протокола матча."""
full_name = str(full_name or "").strip()
if not full_name:
return None
first_name, last_name = _split_coach_name_for_autocreate(full_name)
external_id = str(external_id or "").strip()
role = str(role or "").strip()
query = """
INSERT INTO coaches (
external_id,
team_id,
player,
lastname,
name,
amplua,
is_active,
created_at,
updated_at
)
VALUES (NULLIF(%s, ''), %s, %s, %s, %s, %s, TRUE, NOW(), NOW())
ON CONFLICT (external_id)
DO UPDATE SET
team_id = EXCLUDED.team_id,
player = COALESCE(NULLIF(EXCLUDED.player, ''), coaches.player),
lastname = COALESCE(NULLIF(EXCLUDED.lastname, ''), coaches.lastname),
name = COALESCE(NULLIF(EXCLUDED.name, ''), coaches.name),
amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), coaches.amplua),
is_active = TRUE,
updated_at = NOW()
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id,
team_id,
full_name,
last_name,
first_name,
role,
),
)
row = cur.fetchone()
conn.commit()
return row[0] if row else None
except Exception:
conn.rollback()
raise
finally:
conn.close()
def search_coaches_for_admin(q: str = "") -> list[dict]: def search_coaches_for_admin(q: str = "") -> list[dict]:
conn = get_connection() conn = get_connection()
try: try:
@@ -284,3 +360,58 @@ def update_coach_admin(
raise raise
finally: finally:
conn.close() conn.close()
def create_coach_admin(
team_id: int,
full_name: str,
first_name: str = "",
last_name: str = "",
external_id: str = "",
birth_date: str = "",
role: str = "",
is_active: bool = True,
) -> int:
"""Создаёт тренера вручную из административного раздела."""
query = """
INSERT INTO coaches (
external_id,
team_id,
player,
lastname,
name,
born,
amplua,
is_active,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, %s, NOW(), NOW()
)
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id.strip(),
int(team_id),
full_name.strip(),
last_name.strip(),
first_name.strip(),
birth_date.strip(),
role.strip(),
bool(is_active),
),
)
row = cur.fetchone()
conn.commit()
return int(row[0])
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -15,7 +15,7 @@ def get_match_coaches_grouped(
mc.side, mc.side,
mc.coach_id, mc.coach_id,
COALESCE(c.player, c.name, '') AS coach_name, COALESCE(c.player, c.name, '') AS coach_name,
COALESCE(mc.role, c.amplua, '') AS role COALESCE(NULLIF(c.amplua, ''), mc.role, '') AS role
FROM match_coaches mc FROM match_coaches mc
JOIN coaches c JOIN coaches c
ON c.id = mc.coach_id ON c.id = mc.coach_id

View File

@@ -149,7 +149,7 @@ def get_match_formations(match_id: int, team_id: int) -> list[dict]:
mf.player_id, mf.player_id,
mf.player_name, mf.player_name,
mf.number, mf.number,
mf.position, p.position,
mf.is_captain, mf.is_captain,
mf.x, mf.x,
mf.y, mf.y,
@@ -279,7 +279,7 @@ def sort_players_by_side(players: list[dict]) -> list[dict]:
return sorted( return sorted(
players, players,
key=lambda p: ( key=lambda p: (
side_order.get(parse_position(p.get("position", ""))[1], 1), side_order.get(p.get("position", "")[1], 1),
str(p.get("number", "")), str(p.get("number", "")),
str(p.get("last_name", "") or p.get("player_name", "")), str(p.get("last_name", "") or p.get("player_name", "")),
), ),

View File

@@ -59,6 +59,7 @@ def _player_to_editor_dict(p: dict) -> dict:
"number": str(p.get("number", "") or ""), "number": str(p.get("number", "") or ""),
"position": p.get("position", "") or "", "position": p.get("position", "") or "",
"is_captain": bool(p.get("is_captain")), "is_captain": bool(p.get("is_captain")),
"photo_enabled": bool(p.get("photo_enabled")),
} }
@@ -89,8 +90,9 @@ def get_match_lineup_for_editor(
COALESCE(p.last_name, '') AS last_name, COALESCE(p.last_name, '') AS last_name,
COALESCE(p.first_name, '') AS first_name, COALESCE(p.first_name, '') AS first_name,
COALESCE(mlp.number::text, '') AS number, COALESCE(mlp.number::text, '') AS number,
COALESCE(mlp.position, '') AS position, COALESCE(p.position, '') AS position,
COALESCE(mlp.is_captain, FALSE) AS is_captain COALESCE(mlp.is_captain, FALSE) AS is_captain,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineup_players mlp FROM match_lineup_players mlp
JOIN players p JOIN players p
ON p.id = mlp.player_id ON p.id = mlp.player_id
@@ -108,7 +110,7 @@ def get_match_lineup_for_editor(
mc.sort_order, mc.sort_order,
c.id AS coach_id, c.id AS coach_id,
COALESCE(c.player, '') AS coach_name, COALESCE(c.player, '') AS coach_name,
COALESCE(mc.role, c.amplua, '') AS role COALESCE(NULLIF(c.amplua, ''), mc.role, '') AS role
FROM match_coaches mc FROM match_coaches mc
JOIN coaches c JOIN coaches c
ON c.id = mc.coach_id ON c.id = mc.coach_id
@@ -141,6 +143,7 @@ def get_match_lineup_for_editor(
"number": row[7] or "", "number": row[7] or "",
"position": row[8] or "", "position": row[8] or "",
"is_captain": bool(row[9]), "is_captain": bool(row[9]),
"photo_enabled": bool(row[10]),
} }
side = row[0] side = row[0]
@@ -228,12 +231,28 @@ def get_match_lineup_for_vmix(
COALESCE(p.last_name, '') AS last_name, COALESCE(p.last_name, '') AS last_name,
COALESCE(p.first_name, '') AS first_name, COALESCE(p.first_name, '') AS first_name,
COALESCE(mlp.number::text, '') AS number, COALESCE(mlp.number::text, '') AS number,
COALESCE(mlp.position, '') AS position, COALESCE(p.position, '') AS position,
COALESCE(mlp.is_captain, FALSE) AS is_captain, COALESCE(mlp.is_captain, FALSE) AS is_captain,
COALESCE(p.position, '') AS pos COALESCE(p.position, '') AS pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineup_players mlp FROM match_lineup_players mlp
JOIN players p LEFT JOIN LATERAL (
ON p.id = mlp.player_id SELECT p.*
FROM players p
WHERE p.team_id = CASE WHEN mlp.side = 'home' THEN %s ELSE %s END
AND (
p.id = mlp.player_id
OR (
COALESCE(p.number::text, '') <> ''
AND COALESCE(p.number::text, '') = COALESCE(mlp.number::text, '')
)
)
ORDER BY
CASE WHEN p.id = mlp.player_id THEN 0 ELSE 1 END,
p.id
LIMIT 1
) p ON TRUE
WHERE mlp.match_id = %s WHERE mlp.match_id = %s
ORDER BY ORDER BY
CASE CASE
@@ -248,7 +267,7 @@ def get_match_lineup_for_vmix(
COALESCE(p.first_name, ''), COALESCE(p.first_name, ''),
p.id p.id
""", """,
(match_id,), (home_team_id, away_team_id, match_id),
) )
player_rows = cur.fetchall() player_rows = cur.fetchall()
@@ -259,7 +278,7 @@ def get_match_lineup_for_vmix(
mc.sort_order, mc.sort_order,
c.id AS coach_id, c.id AS coach_id,
COALESCE(c.player, '') AS coach_name, COALESCE(c.player, '') AS coach_name,
COALESCE(mc.role, c.amplua, '') AS role COALESCE(NULLIF(c.amplua, ''), mc.role, '') AS role
FROM match_coaches mc FROM match_coaches mc
JOIN coaches c JOIN coaches c
ON c.id = mc.coach_id ON c.id = mc.coach_id
@@ -293,6 +312,9 @@ def get_match_lineup_for_vmix(
"position": row[8] or "", "position": row[8] or "",
"is_captain": bool(row[9]), "is_captain": bool(row[9]),
"pos": row[10] or "", "pos": row[10] or "",
"photo": row[11] or "",
"photo_enabled": bool(row[12]),
"team_id": home_team_id if row[0] == "home" else away_team_id,
} }
side = row[0] side = row[0]
@@ -449,6 +471,52 @@ def save_match_lineup_for_editor(
insert_players("away", "starting", away_starting) insert_players("away", "starting", away_starting)
insert_players("away", "bench", away_bench) insert_players("away", "bench", away_bench)
# Синхронизируем капитана с уже сохранённой расстановкой.
# /home-formations и /away-formations читают is_captain из
# match_formations, поэтому ручная смена капитана в редакторе
# состава должна сразу попадать и туда без пересохранения
# вкладки «Расстановки».
def sync_formation_captain(side: str, team_id: int) -> None:
cur.execute(
"""
UPDATE match_formations mf
SET
is_captain = COALESCE((
SELECT mlp.is_captain
FROM match_lineup_players mlp
WHERE mlp.match_id = mf.match_id
AND mlp.side = %s
AND mlp.role = 'starting'
AND (
(
mf.player_id IS NOT NULL
AND mlp.player_id = mf.player_id
)
OR (
COALESCE(NULLIF(TRIM(mf.number), ''), '') <> ''
AND COALESCE(mlp.number::text, '') = COALESCE(mf.number, '')
)
)
ORDER BY
CASE
WHEN mf.player_id IS NOT NULL
AND mlp.player_id = mf.player_id
THEN 0
ELSE 1
END,
mlp.sort_order
LIMIT 1
), FALSE),
updated_at = NOW()
WHERE mf.match_id = %s
AND mf.team_id = %s
""",
(side, match_id, team_id),
)
sync_formation_captain("home", home_team_id)
sync_formation_captain("away", away_team_id)
insert_coaches("home", home_coaches) insert_coaches("home", home_coaches)
insert_coaches("away", away_coaches) insert_coaches("away", away_coaches)

View File

@@ -0,0 +1,295 @@
from db import get_connection
VALID_SIDES = {"home", "away"}
VALID_RESULTS = {"scored", "missed"}
def ensure_match_penalty_tables() -> None:
"""Создает таблицы для серии пенальти."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS match_penalty_settings (
match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
max_rounds INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS match_penalties (
id SERIAL PRIMARY KEY,
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')),
shot_number INTEGER NOT NULL CHECK (shot_number > 0),
result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (match_id, side, shot_number)
);
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_side(side: str) -> str:
side = str(side or "").strip().lower()
if side not in VALID_SIDES:
raise ValueError("Некорректная сторона пенальти")
return side
def _normalize_result(result: str) -> str:
result = str(result or "").strip().lower()
if result not in VALID_RESULTS:
raise ValueError("Некорректный результат пенальти")
return result
def _ensure_penalty_rounds(cur, match_id: int, max_rounds: int) -> None:
safe_rounds = max(5, int(max_rounds or 5))
cur.execute(
"""
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT (match_id)
DO UPDATE SET
max_rounds = GREATEST(match_penalty_settings.max_rounds, EXCLUDED.max_rounds),
updated_at = NOW();
""",
(match_id, safe_rounds),
)
def get_penalty_state(match_id: int, home_team_name: str = "", away_team_name: str = "") -> dict:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(max_rounds, 5)
FROM match_penalty_settings
WHERE match_id = %s;
""",
(match_id,),
)
settings_row = cur.fetchone()
cur.execute(
"""
SELECT side, shot_number, result
FROM match_penalties
WHERE match_id = %s
ORDER BY shot_number, side;
""",
(match_id,),
)
rows = cur.fetchall()
finally:
conn.close()
max_rounds = int(settings_row[0]) if settings_row else 5
for _, shot_number, _ in rows:
max_rounds = max(max_rounds, int(shot_number or 0), 5)
shots_map = {
(str(side), int(shot_number)): str(result)
for side, shot_number, result in rows
}
rounds = []
totals = {"home": 0, "away": 0}
completed = {"home": 0, "away": 0}
for number in range(1, max_rounds + 1):
row = {"number": number}
for side in ("home", "away"):
result = shots_map.get((side, number), "")
if result:
completed[side] += 1
if result == "scored":
totals[side] += 1
row[side] = result
rounds.append(row)
shots = [
{"side": str(side), "shot_number": int(shot_number), "result": str(result)}
for side, shot_number, result in rows
]
return {
"match_id": match_id,
"home_team": home_team_name or "Хозяева",
"away_team": away_team_name or "Гости",
"max_rounds": max_rounds,
"totals": totals,
"completed": completed,
"rounds": rounds,
"shots": shots,
}
def set_penalty_shot(match_id: int, side: str, shot_number: int, result: str) -> None:
side = _normalize_side(side)
result = _normalize_result(result)
safe_shot_number = max(1, int(shot_number or 1))
conn = get_connection()
try:
with conn.cursor() as cur:
_ensure_penalty_rounds(cur, match_id, safe_shot_number)
cur.execute(
"""
INSERT INTO match_penalties (match_id, side, shot_number, result, created_at, updated_at)
VALUES (%s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (match_id, side, shot_number)
DO UPDATE SET
result = EXCLUDED.result,
updated_at = NOW();
""",
(match_id, side, safe_shot_number, result),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def delete_penalty_shot(match_id: int, side: str, shot_number: int) -> None:
side = _normalize_side(side)
safe_shot_number = max(1, int(shot_number or 1))
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
DELETE FROM match_penalties
WHERE match_id = %s AND side = %s AND shot_number = %s;
""",
(match_id, side, safe_shot_number),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def add_penalty_round(match_id: int) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
VALUES (%s, 6, NOW(), NOW())
ON CONFLICT (match_id)
DO UPDATE SET
max_rounds = GREATEST(match_penalty_settings.max_rounds + 1, 6),
updated_at = NOW()
RETURNING max_rounds;
""",
(match_id,),
)
max_rounds = int(cur.fetchone()[0])
conn.commit()
return max_rounds
except Exception:
conn.rollback()
raise
finally:
conn.close()
def delete_last_penalty_round(match_id: int) -> int:
"""Удаляет последнюю добавленную строку серии пенальти и ее данные.
Первые 5 строк считаются базовыми и не удаляются. Если добавлена 6-я,
7-я и т.д. строка, удаляется самая последняя строка вместе с ударами
обеих команд в этой строке.
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(max_rounds, 5)
FROM match_penalty_settings
WHERE match_id = %s;
""",
(match_id,),
)
settings_row = cur.fetchone()
settings_max = int(settings_row[0]) if settings_row else 5
cur.execute(
"""
SELECT COALESCE(MAX(shot_number), 0)
FROM match_penalties
WHERE match_id = %s;
""",
(match_id,),
)
shots_max = int(cur.fetchone()[0] or 0)
current_max = max(5, settings_max, shots_max)
if current_max <= 5:
_ensure_penalty_rounds(cur, match_id, 5)
conn.commit()
return 5
new_max = max(5, current_max - 1)
cur.execute(
"""
DELETE FROM match_penalties
WHERE match_id = %s AND shot_number = %s;
""",
(match_id, current_max),
)
cur.execute(
"""
INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at)
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT (match_id)
DO UPDATE SET
max_rounds = EXCLUDED.max_rounds,
updated_at = NOW();
""",
(match_id, new_max),
)
conn.commit()
return new_max
except Exception:
conn.rollback()
raise
finally:
conn.close()
def clear_penalties(match_id: int) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM match_penalties WHERE match_id = %s;", (match_id,))
cur.execute("DELETE FROM match_penalty_settings WHERE match_id = %s;", (match_id,))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -3,8 +3,142 @@ from numpy import place
from db import get_connection from db import get_connection
from repositories.team_repository import get_team_id_by_external_id from repositories.team_repository import get_team_id_by_external_id
from repositories.stadium_repository import get_or_create_stadium from repositories.stadium_repository import get_or_create_stadium
from parsers.parser_sources import get_default_source_key, list_parser_sources
def ensure_match_source_key_column() -> None:
"""Добавляет источник турнира для матчей, чтобы vMix выбирал правильную папку логотипов."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE matches
ADD COLUMN IF NOT EXISTS source_key VARCHAR(50);
"""
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _normalize_source_key(value: str | None) -> str:
return str(value or "").upper().strip()
def infer_match_source_key(
explicit_source_key: str | None = None,
season: str | None = None,
tour: str | None = None,
) -> str:
"""Определяет источник матча, даже если старые строки matches.source_key ещё пустые.
Приоритет:
1. matches.source_key;
2. точное совпадение season с parser_sources.season;
3. кубковые слова в tour;
4. обычный дефолт из настроек проекта.
"""
explicit = _normalize_source_key(explicit_source_key)
if explicit:
return explicit
season_value = str(season or "").strip()
tour_value = str(tour or "").lower().strip()
try:
sources = list_parser_sources()
except Exception:
sources = []
if season_value and sources:
matches = [
_normalize_source_key(source.get("key"))
for source in sources
if str(source.get("season") or "").strip() == season_value
]
matches = [key for key in matches if key]
if len(matches) == 1:
return matches[0]
cup_markers = (
"кубок",
"финал",
"полуфинал",
"четвертьфинал",
"1/2",
"1/4",
"1/8",
"1/16",
"групп",
"этап",
"стад",
)
if any(marker in tour_value for marker in cup_markers):
known_keys = {_normalize_source_key(source.get("key")) for source in sources}
if "RUSSIAN_CUP" in known_keys:
return "RUSSIAN_CUP"
try:
return _normalize_source_key(get_default_source_key()) or "SUPERLEAGUE"
except Exception:
return "SUPERLEAGUE"
def resolve_match_source_key(match_id: int | str | None, fallback: str | None = None) -> str:
"""Возвращает source_key для матча и, если возможно, записывает его в matches.
Это нужно для старых матчей Кубка России, которые могли быть загружены до появления
колонки source_key. Без этого vMix скачивает пресет и собирает пути как для Суперлиги.
"""
fallback_key = _normalize_source_key(fallback)
if not match_id:
return fallback_key or infer_match_source_key(fallback_key)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT source_key, season, tour
FROM matches
WHERE id = %s
LIMIT 1;
""",
(match_id,),
)
row = cur.fetchone()
if not row:
return fallback_key or infer_match_source_key(fallback_key)
explicit_key, season, tour = row
source_key = infer_match_source_key(explicit_key or fallback_key, season, tour)
if source_key and not _normalize_source_key(explicit_key):
cur.execute(
"""
UPDATE matches
SET source_key = %s, updated_at = NOW()
WHERE id = %s
AND (source_key IS NULL OR TRIM(source_key) = '');
""",
(source_key, match_id),
)
conn.commit()
return source_key
except Exception:
conn.rollback()
return fallback_key or infer_match_source_key(fallback_key)
finally:
conn.close()
def upsert_match( def upsert_match(
external_id: str, external_id: str,
@@ -20,6 +154,7 @@ def upsert_match(
stadium_id: int | None = None, stadium_id: int | None = None,
date_raw: str | None = None, date_raw: str | None = None,
score_add: str | None = None, score_add: str | None = None,
source_key: str | None = None,
) -> None: ) -> None:
query = """ query = """
INSERT INTO matches ( INSERT INTO matches (
@@ -36,10 +171,11 @@ INSERT INTO matches (
stadium_id, stadium_id,
date_raw, date_raw,
score_add, score_add,
source_key,
created_at, created_at,
updated_at updated_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (external_id) ON CONFLICT (external_id)
DO UPDATE SET DO UPDATE SET
home_team_id = EXCLUDED.home_team_id, home_team_id = EXCLUDED.home_team_id,
@@ -54,6 +190,7 @@ DO UPDATE SET
stadium_id = EXCLUDED.stadium_id, stadium_id = EXCLUDED.stadium_id,
date_raw = EXCLUDED.date_raw, date_raw = EXCLUDED.date_raw,
score_add = EXCLUDED.score_add, score_add = EXCLUDED.score_add,
source_key = COALESCE(EXCLUDED.source_key, matches.source_key),
updated_at = NOW(); updated_at = NOW();
""" """
@@ -76,6 +213,7 @@ DO UPDATE SET
stadium_id, stadium_id,
date_raw, date_raw,
score_add, score_add,
source_key,
), ),
) )
conn.commit() conn.commit()
@@ -100,6 +238,7 @@ def upsert_match_by_team_external_ids(
stadium_id: int | None = None, stadium_id: int | None = None,
date_raw: str | None = None, date_raw: str | None = None,
score_add: str | None = None, score_add: str | None = None,
source_key: str | None = None,
) -> None: ) -> None:
home_team_id = get_team_id_by_external_id(home_team_external_id) home_team_id = get_team_id_by_external_id(home_team_external_id)
away_team_id = get_team_id_by_external_id(away_team_external_id) away_team_id = get_team_id_by_external_id(away_team_external_id)
@@ -125,6 +264,7 @@ def upsert_match_by_team_external_ids(
stadium_id=stadium_id, stadium_id=stadium_id,
date_raw=date_raw, date_raw=date_raw,
score_add=score_add, score_add=score_add,
source_key=source_key,
) )
@@ -238,7 +378,8 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
m.home_score, m.home_score,
m.away_score, m.away_score,
ht.name AS home_team_name, ht.name AS home_team_name,
at.name AS away_team_name at.name AS away_team_name,
COALESCE(m.channel, '') AS channel
FROM matches m FROM matches m
LEFT JOIN teams ht ON ht.id = m.home_team_id LEFT JOIN teams ht ON ht.id = m.home_team_id
LEFT JOIN teams at ON at.id = m.away_team_id LEFT JOIN teams at ON at.id = m.away_team_id
@@ -262,6 +403,7 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]:
"away_score": r[6], "away_score": r[6],
"home_team_name": r[7] or "", "home_team_name": r[7] or "",
"away_team_name": r[8] or "", "away_team_name": r[8] or "",
"channel": r[9] or "",
} }
for r in rows for r in rows
] ]

View File

@@ -1,6 +1,8 @@
import secrets import secrets
from db import get_connection from db import get_connection
from parsers.parser_sources import build_logo_variant_path
from repositories.match_repository import resolve_match_source_key
def create_match_session( def create_match_session(
@@ -61,10 +63,13 @@ def get_match_session_by_token(session_token: str):
ht.id AS home_team_id, ht.id AS home_team_id,
ht.name AS home_team_name, ht.name AS home_team_name,
ht.logo_url AS home_team_logo, ht.logo_url AS home_team_logo,
ht.logo_path AS home_team_logo_path,
at.id AS away_team_id, at.id AS away_team_id,
at.name AS away_team_name, at.name AS away_team_name,
at.logo_url AS away_team_logo at.logo_url AS away_team_logo,
at.logo_path AS away_team_logo_path,
m.source_key AS source_key
FROM match_sessions ms FROM match_sessions ms
JOIN matches m ON m.id = ms.match_id JOIN matches m ON m.id = ms.match_id
@@ -78,7 +83,22 @@ def get_match_session_by_token(session_token: str):
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (session_token,))
return cur.fetchone() row = cur.fetchone()
if not row:
return None
row = list(row)
source_key = resolve_match_source_key(row[1], row[21] if len(row) > 21 else None)
if len(row) > 21:
row[21] = source_key
# Эти пути используются рабочей страницей при отправке команд
# SetImage через agent.exe для нейтральных титров. Для «Динамо» и
# «Зенита» в титрах нужен синий вариант логотипа; остальные команды
# остаются без изменений.
row[16] = build_logo_variant_path(source_key, row[16], "blue")
row[20] = build_logo_variant_path(source_key, row[20], "blue")
return tuple(row)
finally: finally:
conn.close() conn.close()
@@ -207,3 +227,45 @@ def list_available_tours():
return tours return tours
finally: finally:
conn.close() conn.close()
def list_matches_for_admin(
today_only: bool = False,
tour: str | None = None,
hide_finished: bool = True,
):
query = """
SELECT
m.id,
m.external_id,
m.match_date,
m.status,
m.tour,
m.season,
ht.name AS home_team_name,
at.name AS away_team_name
FROM matches m
JOIN teams ht ON ht.id = m.home_team_id
JOIN teams at ON at.id = m.away_team_id
WHERE 1=1
"""
params = []
if today_only:
query += " AND DATE(m.match_date) = CURRENT_DATE "
if tour:
query += " AND m.tour = %s "
params.append(tour)
if hide_finished:
query += " AND COALESCE(m.status, 'scheduled') <> 'finished' "
query += " ORDER BY m.match_date ASC NULLS LAST, m.id ASC; "
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
finally:
conn.close()

View File

@@ -8,12 +8,14 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
ml.player_id, ml.player_id,
ml.player_name, ml.player_name,
ml.number, ml.number,
ml.position, p.position,
ml.lineup_type, ml.lineup_type,
ml.is_captain, ml.is_captain,
p.last_name, p.last_name,
p.first_name, p.first_name,
p.position as pos p.position as pos,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM match_lineups ml FROM match_lineups ml
LEFT JOIN players p ON p.id = ml.player_id LEFT JOIN players p ON p.id = ml.player_id
WHERE ml.match_id = %s; WHERE ml.match_id = %s;
@@ -46,10 +48,13 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
last_name, last_name,
first_name, first_name,
pos, pos,
photo,
photo_enabled,
) = row ) = row
item = { item = {
"player_id": player_id, "player_id": player_id,
"team_id": team_id,
"number": number or "", "number": number or "",
"last_name": last_name or "", "last_name": last_name or "",
"first_name": first_name or "", "first_name": first_name or "",
@@ -57,6 +62,8 @@ def get_match_lineups_grouped(match_id: int, home_team_id: int, away_team_id: in
"position": position or "", "position": position or "",
"is_captain": bool(is_captain), "is_captain": bool(is_captain),
"pos": pos, "pos": pos,
"photo": photo or "",
"photo_enabled": bool(photo_enabled),
} }
if team_id == home_team_id and lineup_type == "starting": if team_id == home_team_id and lineup_type == "starting":

View File

@@ -205,7 +205,8 @@ def get_player_id_by_name_and_team(full_name: str, team_id: int) -> int | None:
def get_player_id_by_external_id(external_id: str) -> int | None: def get_player_id_by_external_id(external_id: str) -> int | None:
query = """ query = """
SELECT id SELECT id,
position
FROM players FROM players
WHERE external_id = %s WHERE external_id = %s
LIMIT 1; LIMIT 1;
@@ -216,54 +217,193 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (str(external_id).strip(),)) cur.execute(query, (str(external_id).strip(),))
row = cur.fetchone() row = cur.fetchone()
return row[0] if row else None return row if row else None
finally: finally:
conn.close() conn.close()
from db import get_connection
def _split_full_name_for_autocreate(full_name: str) -> tuple[str, str]:
"""Аккуратно делит имя с сайта на first_name / last_name для новых записей."""
parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()]
if not parts:
return "", ""
if len(parts) == 1:
return "", parts[0]
# На сайте чаще приходит "Имя Фамилия". Полное имя всё равно сохраняем отдельно,
# поэтому даже при другом порядке данные можно быстро поправить в справочнике.
first_name = parts[0]
last_name = " ".join(parts[1:])
return first_name, last_name
def search_players_for_admin(q: str = "") -> list[dict]: def create_player_from_lineup(
team_id: int,
external_id: str = "",
full_name: str = "",
number: str = "",
position: str = "",
) -> tuple[int | None, str | None]:
"""Создаёт минимальную карточку игрока из протокола матча и возвращает (id, position).
Используется при загрузке составов с сайта, когда игрока ещё нет в справочнике.
"""
full_name = str(full_name or "").strip()
if not full_name:
return None, None
first_name, last_name = _split_full_name_for_autocreate(full_name)
external_id = str(external_id or "").strip()
number = str(number or "").strip()
position = str(position or "").strip()
query = """
INSERT INTO players (
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
pos,
amplua,
is_active,
photo_enabled,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, %s, %s, %s, %s, TRUE, FALSE,
NOW(), NOW()
)
ON CONFLICT (external_id)
DO UPDATE SET
team_id = EXCLUDED.team_id,
full_name = COALESCE(NULLIF(EXCLUDED.full_name, ''), players.full_name),
first_name = COALESCE(NULLIF(EXCLUDED.first_name, ''), players.first_name),
last_name = COALESCE(NULLIF(EXCLUDED.last_name, ''), players.last_name),
number = COALESCE(NULLIF(EXCLUDED.number, ''), players.number),
position = COALESCE(NULLIF(EXCLUDED.position, ''), players.position),
pos = COALESCE(NULLIF(EXCLUDED.pos, ''), players.pos),
amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), players.amplua),
is_active = TRUE,
updated_at = NOW()
RETURNING id, position;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
position,
position,
),
)
row = cur.fetchone()
conn.commit()
return row if row else (None, None)
except Exception:
conn.rollback()
raise
finally:
conn.close()
PLAYER_ADMIN_SORT_COLUMNS = {
"id": "p.id",
"full_name": "p.full_name",
"first_name": "p.first_name",
"last_name": "p.last_name",
"external_id": "p.external_id",
"position": "p.position",
"team_name": "t.name",
"photo": "p.photo_enabled",
}
def ensure_player_photo_enabled_column() -> None:
"""Добавляет флаг использования фото игрока, если база ещё не обновлена."""
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
if q.strip():
pattern = f"%{q.strip()}%"
cur.execute( cur.execute(
""" """
SELECT ALTER TABLE players
p.id, ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
p.full_name, """
p.first_name, )
p.last_name, conn.commit()
p.external_id, except Exception:
p.position conn.rollback()
FROM players p raise
finally:
conn.close()
def _normalize_player_sort(sort_by: str = "id", sort_dir: str = "desc") -> tuple[str, str, str, str]:
sort_by = (sort_by or "id").strip()
if sort_by not in PLAYER_ADMIN_SORT_COLUMNS:
sort_by = "id"
sort_dir = (sort_dir or "desc").strip().lower()
if sort_dir not in {"asc", "desc"}:
sort_dir = "desc"
return sort_by, sort_dir, PLAYER_ADMIN_SORT_COLUMNS[sort_by], sort_dir.upper()
def search_players_for_admin(q: str = "", sort_by: str = "id", sort_dir: str = "desc") -> list[dict]:
sort_by, sort_dir, order_column, order_direction = _normalize_player_sort(sort_by, sort_dir)
limit = 200
conn = get_connection()
try:
with conn.cursor() as cur:
params: list = []
where_sql = ""
if q.strip():
pattern = f"%{q.strip()}%"
where_sql = """
WHERE WHERE
p.full_name ILIKE %s p.full_name ILIKE %s
OR p.first_name ILIKE %s OR p.first_name ILIKE %s
OR p.last_name ILIKE %s OR p.last_name ILIKE %s
OR COALESCE(p.external_id, '') ILIKE %s OR COALESCE(p.external_id, '') ILIKE %s
ORDER BY p.full_name ASC, p.id ASC OR COALESCE(t.name, '') ILIKE %s
LIMIT 200
""",
(pattern, pattern, pattern, pattern),
)
else:
cur.execute(
""" """
params.extend([pattern, pattern, pattern, pattern, pattern])
params.append(limit)
cur.execute(
f"""
SELECT SELECT
p.id, p.id,
p.full_name, p.full_name,
p.first_name, p.first_name,
p.last_name, p.last_name,
p.external_id, p.external_id,
p.position p.position,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p FROM players p
ORDER BY p.id DESC LEFT JOIN teams t ON t.id = p.team_id
LIMIT 200 {where_sql}
""" ORDER BY {order_column} {order_direction} NULLS LAST, p.id ASC
LIMIT %s
""",
tuple(params),
) )
rows = cur.fetchall() rows = cur.fetchall()
@@ -276,6 +416,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
"last_name": row[3] or "", "last_name": row[3] or "",
"external_id": row[4] or "", "external_id": row[4] or "",
"position": row[5] or "", "position": row[5] or "",
"team_name": row[6] or "",
"photo_enabled": bool(row[7]),
} }
for row in rows for row in rows
] ]
@@ -298,8 +440,11 @@ def get_player_by_id(player_id: int) -> dict | None:
p.position, p.position,
p.born, p.born,
p.photo, p.photo,
p.video p.video,
COALESCE(t.name, '') AS team_name,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p FROM players p
LEFT JOIN teams t ON t.id = p.team_id
WHERE p.id = %s WHERE p.id = %s
LIMIT 1 LIMIT 1
""", """,
@@ -320,6 +465,8 @@ def get_player_by_id(player_id: int) -> dict | None:
"birth_date": normalize_birth_date_for_input(row[6]), "birth_date": normalize_birth_date_for_input(row[6]),
"photo": row[7] or "", "photo": row[7] or "",
"video": row[8] or "", "video": row[8] or "",
"team_name": row[9] or "",
"photo_enabled": bool(row[10]),
} }
finally: finally:
conn.close() conn.close()
@@ -335,6 +482,7 @@ def update_player_admin(
birth_date: str = "", birth_date: str = "",
photo: str = "", photo: str = "",
video: str = "", video: str = "",
photo_enabled: bool = False,
) -> None: ) -> None:
conn = get_connection() conn = get_connection()
try: try:
@@ -350,7 +498,9 @@ def update_player_admin(
position = %s, position = %s,
born = NULLIF(%s, '')::date, born = NULLIF(%s, '')::date,
photo = NULLIF(%s, ''), photo = NULLIF(%s, ''),
video = NULLIF(%s, '') video = NULLIF(%s, ''),
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s WHERE id = %s
""", """,
( (
@@ -362,6 +512,7 @@ def update_player_admin(
birth_date.strip(), birth_date.strip(),
photo.strip(), photo.strip(),
video.strip(), video.strip(),
bool(photo_enabled),
player_id, player_id,
), ),
) )
@@ -371,3 +522,123 @@ def update_player_admin(
raise raise
finally: finally:
conn.close() conn.close()
def update_player_photo_enabled(player_id: int, photo_enabled: bool = False) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE players
SET
photo_enabled = %s,
updated_at = NOW()
WHERE id = %s
""",
(bool(photo_enabled), player_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def create_player_admin(
team_id: int,
full_name: str,
first_name: str = "",
last_name: str = "",
external_id: str = "",
number: str = "",
position: str = "",
birth_date: str = "",
photo: str = "",
video: str = "",
height_cm: int | None = None,
weight_kg: int | None = None,
games: int = 0,
goals: int = 0,
penaltys: int = 0,
assists: int = 0,
yellows: int = 0,
reds: int = 0,
is_active: bool = True,
photo_enabled: bool = False,
) -> int:
"""Создаёт игрока вручную из административного раздела."""
query = """
INSERT INTO players (
external_id,
team_id,
full_name,
first_name,
last_name,
number,
position,
pos,
amplua,
born,
photo,
video,
height_cm,
weight_kg,
games,
goals,
penaltys,
assists,
yellows,
reds,
is_active,
photo_enabled,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, %s, %s,
NULLIF(%s, '')::date, NULLIF(%s, ''), NULLIF(%s, ''), %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()
)
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id.strip(),
int(team_id),
full_name.strip(),
first_name.strip(),
last_name.strip(),
number.strip(),
position.strip(),
position.strip(),
position.strip(),
birth_date.strip(),
photo.strip(),
video.strip(),
height_cm,
weight_kg,
int(games),
int(goals),
int(penaltys),
int(assists),
int(yellows),
int(reds),
bool(is_active),
bool(photo_enabled),
),
)
row = cur.fetchone()
conn.commit()
return int(row[0])
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -0,0 +1,349 @@
from __future__ import annotations
from typing import Any
from db import get_connection
DEFAULT_APP_SETTINGS = {
"default_parser_source_key": "SUPERLEAGUE",
"rfs_base_url": "https://wfl.rfs.ru",
}
DEFAULT_PARSER_SOURCES = [
{
"key": "SUPERLEAGUE",
"title": "Суперлига 2026",
"tournament_id": "1061879",
"round_id": "1117550",
"season": "2025/2026",
"calendar_type": "tours",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов",
"teams_url": "https://wfl.rfs.ru/tournament/1061879/teams",
"schedule_url": "https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours",
"standings_url": "https://wfl.rfs.ru/tournament/1061879/tables",
"match_base_url": "https://wfl.rfs.ru/match/",
"base_url": "https://wfl.rfs.ru",
"sort_order": 10,
"is_active": True,
},
{
"key": "RUSSIAN_CUP",
"title": "Кубок России 2026",
"tournament_id": "1064908",
"round_id": "1125159",
"season": "2026",
"calendar_type": "stages",
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
"channel_logo_base_path": r"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов",
"teams_url": "https://wfl.rfs.ru/tournament/1064908/teams",
"schedule_url": "https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages",
"standings_url": "https://wfl.rfs.ru/tournament/1064908/tables",
"match_base_url": "https://wfl.rfs.ru/match/",
"base_url": "https://wfl.rfs.ru",
"sort_order": 20,
"is_active": True,
},
]
SOURCE_SELECT_SQL = """
SELECT key, title, tournament_id, round_id, season, calendar_type,
logo_base_path, photo_base_path, channel_logo_base_path,
teams_url, schedule_url, standings_url,
match_base_url, base_url, sort_order, is_active
FROM parser_sources
"""
def _row_to_source(row: tuple) -> dict[str, Any]:
return {
"key": row[0] or "",
"title": row[1] or "",
"tournament_id": row[2] or "",
"round_id": row[3] or "",
"season": row[4] or "",
"calendar_type": row[5] or "tours",
"logo_base_path": row[6] or "",
"photo_base_path": row[7] or "",
"channel_logo_base_path": row[8] or "",
"teams_url": row[9] or "",
"schedule_url": row[10] or "",
"standings_url": row[11] or "",
"match_base_url": row[12] or "",
"base_url": row[13] or "https://wfl.rfs.ru",
"sort_order": row[14] or 0,
"is_active": bool(row[15]),
}
def ensure_project_settings_tables() -> None:
"""Создаёт таблицы настроек проекта и добавляет стандартные источники, если их ещё нет."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS parser_sources (
key VARCHAR(50) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
tournament_id VARCHAR(100),
round_id VARCHAR(100),
season VARCHAR(50),
calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours',
logo_base_path TEXT,
photo_base_path TEXT,
channel_logo_base_path TEXT,
teams_url TEXT,
schedule_url TEXT,
standings_url TEXT,
match_base_url TEXT,
base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
cur.execute("ALTER TABLE parser_sources ADD COLUMN IF NOT EXISTS photo_base_path TEXT;")
cur.execute("ALTER TABLE parser_sources ADD COLUMN IF NOT EXISTS channel_logo_base_path TEXT;")
for key, value in DEFAULT_APP_SETTINGS.items():
cur.execute(
"""
INSERT INTO app_settings (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO NOTHING;
""",
(key, value),
)
for source in DEFAULT_PARSER_SOURCES:
cur.execute(
"""
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type,
logo_base_path, photo_base_path, channel_logo_base_path,
teams_url, schedule_url, standings_url, match_base_url, base_url,
sort_order, is_active, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (key) DO NOTHING;
""",
(
source["key"],
source["title"],
source["tournament_id"],
source["round_id"],
source["season"],
source["calendar_type"],
source["logo_base_path"],
source["photo_base_path"],
source["channel_logo_base_path"],
source["teams_url"],
source["schedule_url"],
source["standings_url"],
source["match_base_url"],
source["base_url"],
source["sort_order"],
source["is_active"],
),
)
for source in DEFAULT_PARSER_SOURCES:
cur.execute(
"""
UPDATE parser_sources
SET photo_base_path = %s, updated_at = NOW()
WHERE key = %s
AND (photo_base_path IS NULL OR TRIM(photo_base_path) = '');
""",
(source.get("photo_base_path") or "", source["key"]),
)
cur.execute(
"""
UPDATE parser_sources
SET channel_logo_base_path = %s, updated_at = NOW()
WHERE key = %s
AND (channel_logo_base_path IS NULL OR TRIM(channel_logo_base_path) = '');
""",
(source.get("channel_logo_base_path") or "", source["key"]),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_app_settings() -> dict[str, str]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT key, value FROM app_settings;")
rows = cur.fetchall()
settings = dict(DEFAULT_APP_SETTINGS)
settings.update({row[0]: row[1] or "" for row in rows})
return settings
finally:
conn.close()
def get_app_setting(key: str, default: str = "") -> str:
return get_app_settings().get(key, default)
def update_app_setting(key: str, value: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO app_settings (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();
""",
(key, value or ""),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def list_parser_sources_from_db(active_only: bool = True) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor() as cur:
if active_only:
cur.execute(SOURCE_SELECT_SQL + " WHERE is_active = TRUE ORDER BY sort_order ASC, title ASC;")
else:
cur.execute(SOURCE_SELECT_SQL + " ORDER BY sort_order ASC, title ASC;")
rows = cur.fetchall()
return [_row_to_source(row) for row in rows]
finally:
conn.close()
def get_parser_source_from_db(source_key: str) -> dict[str, Any] | None:
source_key = (source_key or "").upper().strip()
if not source_key:
return None
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(SOURCE_SELECT_SQL + " WHERE key = %s LIMIT 1;", (source_key,))
row = cur.fetchone()
return _row_to_source(row) if row else None
finally:
conn.close()
def update_parser_source(source_key: str, values: dict[str, str]) -> None:
source_key = (source_key or "").upper().strip()
if not source_key:
raise ValueError("Не указан ключ источника")
current = get_parser_source_from_db(source_key)
if not current:
current = next((item for item in DEFAULT_PARSER_SOURCES if item["key"] == source_key), None)
if not current:
current = {
"key": source_key,
"title": source_key,
"tournament_id": "",
"round_id": "",
"season": "",
"calendar_type": "tours",
"logo_base_path": "",
"photo_base_path": "",
"channel_logo_base_path": "",
"teams_url": "",
"schedule_url": "",
"standings_url": "",
"match_base_url": "",
"base_url": DEFAULT_APP_SETTINGS["rfs_base_url"],
"sort_order": 100,
"is_active": True,
}
merged = {**current, **{key: value for key, value in values.items() if value is not None}}
merged["key"] = source_key
merged["calendar_type"] = merged.get("calendar_type") or "tours"
merged["base_url"] = merged.get("base_url") or DEFAULT_APP_SETTINGS["rfs_base_url"]
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type,
logo_base_path, photo_base_path, channel_logo_base_path,
teams_url, schedule_url, standings_url,
match_base_url, base_url, sort_order, is_active, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT (key)
DO UPDATE SET
title = EXCLUDED.title,
tournament_id = EXCLUDED.tournament_id,
round_id = EXCLUDED.round_id,
season = EXCLUDED.season,
calendar_type = EXCLUDED.calendar_type,
logo_base_path = EXCLUDED.logo_base_path,
photo_base_path = EXCLUDED.photo_base_path,
channel_logo_base_path = EXCLUDED.channel_logo_base_path,
teams_url = EXCLUDED.teams_url,
schedule_url = EXCLUDED.schedule_url,
standings_url = EXCLUDED.standings_url,
match_base_url = EXCLUDED.match_base_url,
base_url = EXCLUDED.base_url,
sort_order = EXCLUDED.sort_order,
is_active = EXCLUDED.is_active,
updated_at = NOW();
""",
(
merged.get("key", source_key),
merged.get("title") or source_key,
merged.get("tournament_id") or "",
merged.get("round_id") or "",
merged.get("season") or "",
merged.get("calendar_type") or "tours",
merged.get("logo_base_path") or "",
merged.get("photo_base_path") or "",
merged.get("channel_logo_base_path") or "",
merged.get("teams_url") or "",
merged.get("schedule_url") or "",
merged.get("standings_url") or "",
merged.get("match_base_url") or "",
merged.get("base_url") or DEFAULT_APP_SETTINGS["rfs_base_url"],
int(merged.get("sort_order") or 100),
bool(merged.get("is_active", True)),
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -266,3 +266,55 @@ def replace_match_referees(match_id: int, rows: list[dict]) -> None:
raise raise
finally: finally:
conn.close() conn.close()
def create_referee_admin(
full_name: str,
first_name: str = "",
last_name: str = "",
middle_name: str = "",
city: str = "",
external_id: str = "",
is_active: bool = True,
) -> int:
"""Создаёт судью вручную из административного раздела."""
query = """
INSERT INTO referees (
external_id,
full_name,
lastname,
name,
middle_name,
city,
is_active,
created_at,
updated_at
)
VALUES (
NULLIF(%s, ''), %s, %s, %s, %s, NULLIF(%s, ''), %s, NOW(), NOW()
)
RETURNING id;
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
query,
(
external_id.strip(),
full_name.strip(),
last_name.strip(),
first_name.strip(),
middle_name.strip(),
city.strip(),
bool(is_active),
),
)
row = cur.fetchone()
conn.commit()
return int(row[0])
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -1,4 +1,5 @@
from db import get_connection from db import get_connection
from parsers.parser_sources import extract_logo_filename
def upsert_team( def upsert_team(
@@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]:
"full_name": row[2] or "", "full_name": row[2] or "",
"short_name_3": row[3] or "", "short_name_3": row[3] or "",
"city": row[4] or "", "city": row[4] or "",
"logo_path": row[5] or "", "logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "", "external_id": row[6] or "",
} }
for row in rows for row in rows
@@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None:
"full_name": row[2] or "", "full_name": row[2] or "",
"short_name_3": row[3] or "", "short_name_3": row[3] or "",
"city": row[4] or "", "city": row[4] or "",
"logo_path": row[5] or "", "logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "", "external_id": row[6] or "",
} }
finally: finally:
@@ -227,7 +228,7 @@ def update_team_admin(
full_name.strip(), full_name.strip(),
short_name_3.strip().upper(), short_name_3.strip().upper(),
city.strip(), city.strip(),
logo_path.strip(), extract_logo_filename(logo_path),
external_id.strip(), external_id.strip(),
team_id, team_id,
), ),
@@ -238,3 +239,29 @@ def update_team_admin(
raise raise
finally: finally:
conn.close() conn.close()
def list_teams_for_admin_select() -> list[dict]:
"""Возвращает полный компактный список команд для выпадающих списков админки."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, short_name_3, city
FROM teams
ORDER BY name ASC, id ASC
"""
)
rows = cur.fetchall()
return [
{
"id": row[0],
"name": row[1] or "",
"short_name_3": row[2] or "",
"city": row[3] or "",
}
for row in rows
]
finally:
conn.close()

View File

@@ -22,7 +22,8 @@ def get_team_players_for_match_editor(
COALESCE(p.first_name, '') AS first_name, COALESCE(p.first_name, '') AS first_name,
COALESCE(p.number::text, '') AS number, COALESCE(p.number::text, '') AS number,
COALESCE(p.position, '') AS position, COALESCE(p.position, '') AS position,
FALSE AS is_captain FALSE AS is_captain,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p FROM players p
WHERE p.team_id = %s WHERE p.team_id = %s
ORDER BY ORDER BY
@@ -51,6 +52,7 @@ def get_team_players_for_match_editor(
"number": row[4] or "", "number": row[4] or "",
"position": row[5] or "", "position": row[5] or "",
"is_captain": bool(row[6]), "is_captain": bool(row[6]),
"photo_enabled": bool(row[7]),
} }
for row in rows for row in rows
] ]

523
scheduler.py Normal file
View File

@@ -0,0 +1,523 @@
import signal
import threading
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from psycopg2.extras import RealDictCursor
from db import get_connection
from parsers.parser_standings import run_parser_standings
import requests
from bs4 import BeautifulSoup
from source_config import match_url
TZ = ZoneInfo("Europe/Moscow")
# За сколько до матча запускать watcher
MATCH_START_LEAD_MINUTES = 1
# Как часто обновлять live-матч
LIVE_MATCH_POLL_SECONDS = 30
# Как часто проверять матчи дня
MATCHES_LOOP_SECONDS = 60
# Как часто обновлять турнирную таблицу
STANDINGS_LOOP_SECONDS = 30
# Через сколько секунд между попытками после ошибки в worker
WORKER_ERROR_RETRY_SECONDS = 30
# Если подряд слишком много ошибок, worker остановится
WORKER_MAX_ERRORS_IN_ROW = 20
ACTIVE_MATCH_WORKERS: dict[int, threading.Event] = {}
ACTIVE_MATCH_THREADS: dict[int, threading.Thread] = {}
REGISTRY_LOCK = threading.Lock()
STANDINGS_LOCK = threading.Lock()
APP_STOP_EVENT = threading.Event()
# =========================================================
# ВСПОМОГАТЕЛЬНОЕ
# =========================================================
def log(message: str) -> None:
now = datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")
print(f"[{now}] {message}")
def local_now_naive() -> datetime:
"""
В БД поле match_date у тебя TIMESTAMP без timezone.
Поэтому для корректного сравнения берем локальное время как naive.
"""
return datetime.now(TZ).replace(tzinfo=None)
def normalize_status(value: str | None) -> str:
status = (value or "").strip().lower()
if status in {"scheduled", "live", "finished", "postponed", "cancelled"}:
return status
return "scheduled"
# =========================================================
# БАЗА ДАННЫХ
# =========================================================
def get_today_matches_from_db() -> list[dict]:
"""
Возвращает матчи на сегодня, которые еще потенциально актуальны
для мониторинга. finished / cancelled можно не брать.
"""
now_local = datetime.now(TZ)
day_start = now_local.replace(
hour=0,
minute=0,
second=0,
microsecond=0,
tzinfo=None,
)
day_end = day_start + timedelta(days=1)
query = """
SELECT
m.id,
m.external_id,
m.match_date,
m.status,
m.home_score,
m.away_score,
m.tour,
m.season,
m.place,
m.date_raw,
m.score_add,
m.home_team_id,
m.away_team_id
FROM matches m
WHERE m.match_date >= %s
AND m.match_date < %s
AND COALESCE(m.status, 'scheduled') NOT IN ('finished', 'cancelled')
ORDER BY m.match_date ASC, m.id ASC
"""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, (day_start, day_end))
rows = cur.fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
def update_match_live_state_in_db(
match_id: int,
status: str,
home_score: int | None,
away_score: int | None,
) -> None:
"""
Обновляет только live-состояние матча.
"""
query = """
UPDATE matches
SET
status = %s,
home_score = %s,
away_score = %s,
updated_at = NOW()
WHERE id = %s
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, (status, home_score, away_score, match_id))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_match_snapshot_by_id(match_id: int) -> dict | None:
query = """
SELECT
m.id,
m.external_id,
m.match_date,
m.status,
m.home_score,
m.away_score,
m.tour,
m.season,
m.place,
m.date_raw,
m.score_add,
m.home_team_id,
m.away_team_id
FROM matches m
WHERE m.id = %s
LIMIT 1
"""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, (match_id,))
row = cur.fetchone()
return dict(row) if row else None
finally:
conn.close()
# =========================================================
# ЭТУ ФУНКЦИЮ ТЫ ЗАПОЛНИШЬ САМ
# =========================================================
def fetch_html(url: str) -> str:
headers = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
return r.text
def parse_score(value: str) -> int | None:
value = value.strip()
if value == "-":
return 0
return int(value)
def fetch_match_live_data(match_id: int) -> dict:
html = fetch_html(match_url(match_id))
soup = BeautifulSoup(html, "html.parser")
score_box = soup.find("div", class_="score__container")
if score_box:
scores = score_box.find_all("div", class_="score__item")
home = parse_score(scores[0].text) if len(scores) > 0 else None
away = parse_score(scores[2].text) if len(scores) > 2 else None
else:
home = None
away = None
live = soup.find("section", class_=lambda c: c and "game--live" in c)
if live:
return {"status": "live", "home_score": home, "away_score": away}
# else:
if home and away:
return {"status": "finished", "home_score": home, "away_score": away}
return {"status": "scheduled", "home_score": home, "away_score": away}
# =========================================================
# ЛОГИКА ЗАПУСКА WATCHER'ОВ
# =========================================================
def should_start_worker(match_row: dict, now_naive: datetime) -> bool:
"""
Нужно стартовать worker, если:
- матч еще не finished/cancelled
- и до старта осталось <= 1 минуты
- либо старт уже был раньше
"""
status = normalize_status(match_row.get("status"))
match_dt = match_row.get("match_date")
if match_dt is None:
return False
if status in {"finished", "cancelled"}:
return False
start_at = match_dt - timedelta(minutes=MATCH_START_LEAD_MINUTES)
return now_naive >= start_at
def is_match_terminal_status(status: str | None) -> bool:
return normalize_status(status) in {"finished", "cancelled"}
def register_worker(match_id: int, stop_event: threading.Event, thread: threading.Thread) -> bool:
with REGISTRY_LOCK:
if match_id in ACTIVE_MATCH_WORKERS:
return False
ACTIVE_MATCH_WORKERS[match_id] = stop_event
ACTIVE_MATCH_THREADS[match_id] = thread
return True
def unregister_worker(match_id: int) -> None:
with REGISTRY_LOCK:
ACTIVE_MATCH_WORKERS.pop(match_id, None)
ACTIVE_MATCH_THREADS.pop(match_id, None)
def stop_all_workers() -> None:
with REGISTRY_LOCK:
items = list(ACTIVE_MATCH_WORKERS.items())
for match_id, stop_event in items:
log(f"[scheduler] stopping worker for match_id={match_id}")
stop_event.set()
with REGISTRY_LOCK:
threads = list(ACTIVE_MATCH_THREADS.items())
for match_id, thread in threads:
if thread.is_alive():
thread.join(timeout=5)
log(f"[scheduler] worker joined for match_id={match_id}")
# =========================================================
# WORKER ОДНОГО МАТЧА
# =========================================================
def live_match_worker(match_row: dict, stop_event: threading.Event) -> None:
match_id = match_row["id"]
external_id = match_row.get("external_id")
errors_in_row = 0
log(f"[worker] started match_id={match_id} external_id={external_id}")
try:
while not APP_STOP_EVENT.is_set() and not stop_event.is_set():
# На каждой итерации перечитываем матч из БД:
# это полезно, если кто-то руками поменял статус или счет
current_match = get_match_snapshot_by_id(match_id)
if not current_match:
log(f"[worker] match_id={match_id} not found anymore, stopping")
break
current_status = normalize_status(current_match.get("status"))
if is_match_terminal_status(current_status):
log(f"[worker] match_id={match_id} already terminal status={current_status}, stopping")
break
try:
data = fetch_match_live_data(external_id)
errors_in_row = 0
except NotImplementedError:
log(f"[worker] fetch_match_live_data() is not implemented for match_id={match_id}")
break
except Exception as exc:
errors_in_row += 1
log(
f"[worker][error] match_id={match_id} "
f"fetch failed ({errors_in_row}/{WORKER_MAX_ERRORS_IN_ROW}): {exc}"
)
if errors_in_row >= WORKER_MAX_ERRORS_IN_ROW:
log(f"[worker] match_id={match_id} too many errors, stopping")
break
stop_event.wait(WORKER_ERROR_RETRY_SECONDS)
continue
new_status = normalize_status(data.get("status"))
new_home_score = data.get("home_score")
new_away_score = data.get("away_score")
changed = (
current_status != new_status
or current_match.get("home_score") != new_home_score
or current_match.get("away_score") != new_away_score
)
if changed:
update_match_live_state_in_db(
match_id=match_id,
status=new_status,
home_score=new_home_score,
away_score=new_away_score,
)
log(
f"[worker] match_id={match_id} updated "
f"status={new_status} score={new_home_score}:{new_away_score}"
)
else:
log(
f"[worker] match_id={match_id} no changes "
f"status={new_status} score={new_home_score}:{new_away_score}"
)
if is_match_terminal_status(new_status):
log(f"[worker] match_id={match_id} reached terminal status={new_status}, stopping")
break
stop_event.wait(LIVE_MATCH_POLL_SECONDS)
finally:
unregister_worker(match_id)
log(f"[worker] stopped match_id={match_id} external_id={external_id}")
# =========================================================
# LOOP: ПРОВЕРКА МАТЧЕЙ СЕГОДНЯ
# =========================================================
def watch_today_matches_once() -> None:
now_naive = local_now_naive()
try:
matches = get_today_matches_from_db()
except Exception as exc:
log(f"[matches_loop][error] failed to load today matches: {exc}")
return
if not matches:
log("[matches_loop] no matches today")
return
log(f"[matches_loop] today matches found: {len(matches)}")
for match_row in matches:
match_id = match_row["id"]
if not should_start_worker(match_row, now_naive):
continue
stop_event = threading.Event()
thread = threading.Thread(
target=live_match_worker,
args=(match_row, stop_event),
daemon=True,
name=f"live_match_worker_{match_id}",
)
registered = register_worker(match_id, stop_event, thread)
if not registered:
continue
thread.start()
log(
f"[matches_loop] worker launched "
f"match_id={match_id} external_id={match_row.get('external_id')}"
)
def matches_loop() -> None:
log("[matches_loop] started")
while not APP_STOP_EVENT.is_set():
started_at = time.time()
try:
watch_today_matches_once()
except Exception as exc:
log(f"[matches_loop][fatal] {exc}")
elapsed = time.time() - started_at
sleep_seconds = max(1, MATCHES_LOOP_SECONDS - int(elapsed))
APP_STOP_EVENT.wait(sleep_seconds)
log("[matches_loop] stopped")
# =========================================================
# LOOP: ТУРНИРНАЯ ТАБЛИЦА
# =========================================================
def standings_job() -> None:
# Не даем двум запускам standings идти одновременно
acquired = STANDINGS_LOCK.acquire(blocking=False)
if not acquired:
log("[standings_loop] skipped: previous standings job still running")
return
try:
run_parser_standings()
log("[standings_loop] standings updated")
except Exception as exc:
log(f"[standings_loop][error] {exc}")
finally:
STANDINGS_LOCK.release()
def standings_loop() -> None:
log("[standings_loop] started")
while not APP_STOP_EVENT.is_set():
started_at = time.time()
try:
standings_job()
except Exception as exc:
log(f"[standings_loop][fatal] {exc}")
elapsed = time.time() - started_at
sleep_seconds = max(1, STANDINGS_LOOP_SECONDS - int(elapsed))
APP_STOP_EVENT.wait(sleep_seconds)
log("[standings_loop] stopped")
# =========================================================
# ОСТАНОВКА ПРОЦЕССА
# =========================================================
def shutdown(signum=None, frame=None) -> None:
if APP_STOP_EVENT.is_set():
return
log(f"[scheduler] shutdown requested signum={signum}")
APP_STOP_EVENT.set()
stop_all_workers()
def install_signal_handlers() -> None:
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
# =========================================================
# СТАРТ
# =========================================================
def run_scheduler(with_signal_handlers: bool = False) -> None:
if with_signal_handlers:
install_signal_handlers()
log("[scheduler] started")
matches_thread = threading.Thread(
target=matches_loop,
daemon=True,
name="matches_loop_thread",
)
standings_thread = threading.Thread(
target=standings_loop,
daemon=True,
name="standings_loop_thread",
)
matches_thread.start()
standings_thread.start()
try:
while not APP_STOP_EVENT.is_set():
time.sleep(1)
finally:
shutdown()
matches_thread.join(timeout=5)
standings_thread.join(timeout=5)
log("[scheduler] fully stopped")
if __name__ == "__main__":
run_scheduler()

View File

@@ -1,23 +1,13 @@
import os import os
import sys import sys
from getpass import getpass from getpass import getpass
from db import get_connection
sys.path.append(os.path.dirname(os.path.dirname(__file__))) sys.path.append(os.path.dirname(os.path.dirname(__file__)))
import psycopg2
from services.auth_service import hash_password from services.auth_service import hash_password
def get_connection():
return psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=os.getenv("DB_PORT", 5432),
dbname=os.getenv("DB_NAME", "wfl_db"),
user=os.getenv("DB_USER", "postgres"),
password=os.getenv("DB_PASSWORD", "159753"),
)
def main(): def main():
username = input("Username: ").strip() username = input("Username: ").strip()
password = getpass("Password: ").strip() password = getpass("Password: ").strip()

View File

@@ -15,7 +15,7 @@ from repositories.auth_repository import (
revoke_auth_session, revoke_auth_session,
) )
IDLE_TIMEOUT_SECONDS = 2 * 60 * 60 IDLE_TIMEOUT_SECONDS = 4 * 60 * 60
SESSION_TOUCH_THROTTLE_SECONDS = 60 SESSION_TOUCH_THROTTLE_SECONDS = 60
PBKDF2_ITERATIONS = 260_000 PBKDF2_ITERATIONS = 260_000

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import os
import shutil
from datetime import datetime
from dotenv import dotenv_values, load_dotenv, set_key
from parsers import parser_sources
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ENV_PATH = PROJECT_ROOT / ".env"
def _field(key: str, label: str, value: str = "", *, field_type: str = "text", options: list[dict] | None = None, help_text: str = "", wide: bool = False) -> dict[str, Any]:
return {
"key": key,
"label": label,
"value": value or "",
"type": field_type,
"options": options or [],
"help": help_text,
"wide": wide,
}
def _source_field_key(source_key: str, field: str) -> str:
return f"{source_key}_{field}"
def get_env_file_path() -> Path:
return ENV_PATH
def _dotenv_values() -> dict[str, str]:
if not ENV_PATH.exists():
return {}
raw = dotenv_values(ENV_PATH)
return {key: str(value or "") for key, value in raw.items() if key}
def build_env_settings_context() -> dict[str, Any]:
"""Данные для формы редактирования безопасных ключей .env."""
env_values = _dotenv_values()
sources = parser_sources.list_parser_sources()
source_options = [
{"value": source["key"], "label": source.get("title") or source["key"]}
for source in sources
]
groups: list[dict[str, Any]] = [
{
"title": "Общие настройки источников",
"subtitle": "Эти значения влияют на выбор активного турнира по умолчанию и базовый сайт RFS.",
"fields": [
_field(
"DATA_EVENT_CODE",
"Активный источник по умолчанию",
parser_sources.get_default_source_key(),
field_type="select",
options=source_options,
help_text="Используется, если парсер запускается без выбора источника.",
),
_field(
"RFS_BASE_URL",
"Базовый URL RFS/WFL",
parser_sources.RFS_BASE_URL,
help_text="Обычно https://wfl.rfs.ru",
wide=True,
),
],
}
]
for source in sources:
source_key = source["key"]
title = source.get("title") or source_key
groups.append(
{
"title": title,
"subtitle": f"Ключ источника: {source_key}",
"fields": [
_field(_source_field_key(source_key, "EVENT_NAME"), "Название в интерфейсе", source.get("title", "")),
_field(_source_field_key(source_key, "SEASON"), "Сезон", source.get("season", "")),
_field(_source_field_key(source_key, "TOURNAMENT_ID"), "Tournament ID", source.get("tournament_id", "")),
_field(_source_field_key(source_key, "ROUND_ID"), "Round ID", source.get("round_id", "")),
_field(
_source_field_key(source_key, "CALENDAR_TYPE"),
"Тип календаря",
source.get("calendar_type", "tours"),
field_type="select",
options=[
{"value": "tours", "label": "tours — туры"},
{"value": "stages", "label": "stages — стадии"},
],
),
_field(_source_field_key(source_key, "LOGO_BASE_PATH"), "Папка логотипов", source.get("logo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "TEAMS_URL"), "Ссылка на команды", source.get("teams_url", ""), wide=True),
_field(_source_field_key(source_key, "SCHEDULE_URL"), "Ссылка на расписание", source.get("schedule_url", ""), wide=True),
_field(_source_field_key(source_key, "STANDINGS_URL"), "Ссылка на турнирку", source.get("standings_url", ""), wide=True),
_field(_source_field_key(source_key, "MATCH_BASE_URL"), "Базовая ссылка матча", source.get("match_base_url", ""), wide=True),
],
}
)
editable_keys = [field["key"] for group in groups for field in group["fields"]]
return {
"env_path": str(ENV_PATH),
"groups": groups,
"editable_keys": editable_keys,
"raw_values": env_values,
}
def get_editable_env_keys() -> set[str]:
context = build_env_settings_context()
return set(context["editable_keys"])
def save_env_settings(form_values: dict[str, str]) -> list[str]:
"""Сохраняет только разрешённые ключи в .env и обновляет os.environ."""
editable_keys = get_editable_env_keys()
updates: list[str] = []
ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
if not ENV_PATH.exists():
ENV_PATH.write_text("", encoding="utf-8")
backup_path = ENV_PATH.with_suffix(".env.bak")
try:
shutil.copy2(ENV_PATH, backup_path)
except Exception:
# Бэкап полезен, но не должен ломать сохранение настроек.
pass
for key in sorted(editable_keys):
if key not in form_values:
continue
value = str(form_values.get(key) or "").strip()
set_key(str(ENV_PATH), key, value, quote_mode="always")
os.environ[key] = value
updates.append(key)
load_dotenv(ENV_PATH, override=True)
return updates

View File

@@ -7,13 +7,18 @@ from repositories.match_repository import (
from repositories.player_repository import ( from repositories.player_repository import (
get_player_id_by_name_and_team, get_player_id_by_name_and_team,
get_player_id_by_external_id, get_player_id_by_external_id,
create_player_from_lineup,
) )
from repositories.coach_repository import ( from repositories.coach_repository import (
get_coach_id_by_name_and_team, get_coach_id_by_name_and_team,
get_coach_id_by_external_id, get_coach_id_by_external_id,
create_coach_from_lineup,
) )
from repositories.referee_repository import get_referee_id_by_name, upsert_referee from repositories.referee_repository import get_referee_id_by_name, upsert_referee
from repositories.match_lineup_repository import replace_match_lineups, save_match_lineup_for_editor from repositories.match_lineup_repository import (
replace_match_lineups,
get_match_lineup_for_editor,
)
from repositories.match_coach_repository import replace_match_coaches from repositories.match_coach_repository import replace_match_coaches
from repositories.match_referee_repository import replace_match_referees from repositories.match_referee_repository import replace_match_referees
@@ -33,150 +38,213 @@ def sync_match_page(
raise ValueError(f"Match not found by external_id: {match_external_id}") raise ValueError(f"Match not found by external_id: {match_external_id}")
match_id, _, home_team_id, away_team_id = match_row match_id, _, home_team_id, away_team_id = match_row
# Сохраняем текущий выбор капитана ДО очистки данных матча. Это важно
# при повторной загрузке с сайта: на сайте капитана иногда не указывают,
# и в таком случае нельзя молча терять уже сделанный оператором выбор.
previous_lineup = get_match_lineup_for_editor(
match_id=match_id,
home_team_id=home_team_id,
away_team_id=away_team_id,
)
def previous_captain(side: str) -> dict | None:
rows = previous_lineup.get(f"{side}_starting", []) or []
for player in rows:
if bool(player.get("is_captain")):
return {
"player_id": player.get("player_id"),
"number": str(player.get("number") or "").strip(),
}
return None
previous_captains = {
"home": previous_captain("home"),
"away": previous_captain("away"),
}
clear_match_squad_data(match_id) clear_match_squad_data(match_id)
created_players_count = 0
created_coaches_count = 0
def resolve_player(player: dict, team_id: int) -> tuple[int | None, str | None]:
"""Находит игрока из протокола или создаёт его в справочнике автоматически."""
nonlocal created_players_count
player_name = str(player.get("player_name") or "").strip()
player_external_id = str(player.get("player_external_id") or "").strip()
number = str(player.get("number") or "").strip()
position = str(player.get("position") or "").strip()
player_id = None
player_position = None
if player_external_id:
player_row = get_player_id_by_external_id(player_external_id)
if player_row:
player_id, player_position = player_row
if player_id is None and player_name:
player_id = get_player_id_by_name_and_team(player_name, team_id)
if player_id is None and player_name:
player_row = create_player_from_lineup(
team_id=team_id,
external_id=player_external_id,
full_name=player_name,
number=number,
position=position,
)
if player_row:
player_id, player_position = player_row
created_players_count += 1
return player_id, player_position or position
def resolve_coach(coach: dict, team_id: int) -> int | None:
"""Находит тренера из протокола или создаёт его в справочнике автоматически."""
nonlocal created_coaches_count
coach_name = str(coach.get("coach_name") or "").strip()
coach_external_id = str(coach.get("coach_external_id") or "").strip()
role = str(coach.get("role") or "").strip()
coach_id = None
if coach_external_id:
coach_id = get_coach_id_by_external_id(coach_external_id)
if coach_id is None and coach_name:
coach_id = get_coach_id_by_name_and_team(coach_name, team_id)
if coach_id is None and coach_name:
coach_id = create_coach_from_lineup(
team_id=team_id,
external_id=coach_external_id,
full_name=coach_name,
role=role,
)
if coach_id:
created_coaches_count += 1
return coach_id
lineup_rows = [] lineup_rows = []
for player in home_starting:
player_id = None
if player.get("player_external_id"): def append_players(players: list[dict], team_id: int, lineup_type: str) -> None:
player_id = get_player_id_by_external_id(player["player_external_id"]) for player in players:
player_id, player_position = resolve_player(player, team_id)
if player_id is None:
player_id = get_player_id_by_name_and_team(
player["player_name"], home_team_id
)
lineup_rows.append( lineup_rows.append(
{ {
"match_id": match_id, "match_id": match_id,
"team_id": home_team_id, "team_id": team_id,
"player_id": player_id, "player_id": player_id,
"player_name": player["player_name"], "player_name": player.get("player_name") or "",
"number": player.get("number"), "number": player.get("number"),
"position": player.get("position"), "position": player.get("position"),
"is_captain": bool(player.get("is_captain")), "position_full": player_position,
"lineup_type": "starting", # Капитаном может быть только игрок основного состава.
# Если сайт по ошибке пометил игрока запаса, не переносим
# такой флаг в матчевые данные.
"is_captain": bool(player.get("is_captain")) and lineup_type == "starting",
"lineup_type": lineup_type,
"source": "parser", "source": "parser",
} }
) )
for player in away_starting: append_players(home_starting, home_team_id, "starting")
player_id = None append_players(away_starting, away_team_id, "starting")
append_players(home_bench, home_team_id, "bench")
append_players(away_bench, away_team_id, "bench")
if player.get("player_external_id"): def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
player_id = get_player_id_by_external_id(player["player_external_id"]) """Возвращает старого капитана только когда сайт не указал нового.
if player_id is None: Восстановление разрешено исключительно для игрока, который всё ещё
player_id = get_player_id_by_name_and_team( присутствует в основном составе. Если прежний капитан отсутствует или
player["player_name"], away_team_id ушёл в запас, капитан остаётся не выбран — веб-интерфейс покажет
) обязательное предупреждение оператору.
"""
starting_rows = [
row for row in lineup_rows
if row.get("team_id") == team_id and row.get("lineup_type") == "starting"
]
lineup_rows.append( imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
{
"match_id": match_id,
"team_id": away_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"is_captain": bool(player.get("is_captain")),
"lineup_type": "starting",
"source": "parser",
}
)
for player in home_bench: # Ровно один капитан с сайта — корректные данные, они имеют приоритет.
player_id = None if len(imported_captains) == 1:
return False
if player.get("player_external_id"): # Если сайт по ошибке передал больше одного капитана, не выбираем
player_id = get_player_id_by_external_id(player["player_external_id"]) # случайного игрока. Сбрасываем конфликт и используем тот же безопасный
# fallback, что и при полностью отсутствующем капитане.
if len(imported_captains) > 1:
for row in imported_captains:
row["is_captain"] = False
if player_id is None: old_captain = previous_captains.get(side)
player_id = get_player_id_by_name_and_team( if not old_captain:
player["player_name"], home_team_id return False
)
lineup_rows.append( old_player_id = old_captain.get("player_id")
{ old_number = str(old_captain.get("number") or "").strip()
"match_id": match_id,
"team_id": home_team_id,
"player_id": player_id,
"player_name": player["player_name"],
"number": player.get("number"),
"position": player.get("position"),
"is_captain": bool(player.get("is_captain")),
"lineup_type": "bench",
"source": "parser",
}
)
for player in away_bench: # Сначала точное совпадение по player_id.
player_id = None if old_player_id is not None:
for row in starting_rows:
if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id):
row["is_captain"] = True
return True
if player.get("player_external_id"): # Fallback для старых/неполных данных — по игровому номеру.
player_id = get_player_id_by_external_id(player["player_external_id"]) if old_number:
matches = [
row for row in starting_rows
if str(row.get("number") or "").strip() == old_number
]
if len(matches) == 1:
matches[0]["is_captain"] = True
return True
if player_id is None: return False
player_id = get_player_id_by_name_and_team(
player["player_name"], away_team_id
)
lineup_rows.append( restored_home_captain = restore_previous_captain_if_missing("home", home_team_id)
{ restored_away_captain = restore_previous_captain_if_missing("away", away_team_id)
"match_id": match_id,
"team_id": away_team_id, if restored_home_captain or restored_away_captain:
"player_id": player_id, restored = []
"player_name": player["player_name"], if restored_home_captain:
"number": player.get("number"), restored.append("home")
"position": player.get("position"), if restored_away_captain:
"is_captain": bool(player.get("is_captain")), restored.append("away")
"lineup_type": "bench", print(
"source": "parser", f"[parser_game] website captain missing for match={match_external_id}; "
} f"preserved previous captain for: {', '.join(restored)}"
) )
coach_rows = [] coach_rows = []
for coach in home_coaches: def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
coach_id = None for coach in coaches:
coach_id = resolve_coach(coach, team_id)
if coach.get("coach_external_id"):
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
if coach_id is None:
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
coach_rows.append( coach_rows.append(
{ {
"match_id": match_id, "match_id": match_id,
"team_id": home_team_id, "team_id": team_id,
"side": side,
"coach_id": coach_id, "coach_id": coach_id,
"coach_name": coach["coach_name"], "coach_name": coach.get("coach_name") or "",
"role": coach.get("role"), "role": coach.get("role"),
"source": "parser", "source": "parser",
} }
) )
for coach in away_coaches: append_coaches(home_coaches, home_team_id, "home")
coach_id = None append_coaches(away_coaches, away_team_id, "away")
if coach.get("coach_external_id"):
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
if coach_id is None:
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], away_team_id)
coach_rows.append(
{
"match_id": match_id,
"team_id": away_team_id,
"coach_id": coach_id,
"coach_name": coach["coach_name"],
"role": coach.get("role"),
"source": "parser",
}
)
referee_rows = [] referee_rows = []
@@ -199,13 +267,15 @@ def sync_match_page(
try: try:
replace_match_lineups(match_id, lineup_rows) replace_match_lineups(match_id, lineup_rows)
for row in coach_rows:
row["side"] = "home" if row["team_id"] == home_team_id else "away"
replace_match_coaches(match_id, coach_rows) replace_match_coaches(match_id, coach_rows)
replace_match_referees(match_id, referee_rows) replace_match_referees(match_id, referee_rows)
mark_match_parsed(match_external_id) mark_match_parsed(match_external_id)
if created_players_count or created_coaches_count:
print(
f"[parser_game] auto-created for match={match_external_id}: "
f"players={created_players_count}, coaches={created_coaches_count}"
)
except Exception as e: except Exception as e:
mark_match_parse_error(match_external_id, str(e)) mark_match_parse_error(match_external_id, str(e))
raise raise

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
from typing import Any
from parsers import parser_sources
from repositories.project_settings_repository import update_app_setting, update_parser_source
def _field(
key: str,
label: str,
value: str = "",
*,
field_type: str = "text",
options: list[dict] | None = None,
help_text: str = "",
wide: bool = False,
) -> dict[str, Any]:
return {
"key": key,
"label": label,
"value": value or "",
"type": field_type,
"options": options or [],
"help": help_text,
"wide": wide,
}
def _source_field_key(source_key: str, field: str) -> str:
return f"{source_key}_{field}"
def build_project_settings_context() -> dict[str, Any]:
"""Данные для формы редактирования настроек проекта из БД."""
sources = parser_sources.list_parser_sources()
source_options = [
{"value": source["key"], "label": source.get("title") or source["key"]}
for source in sources
]
groups: list[dict[str, Any]] = [
{
"title": "Общие настройки проекта",
"subtitle": "Эти значения хранятся в базе данных, а не в .env.",
"fields": [
_field(
"DATA_EVENT_CODE",
"Активный источник по умолчанию",
parser_sources.get_default_source_key(),
field_type="select",
options=source_options,
help_text="Используется, если парсер запускается без выбора источника.",
),
_field(
"RFS_BASE_URL",
"Базовый URL RFS/WFL",
parser_sources.get_rfs_base_url(),
help_text="Обычно https://wfl.rfs.ru",
wide=True,
),
],
}
]
for source in sources:
source_key = source["key"]
title = source.get("title") or source_key
groups.append(
{
"title": title,
"subtitle": f"Ключ источника: {source_key}",
"fields": [
_field(_source_field_key(source_key, "EVENT_NAME"), "Название в интерфейсе", source.get("title", "")),
_field(_source_field_key(source_key, "SEASON"), "Сезон", source.get("season", "")),
_field(_source_field_key(source_key, "TOURNAMENT_ID"), "Tournament ID", source.get("tournament_id", "")),
_field(_source_field_key(source_key, "ROUND_ID"), "Round ID", source.get("round_id", "")),
_field(
_source_field_key(source_key, "CALENDAR_TYPE"),
"Тип календаря",
source.get("calendar_type", "tours"),
field_type="select",
options=[
{"value": "tours", "label": "tours — туры"},
{"value": "stages", "label": "stages — стадии"},
],
),
_field(_source_field_key(source_key, "LOGO_BASE_PATH"), "Папка логотипов", source.get("logo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "PHOTO_BASE_PATH"), "Папка фотографий", source.get("photo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "CHANNEL_LOGO_BASE_PATH"), "Папка логотипов каналов", source.get("channel_logo_base_path", ""), wide=True),
_field(_source_field_key(source_key, "TEAMS_URL"), "Ссылка на команды", source.get("teams_url", ""), wide=True),
_field(_source_field_key(source_key, "SCHEDULE_URL"), "Ссылка на расписание", source.get("schedule_url", ""), wide=True),
_field(_source_field_key(source_key, "STANDINGS_URL"), "Ссылка на турнирку", source.get("standings_url", ""), wide=True),
_field(_source_field_key(source_key, "MATCH_BASE_URL"), "Базовая ссылка матча", source.get("match_base_url", ""), wide=True),
],
}
)
editable_keys = [field["key"] for group in groups for field in group["fields"]]
return {
"storage_title": "База данных",
"groups": groups,
"editable_keys": editable_keys,
}
def save_project_settings(form_values: dict[str, str]) -> list[str]:
"""Сохраняет настройки проекта в БД."""
updated: list[str] = []
default_source_key = str(form_values.get("DATA_EVENT_CODE") or "SUPERLEAGUE").upper().strip()
rfs_base_url = str(form_values.get("RFS_BASE_URL") or "https://wfl.rfs.ru").strip().rstrip("/")
update_app_setting("default_parser_source_key", default_source_key)
update_app_setting("rfs_base_url", rfs_base_url)
updated.extend(["DATA_EVENT_CODE", "RFS_BASE_URL"])
sources = parser_sources.list_parser_sources()
field_map = {
"EVENT_NAME": "title",
"SEASON": "season",
"TOURNAMENT_ID": "tournament_id",
"ROUND_ID": "round_id",
"CALENDAR_TYPE": "calendar_type",
"LOGO_BASE_PATH": "logo_base_path",
"PHOTO_BASE_PATH": "photo_base_path",
"CHANNEL_LOGO_BASE_PATH": "channel_logo_base_path",
"TEAMS_URL": "teams_url",
"SCHEDULE_URL": "schedule_url",
"STANDINGS_URL": "standings_url",
"MATCH_BASE_URL": "match_base_url",
}
for source in sources:
source_key = source["key"]
values: dict[str, str] = {"base_url": rfs_base_url}
for form_field, db_field in field_map.items():
full_key = _source_field_key(source_key, form_field)
if full_key in form_values:
values[db_field] = str(form_values.get(full_key) or "").strip()
updated.append(full_key)
update_parser_source(source_key, values)
return updated

View File

@@ -16,4 +16,5 @@ def sync_matches(matches_data: list[dict]) -> None:
place=match.get("place"), place=match.get("place"),
date_raw=match.get("date_raw"), date_raw=match.get("date_raw"),
score_add=match.get("score_add"), score_add=match.get("score_add"),
source_key=match.get("source_key"),
) )

View File

@@ -1,9 +1,268 @@
# services/vmix_json_service.py # services/vmix_json_service.py
from db import get_connection from db import get_connection
from repositories.match_lineup_repository import get_match_lineup_for_vmix from repositories.match_lineup_repository import get_match_lineup_for_vmix
from repositories.match_repository import resolve_match_source_key
from parsers.parser_sources import build_empty_photo_path, build_logo_path, build_logo_variant_path, build_photo_path
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name): DEFAULT_PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
EMPTY_PHOTO_PATH = DEFAULT_PHOTO_BASE_PATH + r"\EMPTY.png"
def _session_source_key(session_row) -> str | None:
try:
match_id = session_row[1]
except Exception:
match_id = None
try:
value = session_row[21]
except Exception:
value = None
return resolve_match_source_key(match_id, value)
def build_generated_player_photo_path(
team_name: str,
last_name: str,
first_name: str,
source_key: str | None = None,
) -> str:
relative_file = (
str(team_name or "").strip()
+ "\\"
+ (str(last_name or "") + " " + str(first_name or "")).strip()
+ ".png"
)
return build_photo_path(source_key, relative_file)
def resolve_player_photo(
photo_enabled: bool,
generated_photo: str,
source_key: str | None = None,
) -> str:
if photo_enabled:
return generated_photo
return build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
def _normalize_text(value) -> str:
return " ".join(str(value or "").strip().lower().split())
def _player_name_variants(player: dict) -> set[str]:
first_name = _normalize_text(player.get("first_name"))
last_name = _normalize_text(player.get("last_name"))
player_name = _normalize_text(player.get("player_name"))
full_name = _normalize_text(player.get("full_name"))
variants = {player_name, full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _candidate_name_variants(row: tuple) -> set[str]:
_, full_name, last_name, first_name, *_ = row
full_name = _normalize_text(full_name)
last_name = _normalize_text(last_name)
first_name = _normalize_text(first_name)
variants = {full_name}
if first_name or last_name:
variants.add(f"{last_name} {first_name}".strip())
variants.add(f"{first_name} {last_name}".strip())
return {v for v in variants if v}
def _find_player_photo_state(
*,
player_id=None,
team_id=None,
player_name: str = "",
first_name: str = "",
last_name: str = "",
full_name: str = "",
number: str = "",
) -> tuple[str | None, bool] | None:
"""
Берёт актуальное состояние галочки photo_enabled прямо из players.
Если в составе нет корректного player_id, пробует найти игрока по команде, номеру и имени.
Это важно для старых/загруженных с сайта составов, где player_id мог быть пустым.
Значение p.photo здесь не используется для JSON-пути: путь генерируется по старой схеме.
"""
try:
player_id_int = int(player_id) if str(player_id or "").strip().isdigit() else None
except Exception:
player_id_int = None
try:
team_id_int = int(team_id) if str(team_id or "").strip().isdigit() else None
except Exception:
team_id_int = None
target_number = str(number or "").strip()
target_player = {
"player_name": player_name,
"first_name": first_name,
"last_name": last_name,
"full_name": full_name,
}
target_names = _player_name_variants(target_player)
where_parts = []
params = []
if player_id_int is not None:
where_parts.append("p.id = %s")
params.append(player_id_int)
if team_id_int is not None:
team_conditions = []
team_params = []
if target_number:
team_conditions.append("COALESCE(p.number::text, '') = %s")
team_params.append(target_number)
for name in sorted(target_names):
like = f"%{name}%"
team_conditions.append(
"""
(
LOWER(COALESCE(p.full_name, '')) = %s
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = %s
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = %s
OR LOWER(COALESCE(p.full_name, '')) LIKE %s
)
"""
)
team_params.extend([name, name, name, like])
if team_conditions:
where_parts.append(f"(p.team_id = %s AND ({' OR '.join(team_conditions)}))")
params.append(team_id_int)
params.extend(team_params)
if not where_parts:
return None
query = f"""
SELECT
p.id,
COALESCE(p.full_name, ''),
COALESCE(p.last_name, ''),
COALESCE(p.first_name, ''),
COALESCE(p.number::text, ''),
p.team_id,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
FROM players p
WHERE {' OR '.join(where_parts)}
LIMIT 50
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
finally:
conn.close()
best_row = None
best_score = -1
for row in rows:
row_id, _, _, _, row_number, row_team_id, *_ = row
row_names = _candidate_name_variants(row)
id_match = player_id_int is not None and int(row_id) == player_id_int
team_match = team_id_int is not None and int(row_team_id) == team_id_int
number_match = bool(target_number) and str(row_number or "").strip() == target_number
name_match = bool(target_names.intersection(row_names))
partial_name_match = False
if target_names and row_names:
partial_name_match = any(
target in candidate or candidate in target
for target in target_names
for candidate in row_names
if len(target) >= 4 and len(candidate) >= 4
)
# Если ID выглядит как номер игрока и случайно совпал с чужим players.id,
# не считаем его надёжным без совпадения команды/имени/номера.
trusted_id_match = id_match and (
(team_id_int is None or team_match)
and (not target_number and not target_names or number_match or name_match or partial_name_match)
)
score = 0
if trusted_id_match:
score += 100
if team_match:
score += 40
if number_match:
score += 30
if name_match:
score += 35
elif partial_name_match:
score += 15
# Для поиска без надёжного ID нужно хотя бы совпадение команды и номера/имени.
if not trusted_id_match and not (team_match and (number_match or name_match or partial_name_match)):
continue
if score > best_score:
best_score = score
best_row = row
if not best_row:
return None
return best_row[6], bool(best_row[7])
def resolve_player_photo_for_json(
player: dict,
generated_photo: str,
fallback_team_id=None,
source_key: str | None = None,
) -> str:
state = _find_player_photo_state(
player_id=player.get("player_id") or player.get("id"),
team_id=player.get("team_id") or fallback_team_id,
player_name=player.get("player_name") or "",
first_name=player.get("first_name") or "",
last_name=player.get("last_name") or "",
full_name=player.get("full_name") or "",
number=player.get("number") or player.get("player_number") or "",
)
if state is not None:
photo_value, photo_enabled = state
resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo
return resolve_player_photo(
photo_enabled=photo_enabled,
generated_photo=resolved_photo,
source_key=source_key,
)
photo_value = player.get("photo") or ""
resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo
return resolve_player_photo(
photo_enabled=player.get("photo_enabled"),
generated_photo=resolved_photo,
source_key=source_key,
)
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name, source_key=None):
lineups = get_match_lineup_for_vmix( lineups = get_match_lineup_for_vmix(
match_id=match_id, match_id=match_id,
home_team_id=home_team_id, home_team_id=home_team_id,
@@ -14,6 +273,8 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
result = [] result = []
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
for p in players: for p in players:
suffix = [] suffix = []
if "вратарь" in (p.get("pos") or "").lower(): if "вратарь" in (p.get("pos") or "").lower():
@@ -40,15 +301,16 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
+ (f" {', '.join(suffix)}" if suffix else ""), + (f" {', '.join(suffix)}" if suffix else ""),
"pos": p.get("pos", ""), "pos": p.get("pos", ""),
"position": p.get("position", ""), "position": p.get("position", ""),
"photo": ( "photo": resolve_player_photo_for_json(
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" p,
+ "\\" generated_photo=build_generated_player_photo_path(
+ (team_a_name if "home" in name else team_b_name) team_name=team_a_name if "home" in name else team_b_name,
+ "\\" last_name=p.get("last_name", ""),
+ (p.get("last_name", "") first_name=p.get("first_name", ""),
+ " " source_key=source_key,
+ p.get("first_name", "")).strip() ),
+ ".png" fallback_team_id=fallback_team_id,
source_key=source_key,
), ),
} }
) )
@@ -78,43 +340,16 @@ def get_vmix_match_info_by_token(session_token: str):
m.tour, m.tour,
ht.logo_path AS home_logo, ht.logo_path AS home_logo,
REPLACE(at.logo_path, 'HOME', 'AWAY') AS away_logo, at.logo_path AS away_logo,
ref1.referee_name AS referee1, ref1.referee_name AS referee1,
ref2.referee_name AS referee2, ref2.referee_name AS referee2,
ref3.referee_name AS referee3, ref3.referee_name AS referee3,
ref4.referee_name AS referee4, ref4.referee_name AS referee4,
CASE ht.logo_path AS home_logo1,
WHEN ht.name ILIKE '%%динамо%%' at.logo_path AS away_logo1,
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый') ht.logo_path AS home_logo2,
WHEN ht.name ILIKE '%%зенит%%' at.logo_path AS away_logo2,
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(ht.logo_path, 'HOME\\', '')
END AS home_logo1,
CASE
WHEN at.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN at.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(at.logo_path, 'HOME\\', '')
END AS away_logo1,
CASE
WHEN ht.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий')
WHEN ht.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий')
ELSE REPLACE(ht.logo_path, 'HOME\\', '')
END AS home_logo2,
CASE
WHEN at.name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий')
WHEN at.name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий')
ELSE REPLACE(at.logo_path, 'HOME\\', '')
END AS away_logo2,
ht.city AS home_city, ht.city AS home_city,
at.city AS away_city, at.city AS away_city,
@@ -123,7 +358,8 @@ def get_vmix_match_info_by_token(session_token: str):
c1.amplua AS coach_amplua1, c1.amplua AS coach_amplua1,
TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2, TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2,
c2.amplua AS coach_amplua2 c2.amplua AS coach_amplua2,
m.source_key AS source_key
FROM match_sessions ms FROM match_sessions ms
JOIN matches m ON m.id = ms.match_id JOIN matches m ON m.id = ms.match_id
@@ -151,23 +387,35 @@ def get_vmix_match_info_by_token(session_token: str):
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (session_token,))
return cur.fetchone() row = cur.fetchone()
if not row:
return None
row = list(row)
source_key = resolve_match_source_key(row[0], row[30] if len(row) > 30 else None)
if len(row) > 30:
row[30] = source_key
row[14] = build_logo_path(source_key, row[14])
row[15] = build_logo_path(source_key, row[15])
row[20] = build_logo_variant_path(source_key, row[20], "white")
row[21] = build_logo_variant_path(source_key, row[21], "white")
row[22] = build_logo_variant_path(source_key, row[22], "blue")
row[23] = build_logo_variant_path(source_key, row[23], "blue")
return tuple(row)
finally: finally:
conn.close() conn.close()
def get_vmix_standings(session_token: str): def get_vmix_standings(session_row):
source_key = _session_source_key(session_row)
season = session_row[11] if len(session_row) > 11 else None
query = """ query = """
SELECT SELECT
s.position, s.position,
t.full_name, COALESCE(t.full_name, t.name) AS team_name,
CASE t.logo_path AS logo,
WHEN t.full_name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN t.full_name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(t.logo_path, 'HOME\\', '')
END AS logo,
s.played, s.played,
s.wins, s.wins,
s.losses, s.losses,
@@ -177,52 +425,66 @@ def get_vmix_standings(session_token: str):
s.team_id s.team_id
FROM standings s FROM standings s
LEFT JOIN teams t ON s.team_id = t.id LEFT JOIN teams t ON s.team_id = t.id
WHERE (%s IS NULL OR s.season = %s)
ORDER BY s.position ORDER BY s.position
""" """
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (season, season))
return cur.fetchall() rows = cur.fetchall()
result = []
for row in rows:
row = list(row)
row[2] = build_logo_variant_path(source_key, row[2], "white")
result.append(tuple(row))
return result
finally: finally:
conn.close() conn.close()
def get_vmix_schedule(session_token: str): def get_vmix_schedule(session_row):
source_key = _session_source_key(session_row)
tour = session_row[10] if len(session_row) > 10 else None
season = session_row[11] if len(session_row) > 11 else None
source_filter = source_key or "SUPERLEAGUE"
query = """ query = """
SELECT SELECT
CASE t1.logo_path AS logo1,
WHEN t1.full_name ILIKE '%%динамо%%' t2.logo_path AS logo2,
THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN t1.full_name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(t1.logo_path, 'HOME\\', '')
END AS logo1,
CASE
WHEN t2.full_name ILIKE '%%динамо%%'
THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый')
WHEN t2.full_name ILIKE '%%зенит%%'
THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
ELSE REPLACE(t2.logo_path, 'HOME\\', '')
END AS logo2,
m.home_score, m.home_score,
m.away_score, m.away_score,
m.match_date, m.match_date,
m.status, m.status,
m.id m.id,
COALESCE(m.channel, '') AS channel,
COALESCE(NULLIF(m.source_key, ''), %s) AS source_key
FROM matches m FROM matches m
LEFT JOIN teams t1 ON m.home_team_id = t1.id LEFT JOIN teams t1 ON m.home_team_id = t1.id
LEFT JOIN teams t2 ON m.away_team_id = t2.id LEFT JOIN teams t2 ON m.away_team_id = t2.id
WHERE m.tour = %s WHERE m.tour = %s
AND (%s IS NULL OR m.season = %s)
AND COALESCE(NULLIF(m.source_key, ''), %s) = %s
ORDER BY m.match_date, m.id ORDER BY m.match_date, m.id
""" """
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token[10],)) cur.execute(query, (source_filter, tour, season, season, source_filter, source_filter))
return cur.fetchall() rows = cur.fetchall()
result = []
for row in rows:
row = list(row)
row_source_key = row[8] if len(row) > 8 else source_key
row[0] = build_logo_variant_path(row_source_key, row[0], "white")
row[1] = build_logo_variant_path(row_source_key, row[1], "white")
result.append(tuple(row))
return result
finally: finally:
conn.close() conn.close()
@@ -230,13 +492,59 @@ def get_vmix_schedule(session_token: str):
def get_vmix_team_formations(session_token: str, team_id: int): def get_vmix_team_formations(session_token: str, team_id: int):
query = """ query = """
SELECT SELECT
p.last_name, COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
mf.is_captain, mf.is_captain,
p.number, COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
p.position, COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
p.first_name COALESCE(p.first_name, '') AS first_name,
p.photo,
COALESCE(p.photo_enabled, FALSE) AS photo_enabled,
mf.player_id,
mf.team_id,
COALESCE(mf.player_name, '') AS player_name
FROM match_formations mf FROM match_formations mf
LEFT JOIN players p ON p.id = mf.player_id LEFT JOIN LATERAL (
SELECT p.*
FROM players p
WHERE p.team_id = mf.team_id
AND (
p.id = mf.player_id
OR (
COALESCE(p.number::text, '') <> ''
AND COALESCE(p.number::text, '') = COALESCE(mf.number, '')
)
OR (
COALESCE(mf.player_name, '') <> ''
AND (
LOWER(COALESCE(p.full_name, '')) = LOWER(TRIM(mf.player_name))
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = LOWER(TRIM(mf.player_name))
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = LOWER(TRIM(mf.player_name))
)
)
)
ORDER BY
CASE
WHEN p.id = mf.player_id
AND (
COALESCE(mf.number, '') = ''
OR COALESCE(p.number::text, '') = COALESCE(mf.number, '')
OR LOWER(COALESCE(p.full_name, '')) = LOWER(TRIM(COALESCE(mf.player_name, '')))
OR LOWER(TRIM(COALESCE(p.last_name, '') || ' ' || COALESCE(p.first_name, ''))) = LOWER(TRIM(COALESCE(mf.player_name, '')))
OR LOWER(TRIM(COALESCE(p.first_name, '') || ' ' || COALESCE(p.last_name, ''))) = LOWER(TRIM(COALESCE(mf.player_name, '')))
)
THEN 0
WHEN COALESCE(p.number::text, '') = COALESCE(mf.number, '')
AND COALESCE(mf.player_name, '') <> ''
THEN 1
WHEN COALESCE(mf.player_name, '') <> ''
THEN 2
WHEN COALESCE(p.number::text, '') = COALESCE(mf.number, '')
THEN 3
ELSE 4
END,
p.id
LIMIT 1
) p ON TRUE
WHERE mf.match_id = %s and mf.team_id = %s WHERE mf.match_id = %s and mf.team_id = %s
ORDER BY mf.id ORDER BY mf.id
""" """
@@ -332,7 +640,10 @@ WHERE m.match_id = %s;
conn.close() conn.close()
def get_vmix_players_goal(match_id: int): def get_vmix_players_goal2(match_id: int):
"""запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол)
Иванов 23', 25' (дубли пишуться вместе)
"""
query = """ query = """
WITH goal_events AS ( WITH goal_events AS (
SELECT SELECT
@@ -372,10 +683,10 @@ grouped_players AS (
( (
CASE CASE
WHEN period_no = 1 AND seconds > 2700 WHEN period_no = 1 AND seconds > 2700
THEN '45''' || '+ ' || ((seconds - 2700) / 60)::int::text THEN '45''' || '+' || ((seconds - 2700) / 60)::int::text
WHEN period_no = 2 AND seconds > 5400 WHEN period_no = 2 AND seconds > 5400
THEN '90''' || '+ ' || ((seconds - 5400) / 60)::int::text THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text
WHEN period_no = 2 WHEN period_no = 2
THEN (46 + ((seconds - 2700) / 60)::int)::text || '''' THEN (46 + ((seconds - 2700) / 60)::int)::text || ''''
@@ -422,3 +733,95 @@ ORDER BY COALESCE(h.rn, a.rn);
return cur.fetchall() return cur.fetchall()
finally: finally:
conn.close() conn.close()
def get_vmix_players_goal(match_id: int):
"""запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол)
Каждый гол в отдельной строке, дубли по игроку не объединяются
"""
query = """
WITH goal_events AS (
SELECT
meu.side,
COALESCE(p.last_name, meu.player_name) AS last_name,
meu.seconds,
meu.id,
meu.type,
CASE
WHEN EXISTS (
SELECT 1
FROM match_events_ui m2
WHERE m2.match_id = meu.match_id
AND m2.type = 'period_start_2h'
AND m2.seconds <= meu.seconds
) THEN 2
WHEN EXISTS (
SELECT 1
FROM match_events_ui m2
WHERE m2.match_id = meu.match_id
AND m2.type = 'period_start_1h'
AND m2.seconds <= meu.seconds
) THEN 1
ELSE NULL
END AS period_no
FROM match_events_ui meu
LEFT JOIN players p ON p.id = meu.player_id
WHERE meu.match_id = %s
AND meu.type IN ('goal', 'penalty', 'own_goal')
),
goal_rows AS (
SELECT
side,
last_name,
seconds,
id,
last_name || ' ' ||
(
CASE
WHEN period_no = 1 AND seconds > 2700
THEN '45''' || '+' || ((seconds - 2700) / 60)::int::text
WHEN period_no = 2 AND seconds > 5400
THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text
WHEN period_no = 2
THEN (46 + ((seconds - 2700) / 60)::int)::text || ''''
ELSE (1 + (seconds / 60)::int)::text || ''''
END
) ||
CASE
WHEN type = 'own_goal' THEN ' (АГ)'
WHEN type = 'penalty' THEN ' (П)'
ELSE ''
END AS player_goal
FROM goal_events
),
home_rows AS (
SELECT
ROW_NUMBER() OVER (ORDER BY seconds, id, last_name) AS rn,
player_goal AS player_name1
FROM goal_rows
WHERE side = 'home'
),
away_rows AS (
SELECT
ROW_NUMBER() OVER (ORDER BY seconds, id, last_name) AS rn,
player_goal AS player_name2
FROM goal_rows
WHERE side = 'away'
)
SELECT
COALESCE(h.player_name1, '') AS player_name1,
COALESCE(a.player_name2, '') AS player_name2
FROM home_rows h
FULL OUTER JOIN away_rows a ON a.rn = h.rn
ORDER BY COALESCE(h.rn, a.rn);
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(query, (match_id,))
return cur.fetchall()
finally:
conn.close()

32
source_config.py Normal file
View File

@@ -0,0 +1,32 @@
from parsers.parser_sources import (
RFS_BASE_URL,
get_default_source_key,
get_parser_source,
source_absolute_url,
source_match_url,
)
_default_source = get_parser_source()
# Старые константы оставлены для совместимости с уже существующим кодом.
DATA_EVENT_CODE = get_default_source_key()
DATA_EVENT_NAME = _default_source["title"]
DATA_TOURNAMENT_ID = _default_source["tournament_id"]
DATA_ROUND_ID = _default_source["round_id"]
DATA_SEASON = _default_source["season"]
DATA_TEAMS_URL = _default_source["teams_url"]
DATA_SCHEDULE_URL = _default_source["schedule_url"]
DATA_STANDINGS_URL = _default_source["standings_url"]
DATA_MATCH_BASE_URL = _default_source["match_base_url"]
DATA_LOGO_BASE_PATH = _default_source.get("logo_base_path", "")
DATA_PHOTO_BASE_PATH = _default_source.get("photo_base_path", "")
def absolute_url(path_or_url: str) -> str:
"""Возвращает абсолютную ссылку для href с сайта WFL."""
return source_absolute_url(_default_source, path_or_url)
def match_url(match_external_id: str | int) -> str:
return source_match_url(_default_source, match_external_id)

View File

@@ -0,0 +1,2 @@
ALTER TABLE players
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,4 @@
ALTER TABLE matches
ADD COLUMN IF NOT EXISTS source_key VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_matches_source_key ON matches(source_key);

View File

@@ -0,0 +1,79 @@
-- Настройки проекта и источники парсинга теперь хранятся в БД, а не в .env.
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS parser_sources (
key VARCHAR(50) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
tournament_id VARCHAR(100),
round_id VARCHAR(100),
season VARCHAR(50),
calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours',
logo_base_path TEXT,
photo_base_path TEXT,
teams_url TEXT,
schedule_url TEXT,
standings_url TEXT,
match_base_url TEXT,
base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO app_settings (key, value, updated_at)
VALUES
('default_parser_source_key', 'SUPERLEAGUE', NOW()),
('rfs_base_url', 'https://wfl.rfs.ru', NOW())
ON CONFLICT (key) DO NOTHING;
INSERT INTO parser_sources (
key, title, tournament_id, round_id, season, calendar_type, logo_base_path, photo_base_path,
teams_url, schedule_url, standings_url, match_base_url, base_url,
sort_order, is_active, created_at, updated_at
)
VALUES
(
'SUPERLEAGUE',
'Суперлига 2026',
'1061879',
'1117550',
'2025/2026',
'tours',
'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos',
'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo',
'https://wfl.rfs.ru/tournament/1061879/teams',
'https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours',
'https://wfl.rfs.ru/tournament/1061879/tables',
'https://wfl.rfs.ru/match/',
'https://wfl.rfs.ru',
10,
TRUE,
NOW(),
NOW()
),
(
'RUSSIAN_CUP',
'Кубок России 2026',
'1064908',
'1125159',
'2026',
'stages',
'D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos',
'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo',
'https://wfl.rfs.ru/tournament/1064908/teams',
'https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages',
'https://wfl.rfs.ru/tournament/1064908/tables',
'https://wfl.rfs.ru/match/',
'https://wfl.rfs.ru',
20,
TRUE,
NOW(),
NOW()
)
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,14 @@
-- Папка фотографий для каждого источника турнира.
ALTER TABLE parser_sources
ADD COLUMN IF NOT EXISTS photo_base_path TEXT;
UPDATE parser_sources
SET photo_base_path = 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo'
WHERE key = 'SUPERLEAGUE'
AND (photo_base_path IS NULL OR TRIM(photo_base_path) = '');
UPDATE parser_sources
SET photo_base_path = 'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo'
WHERE key = 'RUSSIAN_CUP'
AND (photo_base_path IS NULL OR TRIM(photo_base_path) = '');

View File

@@ -0,0 +1,12 @@
ALTER TABLE parser_sources
ADD COLUMN IF NOT EXISTS channel_logo_base_path TEXT;
UPDATE parser_sources
SET channel_logo_base_path = 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов'
WHERE key = 'SUPERLEAGUE'
AND (channel_logo_base_path IS NULL OR TRIM(channel_logo_base_path) = '');
UPDATE parser_sources
SET channel_logo_base_path = 'D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов'
WHERE key = 'RUSSIAN_CUP'
AND (channel_logo_base_path IS NULL OR TRIM(channel_logo_base_path) = '');

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS match_penalty_settings (
match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
max_rounds INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS match_penalties (
id SERIAL PRIMARY KEY,
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')),
shot_number INTEGER NOT NULL CHECK (shot_number > 0),
result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (match_id, side, shot_number)
);

BIN
static/docs/L3 scores.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
static/docs/Schedule.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
static/docs/SuperCup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
static/docs/agent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/docs/formation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
static/docs/formation2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
static/docs/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

BIN
static/docs/main window.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
static/docs/matches.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
static/docs/no photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
static/docs/path photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
static/docs/path vmix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
static/docs/penalty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
static/docs/referee.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
static/images/vmix_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because it is too large Load Diff

BIN
static/smith.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
static/smith_full.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -856,7 +856,8 @@ table {
} }
.tour-meta-col { .tour-meta-col {
width: 1%; width: 300px;
min-width: 300px;
white-space: nowrap; white-space: nowrap;
} }
@@ -1896,6 +1897,63 @@ body.edit-mode-on .player-node.dragging {
margin-top: 6px; margin-top: 6px;
} }
.squad-editor-captain-warning {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #f1b9b9;
border-radius: 10px;
background: #fff1f1;
color: #9f1d1d;
font-size: 13px;
font-weight: 800;
line-height: 1.35;
}
#captainRequiredModal {
z-index: 3400;
}
.captain-required-modal-content {
width: min(560px, 92vw);
margin-top: 16vh;
padding: 24px;
overflow: visible;
border: 1px solid #f0c7c7;
}
.captain-required-icon {
width: 54px;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
border-radius: 50%;
background: #fff0f0;
color: #c62828;
border: 2px solid #ef9a9a;
font-size: 30px;
font-weight: 900;
}
.captain-required-copy {
text-align: center;
}
.captain-required-text {
margin-top: 10px;
color: #4d4d4d;
font-size: 15px;
font-weight: 650;
line-height: 1.5;
}
.captain-required-actions {
justify-content: center;
margin-top: 22px;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.squad-editor-grid { .squad-editor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -2175,3 +2233,672 @@ body:not(.edit-mode-on) .referee-search {
border-color: var(--accent-hover); border-color: var(--accent-hover);
transform: translateY(-1px); transform: translateY(-1px);
} }
.matrix-loader {
position: fixed;
inset: 0;
z-index: 99999;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.93);
overflow: hidden;
}
.matrix-loader.active {
display: flex;
}
.matrix-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.matrix-content {
position: relative;
z-index: 2;
text-align: center;
padding: 24px 28px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 255, 120, 0.25);
box-shadow: 0 0 30px rgba(0, 255, 120, 0.12);
backdrop-filter: blur(4px);
}
.matrix-title {
margin-bottom: 10px;
font-size: 28px;
font-weight: 700;
color: #9dffb2;
text-shadow: 0 0 10px rgba(0, 255, 120, 0.35);
}
.matrix-status {
font-size: 15px;
color: #d7ffe1;
opacity: 0.95;
}
.matrix-loader.is-error .matrix-content {
border-color: rgba(255, 90, 90, 0.35);
box-shadow: 0 0 30px rgba(255, 90, 90, 0.12);
}
.matrix-loader.is-error .matrix-title {
color: #ff9c9c;
text-shadow: 0 0 10px rgba(255, 90, 90, 0.25);
}
.matrix-loader.is-error .matrix-status {
color: #ffd4d4;
}
.matrix-loader.is-success .matrix-title {
color: #9dffb2;
}
.player-link {
cursor: pointer;
}
.player-link .last-name,
.player-link .first-name,
.coach-link-btn .coach-name,
.coach-left .coach-name {
color: #8ab4ff;
text-decoration: underline;
text-underline-offset: 2px;
}
.player-link:hover .last-name,
.player-link:hover .first-name,
.coach-link-btn:hover .coach-name,
.coach-left:hover .coach-name {
color: #a9c7ff;
}
.player-link-btn {
display: block;
width: 100%;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.player-link-btn .last-name,
.player-link-btn .first-name {
color: #8ab4ff;
text-decoration: underline;
text-underline-offset: 2px;
}
.player-link-btn:hover .last-name,
.player-link-btn:hover .first-name {
color: #a9c7ff;
}
.player-link-btn:focus-visible {
outline: 2px solid rgba(138, 180, 255, 0.45);
outline-offset: 2px;
border-radius: 6px;
}
.event-time-editable {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
}
.event-time-label {
font-weight: 600;
}
.event-time-edit-btn {
position: absolute;
top: -6px;
left: -6px;
width: 20px;
height: 20px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
background: rgba(20,20,20,0.92);
color: #d0d0d0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
cursor: pointer;
opacity: 0.72;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
}
.event-time-edit-btn:hover {
background: #2d5cff;
border-color: #5f84ff;
color: #fff;
opacity: 1;
transform: scale(1.08);
}
.event-time-edit-btn:active {
transform: scale(0.96);
}
.event-time-edit-btn:focus-visible {
outline: 2px solid rgba(95,132,255,0.45);
outline-offset: 2px;
}
/* ===== Редактирование текущего времени ===== */
.timer-box-clickable {
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.timer-box-clickable:hover {
transform: translateY(-1px);
border-color: rgba(79, 140, 255, 0.55);
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.12);
}
#clockEditorModal {
z-index: 5000;
}
#clockEditorModal .formation-modal-backdrop {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
}
.clock-editor-modal-content {
width: min(460px, 94vw);
margin: 12vh auto 0;
padding: 22px;
background:
linear-gradient(180deg, rgba(31, 36, 46, 0.98), rgba(18, 21, 27, 0.98));
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 18px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
color: var(--text);
}
.clock-editor-modal-content .formation-modal-title {
color: var(--text);
}
.clock-editor-modal-content .formation-modal-subtitle {
color: var(--muted);
}
.clock-editor-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.clock-editor-label {
font-size: 13px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.clock-editor-input {
width: 100%;
height: 74px;
background: #0b0d12;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
color: #ffffff;
font-size: 40px;
font-weight: 800;
letter-spacing: 2px;
text-align: center;
outline: none;
font-family: Arial, sans-serif;
}
.clock-editor-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(79, 140, 255, 0.18);
}
.clock-editor-note {
font-size: 13px;
color: var(--muted);
}
#clockEditorModal .formation-modal-footer {
margin-top: 20px;
}
#clockEditorModal .btn-primary {
box-shadow: 0 10px 24px rgba(79, 140, 255, 0.25);
}
.clear-match-modal-content {
width: min(420px, 92vw);
background:
linear-gradient(
180deg,
rgba(31, 36, 46, 0.98),
rgba(18, 21, 27, 0.98)
) !important;
border: 1px solid rgba(255,255,255,0.08);
color: var(--text);
box-shadow: 0 24px 80px rgba(0,0,0,.55);
}
#clearMatchModal .formation-modal-backdrop {
background: rgba(0,0,0,.72);
backdrop-filter: blur(4px);
}
#clearMatchModal .formation-modal-title {
color: #fff;
}
#clearMatchModal .formation-modal-subtitle {
color: var(--muted);
}
.clear-match-warning {
padding: 18px;
border-radius: 14px;
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.28);
color: #ffb8bf;
font-size: 15px;
line-height: 1.5;
}
.btn-danger {
background: #dc3545;
color: white;
border: none;
}
.btn-danger:hover {
background: #e14b5b;
}
.num-with-photo-status {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 44px;
}
.no-photo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 12px;
line-height: 1;
opacity: 0.65;
filter: grayscale(1);
cursor: help;
}
.player-number-value {
display: inline-block;
min-width: 16px;
text-align: center;
}
/* Пенальти Кубка России */
.penalty-panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.penalty-header,
.penalty-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.penalty-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.penalty-subtitle,
.penalty-help,
.penalty-next-label {
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
margin-top: 5px;
}
.penalty-scoreboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.penalty-team-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.penalty-team-card.home {
border-color: rgba(79, 140, 255, 0.28);
}
.penalty-team-card.away {
border-color: rgba(255, 120, 120, 0.25);
}
.penalty-team-name {
color: #fff;
font-size: 16px;
font-weight: 800;
min-height: 22px;
}
.penalty-team-score {
margin-top: 8px;
color: #fff;
font-size: 54px;
line-height: 1;
font-weight: 900;
letter-spacing: -0.04em;
}
.penalty-fast-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.penalty-fast-btn,
.penalty-mini-btn {
border: 0;
cursor: pointer;
color: #fff;
font-weight: 800;
transition: transform 0.12s ease, opacity 0.12s ease, box-shadow 0.12s ease;
}
.penalty-fast-btn:hover,
.penalty-mini-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
}
.penalty-fast-btn {
min-height: 54px;
border-radius: 14px;
font-size: 18px;
}
.penalty-fast-btn.scored,
.penalty-mini-btn.scored {
background: linear-gradient(135deg, #0e9f6e, #12b981);
box-shadow: 0 10px 28px rgba(18, 185, 129, 0.16);
}
.penalty-fast-btn.missed,
.penalty-mini-btn.missed {
background: linear-gradient(135deg, #b91c1c, #ef4444);
box-shadow: 0 10px 28px rgba(239, 68, 68, 0.16);
}
.penalty-table-wrap {
overflow-x: auto;
}
.penalty-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
}
.penalty-table th {
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 10px 6px;
}
.penalty-table td {
padding: 0 10px;
vertical-align: middle;
}
.penalty-round-number {
width: 70px;
color: rgba(255, 255, 255, 0.72);
text-align: center;
font-size: 18px;
font-weight: 900;
}
.penalty-cell {
display: grid;
grid-template-columns: 52px 1fr;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 8px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.penalty-cell.scored {
border-color: rgba(18, 185, 129, 0.38);
background: rgba(18, 185, 129, 0.08);
}
.penalty-cell.missed {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.penalty-mark {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.22);
color: #fff;
font-size: 30px;
line-height: 1;
font-weight: 900;
}
.penalty-cell.scored .penalty-mark {
color: #34d399;
}
.penalty-cell.missed .penalty-mark {
color: #f87171;
}
.penalty-cell-actions {
display: grid;
grid-template-columns: 1fr 1fr minmax(74px, 0.8fr);
gap: 8px;
}
.penalty-mini-btn {
min-height: 38px;
border-radius: 10px;
font-size: 13px;
}
.penalty-mini-btn.active {
outline: 2px solid rgba(255, 255, 255, 0.75);
outline-offset: 2px;
}
.penalty-mini-btn.clear {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.75);
}
.penalty-mini-btn.clear:disabled {
cursor: default;
opacity: 0.35;
transform: none;
}
@media (max-width: 900px) {
.penalty-scoreboard {
grid-template-columns: 1fr;
}
.penalty-header,
.penalty-footer,
.penalty-header-actions {
align-items: stretch;
flex-direction: column;
}
}
/* Расписание тура: компактная ручная правка счёта и статуса */
.tour-actions-col,
.tour-actions-cell {
width: 156px;
text-align: right;
white-space: nowrap;
}
.tour-meta-cell {
min-width: 300px;
}
.tour-score-edit {
min-width: 300px;
}
.tour-edit-line {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
white-space: nowrap;
}
.tour-score-inputs {
display: inline-grid;
grid-template-columns: 52px auto 52px;
gap: 6px;
align-items: center;
width: max-content;
flex: 0 0 auto;
}
.tour-score-input {
width: 52px;
height: 32px;
padding: 4px 6px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
text-align: center;
font-weight: 800;
font-size: 13px;
}
.tour-score-input:disabled,
.tour-status-select:disabled {
opacity: 0.55;
}
.tour-score-separator {
font-weight: 900;
color: rgba(255, 255, 255, 0.78);
}
.tour-status-select {
height: 32px;
width: 128px;
max-width: 128px;
padding: 4px 28px 4px 10px;
flex: 0 0 128px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.tour-status-select option {
color: #111827;
}
.tour-edit-btn {
min-width: 38px;
min-height: 32px;
padding: 6px 9px;
}
.tour-save-actions {
display: inline-flex;
justify-content: flex-end;
gap: 5px;
flex-wrap: nowrap;
}
.tour-save-btn,
.tour-cancel-btn {
min-height: 32px;
padding: 6px 9px;
font-size: 12px;
}
/* Расписание тура: редактирование включается общим карандашом в шапке */
.tour-edit-only {
display: none;
}
body.edit-mode-on .tour-edit-only {
display: table-cell;
}

Some files were not shown because too many files have changed in this diff Show More