Compare commits
190 Commits
4ce5faed59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c759f34f0f | |||
| b29fc06a4f | |||
| adea3a8bd6 | |||
| 2c2d9b9a7c | |||
| e17c287d65 | |||
| 60ec71c73d | |||
| 77adc31a1a | |||
| 8e38505f69 | |||
| 504cf77312 | |||
| 00f8533236 | |||
| 87cd044c3f | |||
| 6fe4eca0ba | |||
| c646691142 | |||
| 2b3c882d25 | |||
| c724c7e965 | |||
| d689b853dc | |||
| 3ec7f57d46 | |||
| aeafdc9887 | |||
| a215ba526b | |||
| e7b215af5e | |||
| 03a68ee8ca | |||
| e32fdcb74e | |||
| 5b056550ed | |||
| 1303b3b562 | |||
| 61861085f8 | |||
| e98d014243 | |||
| 57907fd86b | |||
| b0fe3ad5a4 | |||
| 24c10ea5ca | |||
| 425d1f981d | |||
| 3867579508 | |||
| 4580ff1d3e | |||
| 55cb965d9f | |||
| 3e6f767a12 | |||
| f63061a06d | |||
| cd370cf04f | |||
| e314aa0ad2 | |||
| 9df79f77df | |||
| a9d580795d | |||
| d34776a2d6 | |||
| 59df51f6be | |||
| 91eee7c08c | |||
| 81556cfd43 | |||
| 96b257cff1 | |||
| 2de22915cf | |||
| d8127dccaf | |||
| 4336ab19df | |||
| 495121a8b7 | |||
| 998774bf1c | |||
| 64cd3891ea | |||
| 3ff7c95018 | |||
| 9db889ca4b | |||
| 508d209377 | |||
| 3738538d23 | |||
| 21e37a1097 | |||
| 801936518a | |||
| 18bac0d12d | |||
| 6c449ef7df | |||
| 23ecbc0f57 | |||
| 3ff5d42045 | |||
| 28d7d01fdf | |||
| 57f783e70d | |||
| 9bbafe7c35 | |||
| 50c4755b38 | |||
| 04b1b8796e | |||
| 2df9c97608 | |||
| 685fcdabef | |||
| c970a9d755 | |||
| 5d00fdeaf5 | |||
| 214ab84fc6 | |||
| 2e3aba7c5a | |||
| 121095882c | |||
| e671859f71 | |||
| 79c679f35d | |||
| 4e6806c553 | |||
| a8947556a5 | |||
| d8a31442c9 | |||
| 761f46a932 | |||
| 5110c402e8 | |||
| eda35ade7e | |||
| e3b612433c | |||
| d7f13dcfe2 | |||
| 007939a05e | |||
| 48f752d512 | |||
| 13b859a9fa | |||
| 4f8e2f9b92 | |||
| 7f07f982d8 | |||
| a74aab3acd | |||
| cfb746ebec | |||
| 69e40984f2 | |||
| 636a9a5e59 | |||
| 4f1dc64d5d | |||
| 9a61796f06 | |||
| 647bf9f68c | |||
| 0ede5beb6b | |||
| 5fb6189b2d | |||
| c4f2cb34da | |||
| 2c21bce9ed | |||
| c9c6761303 | |||
| 62301faf93 | |||
| 03744d9c1a | |||
| c8decf6245 | |||
| 1410b4a9ce | |||
| 24bf2124b1 | |||
| 054e44191a | |||
| 936b4c3a66 | |||
| 99c9aef1d0 | |||
| 6c50e7e2fe | |||
| 312a006312 | |||
| 58e36ac55c | |||
| 5841ae9a2f | |||
| a91e14dabc | |||
| 2c7222260d | |||
| 25bd34d94f | |||
| f2115d356e | |||
| 7d6c97ffc4 | |||
| 28d21c400f | |||
| 9e80041a09 | |||
| 26621308fe | |||
| 7bc3fd977a | |||
| 32d1f8afa6 | |||
| 44ec5cc69a | |||
| f9e6c31b33 | |||
| 946e461fc0 | |||
| 655e9d0e27 | |||
| 80088f0ee5 | |||
| 80bcc27abe | |||
| 5efabfb7ca | |||
| a13238dc6e | |||
| 277cc83d0e | |||
| 81bf26d8aa | |||
| 66644f420e | |||
| c03b767979 | |||
| bb1019f816 | |||
| bb0d1111f0 | |||
| c5936f31d4 | |||
| 42ce3587aa | |||
| 6974e3cd1a | |||
| ca9250ae86 | |||
| 50de4ea68c | |||
| 23300a4a37 | |||
| a7597f943c | |||
| f7e7c09558 | |||
| 7ff30c646e | |||
| 49f76e746f | |||
| 53861cd028 | |||
| a70609ec20 | |||
| 5f63c15e95 | |||
| ca3a282cd3 | |||
| 66ea8a7a80 | |||
| 1640368a81 | |||
| 0f11e17c70 | |||
| 8d98decd62 | |||
| 7d804050f7 | |||
| 5fb43e9b80 | |||
| 55e680fce8 | |||
| 92b1287485 | |||
| 4f83d95ed6 | |||
| 338335928b | |||
| c0f9016d55 | |||
| ade733bb3d | |||
| 2211d07562 | |||
| f2a49d9385 | |||
| 30fffb179a | |||
| e4721d823e | |||
| 640211ff59 | |||
| 82833afcc5 | |||
| d3fa490fc1 | |||
| f44792f2f2 | |||
| f62c48ccc3 | |||
| c4faace06a | |||
| 150b400fc6 | |||
| 6b1dfe9cb4 | |||
| fff2e909f8 | |||
| 25b9bd183d | |||
| af17b088cf | |||
| 8c40687942 | |||
| 2795cd5a5c | |||
| 33b46792c4 | |||
| 84e49b2b39 | |||
| b42449d42b | |||
| e8a7c5934b | |||
| ac1904f538 | |||
| de7c798837 | |||
| 90c39957a1 | |||
| d3f4bd1c8d | |||
| c8effe0307 | |||
| 87442bdfdd | |||
| bf1fd38ab0 | |||
| 1f20a6c726 |
7
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
/__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`.
|
||||
139
agent.py
@@ -5,19 +5,88 @@ import socket
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
from xml.etree import ElementTree as ET
|
||||
import os
|
||||
import sys
|
||||
import ctypes
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
|
||||
VMIX_API = "http://127.0.0.1:8088/api"
|
||||
WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
|
||||
# WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
|
||||
# WS_BASE = "wss://wfl.tvstart.ru/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]}"
|
||||
POLL_INTERVAL = 3
|
||||
REQUEST_TIMEOUT = 3
|
||||
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():
|
||||
try:
|
||||
resp = requests.get(VMIX_API, timeout=REQUEST_TIMEOUT)
|
||||
@@ -36,12 +105,14 @@ def read_vmix_dynamic_values():
|
||||
session_token = get_text("./dynamic/value1")
|
||||
match_id = get_text("./dynamic/value2")
|
||||
group_name = get_text("./dynamic/value3")
|
||||
operator_login = get_text("./dynamic/value4")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"session_token": session_token,
|
||||
"match_id": int(match_id) if match_id and match_id.isdigit() else None,
|
||||
"group_name": group_name,
|
||||
"operator_login": operator_login,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
@@ -50,8 +121,10 @@ def read_vmix_dynamic_values():
|
||||
"session_token": None,
|
||||
"match_id": None,
|
||||
"group_name": None,
|
||||
"operator_login": None,
|
||||
}
|
||||
|
||||
|
||||
def execute_vmix_command(path: str):
|
||||
path = str(path or "").strip()
|
||||
if not path.startswith("/api/"):
|
||||
@@ -73,46 +146,55 @@ def execute_vmix_command(path: str):
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def wait_for_session():
|
||||
|
||||
async def wait_for_routing():
|
||||
while True:
|
||||
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
|
||||
|
||||
print("[agent] waiting for vMix session...", data)
|
||||
print("[agent] waiting for match_id + operator_login...", data)
|
||||
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 = {
|
||||
"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:
|
||||
params["group_name"] = group_name
|
||||
return f"{WS_BASE}?{urlencode(params)}"
|
||||
|
||||
|
||||
async def ping_loop(ws):
|
||||
while True:
|
||||
await asyncio.sleep(15)
|
||||
await ws.send(json.dumps({"type": "ping"}))
|
||||
|
||||
|
||||
async def run_agent():
|
||||
current_session = None
|
||||
current_match_id = None
|
||||
current_group_name = None
|
||||
current_operator_login = None
|
||||
|
||||
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"]
|
||||
group_name = vmix_data["group_name"]
|
||||
operator_login = vmix_data["operator_login"]
|
||||
|
||||
if current_session != session_token:
|
||||
print(f"[agent] found session: {session_token}")
|
||||
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] 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:
|
||||
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({
|
||||
"type": "register",
|
||||
"client_id": CLIENT_ID,
|
||||
"session_token": session_token,
|
||||
"match_id": match_id,
|
||||
"group_name": group_name,
|
||||
"operator_name": operator_login,
|
||||
}))
|
||||
|
||||
pinger = asyncio.create_task(ping_loop(ws))
|
||||
|
||||
try:
|
||||
while True:
|
||||
# следим, не сменился ли проект/session в vMix
|
||||
latest = read_vmix_dynamic_values()
|
||||
if latest["ok"] and latest["session_token"] and latest["session_token"] != current_session:
|
||||
print("[agent] session changed in vMix, reconnecting")
|
||||
break
|
||||
if latest["ok"]:
|
||||
latest_match_id = latest.get("match_id")
|
||||
latest_group_name = latest.get("group_name")
|
||||
latest_operator_login = latest.get("operator_login")
|
||||
if (
|
||||
latest_match_id != match_id
|
||||
or latest_group_name != group_name
|
||||
or latest_operator_login != operator_login
|
||||
):
|
||||
print("[agent] routing meta changed in vMix, reconnecting")
|
||||
break
|
||||
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=3)
|
||||
@@ -149,7 +238,8 @@ async def run_agent():
|
||||
await ws.send(json.dumps({
|
||||
"type": "vmix_result",
|
||||
"client_id": CLIENT_ID,
|
||||
"session_token": session_token,
|
||||
"match_id": match_id,
|
||||
"operator_name": operator_login,
|
||||
"results": results,
|
||||
"sent_at": time.time(),
|
||||
}))
|
||||
@@ -161,5 +251,8 @@ async def run_agent():
|
||||
|
||||
await asyncio.sleep(RECONNECT_DELAY)
|
||||
|
||||
|
||||
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 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:
|
||||
@@ -20,12 +21,37 @@ def extract_player_id_from_href(href: str) -> str:
|
||||
|
||||
|
||||
def detect_captain(item) -> bool:
|
||||
"""Определяет капитана по текстовой метке или отдельному HTML-маркеру сайта.
|
||||
|
||||
На странице протокола капитан может быть обозначен как ``(К)`` / ``(C)``,
|
||||
а в некоторых версиях вёрстки — отдельным элементом/иконкой с captain в
|
||||
class, title, aria-label или data-атрибуте. Если сайт вообще не указал
|
||||
капитана, функция корректно возвращает False.
|
||||
"""
|
||||
if not item:
|
||||
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]]:
|
||||
home_starting = []
|
||||
@@ -197,30 +223,31 @@ def parse_game_page(html: str) -> dict:
|
||||
|
||||
|
||||
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)
|
||||
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(
|
||||
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"],
|
||||
)
|
||||
|
||||
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'])}"
|
||||
)
|
||||
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 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 = {
|
||||
"Пз.": "Полузащитник",
|
||||
"Вр.": "Вратарь",
|
||||
@@ -23,19 +22,26 @@ def fetch_html(url: str) -> str:
|
||||
return r.text
|
||||
|
||||
|
||||
def get_links(html: str) -> list[dict]:
|
||||
def get_links(html: str, source: dict) -> list[dict]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
links: list[dict] = []
|
||||
|
||||
items = soup.find("ul", class_="teams__list").find_all("li")
|
||||
for i in items:
|
||||
href = i.find("a", class_="teams__link").get("href")
|
||||
team_external_id = href.split("team_id=")[-1].strip()
|
||||
teams_list = soup.find("ul", class_="teams__list")
|
||||
if not teams_list:
|
||||
return links
|
||||
|
||||
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(
|
||||
{
|
||||
"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 ""
|
||||
parts = full_player.split()
|
||||
pos_short = pos_td.get_text(strip=True) if pos_td else ""
|
||||
|
||||
players.append(
|
||||
{
|
||||
"player_id": player_id or "",
|
||||
"number": number_td.get_text(strip=True) if number_td else "",
|
||||
"pos": pos_td.get_text(strip=True) if pos_td else "",
|
||||
"amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""],
|
||||
"pos": pos_short,
|
||||
"amplua": AMPLUA_FULL.get(pos_short, pos_short),
|
||||
"player": full_player,
|
||||
"lastname": parts[0] if len(parts) >= 1 else "",
|
||||
"name": parts[-1] if len(parts) >= 2 else "",
|
||||
@@ -133,7 +140,7 @@ def parse_team(html: str) -> dict:
|
||||
"lastname": last_name,
|
||||
"player": f"{last_name} {first_name}".strip(),
|
||||
"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:
|
||||
html = fetch_html(URL_TEAMS)
|
||||
links = get_links(html)
|
||||
def run_parser_players(source_key: str | None = None) -> None:
|
||||
source = get_parser_source(source_key)
|
||||
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:
|
||||
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:
|
||||
print(f"[parser_players] error team={item['team_external_id']}: {e}")
|
||||
|
||||
if not links:
|
||||
print("[parser_players] Команды для парсинга игроков не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_parser_players()
|
||||
|
||||
@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
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 parsers.parser_sources import get_parser_source
|
||||
|
||||
|
||||
MONTHS_RU = {
|
||||
@@ -24,11 +25,6 @@ MONTHS_RU = {
|
||||
|
||||
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:
|
||||
@@ -76,7 +72,7 @@ def safe_int(value: str | None) -> int | 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")
|
||||
matches_data: list[dict] = []
|
||||
|
||||
@@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]:
|
||||
"home_score": home_score,
|
||||
"away_score": away_score,
|
||||
"tour": tour,
|
||||
"season": SEASON,
|
||||
"season": season,
|
||||
"place": place,
|
||||
"date_raw": time_site,
|
||||
"score_add": score_add,
|
||||
"source_key": source_key,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -162,11 +159,18 @@ def parse_schedule(html: str) -> list[dict]:
|
||||
return matches_data
|
||||
|
||||
|
||||
def run_parser_schedule() -> None:
|
||||
html = fetch_html(URL_SCHEDULE)
|
||||
matches_data = parse_schedule(html)
|
||||
sync_matches(matches_data)
|
||||
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
|
||||
def run_parser_schedule(source_key: str | None = None) -> None:
|
||||
source = get_parser_source(source_key)
|
||||
print(f"[parser_schedule] Источник: {source['title']}")
|
||||
print(f"[parser_schedule] URL: {source['schedule_url']}")
|
||||
|
||||
html = fetch_html(source["schedule_url"])
|
||||
matches_data = parse_schedule(html, source["season"], source["key"])
|
||||
if matches_data:
|
||||
sync_matches(matches_data)
|
||||
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
|
||||
else:
|
||||
print("[parser_schedule] Матчи не найдены")
|
||||
|
||||
|
||||
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 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:
|
||||
@@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]:
|
||||
return standings
|
||||
|
||||
|
||||
def run_parser_standings() -> None:
|
||||
html = fetch_html(URL_STANDINGS)
|
||||
def run_parser_standings(source_key: str | None = None) -> None:
|
||||
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)
|
||||
if standings_rows:
|
||||
sync_standings(
|
||||
season=source["season"],
|
||||
standings_rows=standings_rows,
|
||||
)
|
||||
|
||||
sync_standings(
|
||||
season=SEASON,
|
||||
standings_rows=standings_rows,
|
||||
)
|
||||
|
||||
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
|
||||
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
|
||||
else:
|
||||
print("[parser_standings] Строки турнирной таблицы не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,9 +3,7 @@ from bs4 import BeautifulSoup
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from services.teams_service import sync_teams
|
||||
|
||||
|
||||
TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams"
|
||||
from parsers.parser_sources import get_parser_source, source_absolute_url
|
||||
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
@@ -15,45 +13,51 @@ def fetch_html(url: str) -> str:
|
||||
return response.text
|
||||
|
||||
|
||||
def get_links(html) -> list:
|
||||
def get_links(html: str, source: dict) -> list[str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
links: list[dict] = []
|
||||
items = soup.find("ul", class_="teams__list").find_all("li")
|
||||
for i in items:
|
||||
links.append(
|
||||
"https://wfl.rfs.ru/team/"
|
||||
+ i.find("a", class_="teams__link").get("href").split("team_id=")[-1]
|
||||
)
|
||||
links: list[str] = []
|
||||
|
||||
teams_list = soup.find("ul", class_="teams__list")
|
||||
if not teams_list:
|
||||
return links
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_url_teams() -> list[dict]:
|
||||
html = fetch_html(TEAMS_URL)
|
||||
links = get_links(html)
|
||||
def get_url_teams(source_key: str | None = None) -> list[dict]:
|
||||
source = get_parser_source(source_key)
|
||||
html = fetch_html(source["teams_url"])
|
||||
links = get_links(html, source)
|
||||
teams_data: list[dict] = []
|
||||
|
||||
with ThreadPoolExecutor() as pool:
|
||||
responses = [
|
||||
pool.submit(
|
||||
fetch_html,
|
||||
link,
|
||||
)
|
||||
for link in links
|
||||
]
|
||||
responses = [pool.submit(fetch_html, link) for link in links]
|
||||
for result in responses:
|
||||
try:
|
||||
html = result.result()
|
||||
team_data = parse_teams_html(html)
|
||||
teams_data.append(team_data)
|
||||
except Exception as e:
|
||||
print(f"Error fetching team data: {e}")
|
||||
print(f"[parser_teams] Error fetching team data: {e}")
|
||||
|
||||
return teams_data
|
||||
|
||||
|
||||
def parse_teams_html(html: str) -> dict:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
teams_data: dict = {}
|
||||
name = soup.find("a", class_="team-promo__team-name").text.strip()
|
||||
external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1]
|
||||
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()
|
||||
tournaments = stat_info[3].text.strip()
|
||||
|
||||
teams_data = {
|
||||
return {
|
||||
"external_id": str(external_id),
|
||||
"name": name,
|
||||
"logo_url": logo_url,
|
||||
@@ -75,13 +79,18 @@ def parse_teams_html(html: str) -> dict:
|
||||
"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()
|
||||
sync_teams(teams_data)
|
||||
print(f"Teams synced: {len(teams_data)}")
|
||||
teams_data = get_url_teams(source_key)
|
||||
if teams_data:
|
||||
sync_teams(teams_data)
|
||||
print(f"[parser_teams] Teams synced: {len(teams_data)}")
|
||||
else:
|
||||
print("[parser_teams] Команды не найдены")
|
||||
|
||||
|
||||
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]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -283,4 +359,59 @@ def update_coach_admin(
|
||||
conn.rollback()
|
||||
raise
|
||||
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.coach_id,
|
||||
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
|
||||
JOIN coaches c
|
||||
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_name,
|
||||
mf.number,
|
||||
mf.position,
|
||||
p.position,
|
||||
mf.is_captain,
|
||||
mf.x,
|
||||
mf.y,
|
||||
@@ -279,7 +279,7 @@ def sort_players_by_side(players: list[dict]) -> list[dict]:
|
||||
return sorted(
|
||||
players,
|
||||
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("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 ""),
|
||||
"position": p.get("position", "") or "",
|
||||
"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.first_name, '') AS first_name,
|
||||
COALESCE(mlp.number::text, '') AS number,
|
||||
COALESCE(mlp.position, '') AS position,
|
||||
COALESCE(mlp.is_captain, FALSE) AS is_captain
|
||||
COALESCE(p.position, '') AS position,
|
||||
COALESCE(mlp.is_captain, FALSE) AS is_captain,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM match_lineup_players mlp
|
||||
JOIN players p
|
||||
ON p.id = mlp.player_id
|
||||
@@ -108,7 +110,7 @@ def get_match_lineup_for_editor(
|
||||
mc.sort_order,
|
||||
c.id AS coach_id,
|
||||
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
|
||||
JOIN coaches c
|
||||
ON c.id = mc.coach_id
|
||||
@@ -141,6 +143,7 @@ def get_match_lineup_for_editor(
|
||||
"number": row[7] or "",
|
||||
"position": row[8] or "",
|
||||
"is_captain": bool(row[9]),
|
||||
"photo_enabled": bool(row[10]),
|
||||
}
|
||||
|
||||
side = row[0]
|
||||
@@ -228,12 +231,28 @@ def get_match_lineup_for_vmix(
|
||||
COALESCE(p.last_name, '') AS last_name,
|
||||
COALESCE(p.first_name, '') AS first_name,
|
||||
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(p.position, '') AS pos
|
||||
COALESCE(p.position, '') AS pos,
|
||||
p.photo,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM match_lineup_players mlp
|
||||
JOIN players p
|
||||
ON p.id = mlp.player_id
|
||||
LEFT JOIN LATERAL (
|
||||
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
|
||||
ORDER BY
|
||||
CASE
|
||||
@@ -248,7 +267,7 @@ def get_match_lineup_for_vmix(
|
||||
COALESCE(p.first_name, ''),
|
||||
p.id
|
||||
""",
|
||||
(match_id,),
|
||||
(home_team_id, away_team_id, match_id),
|
||||
)
|
||||
player_rows = cur.fetchall()
|
||||
|
||||
@@ -259,7 +278,7 @@ def get_match_lineup_for_vmix(
|
||||
mc.sort_order,
|
||||
c.id AS coach_id,
|
||||
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
|
||||
JOIN coaches c
|
||||
ON c.id = mc.coach_id
|
||||
@@ -293,6 +312,9 @@ def get_match_lineup_for_vmix(
|
||||
"position": row[8] or "",
|
||||
"is_captain": bool(row[9]),
|
||||
"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]
|
||||
@@ -449,6 +471,52 @@ def save_match_lineup_for_editor(
|
||||
insert_players("away", "starting", away_starting)
|
||||
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("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 repositories.team_repository import get_team_id_by_external_id
|
||||
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(
|
||||
external_id: str,
|
||||
@@ -20,6 +154,7 @@ def upsert_match(
|
||||
stadium_id: int | None = None,
|
||||
date_raw: str | None = None,
|
||||
score_add: str | None = None,
|
||||
source_key: str | None = None,
|
||||
) -> None:
|
||||
query = """
|
||||
INSERT INTO matches (
|
||||
@@ -36,10 +171,11 @@ INSERT INTO matches (
|
||||
stadium_id,
|
||||
date_raw,
|
||||
score_add,
|
||||
source_key,
|
||||
created_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)
|
||||
DO UPDATE SET
|
||||
home_team_id = EXCLUDED.home_team_id,
|
||||
@@ -54,6 +190,7 @@ DO UPDATE SET
|
||||
stadium_id = EXCLUDED.stadium_id,
|
||||
date_raw = EXCLUDED.date_raw,
|
||||
score_add = EXCLUDED.score_add,
|
||||
source_key = COALESCE(EXCLUDED.source_key, matches.source_key),
|
||||
updated_at = NOW();
|
||||
"""
|
||||
|
||||
@@ -76,6 +213,7 @@ DO UPDATE SET
|
||||
stadium_id,
|
||||
date_raw,
|
||||
score_add,
|
||||
source_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -100,6 +238,7 @@ def upsert_match_by_team_external_ids(
|
||||
stadium_id: int | None = None,
|
||||
date_raw: str | None = None,
|
||||
score_add: str | None = None,
|
||||
source_key: str | None = None,
|
||||
) -> None:
|
||||
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)
|
||||
@@ -125,6 +264,7 @@ def upsert_match_by_team_external_ids(
|
||||
stadium_id=stadium_id,
|
||||
date_raw=date_raw,
|
||||
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.away_score,
|
||||
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
|
||||
LEFT JOIN teams ht ON ht.id = m.home_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],
|
||||
"home_team_name": r[7] or "",
|
||||
"away_team_name": r[8] or "",
|
||||
"channel": r[9] or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import secrets
|
||||
|
||||
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(
|
||||
@@ -61,10 +63,13 @@ def get_match_session_by_token(session_token: str):
|
||||
ht.id AS home_team_id,
|
||||
ht.name AS home_team_name,
|
||||
ht.logo_url AS home_team_logo,
|
||||
ht.logo_path AS home_team_logo_path,
|
||||
|
||||
at.id AS away_team_id,
|
||||
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
|
||||
JOIN matches m ON m.id = ms.match_id
|
||||
@@ -78,7 +83,22 @@ def get_match_session_by_token(session_token: str):
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
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:
|
||||
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_name,
|
||||
ml.number,
|
||||
ml.position,
|
||||
p.position,
|
||||
ml.lineup_type,
|
||||
ml.is_captain,
|
||||
p.last_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
|
||||
LEFT JOIN players p ON p.id = ml.player_id
|
||||
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,
|
||||
first_name,
|
||||
pos,
|
||||
photo,
|
||||
photo_enabled,
|
||||
) = row
|
||||
|
||||
item = {
|
||||
"player_id": player_id,
|
||||
"team_id": team_id,
|
||||
"number": number or "",
|
||||
"last_name": last_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 "",
|
||||
"is_captain": bool(is_captain),
|
||||
"pos": pos,
|
||||
"photo": photo or "",
|
||||
"photo_enabled": bool(photo_enabled),
|
||||
}
|
||||
|
||||
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:
|
||||
query = """
|
||||
SELECT id
|
||||
SELECT id,
|
||||
position
|
||||
FROM players
|
||||
WHERE external_id = %s
|
||||
LIMIT 1;
|
||||
@@ -216,55 +217,194 @@ def get_player_id_by_external_id(external_id: str) -> int | None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (str(external_id).strip(),))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
return row if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
from db import get_connection
|
||||
|
||||
def _split_full_name_for_autocreate(full_name: str) -> tuple[str, str]:
|
||||
"""Аккуратно делит имя с сайта на first_name / last_name для новых записей."""
|
||||
parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()]
|
||||
if not parts:
|
||||
return "", ""
|
||||
if len(parts) == 1:
|
||||
return "", parts[0]
|
||||
|
||||
# На сайте чаще приходит "Имя Фамилия". Полное имя всё равно сохраняем отдельно,
|
||||
# поэтому даже при другом порядке данные можно быстро поправить в справочнике.
|
||||
first_name = parts[0]
|
||||
last_name = " ".join(parts[1:])
|
||||
return first_name, last_name
|
||||
|
||||
|
||||
def search_players_for_admin(q: str = "") -> list[dict]:
|
||||
def create_player_from_lineup(
|
||||
team_id: int,
|
||||
external_id: str = "",
|
||||
full_name: str = "",
|
||||
number: str = "",
|
||||
position: str = "",
|
||||
) -> tuple[int | None, str | None]:
|
||||
"""Создаёт минимальную карточку игрока из протокола матча и возвращает (id, position).
|
||||
|
||||
Используется при загрузке составов с сайта, когда игрока ещё нет в справочнике.
|
||||
"""
|
||||
full_name = str(full_name or "").strip()
|
||||
if not full_name:
|
||||
return None, None
|
||||
|
||||
first_name, last_name = _split_full_name_for_autocreate(full_name)
|
||||
external_id = str(external_id or "").strip()
|
||||
number = str(number or "").strip()
|
||||
position = str(position or "").strip()
|
||||
|
||||
query = """
|
||||
INSERT INTO players (
|
||||
external_id,
|
||||
team_id,
|
||||
full_name,
|
||||
first_name,
|
||||
last_name,
|
||||
number,
|
||||
position,
|
||||
pos,
|
||||
amplua,
|
||||
is_active,
|
||||
photo_enabled,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
NULLIF(%s, ''), %s, %s, %s, %s, %s, %s, %s, %s, TRUE, FALSE,
|
||||
NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (external_id)
|
||||
DO UPDATE SET
|
||||
team_id = EXCLUDED.team_id,
|
||||
full_name = COALESCE(NULLIF(EXCLUDED.full_name, ''), players.full_name),
|
||||
first_name = COALESCE(NULLIF(EXCLUDED.first_name, ''), players.first_name),
|
||||
last_name = COALESCE(NULLIF(EXCLUDED.last_name, ''), players.last_name),
|
||||
number = COALESCE(NULLIF(EXCLUDED.number, ''), players.number),
|
||||
position = COALESCE(NULLIF(EXCLUDED.position, ''), players.position),
|
||||
pos = COALESCE(NULLIF(EXCLUDED.pos, ''), players.pos),
|
||||
amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), players.amplua),
|
||||
is_active = TRUE,
|
||||
updated_at = NOW()
|
||||
RETURNING id, position;
|
||||
"""
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
query,
|
||||
(
|
||||
external_id,
|
||||
team_id,
|
||||
full_name,
|
||||
first_name,
|
||||
last_name,
|
||||
number,
|
||||
position,
|
||||
position,
|
||||
position,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return row if row else (None, None)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
PLAYER_ADMIN_SORT_COLUMNS = {
|
||||
"id": "p.id",
|
||||
"full_name": "p.full_name",
|
||||
"first_name": "p.first_name",
|
||||
"last_name": "p.last_name",
|
||||
"external_id": "p.external_id",
|
||||
"position": "p.position",
|
||||
"team_name": "t.name",
|
||||
"photo": "p.photo_enabled",
|
||||
}
|
||||
|
||||
|
||||
def ensure_player_photo_enabled_column() -> None:
|
||||
"""Добавляет флаг использования фото игрока, если база ещё не обновлена."""
|
||||
conn = get_connection()
|
||||
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():
|
||||
pattern = f"%{q.strip()}%"
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.full_name,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.external_id,
|
||||
p.position
|
||||
FROM players p
|
||||
where_sql = """
|
||||
WHERE
|
||||
p.full_name ILIKE %s
|
||||
OR p.first_name ILIKE %s
|
||||
OR p.last_name ILIKE %s
|
||||
OR COALESCE(p.external_id, '') ILIKE %s
|
||||
ORDER BY p.full_name ASC, p.id ASC
|
||||
LIMIT 200
|
||||
""",
|
||||
(pattern, pattern, pattern, pattern),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.full_name,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.external_id,
|
||||
p.position
|
||||
FROM players p
|
||||
ORDER BY p.id DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
)
|
||||
OR COALESCE(t.name, '') ILIKE %s
|
||||
"""
|
||||
params.extend([pattern, pattern, pattern, pattern, pattern])
|
||||
|
||||
params.append(limit)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
p.id,
|
||||
p.full_name,
|
||||
p.first_name,
|
||||
p.last_name,
|
||||
p.external_id,
|
||||
p.position,
|
||||
COALESCE(t.name, '') AS team_name,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM players p
|
||||
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()
|
||||
|
||||
@@ -276,6 +416,8 @@ def search_players_for_admin(q: str = "") -> list[dict]:
|
||||
"last_name": row[3] or "",
|
||||
"external_id": row[4] or "",
|
||||
"position": row[5] or "",
|
||||
"team_name": row[6] or "",
|
||||
"photo_enabled": bool(row[7]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -298,8 +440,11 @@ def get_player_by_id(player_id: int) -> dict | None:
|
||||
p.position,
|
||||
p.born,
|
||||
p.photo,
|
||||
p.video
|
||||
p.video,
|
||||
COALESCE(t.name, '') AS team_name,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM players p
|
||||
LEFT JOIN teams t ON t.id = p.team_id
|
||||
WHERE p.id = %s
|
||||
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]),
|
||||
"photo": row[7] or "",
|
||||
"video": row[8] or "",
|
||||
"team_name": row[9] or "",
|
||||
"photo_enabled": bool(row[10]),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -335,6 +482,7 @@ def update_player_admin(
|
||||
birth_date: str = "",
|
||||
photo: str = "",
|
||||
video: str = "",
|
||||
photo_enabled: bool = False,
|
||||
) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -350,7 +498,9 @@ def update_player_admin(
|
||||
position = %s,
|
||||
born = NULLIF(%s, '')::date,
|
||||
photo = NULLIF(%s, ''),
|
||||
video = NULLIF(%s, '')
|
||||
video = NULLIF(%s, ''),
|
||||
photo_enabled = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
@@ -362,6 +512,7 @@ def update_player_admin(
|
||||
birth_date.strip(),
|
||||
photo.strip(),
|
||||
video.strip(),
|
||||
bool(photo_enabled),
|
||||
player_id,
|
||||
),
|
||||
)
|
||||
@@ -371,3 +522,123 @@ def update_player_admin(
|
||||
raise
|
||||
finally:
|
||||
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()
|
||||
raise
|
||||
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 parsers.parser_sources import extract_logo_filename
|
||||
|
||||
|
||||
def upsert_team(
|
||||
@@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]:
|
||||
"full_name": row[2] or "",
|
||||
"short_name_3": row[3] or "",
|
||||
"city": row[4] or "",
|
||||
"logo_path": row[5] or "",
|
||||
"logo_path": extract_logo_filename(row[5]),
|
||||
"external_id": row[6] or "",
|
||||
}
|
||||
for row in rows
|
||||
@@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None:
|
||||
"full_name": row[2] or "",
|
||||
"short_name_3": row[3] or "",
|
||||
"city": row[4] or "",
|
||||
"logo_path": row[5] or "",
|
||||
"logo_path": extract_logo_filename(row[5]),
|
||||
"external_id": row[6] or "",
|
||||
}
|
||||
finally:
|
||||
@@ -227,7 +228,7 @@ def update_team_admin(
|
||||
full_name.strip(),
|
||||
short_name_3.strip().upper(),
|
||||
city.strip(),
|
||||
logo_path.strip(),
|
||||
extract_logo_filename(logo_path),
|
||||
external_id.strip(),
|
||||
team_id,
|
||||
),
|
||||
@@ -237,4 +238,30 @@ def update_team_admin(
|
||||
conn.rollback()
|
||||
raise
|
||||
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.number::text, '') AS number,
|
||||
COALESCE(p.position, '') AS position,
|
||||
FALSE AS is_captain
|
||||
FALSE AS is_captain,
|
||||
COALESCE(p.photo_enabled, FALSE) AS photo_enabled
|
||||
FROM players p
|
||||
WHERE p.team_id = %s
|
||||
ORDER BY
|
||||
@@ -51,6 +52,7 @@ def get_team_players_for_match_editor(
|
||||
"number": row[4] or "",
|
||||
"position": row[5] or "",
|
||||
"is_captain": bool(row[6]),
|
||||
"photo_enabled": bool(row[7]),
|
||||
}
|
||||
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()
|
||||
@@ -15,7 +15,7 @@ from repositories.auth_repository import (
|
||||
revoke_auth_session,
|
||||
)
|
||||
|
||||
IDLE_TIMEOUT_SECONDS = 2 * 60 * 60
|
||||
IDLE_TIMEOUT_SECONDS = 4 * 60 * 60
|
||||
SESSION_TOUCH_THROTTLE_SECONDS = 60
|
||||
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 (
|
||||
get_player_id_by_name_and_team,
|
||||
get_player_id_by_external_id,
|
||||
create_player_from_lineup,
|
||||
)
|
||||
from repositories.coach_repository import (
|
||||
get_coach_id_by_name_and_team,
|
||||
get_coach_id_by_external_id,
|
||||
create_coach_from_lineup,
|
||||
)
|
||||
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_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}")
|
||||
|
||||
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)
|
||||
|
||||
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 = []
|
||||
for player in home_starting:
|
||||
player_id = None
|
||||
|
||||
if player.get("player_external_id"):
|
||||
player_id = get_player_id_by_external_id(player["player_external_id"])
|
||||
def append_players(players: list[dict], team_id: int, lineup_type: str) -> None:
|
||||
for player in players:
|
||||
player_id, player_position = resolve_player(player, team_id)
|
||||
|
||||
if player_id is None:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], home_team_id
|
||||
lineup_rows.append(
|
||||
{
|
||||
"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(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"team_id": home_team_id,
|
||||
"player_id": player_id,
|
||||
"player_name": player["player_name"],
|
||||
"number": player.get("number"),
|
||||
"position": player.get("position"),
|
||||
"is_captain": bool(player.get("is_captain")),
|
||||
"lineup_type": "starting",
|
||||
"source": "parser",
|
||||
}
|
||||
)
|
||||
append_players(home_starting, home_team_id, "starting")
|
||||
append_players(away_starting, away_team_id, "starting")
|
||||
append_players(home_bench, home_team_id, "bench")
|
||||
append_players(away_bench, away_team_id, "bench")
|
||||
|
||||
for player in away_starting:
|
||||
player_id = None
|
||||
def restore_previous_captain_if_missing(side: str, team_id: int) -> bool:
|
||||
"""Возвращает старого капитана только когда сайт не указал нового.
|
||||
|
||||
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:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], away_team_id
|
||||
)
|
||||
imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))]
|
||||
|
||||
lineup_rows.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"team_id": away_team_id,
|
||||
"player_id": player_id,
|
||||
"player_name": player["player_name"],
|
||||
"number": player.get("number"),
|
||||
"position": player.get("position"),
|
||||
"is_captain": bool(player.get("is_captain")),
|
||||
"lineup_type": "starting",
|
||||
"source": "parser",
|
||||
}
|
||||
)
|
||||
# Ровно один капитан с сайта — корректные данные, они имеют приоритет.
|
||||
if len(imported_captains) == 1:
|
||||
return False
|
||||
|
||||
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"):
|
||||
player_id = get_player_id_by_external_id(player["player_external_id"])
|
||||
old_captain = previous_captains.get(side)
|
||||
if not old_captain:
|
||||
return False
|
||||
|
||||
if player_id is None:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], home_team_id
|
||||
)
|
||||
old_player_id = old_captain.get("player_id")
|
||||
old_number = str(old_captain.get("number") or "").strip()
|
||||
|
||||
lineup_rows.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"team_id": home_team_id,
|
||||
"player_id": player_id,
|
||||
"player_name": player["player_name"],
|
||||
"number": player.get("number"),
|
||||
"position": player.get("position"),
|
||||
"is_captain": bool(player.get("is_captain")),
|
||||
"lineup_type": "bench",
|
||||
"source": "parser",
|
||||
}
|
||||
)
|
||||
# Сначала точное совпадение по player_id.
|
||||
if old_player_id is not None:
|
||||
for row in starting_rows:
|
||||
if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id):
|
||||
row["is_captain"] = True
|
||||
return True
|
||||
|
||||
for player in away_bench:
|
||||
player_id = None
|
||||
# Fallback для старых/неполных данных — по игровому номеру.
|
||||
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"):
|
||||
player_id = get_player_id_by_external_id(player["player_external_id"])
|
||||
return False
|
||||
|
||||
if player_id is None:
|
||||
player_id = get_player_id_by_name_and_team(
|
||||
player["player_name"], away_team_id
|
||||
)
|
||||
restored_home_captain = restore_previous_captain_if_missing("home", home_team_id)
|
||||
restored_away_captain = restore_previous_captain_if_missing("away", away_team_id)
|
||||
|
||||
lineup_rows.append(
|
||||
{
|
||||
"match_id": match_id,
|
||||
"team_id": away_team_id,
|
||||
"player_id": player_id,
|
||||
"player_name": player["player_name"],
|
||||
"number": player.get("number"),
|
||||
"position": player.get("position"),
|
||||
"is_captain": bool(player.get("is_captain")),
|
||||
"lineup_type": "bench",
|
||||
"source": "parser",
|
||||
}
|
||||
if restored_home_captain or restored_away_captain:
|
||||
restored = []
|
||||
if restored_home_captain:
|
||||
restored.append("home")
|
||||
if restored_away_captain:
|
||||
restored.append("away")
|
||||
print(
|
||||
f"[parser_game] website captain missing for match={match_external_id}; "
|
||||
f"preserved previous captain for: {', '.join(restored)}"
|
||||
)
|
||||
|
||||
coach_rows = []
|
||||
|
||||
for coach in home_coaches:
|
||||
coach_id = None
|
||||
def append_coaches(coaches: list[dict], team_id: int, side: str) -> None:
|
||||
for coach in coaches:
|
||||
coach_id = resolve_coach(coach, team_id)
|
||||
|
||||
if coach.get("coach_external_id"):
|
||||
coach_id = get_coach_id_by_external_id(coach["coach_external_id"])
|
||||
coach_rows.append(
|
||||
{
|
||||
"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:
|
||||
coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id)
|
||||
|
||||
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",
|
||||
}
|
||||
)
|
||||
append_coaches(home_coaches, home_team_id, "home")
|
||||
append_coaches(away_coaches, away_team_id, "away")
|
||||
|
||||
referee_rows = []
|
||||
|
||||
@@ -199,13 +267,15 @@ def sync_match_page(
|
||||
|
||||
try:
|
||||
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_referees(match_id, referee_rows)
|
||||
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:
|
||||
mark_match_parse_error(match_external_id, str(e))
|
||||
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"),
|
||||
date_raw=match.get("date_raw"),
|
||||
score_add=match.get("score_add"),
|
||||
)
|
||||
source_key=match.get("source_key"),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,268 @@
|
||||
# services/vmix_json_service.py
|
||||
from db import get_connection
|
||||
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(
|
||||
match_id=match_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 = []
|
||||
|
||||
fallback_team_id = home_team_id if str(name).startswith("home") else away_team_id
|
||||
|
||||
for p in players:
|
||||
suffix = []
|
||||
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 ""),
|
||||
"pos": p.get("pos", ""),
|
||||
"position": p.get("position", ""),
|
||||
"photo": (
|
||||
r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
|
||||
+ "\\"
|
||||
+ (team_a_name if "home" in name else team_b_name)
|
||||
+ "\\"
|
||||
+ (p.get("last_name", "")
|
||||
+ " "
|
||||
+ p.get("first_name", "")).strip()
|
||||
+ ".png"
|
||||
),
|
||||
}
|
||||
"photo": resolve_player_photo_for_json(
|
||||
p,
|
||||
generated_photo=build_generated_player_photo_path(
|
||||
team_name=team_a_name if "home" in name else team_b_name,
|
||||
last_name=p.get("last_name", ""),
|
||||
first_name=p.get("first_name", ""),
|
||||
source_key=source_key,
|
||||
),
|
||||
fallback_team_id=fallback_team_id,
|
||||
source_key=source_key,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {"players": result}
|
||||
@@ -78,43 +340,16 @@ def get_vmix_match_info_by_token(session_token: str):
|
||||
|
||||
m.tour,
|
||||
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,
|
||||
ref2.referee_name AS referee2,
|
||||
ref3.referee_name AS referee3,
|
||||
ref4.referee_name AS referee4,
|
||||
|
||||
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_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.logo_path AS home_logo1,
|
||||
at.logo_path AS away_logo1,
|
||||
ht.logo_path AS home_logo2,
|
||||
at.logo_path AS away_logo2,
|
||||
|
||||
ht.city AS home_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,
|
||||
|
||||
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
|
||||
JOIN matches m ON m.id = ms.match_id
|
||||
@@ -151,23 +387,35 @@ def get_vmix_match_info_by_token(session_token: str):
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
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:
|
||||
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 = """
|
||||
SELECT
|
||||
s.position,
|
||||
t.full_name,
|
||||
CASE
|
||||
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,
|
||||
COALESCE(t.full_name, t.name) AS team_name,
|
||||
t.logo_path AS logo,
|
||||
s.played,
|
||||
s.wins,
|
||||
s.losses,
|
||||
@@ -177,52 +425,66 @@ def get_vmix_standings(session_token: str):
|
||||
s.team_id
|
||||
FROM standings s
|
||||
LEFT JOIN teams t ON s.team_id = t.id
|
||||
WHERE (%s IS NULL OR s.season = %s)
|
||||
ORDER BY s.position
|
||||
"""
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (session_token,))
|
||||
return cur.fetchall()
|
||||
cur.execute(query, (season, season))
|
||||
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:
|
||||
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 = """
|
||||
SELECT
|
||||
CASE
|
||||
WHEN t1.full_name ILIKE '%%динамо%%'
|
||||
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,
|
||||
t1.logo_path AS logo1,
|
||||
t2.logo_path AS logo2,
|
||||
m.home_score,
|
||||
m.away_score,
|
||||
m.match_date,
|
||||
m.status,
|
||||
m.id
|
||||
m.id,
|
||||
COALESCE(m.channel, '') AS channel,
|
||||
COALESCE(NULLIF(m.source_key, ''), %s) AS source_key
|
||||
FROM matches m
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
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
|
||||
"""
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (session_token[10],))
|
||||
return cur.fetchall()
|
||||
cur.execute(query, (source_filter, tour, season, season, source_filter, source_filter))
|
||||
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:
|
||||
conn.close()
|
||||
|
||||
@@ -230,13 +492,59 @@ def get_vmix_schedule(session_token: str):
|
||||
def get_vmix_team_formations(session_token: str, team_id: int):
|
||||
query = """
|
||||
SELECT
|
||||
p.last_name,
|
||||
COALESCE(NULLIF(p.last_name, ''), NULLIF(mf.player_name, ''), '') AS last_name,
|
||||
mf.is_captain,
|
||||
p.number,
|
||||
p.position,
|
||||
p.first_name
|
||||
COALESCE(NULLIF(mf.number, ''), p.number::text, '') AS number,
|
||||
COALESCE(NULLIF(mf.position, ''), p.position, '') AS position,
|
||||
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
|
||||
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
|
||||
ORDER BY mf.id
|
||||
"""
|
||||
@@ -332,7 +640,10 @@ WHERE m.match_id = %s;
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_vmix_players_goal(match_id: int):
|
||||
def get_vmix_players_goal2(match_id: int):
|
||||
"""запрос для получения игроков, забивших голы, с указанием минут и типа гола (основной, пенальти, автогол)
|
||||
Иванов 23', 25' (дубли пишуться вместе)
|
||||
"""
|
||||
query = """
|
||||
WITH goal_events AS (
|
||||
SELECT
|
||||
@@ -372,10 +683,10 @@ grouped_players AS (
|
||||
(
|
||||
CASE
|
||||
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
|
||||
THEN '90''' || '+ ' || ((seconds - 5400) / 60)::int::text
|
||||
THEN '90''' || '+' || ((seconds - 5400) / 60)::int::text
|
||||
|
||||
WHEN period_no = 2
|
||||
THEN (46 + ((seconds - 2700) / 60)::int)::text || ''''
|
||||
@@ -422,3 +733,95 @@ ORDER BY COALESCE(h.rn, a.rn);
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
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 {
|
||||
width: 1%;
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1896,6 +1897,63 @@ body.edit-mode-on .player-node.dragging {
|
||||
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) {
|
||||
.squad-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2240,4 +2298,607 @@ body:not(.edit-mode-on) .referee-search {
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Редактирование тренера</title>
|
||||
<link rel="icon" href="/static/smith.ico" type="image/x-icon">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
|
||||