Compare commits
193 Commits
ffdaef5051
...
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 | |||
| 4ce5faed59 | |||
| a20f80453b | |||
| 7baed89b6a |
7
.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
/__pycache__
|
/__pycache__
|
||||||
*.env
|
__pycache__/
|
||||||
|
*.env
|
||||||
|
*.pyc
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
**/__pycache__/
|
||||||
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
@@ -5,12 +5,15 @@ import socket
|
|||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import ctypes
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
VMIX_API = "http://127.0.0.1:8088/api"
|
VMIX_API = "http://127.0.0.1:8088/api"
|
||||||
# WS_BASE = "wss://hostname.tvstart.ru/ws/vmix-client"
|
# WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
|
||||||
WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
|
WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
|
||||||
|
|
||||||
CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
|
CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
|
||||||
@@ -18,6 +21,72 @@ POLL_INTERVAL = 3
|
|||||||
REQUEST_TIMEOUT = 3
|
REQUEST_TIMEOUT = 3
|
||||||
RECONNECT_DELAY = 5
|
RECONNECT_DELAY = 5
|
||||||
|
|
||||||
|
def resource_path(relative_path: str) -> str:
|
||||||
|
try:
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
except Exception:
|
||||||
|
base_path = os.path.abspath(".")
|
||||||
|
return os.path.join(base_path, relative_path)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_green_console():
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
h_out = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
||||||
|
|
||||||
|
mode = ctypes.c_uint()
|
||||||
|
if kernel32.GetConsoleMode(h_out, ctypes.byref(mode)):
|
||||||
|
kernel32.SetConsoleMode(h_out, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||||
|
|
||||||
|
# зелёный текст по умолчанию
|
||||||
|
print("\033[92m", end="")
|
||||||
|
|
||||||
|
|
||||||
|
def set_console_icon():
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
|
||||||
|
ico_path = resource_path("app.ico")
|
||||||
|
if not os.path.exists(ico_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
|
||||||
|
# AppUserModelID помогает Windows корректнее показывать иконку приложения
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("my.agent.console")
|
||||||
|
|
||||||
|
hwnd = kernel32_get_console_window()
|
||||||
|
if not hwnd:
|
||||||
|
return
|
||||||
|
|
||||||
|
IMAGE_ICON = 1
|
||||||
|
LR_LOADFROMFILE = 0x00000010
|
||||||
|
WM_SETICON = 0x0080
|
||||||
|
ICON_SMALL = 0
|
||||||
|
ICON_BIG = 1
|
||||||
|
|
||||||
|
hicon = user32.LoadImageW(
|
||||||
|
None,
|
||||||
|
ico_path,
|
||||||
|
IMAGE_ICON,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
LR_LOADFROMFILE
|
||||||
|
)
|
||||||
|
if hicon:
|
||||||
|
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon)
|
||||||
|
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon)
|
||||||
|
|
||||||
|
|
||||||
|
def kernel32_get_console_window():
|
||||||
|
try:
|
||||||
|
return ctypes.windll.kernel32.GetConsoleWindow()
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def read_vmix_dynamic_values():
|
def read_vmix_dynamic_values():
|
||||||
try:
|
try:
|
||||||
resp = requests.get(VMIX_API, timeout=REQUEST_TIMEOUT)
|
resp = requests.get(VMIX_API, timeout=REQUEST_TIMEOUT)
|
||||||
@@ -36,12 +105,14 @@ def read_vmix_dynamic_values():
|
|||||||
session_token = get_text("./dynamic/value1")
|
session_token = get_text("./dynamic/value1")
|
||||||
match_id = get_text("./dynamic/value2")
|
match_id = get_text("./dynamic/value2")
|
||||||
group_name = get_text("./dynamic/value3")
|
group_name = get_text("./dynamic/value3")
|
||||||
|
operator_login = get_text("./dynamic/value4")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
"match_id": int(match_id) if match_id and match_id.isdigit() else None,
|
"match_id": int(match_id) if match_id and match_id.isdigit() else None,
|
||||||
"group_name": group_name,
|
"group_name": group_name,
|
||||||
|
"operator_login": operator_login,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -50,8 +121,10 @@ def read_vmix_dynamic_values():
|
|||||||
"session_token": None,
|
"session_token": None,
|
||||||
"match_id": None,
|
"match_id": None,
|
||||||
"group_name": None,
|
"group_name": None,
|
||||||
|
"operator_login": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def execute_vmix_command(path: str):
|
def execute_vmix_command(path: str):
|
||||||
path = str(path or "").strip()
|
path = str(path or "").strip()
|
||||||
if not path.startswith("/api/"):
|
if not path.startswith("/api/"):
|
||||||
@@ -73,46 +146,55 @@ def execute_vmix_command(path: str):
|
|||||||
"error": str(e),
|
"error": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def wait_for_session():
|
|
||||||
|
async def wait_for_routing():
|
||||||
while True:
|
while True:
|
||||||
data = read_vmix_dynamic_values()
|
data = read_vmix_dynamic_values()
|
||||||
if data["ok"] and data["session_token"]:
|
if data["ok"] and data["match_id"] is not None and data["operator_login"]:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
print("[agent] waiting for vMix session...", data)
|
print("[agent] waiting for match_id + operator_login...", data)
|
||||||
await asyncio.sleep(POLL_INTERVAL)
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
def build_ws_url(session_token: str, match_id: int | None, group_name: str | None):
|
|
||||||
|
def build_ws_url(match_id: int, group_name: str | None, operator_login: str):
|
||||||
params = {
|
params = {
|
||||||
"client_id": CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
"session_token": session_token,
|
"match_id": match_id,
|
||||||
|
"operator_name": operator_login,
|
||||||
}
|
}
|
||||||
if match_id is not None:
|
|
||||||
params["match_id"] = match_id
|
|
||||||
if group_name:
|
if group_name:
|
||||||
params["group_name"] = group_name
|
params["group_name"] = group_name
|
||||||
return f"{WS_BASE}?{urlencode(params)}"
|
return f"{WS_BASE}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
async def ping_loop(ws):
|
async def ping_loop(ws):
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(15)
|
await asyncio.sleep(15)
|
||||||
await ws.send(json.dumps({"type": "ping"}))
|
await ws.send(json.dumps({"type": "ping"}))
|
||||||
|
|
||||||
|
|
||||||
async def run_agent():
|
async def run_agent():
|
||||||
current_session = None
|
current_match_id = None
|
||||||
|
current_group_name = None
|
||||||
|
current_operator_login = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
vmix_data = await wait_for_session()
|
vmix_data = await wait_for_routing()
|
||||||
|
|
||||||
session_token = vmix_data["session_token"]
|
|
||||||
match_id = vmix_data["match_id"]
|
match_id = vmix_data["match_id"]
|
||||||
group_name = vmix_data["group_name"]
|
group_name = vmix_data["group_name"]
|
||||||
|
operator_login = vmix_data["operator_login"]
|
||||||
|
|
||||||
if current_session != session_token:
|
if current_match_id != match_id or current_operator_login != operator_login or current_group_name != group_name:
|
||||||
print(f"[agent] found session: {session_token}")
|
print(
|
||||||
current_session = session_token
|
f"[agent] routing: match_id={match_id}, operator_login={operator_login}, group_name={group_name}"
|
||||||
|
)
|
||||||
|
current_match_id = match_id
|
||||||
|
current_group_name = group_name
|
||||||
|
current_operator_login = operator_login
|
||||||
|
|
||||||
ws_url = build_ws_url(session_token, match_id, group_name)
|
ws_url = build_ws_url(match_id, group_name, operator_login)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws:
|
async with websockets.connect(ws_url, ping_interval=None, max_size=2**20) as ws:
|
||||||
@@ -121,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__":
|
||||||
asyncio.run(run_agent())
|
setup_green_console()
|
||||||
|
set_console_icon()
|
||||||
|
asyncio.run(run_agent())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
sync_match_page(
|
||||||
|
match_external_id=str(match_external_id).strip(),
|
||||||
|
home_starting=data["home_starting"],
|
||||||
|
away_starting=data["away_starting"],
|
||||||
|
home_bench=data["home_bench"],
|
||||||
|
away_bench=data["away_bench"],
|
||||||
|
home_coaches=data["home_coaches"],
|
||||||
|
away_coaches=data["away_coaches"],
|
||||||
|
referees=data["referees"],
|
||||||
|
)
|
||||||
|
|
||||||
sync_match_page(
|
print(
|
||||||
match_external_id=str(match_external_id).strip(),
|
f"[parser_game] match={match_external_id} "
|
||||||
home_starting=data["home_starting"],
|
f"home_starting={len(data['home_starting'])} "
|
||||||
away_starting=data["away_starting"],
|
f"away_starting={len(data['away_starting'])} "
|
||||||
home_bench=data["home_bench"],
|
f"home_bench={len(data['home_bench'])} "
|
||||||
away_bench=data["away_bench"],
|
f"away_bench={len(data['away_bench'])} "
|
||||||
home_coaches=data["home_coaches"],
|
f"home_coaches={len(data['home_coaches'])} "
|
||||||
away_coaches=data["away_coaches"],
|
f"away_coaches={len(data['away_coaches'])} "
|
||||||
referees=data["referees"],
|
f"referees={len(data['referees'])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
print(
|
|
||||||
f"[parser_game] match={match_external_id} "
|
|
||||||
f"home_starting={len(data['home_starting'])} "
|
|
||||||
f"away_starting={len(data['away_starting'])} "
|
|
||||||
f"home_bench={len(data['home_bench'])} "
|
|
||||||
f"away_bench={len(data['away_bench'])} "
|
|
||||||
f"home_coaches={len(data['home_coaches'])} "
|
|
||||||
f"away_coaches={len(data['away_coaches'])} "
|
|
||||||
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
@@ -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__":
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -283,4 +359,59 @@ def update_coach_admin(
|
|||||||
conn.rollback()
|
conn.rollback()
|
||||||
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
@@ -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()
|
||||||
|
|
||||||
@@ -207,3 +227,45 @@ def list_available_tours():
|
|||||||
return tours
|
return tours
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def list_matches_for_admin(
|
||||||
|
today_only: bool = False,
|
||||||
|
tour: str | None = None,
|
||||||
|
hide_finished: bool = True,
|
||||||
|
):
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.external_id,
|
||||||
|
m.match_date,
|
||||||
|
m.status,
|
||||||
|
m.tour,
|
||||||
|
m.season,
|
||||||
|
ht.name AS home_team_name,
|
||||||
|
at.name AS away_team_name
|
||||||
|
FROM matches m
|
||||||
|
JOIN teams ht ON ht.id = m.home_team_id
|
||||||
|
JOIN teams at ON at.id = m.away_team_id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if today_only:
|
||||||
|
query += " AND DATE(m.match_date) = CURRENT_DATE "
|
||||||
|
|
||||||
|
if tour:
|
||||||
|
query += " AND m.tour = %s "
|
||||||
|
params.append(tour)
|
||||||
|
|
||||||
|
if hide_finished:
|
||||||
|
query += " AND COALESCE(m.status, 'scheduled') <> 'finished' "
|
||||||
|
|
||||||
|
query += " ORDER BY m.match_date ASC NULLS LAST, m.id ASC; "
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
return cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -265,4 +265,56 @@ def replace_match_referees(match_id: int, rows: list[dict]) -> None:
|
|||||||
conn.rollback()
|
conn.rollback()
|
||||||
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,
|
||||||
),
|
),
|
||||||
@@ -237,4 +238,30 @@ def update_team_admin(
|
|||||||
conn.rollback()
|
conn.rollback()
|
||||||
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
@@ -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()
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
|
from db import get_connection
|
||||||
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
from services.auth_service import hash_password
|
from services.auth_service import hash_password
|
||||||
|
|
||||||
|
|
||||||
def get_connection():
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=os.getenv("DB_HOST", "localhost"),
|
|
||||||
port=os.getenv("DB_PORT", 5432),
|
|
||||||
dbname=os.getenv("DB_NAME", "wfl_db"),
|
|
||||||
user=os.getenv("DB_USER", "postgres"),
|
|
||||||
password=os.getenv("DB_PASSWORD", "159753"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
username = input("Username: ").strip()
|
username = input("Username: ").strip()
|
||||||
password = getpass("Password: ").strip()
|
password = getpass("Password: ").strip()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 73 KiB |
BIN
static/docs/Schedule Cup 4.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
static/docs/Schedule.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
static/docs/SuperCup.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
static/docs/agent.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/docs/formation.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
static/docs/formation2.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
static/docs/login.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
static/docs/main window.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
static/docs/manual roster.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
static/docs/matches.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
static/docs/no photo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
static/docs/path photo.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
static/docs/path vmix.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
static/docs/penalty.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
static/docs/referee.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
static/docs/scorebug with title.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
static/images/vmix_icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
1234
static/script.js
BIN
static/smith.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
static/smith_full.ico
Normal file
|
After Width: | Height: | Size: 104 KiB |
@@ -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;
|
||||||
@@ -2174,4 +2232,673 @@ body:not(.edit-mode-on) .referee-search {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
border-color: var(--accent-hover);
|
border-color: var(--accent-hover);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.matrix-loader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.93);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-loader.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(0, 255, 120, 0.25);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 255, 120, 0.12);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-title {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9dffb2;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 120, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-status {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d7ffe1;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-loader.is-error .matrix-content {
|
||||||
|
border-color: rgba(255, 90, 90, 0.35);
|
||||||
|
box-shadow: 0 0 30px rgba(255, 90, 90, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-loader.is-error .matrix-title {
|
||||||
|
color: #ff9c9c;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 90, 90, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-loader.is-error .matrix-status {
|
||||||
|
color: #ffd4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-loader.is-success .matrix-title {
|
||||||
|
color: #9dffb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link .last-name,
|
||||||
|
.player-link .first-name,
|
||||||
|
.coach-link-btn .coach-name,
|
||||||
|
.coach-left .coach-name {
|
||||||
|
color: #8ab4ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link:hover .last-name,
|
||||||
|
.player-link:hover .first-name,
|
||||||
|
.coach-link-btn:hover .coach-name,
|
||||||
|
.coach-left:hover .coach-name {
|
||||||
|
color: #a9c7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link-btn .last-name,
|
||||||
|
.player-link-btn .first-name {
|
||||||
|
color: #8ab4ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link-btn:hover .last-name,
|
||||||
|
.player-link-btn:hover .first-name {
|
||||||
|
color: #a9c7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-link-btn:focus-visible {
|
||||||
|
outline: 2px solid rgba(138, 180, 255, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-editable {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: -6px;
|
||||||
|
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
background: rgba(20,20,20,0.92);
|
||||||
|
color: #d0d0d0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
opacity: 0.72;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-edit-btn:hover {
|
||||||
|
background: #2d5cff;
|
||||||
|
border-color: #5f84ff;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-edit-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-edit-btn:focus-visible {
|
||||||
|
outline: 2px solid rgba(95,132,255,0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Редактирование текущего времени ===== */
|
||||||
|
|
||||||
|
.timer-box-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-box-clickable:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(79, 140, 255, 0.55);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockEditorModal {
|
||||||
|
z-index: 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockEditorModal .formation-modal-backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-modal-content {
|
||||||
|
width: min(460px, 94vw);
|
||||||
|
margin: 12vh auto 0;
|
||||||
|
padding: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(31, 36, 46, 0.98), rgba(18, 21, 27, 0.98));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-modal-content .formation-modal-title {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-modal-content .formation-modal-subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 74px;
|
||||||
|
background: #0b0d12;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px rgba(79, 140, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-editor-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockEditorModal .formation-modal-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockEditorModal .btn-primary {
|
||||||
|
box-shadow: 0 10px 24px rgba(79, 140, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-match-modal-content {
|
||||||
|
width: min(420px, 92vw);
|
||||||
|
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(31, 36, 46, 0.98),
|
||||||
|
rgba(18, 21, 27, 0.98)
|
||||||
|
) !important;
|
||||||
|
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 24px 80px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearMatchModal .formation-modal-backdrop {
|
||||||
|
background: rgba(0,0,0,.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearMatchModal .formation-modal-title {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearMatchModal .formation-modal-subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-match-warning {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.28);
|
||||||
|
color: #ffb8bf;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #e14b5b;
|
||||||
|
}
|
||||||
|
.num-with-photo-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-photo-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.65;
|
||||||
|
filter: grayscale(1);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-number-value {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пенальти Кубка России */
|
||||||
|
.penalty-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-header,
|
||||||
|
.penalty-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-subtitle,
|
||||||
|
.penalty-help,
|
||||||
|
.penalty-next-label {
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-scoreboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-team-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-team-card.home {
|
||||||
|
border-color: rgba(79, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-team-card.away {
|
||||||
|
border-color: rgba(255, 120, 120, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-team-name {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-team-score {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 54px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-fast-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-fast-btn,
|
||||||
|
.penalty-mini-btn {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: transform 0.12s ease, opacity 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-fast-btn:hover,
|
||||||
|
.penalty-mini-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-fast-btn {
|
||||||
|
min-height: 54px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-fast-btn.scored,
|
||||||
|
.penalty-mini-btn.scored {
|
||||||
|
background: linear-gradient(135deg, #0e9f6e, #12b981);
|
||||||
|
box-shadow: 0 10px 28px rgba(18, 185, 129, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-fast-btn.missed,
|
||||||
|
.penalty-mini-btn.missed {
|
||||||
|
background: linear-gradient(135deg, #b91c1c, #ef4444);
|
||||||
|
box-shadow: 0 10px 28px rgba(239, 68, 68, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-table th {
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0 10px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-table td {
|
||||||
|
padding: 0 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-round-number {
|
||||||
|
width: 70px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-cell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-cell.scored {
|
||||||
|
border-color: rgba(18, 185, 129, 0.38);
|
||||||
|
background: rgba(18, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-cell.missed {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-cell.scored .penalty-mark {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-cell.missed .penalty-mark {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-cell-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr minmax(74px, 0.8fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-mini-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-mini-btn.active {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.75);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-mini-btn.clear {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-mini-btn.clear:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.penalty-scoreboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-header,
|
||||||
|
.penalty-footer,
|
||||||
|
.penalty-header-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Расписание тура: компактная ручная правка счёта и статуса */
|
||||||
|
.tour-actions-col,
|
||||||
|
.tour-actions-cell {
|
||||||
|
width: 156px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-meta-cell {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-score-edit {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-edit-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-score-inputs {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: 52px auto 52px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
width: max-content;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-score-input {
|
||||||
|
width: 52px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-score-input:disabled,
|
||||||
|
.tour-status-select:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-score-separator {
|
||||||
|
font-weight: 900;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-status-select {
|
||||||
|
height: 32px;
|
||||||
|
width: 128px;
|
||||||
|
max-width: 128px;
|
||||||
|
padding: 4px 28px 4px 10px;
|
||||||
|
flex: 0 0 128px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-status-select option {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-edit-btn {
|
||||||
|
min-width: 38px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-save-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-save-btn,
|
||||||
|
.tour-cancel-btn {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Расписание тура: редактирование включается общим карандашом в шапке */
|
||||||
|
.tour-edit-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.edit-mode-on .tour-edit-only {
|
||||||
|
display: table-cell;
|
||||||
|
}
|
||||||
|
|||||||