Compare commits
190 Commits
4ce5faed59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c759f34f0f | |||
| b29fc06a4f | |||
| adea3a8bd6 | |||
| 2c2d9b9a7c | |||
| e17c287d65 | |||
| 60ec71c73d | |||
| 77adc31a1a | |||
| 8e38505f69 | |||
| 504cf77312 | |||
| 00f8533236 | |||
| 87cd044c3f | |||
| 6fe4eca0ba | |||
| c646691142 | |||
| 2b3c882d25 | |||
| c724c7e965 | |||
| d689b853dc | |||
| 3ec7f57d46 | |||
| aeafdc9887 | |||
| a215ba526b | |||
| e7b215af5e | |||
| 03a68ee8ca | |||
| e32fdcb74e | |||
| 5b056550ed | |||
| 1303b3b562 | |||
| 61861085f8 | |||
| e98d014243 | |||
| 57907fd86b | |||
| b0fe3ad5a4 | |||
| 24c10ea5ca | |||
| 425d1f981d | |||
| 3867579508 | |||
| 4580ff1d3e | |||
| 55cb965d9f | |||
| 3e6f767a12 | |||
| f63061a06d | |||
| cd370cf04f | |||
| e314aa0ad2 | |||
| 9df79f77df | |||
| a9d580795d | |||
| d34776a2d6 | |||
| 59df51f6be | |||
| 91eee7c08c | |||
| 81556cfd43 | |||
| 96b257cff1 | |||
| 2de22915cf | |||
| d8127dccaf | |||
| 4336ab19df | |||
| 495121a8b7 | |||
| 998774bf1c | |||
| 64cd3891ea | |||
| 3ff7c95018 | |||
| 9db889ca4b | |||
| 508d209377 | |||
| 3738538d23 | |||
| 21e37a1097 | |||
| 801936518a | |||
| 18bac0d12d | |||
| 6c449ef7df | |||
| 23ecbc0f57 | |||
| 3ff5d42045 | |||
| 28d7d01fdf | |||
| 57f783e70d | |||
| 9bbafe7c35 | |||
| 50c4755b38 | |||
| 04b1b8796e | |||
| 2df9c97608 | |||
| 685fcdabef | |||
| c970a9d755 | |||
| 5d00fdeaf5 | |||
| 214ab84fc6 | |||
| 2e3aba7c5a | |||
| 121095882c | |||
| e671859f71 | |||
| 79c679f35d | |||
| 4e6806c553 | |||
| a8947556a5 | |||
| d8a31442c9 | |||
| 761f46a932 | |||
| 5110c402e8 | |||
| eda35ade7e | |||
| e3b612433c | |||
| d7f13dcfe2 | |||
| 007939a05e | |||
| 48f752d512 | |||
| 13b859a9fa | |||
| 4f8e2f9b92 | |||
| 7f07f982d8 | |||
| a74aab3acd | |||
| cfb746ebec | |||
| 69e40984f2 | |||
| 636a9a5e59 | |||
| 4f1dc64d5d | |||
| 9a61796f06 | |||
| 647bf9f68c | |||
| 0ede5beb6b | |||
| 5fb6189b2d | |||
| c4f2cb34da | |||
| 2c21bce9ed | |||
| c9c6761303 | |||
| 62301faf93 | |||
| 03744d9c1a | |||
| c8decf6245 | |||
| 1410b4a9ce | |||
| 24bf2124b1 | |||
| 054e44191a | |||
| 936b4c3a66 | |||
| 99c9aef1d0 | |||
| 6c50e7e2fe | |||
| 312a006312 | |||
| 58e36ac55c | |||
| 5841ae9a2f | |||
| a91e14dabc | |||
| 2c7222260d | |||
| 25bd34d94f | |||
| f2115d356e | |||
| 7d6c97ffc4 | |||
| 28d21c400f | |||
| 9e80041a09 | |||
| 26621308fe | |||
| 7bc3fd977a | |||
| 32d1f8afa6 | |||
| 44ec5cc69a | |||
| f9e6c31b33 | |||
| 946e461fc0 | |||
| 655e9d0e27 | |||
| 80088f0ee5 | |||
| 80bcc27abe | |||
| 5efabfb7ca | |||
| a13238dc6e | |||
| 277cc83d0e | |||
| 81bf26d8aa | |||
| 66644f420e | |||
| c03b767979 | |||
| bb1019f816 | |||
| bb0d1111f0 | |||
| c5936f31d4 | |||
| 42ce3587aa | |||
| 6974e3cd1a | |||
| ca9250ae86 | |||
| 50de4ea68c | |||
| 23300a4a37 | |||
| a7597f943c | |||
| f7e7c09558 | |||
| 7ff30c646e | |||
| 49f76e746f | |||
| 53861cd028 | |||
| a70609ec20 | |||
| 5f63c15e95 | |||
| ca3a282cd3 | |||
| 66ea8a7a80 | |||
| 1640368a81 | |||
| 0f11e17c70 | |||
| 8d98decd62 | |||
| 7d804050f7 | |||
| 5fb43e9b80 | |||
| 55e680fce8 | |||
| 92b1287485 | |||
| 4f83d95ed6 | |||
| 338335928b | |||
| c0f9016d55 | |||
| ade733bb3d | |||
| 2211d07562 | |||
| f2a49d9385 | |||
| 30fffb179a | |||
| e4721d823e | |||
| 640211ff59 | |||
| 82833afcc5 | |||
| d3fa490fc1 | |||
| f44792f2f2 | |||
| f62c48ccc3 | |||
| c4faace06a | |||
| 150b400fc6 | |||
| 6b1dfe9cb4 | |||
| fff2e909f8 | |||
| 25b9bd183d | |||
| af17b088cf | |||
| 8c40687942 | |||
| 2795cd5a5c | |||
| 33b46792c4 | |||
| 84e49b2b39 | |||
| b42449d42b | |||
| e8a7c5934b | |||
| ac1904f538 | |||
| de7c798837 | |||
| 90c39957a1 | |||
| d3f4bd1c8d | |||
| c8effe0307 | |||
| 87442bdfdd | |||
| bf1fd38ab0 | |||
| 1f20a6c726 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
/__pycache__
|
/__pycache__
|
||||||
|
__pycache__/
|
||||||
*.env
|
*.env
|
||||||
|
*.pyc
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
**/__pycache__/
|
||||||
771
README.md
Normal file
771
README.md
Normal 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
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Ограничения и важные замечания
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -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`.
|
|
||||||
137
agent.py
137
agent.py
@@ -5,19 +5,88 @@ 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://wfl.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]}"
|
||||||
POLL_INTERVAL = 3
|
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,20 +203,27 @@ 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")
|
||||||
break
|
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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=3)
|
raw = await asyncio.wait_for(ws.recv(), timeout=3)
|
||||||
@@ -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())
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,30 +223,31 @@ 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)
|
||||||
|
|
||||||
sync_match_page(
|
if data:
|
||||||
match_external_id=str(match_external_id).strip(),
|
sync_match_page(
|
||||||
home_starting=data["home_starting"],
|
match_external_id=str(match_external_id).strip(),
|
||||||
away_starting=data["away_starting"],
|
home_starting=data["home_starting"],
|
||||||
home_bench=data["home_bench"],
|
away_starting=data["away_starting"],
|
||||||
away_bench=data["away_bench"],
|
home_bench=data["home_bench"],
|
||||||
home_coaches=data["home_coaches"],
|
away_bench=data["away_bench"],
|
||||||
away_coaches=data["away_coaches"],
|
home_coaches=data["home_coaches"],
|
||||||
referees=data["referees"],
|
away_coaches=data["away_coaches"],
|
||||||
)
|
referees=data["referees"],
|
||||||
|
)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"[parser_game] match={match_external_id} "
|
f"[parser_game] match={match_external_id} "
|
||||||
f"home_starting={len(data['home_starting'])} "
|
f"home_starting={len(data['home_starting'])} "
|
||||||
f"away_starting={len(data['away_starting'])} "
|
f"away_starting={len(data['away_starting'])} "
|
||||||
f"home_bench={len(data['home_bench'])} "
|
f"home_bench={len(data['home_bench'])} "
|
||||||
f"away_bench={len(data['away_bench'])} "
|
f"away_bench={len(data['away_bench'])} "
|
||||||
f"home_coaches={len(data['home_coaches'])} "
|
f"home_coaches={len(data['home_coaches'])} "
|
||||||
f"away_coaches={len(data['away_coaches'])} "
|
f"away_coaches={len(data['away_coaches'])} "
|
||||||
f"referees={len(data['referees'])}"
|
f"referees={len(data['referees'])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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']}")
|
||||||
sync_matches(matches_data)
|
print(f"[parser_schedule] URL: {source['schedule_url']}")
|
||||||
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
|
|
||||||
|
html = fetch_html(source["schedule_url"])
|
||||||
|
matches_data = parse_schedule(html, source["season"], source["key"])
|
||||||
|
if matches_data:
|
||||||
|
sync_matches(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
291
parsers/parser_sources.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
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)
|
standings_rows = parse_standings(html)
|
||||||
|
if standings_rows:
|
||||||
|
sync_standings(
|
||||||
|
season=source["season"],
|
||||||
|
standings_rows=standings_rows,
|
||||||
|
)
|
||||||
|
|
||||||
sync_standings(
|
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
|
||||||
season=SEASON,
|
else:
|
||||||
standings_rows=standings_rows,
|
print("[parser_standings] Строки турнирной таблицы не найдены")
|
||||||
)
|
|
||||||
|
|
||||||
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", "")),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
295
repositories/match_penalty_repository.py
Normal file
295
repositories/match_penalty_repository.py
Normal 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()
|
||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,55 +217,194 @@ 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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
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()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE players
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
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():
|
if q.strip():
|
||||||
pattern = f"%{q.strip()}%"
|
pattern = f"%{q.strip()}%"
|
||||||
cur.execute(
|
where_sql = """
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.full_name,
|
|
||||||
p.first_name,
|
|
||||||
p.last_name,
|
|
||||||
p.external_id,
|
|
||||||
p.position
|
|
||||||
FROM players p
|
|
||||||
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
|
"""
|
||||||
""",
|
params.extend([pattern, pattern, pattern, pattern, pattern])
|
||||||
(pattern, pattern, pattern, pattern),
|
|
||||||
)
|
params.append(limit)
|
||||||
else:
|
cur.execute(
|
||||||
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,
|
||||||
FROM players p
|
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||||
ORDER BY p.id DESC
|
FROM players p
|
||||||
LIMIT 200
|
LEFT JOIN teams t ON t.id = p.team_id
|
||||||
"""
|
{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()
|
||||||
|
|||||||
349
repositories/project_settings_repository.py
Normal file
349
repositories/project_settings_repository.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
523
scheduler.py
Normal 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()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
|
|||||||
148
services/env_settings_service.py
Normal file
148
services/env_settings_service.py
Normal 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
|
||||||
@@ -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:
|
lineup_rows.append(
|
||||||
player_id = get_player_id_by_name_and_team(
|
{
|
||||||
player["player_name"], home_team_id
|
"match_id": match_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"player_id": player_id,
|
||||||
|
"player_name": player.get("player_name") or "",
|
||||||
|
"number": player.get("number"),
|
||||||
|
"position": player.get("position"),
|
||||||
|
"position_full": player_position,
|
||||||
|
# Капитаном может быть только игрок основного состава.
|
||||||
|
# Если сайт по ошибке пометил игрока запаса, не переносим
|
||||||
|
# такой флаг в матчевые данные.
|
||||||
|
"is_captain": bool(player.get("is_captain")) and lineup_type == "starting",
|
||||||
|
"lineup_type": lineup_type,
|
||||||
|
"source": "parser",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
lineup_rows.append(
|
append_players(home_starting, home_team_id, "starting")
|
||||||
{
|
append_players(away_starting, away_team_id, "starting")
|
||||||
"match_id": match_id,
|
append_players(home_bench, home_team_id, "bench")
|
||||||
"team_id": home_team_id,
|
append_players(away_bench, away_team_id, "bench")
|
||||||
"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 away_starting:
|
def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
|
||||||
player_id = None
|
"""Возвращает старого капитана только когда сайт не указал нового.
|
||||||
|
|
||||||
if player.get("player_external_id"):
|
Восстановление разрешено исключительно для игрока, который всё ещё
|
||||||
player_id = get_player_id_by_external_id(player["player_external_id"])
|
присутствует в основном составе. Если прежний капитан отсутствует или
|
||||||
|
ушёл в запас, капитан остаётся не выбран — веб-интерфейс покажет
|
||||||
|
обязательное предупреждение оператору.
|
||||||
|
"""
|
||||||
|
starting_rows = [
|
||||||
|
row for row in lineup_rows
|
||||||
|
if row.get("team_id") == team_id and row.get("lineup_type") == "starting"
|
||||||
|
]
|
||||||
|
|
||||||
if player_id is None:
|
imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
|
||||||
player_id = get_player_id_by_name_and_team(
|
|
||||||
player["player_name"], away_team_id
|
|
||||||
)
|
|
||||||
|
|
||||||
lineup_rows.append(
|
# Ровно один капитан с сайта — корректные данные, они имеют приоритет.
|
||||||
{
|
if len(imported_captains) == 1:
|
||||||
"match_id": match_id,
|
return False
|
||||||
"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
|
# случайного игрока. Сбрасываем конфликт и используем тот же безопасный
|
||||||
|
# fallback, что и при полностью отсутствующем капитане.
|
||||||
|
if len(imported_captains) > 1:
|
||||||
|
for row in imported_captains:
|
||||||
|
row["is_captain"] = False
|
||||||
|
|
||||||
if player.get("player_external_id"):
|
old_captain = previous_captains.get(side)
|
||||||
player_id = get_player_id_by_external_id(player["player_external_id"])
|
if not old_captain:
|
||||||
|
return False
|
||||||
|
|
||||||
if player_id is None:
|
old_player_id = old_captain.get("player_id")
|
||||||
player_id = get_player_id_by_name_and_team(
|
old_number = str(old_captain.get("number") or "").strip()
|
||||||
player["player_name"], home_team_id
|
|
||||||
)
|
|
||||||
|
|
||||||
lineup_rows.append(
|
# Сначала точное совпадение по player_id.
|
||||||
{
|
if old_player_id is not None:
|
||||||
"match_id": match_id,
|
for row in starting_rows:
|
||||||
"team_id": home_team_id,
|
if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id):
|
||||||
"player_id": player_id,
|
row["is_captain"] = True
|
||||||
"player_name": player["player_name"],
|
return True
|
||||||
"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:
|
# Fallback для старых/неполных данных — по игровому номеру.
|
||||||
player_id = None
|
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.get("player_external_id"):
|
return False
|
||||||
player_id = get_player_id_by_external_id(player["player_external_id"])
|
|
||||||
|
|
||||||
if player_id is None:
|
restored_home_captain = restore_previous_captain_if_missing("home", home_team_id)
|
||||||
player_id = get_player_id_by_name_and_team(
|
restored_away_captain = restore_previous_captain_if_missing("away", away_team_id)
|
||||||
player["player_name"], away_team_id
|
|
||||||
)
|
|
||||||
|
|
||||||
lineup_rows.append(
|
if restored_home_captain or restored_away_captain:
|
||||||
{
|
restored = []
|
||||||
"match_id": match_id,
|
if restored_home_captain:
|
||||||
"team_id": away_team_id,
|
restored.append("home")
|
||||||
"player_id": player_id,
|
if restored_away_captain:
|
||||||
"player_name": player["player_name"],
|
restored.append("away")
|
||||||
"number": player.get("number"),
|
print(
|
||||||
"position": player.get("position"),
|
f"[parser_game] website captain missing for match={match_external_id}; "
|
||||||
"is_captain": bool(player.get("is_captain")),
|
f"preserved previous captain for: {', '.join(restored)}"
|
||||||
"lineup_type": "bench",
|
|
||||||
"source": "parser",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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_rows.append(
|
||||||
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
|
{
|
||||||
|
"match_id": match_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"side": side,
|
||||||
|
"coach_id": coach_id,
|
||||||
|
"coach_name": coach.get("coach_name") or "",
|
||||||
|
"role": coach.get("role"),
|
||||||
|
"source": "parser",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if coach_id is None:
|
append_coaches(home_coaches, home_team_id, "home")
|
||||||
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
|
append_coaches(away_coaches, away_team_id, "away")
|
||||||
|
|
||||||
coach_rows.append(
|
|
||||||
{
|
|
||||||
"match_id": match_id,
|
|
||||||
"team_id": home_team_id,
|
|
||||||
"coach_id": coach_id,
|
|
||||||
"coach_name": coach["coach_name"],
|
|
||||||
"role": coach.get("role"),
|
|
||||||
"source": "parser",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for coach in away_coaches:
|
|
||||||
coach_id = None
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
144
services/project_settings_service.py
Normal file
144
services/project_settings_service.py
Normal 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
|
||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
@@ -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,17 +301,18 @@ 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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"players": result}
|
return {"players": result}
|
||||||
@@ -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
32
source_config.py
Normal 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)
|
||||||
2
sql/002_players_photo_enabled.sql
Normal file
2
sql/002_players_photo_enabled.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE players
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
4
sql/003_matches_source_key.sql
Normal file
4
sql/003_matches_source_key.sql
Normal 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);
|
||||||
79
sql/004_project_settings.sql
Normal file
79
sql/004_project_settings.sql
Normal 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;
|
||||||
14
sql/005_parser_sources_photo_base_path.sql
Normal file
14
sql/005_parser_sources_photo_base_path.sql
Normal 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) = '');
|
||||||
12
sql/006_parser_sources_channel_logo_base_path.sql
Normal file
12
sql/006_parser_sources_channel_logo_base_path.sql
Normal 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) = '');
|
||||||
17
sql/007_match_penalties.sql
Normal file
17
sql/007_match_penalties.sql
Normal 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
BIN
static/docs/L3 scores.png
Normal file
Binary file not shown.
BIN
static/docs/Schedule Cup 4.png
Normal file
BIN
static/docs/Schedule Cup 4.png
Normal file
Binary file not shown.
BIN
static/docs/Schedule.png
Normal file
BIN
static/docs/Schedule.png
Normal file
Binary file not shown.
BIN
static/docs/SuperCup.png
Normal file
BIN
static/docs/SuperCup.png
Normal file
Binary file not shown.
BIN
static/docs/agent.png
Normal file
BIN
static/docs/agent.png
Normal file
Binary file not shown.
BIN
static/docs/formation.png
Normal file
BIN
static/docs/formation.png
Normal file
Binary file not shown.
BIN
static/docs/formation2.png
Normal file
BIN
static/docs/formation2.png
Normal file
Binary file not shown.
BIN
static/docs/login.png
Normal file
BIN
static/docs/login.png
Normal file
Binary file not shown.
BIN
static/docs/main window.png
Normal file
BIN
static/docs/main window.png
Normal file
Binary file not shown.
BIN
static/docs/manual roster.png
Normal file
BIN
static/docs/manual roster.png
Normal file
Binary file not shown.
BIN
static/docs/matches.png
Normal file
BIN
static/docs/matches.png
Normal file
Binary file not shown.
BIN
static/docs/no photo.png
Normal file
BIN
static/docs/no photo.png
Normal file
Binary file not shown.
BIN
static/docs/path photo.png
Normal file
BIN
static/docs/path photo.png
Normal file
Binary file not shown.
BIN
static/docs/path vmix.png
Normal file
BIN
static/docs/path vmix.png
Normal file
Binary file not shown.
BIN
static/docs/penalty.png
Normal file
BIN
static/docs/penalty.png
Normal file
Binary file not shown.
BIN
static/docs/referee.png
Normal file
BIN
static/docs/referee.png
Normal file
Binary file not shown.
BIN
static/docs/scorebug with title.png
Normal file
BIN
static/docs/scorebug with title.png
Normal file
Binary file not shown.
BIN
static/images/vmix_icon.png
Normal file
BIN
static/images/vmix_icon.png
Normal file
Binary file not shown.
1232
static/script.js
1232
static/script.js
File diff suppressed because it is too large
Load Diff
BIN
static/smith.ico
Normal file
BIN
static/smith.ico
Normal file
Binary file not shown.
BIN
static/smith_full.ico
Normal file
BIN
static/smith_full.ico
Normal file
Binary file not shown.
@@ -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;
|
||||||
@@ -2241,3 +2299,606 @@ body:not(.edit-mode-on) .referee-search {
|
|||||||
.matrix-loader.is-success .matrix-title {
|
.matrix-loader.is-success .matrix-title {
|
||||||
color: #9dffb2;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Редактирование тренера</title>
|
<title>Редактирование тренера</title>
|
||||||
|
<link rel="icon" href="/static/smith.ico" type="image/x-icon">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user