Compare commits

...

91 Commits

Author SHA1 Message Date
ca7709ebff Добавил абуз вч отметок 2025-12-12 15:46:00 +03:00
9bf701f05a Визуальные изменение. Доработки и фиксы багов 2025-12-12 15:08:10 +03:00
f5875e5b87 Поправил кубсат и добавил библиотеку luxon 2025-12-11 11:27:01 +03:00
f79efd88e5 Ещё поправил статистику 2025-12-11 10:48:27 +03:00
cf3c7ee01a Поправил ошибку. Чутка поменял статистику. 2025-12-11 09:48:38 +03:00
41e8dc30fd Переосмыслил отметки по ВЧ загрузке. Улучшил статистику 2025-12-10 17:43:38 +03:00
4949a03e68 Поправил теханализ 2025-12-10 12:50:52 +03:00
d889dc7b2a Доделал таблицу с кубсатом 2025-12-10 12:29:43 +03:00
8393734dc3 Фикс заголовков для локальной карты 2025-12-09 10:59:44 +03:00
25fe93231f Добавил зоны для спутников 2025-12-08 15:48:46 +03:00
8fb8b08c93 Добавил работу с заявками на кубсат 2025-12-08 15:37:23 +03:00
2b856ff6dc Добавил поле в модель спутников 2025-12-05 16:32:59 +03:00
cff2c73b6a Повторный фикс url 2025-12-05 12:00:11 +03:00
9c095a7229 Добавил URL flaresolverr в переменные среды 2025-12-05 11:12:22 +03:00
09bbedda18 Добавил локальную карту 2025-12-05 09:52:11 +03:00
727c24fb1f Спрятал secret stat 2025-12-04 14:19:48 +03:00
00b85b5bf2 Микрофикс кнопок 2025-12-04 12:37:12 +03:00
f954f77a6d Добавил локально библиотеку chart js. Сделал секретную статистику 2025-12-04 12:35:08 +03:00
027f971f5a Добавил статистики 2025-12-04 11:33:43 +03:00
30b56de709 Немного поправил визуал 2025-12-04 09:27:06 +03:00
24314b84ac Слои на карте. v0.1/ 2025-12-03 17:32:13 +03:00
4164ea2109 Пофиксил баг с координатами 2025-12-03 14:18:09 +03:00
51eb5f3732 Подправил маркеры на карте 2025-12-03 11:47:41 +03:00
d7d85ac834 Второй.1 трай фикса celery 2025-12-02 17:22:40 +03:00
118c86a73c Второй трай фикса celery 2025-12-02 17:12:42 +03:00
3388f787c7 Первый трай фикса celery 2025-12-02 16:44:19 +03:00
889899080a Поменял теханализ, улучшения по простбам 2025-12-02 14:56:29 +03:00
a18071b7ec Поменял усреднение 2025-12-02 11:47:47 +03:00
b9e17df32c Переделал усреднение. Вариант 1 2025-12-02 09:57:09 +03:00
96f961b0f8 Пофиксил умена зеркал при добавлении 2025-12-02 09:16:36 +03:00
ad479a2069 Добавио интервал выходных 2025-12-01 17:14:52 +03:00
300927c7ea Поправил csv импорт 2025-12-01 16:42:17 +03:00
8d75e47abc Исправил импорт данных с привязкой спутников 2025-12-01 15:48:00 +03:00
c72bf12d41 Добавил альтернативное имя у спутника 2025-12-01 12:19:24 +03:00
01871c3e13 Усредение точек в проекции ГК 2025-12-01 09:54:22 +03:00
d521b6baad Начал с усреднениями 2025-11-28 00:18:04 +03:00
908e11879d Поправил общую карту с footprintaми 2025-11-27 17:36:23 +03:00
eba19126ef Добавил локальную библиотеку для таблиц 2025-11-27 12:29:24 +03:00
0be829b97b Поправил вставку данных 2025-11-27 12:17:41 +03:00
810d3a8f7f Добавил теханализ 2025-11-27 11:36:00 +03:00
efb99ea8d5 Дополнил данные по спутникам при добавлении 2025-11-27 09:35:07 +03:00
bd39717e86 Начал редактирование парсинга спутников 2025-11-26 23:57:21 +03:00
d832171325 Добавил плавную анимацию для нескольких источников 2025-11-26 23:09:29 +03:00
cfaaae9360 Добавил форму для отправки данных 2025-11-26 17:35:59 +03:00
27694a3a7d Добавил анимацию в треку. Добавил 2 локальные js библиотеки 2025-11-26 11:12:14 +03:00
609fd5a1da Добавил объединение источников. Вернул норм карту. Удалил ненужные либы 2025-11-26 10:33:07 +03:00
388753ba31 Добавил трек и поле Примечание к Source 2025-11-25 17:45:34 +03:00
68486d2283 Логи и деплой поправил 2025-11-25 10:54:12 +03:00
e24cf8a105 Поправил баг с сортировкой 2025-11-25 10:19:47 +03:00
7879c3d9b5 Добавил формы создания и пофиксил баг с пользователями 2025-11-24 23:47:00 +03:00
1c18ae96f7 На деплой 2025-11-24 13:57:31 +03:00
a591b79656 Поправил частотный план 2025-11-24 12:11:09 +03:00
ed9a79f94a Подправил частотный план 2025-11-23 23:27:09 +03:00
9a9900cfa6 Сделал деплой 2025-11-23 22:55:32 +03:00
0d239ef1de Переделки и улучшения 2025-11-21 16:56:58 +03:00
58838614a5 Внёс мелкие правки и фиксы 2025-11-21 10:31:26 +03:00
c2c8c8799f Сделал вкладку спутников 2025-11-20 13:44:48 +03:00
1d1c42a8e7 Доделал страницу с Кубсатами 2025-11-20 10:50:27 +03:00
66e1929978 Страница с Кубсатами 2025-11-19 17:36:39 +03:00
4d7cc9f667 Сделал 1 карту на LibreMap 2025-11-18 17:15:03 +03:00
c8bcd1adf0 После рефакторинга 2025-11-18 14:44:32 +03:00
55759ec705 Привязка LyngSat сразу в функция импорта 2025-11-18 10:06:31 +03:00
06a39278d2 Поправил баг с LyngSat и добавил локально библиотеку 2025-11-18 09:36:19 +03:00
c0f2f16303 Добавил геофильтры. Теперь нужен рефакторинг. 2025-11-17 17:44:24 +03:00
b889fb29a3 Добавил информацию о типе объекта. Просто фиксы 2025-11-17 15:54:27 +03:00
f438e74946 Поправил геофильтр и отображения источника в отметках 2025-11-17 10:45:32 +03:00
c55a41f5fe Фильтр по дате ГЛ. Пока не работает 2025-11-16 23:58:34 +03:00
8994a0e500 Правки и улучшения визуала. Добавил функционал отметок. 2025-11-16 23:32:29 +03:00
d9cb243388 Исправил отображения объектов в источниках 2025-11-16 00:16:50 +03:00
9a816e62c2 Поправил алгоритм формирования источников 2025-11-15 21:54:13 +03:00
bc226bfc1a Виджет с усреднёнными точками на карте 2025-11-14 16:58:13 +03:00
d61236dee2 Снова улучшения и добавления 2025-11-14 11:41:19 +03:00
6a26991dc0 Добавил транспондеры к ObjItem шаблону 2025-11-14 08:00:23 +03:00
5ab6770809 Виджет для формы выбора зеркал 2025-11-13 21:09:39 +03:00
8e0d32c307 Улучшение и добавления 2025-11-13 17:54:06 +03:00
122fe74e14 Реструктуризация views 2025-11-13 16:11:37 +03:00
d0a53e251e Переделал страницу с ObjItem. Теперь работает корректно. 2025-11-13 14:21:02 +03:00
50498166e5 Добавил разделение по исчтоника и поправил функцию импорта из Excel csv 2025-11-12 23:49:58 +03:00
a7e8f81ef3 Поправил админку для новой модели 2025-11-12 22:03:00 +03:00
7126974aed Процесс переделки 2025-11-12 17:53:25 +03:00
73ce06deec Закончил показ. Теперь полная переделка 2025-11-12 12:46:08 +03:00
902eb23bd8 Поправил привязку вч загрузки, сделал модальное окно. 2025-11-12 00:04:55 +03:00
5e94086bf0 Привязка данных LyngSat 2025-11-11 22:40:52 +03:00
a3c381b9c7 Добавил кеш к lyngsat 2025-11-11 21:43:59 +03:00
4f21c9d7c8 Настроил сеелери, начал привязку lyngsat 2025-11-11 17:23:36 +03:00
65e6c9a323 Добавил форму для загрузки данных с LyngSat 2025-11-10 23:28:06 +03:00
1b345a3fd9 Переделал модель Parameter и связь с ObjItem 2025-11-10 22:32:26 +03:00
b24ef940ce Добавление данных LyngSat в базу 2025-11-10 17:59:35 +03:00
0858961410 Сделал заготовку для кубсатов 2025-11-10 16:15:55 +03:00
9e9468ed34 Новый визуальный функционал 2025-11-10 14:56:21 +03:00
a0f20f9a60 Рефакторинг и деплоинг 2025-11-09 23:46:08 +03:00
311 changed files with 387717 additions and 27061 deletions

24
.env.dev Normal file
View File

@@ -0,0 +1,24 @@
# Development Environment Variables
# Django Settings
DEBUG=True
# ENVIRONMENT=development
DJANGO_ENVIRONMENT=development
DJANGO_SETTINGS_MODULE=dbapp.settings.development
SECRET_KEY=django-insecure-dev-key-only-for-development
# Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=123456
DB_HOST=db
DB_PORT=5432
# Allowed Hosts
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# PostgreSQL Configuration
POSTGRES_DB=geodb
POSTGRES_USER=geralt
POSTGRES_PASSWORD=123456

28
.env.prod Normal file
View File

@@ -0,0 +1,28 @@
DEBUG=False
# ENVIRONMENT=production
DJANGO_ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.production
SECRET_KEY=django-insecure-dev-key-only-for-production
# Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=123456
DB_HOST=db
DB_PORT=5432
# Allowed Hosts
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# CSRF Trusted Origins (include port if using non-standard port)
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080
# PostgreSQL Configuration
POSTGRES_DB=geodb
POSTGRES_USER=geralt
POSTGRES_PASSWORD=123456
# Redis Configuration
REDIS_URL=redis://redis:6379/1
CELERY_BROKER_URL=redis://redis:6379/0

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
# Ensure shell scripts always use LF line endings
*.sh text eol=lf
entrypoint.sh text eol=lf
# Python files
*.py text eol=lf
# Docker files
Dockerfile text eol=lf
docker-compose*.yaml text eol=lf
.dockerignore text eol=lf

57
.gitignore vendored
View File

@@ -1,20 +1,37 @@
# Python-generated files # Python-generated files
__pycache__/ __pycache__/
*.py[oc] *.py[oc]
build/ build/
dist/ dist/
wheels/ wheels/
*.egg-info *.egg-info
# Virtual environments # Virtual environments
.venv .venv
.hintrc .hintrc
.vscode .vscode
data.json data.json
.env
# Environment files
django-leaflet .env
admin-interface .env.local
.env.*.local
docker-*
maplibre-gl-js-5.10.0.zip # Django
*.log
db.sqlite3
db.sqlite3-journal
staticfiles/
media/
django-leaflet
admin-interface
Тестовые
tiles
.kiro
# Docker
# docker-*
maplibre-gl-js-5.10.0.zip
cert.pem
templ.json

137
Makefile Normal file
View File

@@ -0,0 +1,137 @@
.PHONY: help dev-up dev-down dev-build dev-logs prod-up prod-down prod-build prod-logs shell migrate createsuperuser clean
help:
@echo "Доступные команды:"
@echo ""
@echo "Development:"
@echo " make dev-up - Запустить development окружение"
@echo " make dev-down - Остановить development окружение"
@echo " make dev-build - Пересобрать development контейнеры"
@echo " make dev-logs - Показать логи development"
@echo ""
@echo "Production:"
@echo " make prod-up - Запустить production окружение"
@echo " make prod-down - Остановить production окружение"
@echo " make prod-build - Пересобрать production контейнеры"
@echo " make prod-logs - Показать логи production"
@echo ""
@echo "Celery (Production):"
@echo " make prod-worker-logs - Логи Celery worker"
@echo " make prod-beat-logs - Логи Celery beat"
@echo " make prod-celery-status - Статус Celery"
@echo " make prod-celery-test - Тест Celery подключения"
@echo ""
@echo "Django:"
@echo " make shell - Открыть Django shell"
@echo " make migrate - Выполнить миграции"
@echo " make createsuperuser - Создать суперпользователя"
@echo " make clean - Удалить все контейнеры и volumes"
# Development команды
dev-up:
docker-compose up -d
dev-down:
docker-compose down
dev-build:
docker-compose up -d --build
dev-logs:
docker-compose logs -f
dev-restart:
docker-compose restart web
# Production команды
prod-up:
docker-compose -f docker-compose.prod.yaml up -d
prod-down:
docker-compose -f docker-compose.prod.yaml down
prod-build:
docker-compose -f docker-compose.prod.yaml up -d --build
prod-logs:
docker-compose -f docker-compose.prod.yaml logs -f
prod-restart:
docker-compose -f docker-compose.prod.yaml restart web
# Django команды (для development по умолчанию)
shell:
docker-compose exec web python manage.py shell
migrate:
docker-compose exec web python manage.py migrate
makemigrations:
docker-compose exec web python manage.py makemigrations
createsuperuser:
docker-compose exec web python manage.py createsuperuser
collectstatic:
docker-compose exec web python manage.py collectstatic --noinput
# Для production
prod-shell:
docker-compose -f docker-compose.prod.yaml exec web python manage.py shell
prod-migrate:
docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate
prod-createsuperuser:
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
# Backup и восстановление
backup:
docker-compose exec db pg_dump -U geralt geodb > backup_$(shell date +%Y%m%d_%H%M%S).sql
restore:
@read -p "Введите имя файла backup: " file; \
docker-compose exec -T db psql -U geralt geodb < $$file
# Очистка
clean:
docker-compose down -v
docker system prune -f
clean-all:
docker-compose down -v
docker-compose -f docker-compose.prod.yaml down -v
docker system prune -af --volumes
# Проверка статуса
status:
docker-compose ps
prod-status:
docker-compose -f docker-compose.prod.yaml ps
# Celery команды для production
prod-worker-logs:
docker-compose -f docker-compose.prod.yaml logs -f worker
prod-beat-logs:
docker-compose -f docker-compose.prod.yaml logs -f beat
prod-celery-status:
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
prod-celery-test:
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
prod-redis-test:
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
# Celery команды для development
celery-status:
cd dbapp && uv run celery -A dbapp inspect active
celery-test:
cd dbapp && uv run python test_celery.py
redis-test:
cd dbapp && uv run python check_redis.py

View File

@@ -1,29 +1,60 @@
__pycache__ # Git
*.pyc .git
*.pyo .gitignore
*.pyd .gitattributes
.Python
.pytest_cache # Python
.coverage __pycache__
.git *.py[cod]
.gitignore *$py.class
README.md *.so
.env .Python
.DS_Store *.egg-info
.settings dist/
.vscode build/
.idea *.egg
*.swp
*.swo # Virtual environments
*~ venv/
__pycache__/ env/
*.so ENV/
.Python .venv
.coverage
.pytest_cache # IDE
.venv .vscode/
venv/ .idea/
env/ *.swp
.pyre/ *.swo
node_modules/ *~
.DS_Store
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
/staticfiles/
/media/
# Environment
.env
.env.local
.env.*.local
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Documentation
*.md
docs/
# OS
.DS_Store
Thumbs.db
# Docker
Dockerfile*
docker-compose*.yaml
.dockerignore

View File

@@ -1,10 +1,10 @@
# Production environment variables # Production environment variables
DEBUG=False DEBUG=False
ENVIRONMENT=production ENVIRONMENT=production
SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure
DB_NAME=geodb DB_NAME=geodb
DB_USER=geralt DB_USER=geralt
DB_PASSWORD=your_secure_db_password DB_PASSWORD=123456
DB_HOST=db DB_HOST=db
DB_PORT=5432 DB_PORT=5432
ALLOWED_HOSTS=localhost,yourdomain.com ALLOWED_HOSTS=localhost,yourdomain.com

1
dbapp/.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.7

View File

@@ -1,43 +1,53 @@
# Use Python 3.13 slim image as base FROM python:3.13.7-slim AS builder
FROM python:3.13.9-slim
# Set environment variables # Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
ENV PYTHONDONTWRITEBYTECODE=1 \ RUN apt-get update && apt-get install -y --no-install-recommends \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
DJANGO_SETTINGS_MODULE=dbapp.settings.production
# Install system dependencies including GDAL and PostGIS dependencies
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal-dev \
proj-bin \
proj-data \
libproj-dev \
libgeos-dev \
postgresql-client \
build-essential \ build-essential \
gdal-bin libgdal-dev \
libproj-dev proj-bin \
libpq-dev \ libpq-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Set work directory
WORKDIR /app WORKDIR /app
# Copy project files # Устанавливаем uv пакетно-менеджер глобально
RUN pip install --no-cache-dir uv
# Копируем зависимости
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
# Install uv and dependencies # Синхронизируем зависимости (включая prod + dev), чтобы билдить
RUN pip install --no-cache-dir uv && \ RUN uv sync --locked
uv sync --frozen --no-dev
# Copy project code (после установки зависимостей для лучшего кэширования) # Копируем весь код приложения
COPY . . COPY . .
# Collect static files # --- рантайм-стадия — минимальный образ для продакшена ---
RUN uv run manage.py collectstatic --noinput FROM python:3.13.7-slim
WORKDIR /app
# Устанавливаем только runtime-системные библиотеки
RUN apt-get update && apt-get install -y --no-install-recommends \
gdal-bin \
libproj-dev proj-bin \
libpq5 \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Копируем всё из билдера
COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
COPY --from=builder /usr/local/bin /usr/local/bin
COPY --from=builder /app /app
# Загружаем переменные окружения из .env (см. docker-compose)
ENV PYTHONUNBUFFERED=1 \
PATH="/usr/local/bin:$PATH"
# Делаем entrypoint скрипты исполняемыми
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
# Expose port
EXPOSE 8000 EXPOSE 8000
# Run gunicorn server # Используем entrypoint для инициализации (миграции, статика)
CMD [".venv/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "dbapp.wsgi:application"] ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,73 +0,0 @@
# Multi-stage build for production
FROM python:3.13-slim as requirements-stage
# Install system dependencies
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal-dev \
proj-bin \
proj-data \
libproj-dev \
libgeos-dev \
build-essential \
libpq-dev \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies for GDAL
RUN pip install --upgrade pip && \
pip install --no-cache-dir GDAL==$(gdal-config --version)
WORKDIR /app
# Copy project requirements
COPY pyproject.toml uv.lock ./
# Install uv package manager
RUN pip install --upgrade pip && pip install uv
# Install dependencies using uv
RUN uv pip install --system --only-binary=gdal,shapely,pyproj --no-cache-dir -r uv.lock
# Production stage
FROM python:3.13-slim
# Install runtime system dependencies
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal30 \
libproj25 \
libgeos-c1v5 \
postgresql-client \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=dbapp.settings.production
# Set work directory
WORKDIR /app
# Copy Python dependencies from previous stage
COPY --from=requirements-stage /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=requirements-stage /usr/local/bin /usr/local/bin
# Copy project
COPY . .
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash app && chown -R app:app /app
USER app
# Collect static files
RUN python manage.py collectstatic --noinput
# Expose port
EXPOSE 8000
# Run gunicorn server
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "dbapp.wsgi:application"]

96
dbapp/check_redis.py Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python
"""
Скрипт для проверки подключения к Redis.
Запуск: python check_redis.py
"""
import os
import sys
try:
import redis
except ImportError:
print("❌ Redis библиотека не установлена")
print("Установите: pip install redis")
sys.exit(1)
def check_redis():
"""Проверка подключения к Redis"""
print("=" * 60)
print("ПРОВЕРКА REDIS")
print("=" * 60)
# Получаем URL из переменных окружения
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
print(f"\n1. Broker URL: {broker_url}")
print(f"2. Cache URL: {cache_url}")
# Проверка broker (database 0)
print("\n3. Проверка Celery Broker (db 0)...")
try:
r_broker = redis.from_url(broker_url)
r_broker.ping()
print(" ✓ Подключение успешно")
# Проверка ключей
keys = r_broker.keys("*")
print(f" ✓ Ключей в базе: {len(keys)}")
# Проверка очереди celery
queue_length = r_broker.llen("celery")
print(f" ✓ Задач в очереди 'celery': {queue_length}")
except redis.ConnectionError as e:
print(f" ✗ Ошибка подключения: {e}")
return False
except Exception as e:
print(f" ✗ Ошибка: {e}")
return False
# Проверка cache (database 1)
print("\n4. Проверка Django Cache (db 1)...")
try:
r_cache = redis.from_url(cache_url)
r_cache.ping()
print(" ✓ Подключение успешно")
# Проверка ключей
keys = r_cache.keys("*")
print(f" ✓ Ключей в базе: {len(keys)}")
except redis.ConnectionError as e:
print(f" ✗ Ошибка подключения: {e}")
return False
except Exception as e:
print(f" ✗ Ошибка: {e}")
return False
# Тест записи/чтения
print("\n5. Тест записи/чтения...")
try:
test_key = "test:celery:connection"
test_value = "OK"
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
result = r_broker.get(test_key)
if result and result.decode() == test_value:
print(f" ✓ Запись/чтение работает")
r_broker.delete(test_key)
else:
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
return False
except Exception as e:
print(f" ✗ Ошибка: {e}")
return False
print("\n" + "=" * 60)
print("ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
print("=" * 60)
return True
if __name__ == "__main__":
success = check_redis()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,7 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
try:
from .celery import app as celery_app
__all__ = ('celery_app',)
except ImportError:
pass

View File

@@ -1,16 +1,16 @@
""" """
ASGI config for dbapp project. ASGI config for dbapp project.
It exposes the ASGI callable as a module-level variable named ``application``. It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
""" """
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings')
application = get_asgi_application() application = get_asgi_application()

18
dbapp/dbapp/celery.py Normal file
View File

@@ -0,0 +1,18 @@
"""
Celery configuration for dbapp project.
"""
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development'))
app = Celery('dbapp')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
@app.task(bind=True, ignore_result=True)
def debug_task(self):
print(f'Request: {self.request!r}')

View File

@@ -1,13 +1,22 @@
import os """
from dotenv import load_dotenv Settings module initialization.
# Load environment variables Automatically determines the environment and loads appropriate settings.
load_dotenv() Set DJANGO_ENVIRONMENT environment variable to 'production' or 'development'.
Defaults to 'development' if not set.
# Determine the environment and import the appropriate settings """
ENVIRONMENT = os.getenv('ENVIRONMENT', 'development')
import os
if ENVIRONMENT == 'production':
from .production import * from dotenv import load_dotenv
else:
from .development import * load_dotenv()
ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower()
if ENVIRONMENT == 'production':
from .production import *
print("Loading production settings...")
else:
from .development import *
print("Loading development settings...")

View File

@@ -1,200 +1,290 @@
""" """
Django settings for dbapp project. Django settings for dbapp project.
Generated by 'django-admin startproject' using Django 5.2.7. Base settings shared across all environments.
Environment-specific settings are in development.py and production.py
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
from pathlib import Path
import os import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
if os.name == 'nt': # ============================================================================
OSGEO4W = r"C:\Program Files\OSGeo4W" # PATH CONFIGURATION
assert os.path.isdir(OSGEO4W), "Directory does not exist: " + OSGEO4W # ============================================================================
os.environ['OSGEO4W_ROOT'] = OSGEO4W
os.environ['PROJ_LIB'] = os.path.join(OSGEO4W, r"share\proj")
os.environ['PATH'] = OSGEO4W + r"\bin;" + os.environ['PATH']
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# GDAL/GEOS configuration for Windows
if os.name == "nt":
OSGEO4W = r"C:\Program Files\OSGeo4W"
assert os.path.isdir(OSGEO4W), "Directory does not exist: " + OSGEO4W
os.environ["OSGEO4W_ROOT"] = OSGEO4W
os.environ["PROJ_LIB"] = os.path.join(OSGEO4W, r"share\proj")
os.environ["PATH"] = OSGEO4W + r"\bin;" + os.environ["PATH"]
# GDAL_LIBRARY_PATH = r'C:/Program Files/OSGeo4W/bin/gdall311.dll' # ============================================================================
# Quick-start development settings - unsuitable for production # SECURITY SETTINGS
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # ============================================================================
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq') SECRET_KEY = os.getenv(
"SECRET_KEY", "django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq"
)
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true' # This should be overridden in environment-specific settings
DEBUG = False
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') # Allowed hosts - should be overridden in environment-specific settings
ALLOWED_HOSTS = []
# Application definition # ============================================================================
# APPLICATION DEFINITION
# ============================================================================
INSTALLED_APPS = [ INSTALLED_APPS = [
'dal', # Django Autocomplete Light (must be before admin)
'dal_select2', "dal",
"dal_select2",
# Admin interface customization
"admin_interface", "admin_interface",
"colorfield", "colorfield",
'django.contrib.gis', # Django GIS
'leaflet', "django.contrib.gis",
'dynamic_raw_id', # Django core apps
'django.contrib.admin', "django.contrib.admin",
'django.contrib.humanize', "django.contrib.auth",
'django.contrib.auth', "django.contrib.contenttypes",
'django.contrib.contenttypes', "django.contrib.sessions",
'django.contrib.sessions', "django.contrib.messages",
'django.contrib.messages', "django.contrib.staticfiles",
'django.contrib.staticfiles', "django.contrib.humanize",
'mainapp', # Third-party apps
'mapsapp', "leaflet",
'rangefilter', "dynamic_raw_id",
'django_admin_multiple_choice_list_filter', "rangefilter",
'more_admin_filters', "django_admin_multiple_choice_list_filter",
'import_export', "more_admin_filters",
'debug_toolbar' "import_export",
"django_celery_results",
# Project apps
"mainapp",
"mapsapp",
"lyngsatapp",
] ]
# Note: Custom user model is implemented via OneToOneField relationship
# AUTH_USER_MODEL = 'mainapp.CustomUser' # AUTH_USER_MODEL = 'mainapp.CustomUser'
# ============================================================================
# MIDDLEWARE CONFIGURATION
# ============================================================================
MIDDLEWARE = [ MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware", #Добавил "django.middleware.security.SecurityMiddleware",
'django.middleware.locale.LocaleMiddleware', #Добавил "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.security.SecurityMiddleware', "django.middleware.locale.LocaleMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.middleware.common.CommonMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', #Добавил "django.middleware.csrf.CsrfViewMiddleware",
'django.middleware.common.CommonMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
ROOT_URLCONF = 'dbapp.urls' ROOT_URLCONF = "dbapp.urls"
# ============================================================================
# TEMPLATES CONFIGURATION
# ============================================================================
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [ "DIRS": [
BASE_DIR / 'templates', # Main project templates directory BASE_DIR / "templates",
], ],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.request', "django.template.context_processors.debug",
'django.contrib.auth.context_processors.auth', "django.template.context_processors.request",
'django.contrib.messages.context_processors.messages', "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'dbapp.wsgi.application' WSGI_APPLICATION = "dbapp.wsgi.application"
# ============================================================================
# Database # DATABASE CONFIGURATION
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases # ============================================================================
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': os.getenv('DB_ENGINE', 'django.contrib.gis.db.backends.postgis'), "ENGINE": os.getenv("DB_ENGINE", "django.contrib.gis.db.backends.postgis"),
'NAME': os.getenv('DB_NAME', 'db'), "NAME": os.getenv("DB_NAME", "db"),
'USER': os.getenv('DB_USER', 'user'), "USER": os.getenv("DB_USER", "user"),
'PASSWORD': os.getenv('DB_PASSWORD', 'password'), "PASSWORD": os.getenv("DB_PASSWORD", "password"),
'HOST': os.getenv('DB_HOST', 'localhost'), "HOST": os.getenv("DB_HOST", "localhost"),
'PORT': os.getenv('DB_PORT', '5432'), "PORT": os.getenv("DB_PORT", "5432"),
} }
} }
# Password validation # ============================================================================
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators # PASSWORD VALIDATION
# ============================================================================
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
# { {
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
# }, },
# { {
# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
# }, },
# { {
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
# }, },
# { {
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
# }, },
] ]
# ============================================================================
# INTERNATIONALIZATION
# ============================================================================
# Internationalization LANGUAGE_CODE = "ru"
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'ru' TIME_ZONE = "Europe/Moscow"
TIME_ZONE = 'Europe/Moscow'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Authentication settings # ============================================================================
LOGIN_URL = 'login' # AUTHENTICATION CONFIGURATION
LOGIN_REDIRECT_URL = 'home' # ============================================================================
LOGOUT_REDIRECT_URL = 'home'
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "mainapp:source_list"
LOGOUT_REDIRECT_URL = "mainapp:source_list"
# Static files (CSS, JavaScript, Images) # ============================================================================
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # STATIC FILES CONFIGURATION
# ============================================================================
STATIC_URL = "/static/"
STATIC_URL = '/static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR.parent / 'static', # Reference to the static directory at project root BASE_DIR.parent / "static",
] ]
# STATIC_ROOT will be set in production.py
# ============================================================================
# DEFAULT SETTINGS
# ============================================================================
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
# ============================================================================
# THIRD-PARTY APP CONFIGURATION
# ============================================================================
# AUTH_USER_MODEL = 'mainapp.CustomUser' # Admin Interface
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
SILENCED_SYSTEM_CHECKS = ["security.W019"] SILENCED_SYSTEM_CHECKS = ["security.W019"]
# Leaflet Configuration
LEAFLET_CONFIG = { LEAFLET_CONFIG = {
'ATTRIBUTION_PREFIX': '', "ATTRIBUTION_PREFIX": "",
'TILES': [('Satellite', 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {'attribution': '&copy; Esri', 'maxZoom': 16}), "TILES": [
('Streets', 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {'attribution': '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}) (
"Satellite",
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{"attribution": "&copy; Esri", "maxZoom": 16},
),
(
"Streets",
"http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
"attribution": '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
},
),
], ],
# 'RESET_VIEW': False,
# 'NO_GLOBALS': False,
# 'PLUGINS': {
# 'leaflet-measure': {
# 'css': ['https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.css'],
# 'js': 'https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.js',
# 'auto-include': True,
# },
# 'leaflet-featuregroup': {
# # 'css': ['https://cdn.jsdelivr.net/npm/leaflet-measure@3.1.0/dist/leaflet-measure.min.css'],
# 'js': 'https://cdn.jsdelivr.net/npm/leaflet.featuregroup.subgroup@1.0.2/dist/leaflet.featuregroup.subgroup.min.js',
# 'auto-include': True,
# },
# }
} }
INTERNAL_IPS = [
'127.0.0.1', # ============================================================================
] # CACHE CONFIGURATION
# ============================================================================
# Redis Cache Configuration
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": os.getenv("REDIS_URL", "redis://localhost:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": 50,
"retry_on_timeout": True,
},
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
},
"KEY_PREFIX": "dbapp",
"TIMEOUT": 300, # Default timeout 5 minutes
}
}
# ============================================================================
# CELERY CONFIGURATION
# ============================================================================
# Celery Configuration Options
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv(
"CELERY_BROKER_URL", "redis://localhost:6379/0"
) # Use Redis for results
CELERY_CACHE_BACKEND = "default"
# Celery Task Configuration
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 minutes
CELERY_TASK_ALWAYS_EAGER = False # Set to True for synchronous execution in development
# Celery Beat Configuration (for periodic tasks)
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Celery Result Backend Configuration
CELERY_RESULT_EXTENDED = True
CELERY_RESULT_EXPIRES = 3600 # Results expire after 1 hour
# Celery Logging
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_WORKER_LOG_FORMAT = "[%(asctime)s: %(levelname)s/%(processName)s] %(message)s"
CELERY_WORKER_TASK_LOG_FORMAT = "[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s"
# Celery Accept Content
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = TIME_ZONE
# Celery Exception Handling
CELERY_TASK_IGNORE_RESULT = False
CELERY_TASK_STORE_ERRORS_EVEN_IF_IGNORED = True

View File

@@ -1,9 +1,55 @@
"""
Development-specific settings.
"""
from .base import * from .base import *
# Development-specific settings # ============================================================================
# DEBUG CONFIGURATION
# ============================================================================
DEBUG = True DEBUG = True
# ============================================================================
# ALLOWED HOSTS
# ============================================================================
# Allow all hosts in development # Allow all hosts in development
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
# Additional development settings can go here # ============================================================================
# INSTALLED APPS - Development additions
# ============================================================================
INSTALLED_APPS += [
'debug_toolbar',
]
# ============================================================================
# MIDDLEWARE - Development additions
# ============================================================================
# Add debug toolbar middleware at the beginning
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE
# ============================================================================
# DEBUG TOOLBAR CONFIGURATION
# ============================================================================
INTERNAL_IPS = [
'127.0.0.1',
]
# ============================================================================
# EMAIL CONFIGURATION
# ============================================================================
# Use console backend for development
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# ============================================================================
# STATIC FILES CONFIGURATION FOR DEVELOPMENT
# ============================================================================
# Define STATIC_ROOT for collectstatic command to work in development
STATIC_ROOT = BASE_DIR.parent / "staticfiles"

View File

@@ -1,48 +1,173 @@
from .base import * """
Production-specific settings.
"""
import os import os
from dotenv import load_dotenv
# Production-specific settings from .base import *
# ============================================================================
# DEBUG CONFIGURATION
# ============================================================================
DEBUG = False DEBUG = False
TEMPLATE_DEBUG = DEBUG
# In production, specify allowed hosts explicitly # ============================================================================
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS_PROD', 'localhost,127.0.0.1').split(',') # ALLOWED HOSTS
# ============================================================================
# Security settings for production # In production, specify allowed hosts explicitly from environment variable
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
# CSRF trusted origins (required for forms to work behind proxy)
CSRF_TRUSTED_ORIGINS = os.getenv(
"CSRF_TRUSTED_ORIGINS",
"http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080"
).split(",")
# ============================================================================
# SECURITY SETTINGS
# ============================================================================
# SSL/HTTPS settings (disable for local testing without SSL)
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
# Security headers
SECURE_BROWSER_XSS_FILTER = True SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_SECONDS = 31536000
SECURE_REDIRECT_EXEMPT = []
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Template caching for production # HSTS settings (disable for local testing)
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "False") == "True"
SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "False") == "True"
# Additional security settings
SECURE_REDIRECT_EXEMPT = []
X_FRAME_OPTIONS = "DENY"
# ============================================================================
# TEMPLATE CACHING
# ============================================================================
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [ "DIRS": [
BASE_DIR / 'templates', # Main project templates directory BASE_DIR / "templates",
], ],
'APP_DIRS': True, "APP_DIRS": False,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.request', "django.template.context_processors.debug",
'django.contrib.auth.context_processors.auth', "django.template.context_processors.request",
'django.contrib.messages.context_processors.messages', "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
], ],
'loaders': [ "loaders": [
('django.template.loaders.cached.Loader', [ (
'django.template.loaders.filesystem.Loader', "django.template.loaders.cached.Loader",
'django.template.loaders.app_directories.Loader', [
]), "django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
], ],
}, },
}, },
] ]
# Static files settings for production # ============================================================================
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # STATIC FILES CONFIGURATION
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' # ============================================================================
STATIC_ROOT = BASE_DIR.parent / "staticfiles"
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
# ============================================================================
# LOGGING CONFIGURATION
# ============================================================================
LOGS_DIR = BASE_DIR.parent / "logs"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
# ============================================================================
# CELERY LOGGING CONFIGURATION
# ============================================================================
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"simple": {
"format": "{levelname} {message}",
"style": "{",
},
},
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
},
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "simple",
},
"file": {
"level": "ERROR",
"class": "logging.FileHandler",
"filename": LOGS_DIR / "django_errors.log",
"formatter": "verbose",
},
"celery_file": {
"level": "INFO",
"class": "logging.FileHandler",
"filename": LOGS_DIR / "celery.log",
"formatter": "verbose",
},
"mail_admins": {
"level": "ERROR",
"class": "django.utils.log.AdminEmailHandler",
"filters": ["require_debug_false"],
"formatter": "verbose",
},
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": True,
},
"django.request": {
"handlers": ["mail_admins", "file"],
"level": "ERROR",
"propagate": False,
},
"celery": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
"celery.task": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
"celery.worker": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
},
}
# Force Celery to log to stdout for Docker
CELERY_WORKER_REDIRECT_STDOUTS = True
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"

View File

@@ -14,19 +14,23 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from mainapp import views from mainapp.views import custom_logout
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = [ urlpatterns = [
# path('admin/dynamic_raw_id/', include('dynamic_raw_id.urls')),
path('admin/', admin.site.urls, name='admin'), path('admin/', admin.site.urls, name='admin'),
# path('admin/map/', views.show_map_view, name='admin_show_map'), path('', include('mainapp.urls', namespace='mainapp')),
path('', include('mainapp.urls')), path('', include('mapsapp.urls', namespace='mapsapp')),
path('', include('mapsapp.urls')), path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
# Authentication URLs # Authentication URLs
path('login/', auth_views.LoginView.as_view(), name='login'), path('login/', auth_views.LoginView.as_view(), name='login'),
path('logout/', views.custom_logout, name='logout'), path('logout/', custom_logout, name='logout'),
] + debug_toolbar_urls() ]
# Only include debug toolbar in development
if settings.DEBUG:
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns += debug_toolbar_urls()

View File

@@ -1,16 +1,16 @@
""" """
WSGI config for dbapp project. WSGI config for dbapp project.
It exposes the WSGI callable as a module-level variable named ``application``. It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
""" """
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings')
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -e
echo "Starting Celery Worker..."
# Ждем PostgreSQL
echo "Waiting for PostgreSQL..."
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 1
done
echo "PostgreSQL started"
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
echo "Waiting for Redis..."
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
echo "Redis is unavailable - sleeping"
sleep 1
done
echo "Redis started"
# Создаем директорию для логов
mkdir -p /app/logs
# Запускаем команду (celery worker или beat)
exec "$@"

40
dbapp/entrypoint.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
# Определяем окружение (по умолчанию production)
ENVIRONMENT=${ENVIRONMENT:-production}
echo "Starting in $ENVIRONMENT mode..."
if [ -d "logs" ]; then
echo "Directory logs already exists."
else
echo "Creating logs directory..."
mkdir -p logs
fi
echo "Waiting for PostgreSQL..."
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 1
done
echo "PostgreSQL started"
echo "Running migrations..."
uv run python manage.py migrate --noinput
if [ "$ENVIRONMENT" = "production" ]; then
echo "Collecting static files..."
uv run python manage.py collectstatic --noinput
fi
if [ "$ENVIRONMENT" = "development" ]; then
echo "Starting Django development server..."
exec uv run python manage.py runserver 0.0.0.0:8000
else
echo "Starting Gunicorn..."
exec uv run gunicorn --bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--timeout ${GUNICORN_TIMEOUT:-120} \
dbapp.wsgi:application
fi

View File

@@ -0,0 +1,128 @@
"""
Скрипт для исправления ObjItems без связи с Source.
Для каждого ObjItem без source:
1. Получить координаты из geo_obj
2. Найти ближайший Source (по coords_average)
3. Если расстояние <= 0.5 градуса, связать ObjItem с этим Source
4. Иначе создать новый Source с coords_average = координаты geo_obj
"""
# import os
# import django
# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dbapp.settings")
# django.setup()
# from mainapp.models import ObjItem, Source, CustomUser
# from django.contrib.gis.geos import Point
# from django.contrib.gis.measure import D
# from django.contrib.gis.db.models.functions import Distance
# def calculate_distance_degrees(coord1, coord2):
# """Вычисляет расстояние между двумя координатами в градусах."""
# import math
# lon1, lat1 = coord1
# lon2, lat2 = coord2
# return math.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2)
# def fix_objitems_without_source():
# """Исправляет ObjItems без связи с Source."""
# # Получаем пользователя по умолчанию
# default_user = CustomUser.objects.get(id=1)
# # Получаем все ObjItems без source
# objitems_without_source = ObjItem.objects.filter(source__isnull=True)
# total_count = objitems_without_source.count()
# print(f"Найдено {total_count} ObjItems без source")
# if total_count == 0:
# print("Нечего исправлять!")
# return
# fixed_count = 0
# new_sources_count = 0
# for objitem in objitems_without_source:
# # Проверяем, есть ли geo_obj
# if not hasattr(objitem, 'geo_obj') or not objitem.geo_obj or not objitem.geo_obj.coords:
# print(f"ObjItem {objitem.id} не имеет geo_obj или координат, пропускаем")
# continue
# geo_coords = objitem.geo_obj.coords
# coord_tuple = (geo_coords.x, geo_coords.y)
# # Ищем ближайший Source
# sources_with_coords = Source.objects.filter(coords_average__isnull=False)
# closest_source = None
# min_distance = float('inf')
# for source in sources_with_coords:
# source_coord = (source.coords_average.x, source.coords_average.y)
# distance = calculate_distance_degrees(coord_tuple, source_coord)
# if distance < min_distance:
# min_distance = distance
# closest_source = source
# # Если нашли близкий Source (расстояние <= 0.5 градуса)
# if closest_source and min_distance <= 0.5:
# objitem.source = closest_source
# objitem.save()
# print(f"ObjItem {objitem.id} связан с Source {closest_source.id} (расстояние: {min_distance:.4f}°)")
# fixed_count += 1
# else:
# # Создаем новый Source
# new_source = Source.objects.create(
# coords_average=Point(coord_tuple, srid=4326),
# created_by=default_user
# )
# objitem.source = new_source
# objitem.save()
# print(f"ObjItem {objitem.id} связан с новым Source {new_source.id}")
# fixed_count += 1
# new_sources_count += 1
# print(f"\nГотово!")
# print(f"Исправлено ObjItems: {fixed_count}")
# print(f"Создано новых Source: {new_sources_count}")
# if __name__ == "__main__":
# fix_objitems_without_source()
from geographiclib.geodesic import Geodesic
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
"""
Вычисляет среднюю точку между двумя координатами с использованием геодезических вычислений (с учётом эллипсоида).
:param lat1: Широта первой точки в градусах.
:param lon1: Долгота первой точки в градусах.
:param lat2: Широта второй точки в градусах.
:param lon2: Долгота второй точки в градусах.
:return: Словарь с ключами 'lat' и 'lon' для средней точки, и расстояние(dist) в КМ.
"""
lon1, lat1 = coord1
lon2, lat2 = coord2
geod_inv = Geodesic.WGS84.Inverse(lat1, lon1, lat2, lon2)
azimuth1 = geod_inv['azi1']
distance = geod_inv['s12']
geod_direct = Geodesic.WGS84.Direct(lat1, lon1, azimuth1, distance / 2)
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
# Пример использования
lat1, lon1 = 56.15465080269812, 38.140518028837285
lat2, lon2 = 56.0852, 38.0852
midpoint = calculate_mean_coords((lat1, lon1), (lat2, lon2)) #56.15465080269812, 38.140518028837285
print(f"Средняя точка: {midpoint[0]}")
print(f"Расстояние: {midpoint[1]} км")

View File

@@ -1,8 +1,10 @@
from django.contrib import admin from django.contrib import admin
from .models import LyngSat from .models import LyngSat
@admin.register(LyngSat) @admin.register(LyngSat)
class LyngSatAdmin(admin.ModelAdmin): class LyngSatAdmin(admin.ModelAdmin):
list_display = ("mark", "timestamp") list_display = ("id_satellite", "frequency", "polarization", "modulation", "last_update")
search_fields = ("mark", ) search_fields = ("id_satellite__name", "channel_info")
ordering = ("timestamp",) list_filter = ("id_satellite", "polarization", "modulation", "standard")
ordering = ("-last_update",)
readonly_fields = ("last_update",)

View File

@@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class LyngsatappConfig(AppConfig): class LyngsatappConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'lyngsatapp' name = 'lyngsatapp'

View File

@@ -0,0 +1,564 @@
"""
Асинхронный парсер данных LyngSat с поддержкой кеширования в Redis.
"""
import requests
from bs4 import BeautifulSoup
from datetime import datetime
import re
import logging
from typing import Optional
from django.core.cache import cache
logger = logging.getLogger(__name__)
def parse_satellite_names(satellite_string: str) -> list[str]:
"""Извлекает все возможные имена спутников из строки."""
slash_parts = [part.strip() for part in satellite_string.split('/')]
all_names = []
for part in slash_parts:
main_match = re.match(r'^([^(]+)', part)
if main_match:
main_name = main_match.group(1).strip()
if main_name:
all_names.append(main_name)
bracket_match = re.search(r'\(([^)]+)\)', part)
if bracket_match:
bracket_name = bracket_match.group(1).strip()
if bracket_name:
all_names.append(bracket_name)
seen = set()
result = []
for name in all_names:
if name not in seen:
seen.add(name)
result.append(name.strip().lower())
return result
class AsyncLyngSatParser:
"""
Асинхронный парсер данных для LyngSat с поддержкой кеширования.
Кеширование:
- Страницы регионов кешируются на 7 дней
- Данные спутников кешируются на 1 день
"""
# Время жизни кеша
REGION_CACHE_TTL = 60 * 60 * 24 * 7 # 7 дней
SATELLITE_CACHE_TTL = 60 * 60 * 24 # 1 день
# Префиксы для ключей кеша
REGION_CACHE_PREFIX = "lyngsat_region"
SATELLITE_CACHE_PREFIX = "lyngsat_satellite"
SATELLITE_LIST_CACHE_PREFIX = "lyngsat_sat_list"
def __init__(
self,
flaresolver_url: str = "http://localhost:8191/v1",
regions: list[str] | None = None,
target_sats: list[str] | None = None,
use_cache: bool = True,
):
"""
Инициализация парсера.
Args:
flaresolver_url: URL FlareSolverr для обхода защиты
regions: Список регионов для парсинга
target_sats: Список целевых спутников (в нижнем регистре)
use_cache: Использовать ли кеширование
"""
self.flaresolver_url = flaresolver_url
self.use_cache = use_cache
self.target_sats = (
list(map(lambda sat: sat.strip().lower(), target_sats)) if target_sats else None
)
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
self.BASE_URL = "https://www.lyngsat.com"
def _get_cache_key(self, prefix: str, identifier: str) -> str:
"""Генерирует ключ для кеша."""
return f"{prefix}:{identifier}"
def _get_from_cache(self, key: str) -> Optional[any]:
"""Получает данные из кеша."""
if not self.use_cache:
return None
try:
data = cache.get(key)
if data:
logger.debug(f"Данные получены из кеша: {key}")
return data
except Exception as e:
logger.warning(f"Ошибка при получении из кеша {key}: {e}")
return None
def _set_to_cache(self, key: str, value: any, ttl: int) -> None:
"""Сохраняет данные в кеш."""
if not self.use_cache:
return
try:
cache.set(key, value, timeout=ttl)
logger.debug(f"Данные сохранены в кеш: {key} (TTL: {ttl}s)")
except Exception as e:
logger.warning(f"Ошибка при сохранении в кеш {key}: {e}")
@classmethod
def clear_cache(cls, cache_type: str = "all") -> dict:
"""
Очищает кеш парсера.
Args:
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
Returns:
dict: Статистика очистки
"""
stats = {"cleared": 0, "errors": []}
try:
from django.core.cache import cache as django_cache
if cache_type in ("regions", "all"):
# Очищаем кеш регионов
regions = ["europe", "asia", "america", "atlantic"]
for region in regions:
key = f"{cls.REGION_CACHE_PREFIX}:{region}"
try:
result = django_cache.delete(key)
if result:
stats["cleared"] += 1
logger.info(f"Очищен кеш региона: {region}")
else:
logger.debug(f"Кеш региона {region} не найден или уже удален")
except Exception as e:
error_msg = f"Ошибка при очистке кеша региона {region}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
if cache_type in ("satellites", "all"):
# Для очистки кеша спутников используем keys()
if hasattr(django_cache, 'keys'):
try:
# Очищаем списки спутников
list_keys = django_cache.keys(f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*")
if list_keys:
if hasattr(django_cache, 'delete_many'):
django_cache.delete_many(list_keys)
else:
for key in list_keys:
django_cache.delete(key)
stats["cleared"] += len(list_keys)
logger.info(f"Очищено {len(list_keys)} списков спутников")
# Очищаем данные спутников
sat_keys = django_cache.keys(f"{cls.SATELLITE_CACHE_PREFIX}:*")
if sat_keys:
if hasattr(django_cache, 'delete_many'):
django_cache.delete_many(sat_keys)
else:
for key in sat_keys:
django_cache.delete(key)
stats["cleared"] += len(sat_keys)
logger.info(f"Очищено {len(sat_keys)} данных спутников")
except Exception as e:
error_msg = f"Ошибка при очистке кеша спутников: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
else:
logger.warning("Бэкенд кеша не поддерживает keys()")
logger.info("Для полной очистки используйте: redis-cli flushdb")
except Exception as e:
error_msg = f"Критическая ошибка при очистке кеша: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
return stats
@classmethod
def clear_all_cache(cls) -> dict:
"""Полностью очищает весь кеш LyngSat."""
stats = {"cleared": 0, "errors": []}
try:
from django.core.cache import cache as django_cache
# Для django-redis используем keys() + delete_many()
if hasattr(django_cache, 'keys'):
patterns = [
f"{cls.REGION_CACHE_PREFIX}:*",
f"{cls.SATELLITE_CACHE_PREFIX}:*",
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
]
all_keys = []
for pattern in patterns:
try:
keys = django_cache.keys(pattern)
if keys:
all_keys.extend(keys)
logger.info(f"Найдено {len(keys)} ключей по паттерну: {pattern}")
except Exception as e:
error_msg = f"Ошибка при поиске ключей {pattern}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
# Удаляем все найденные ключи
if all_keys:
try:
if hasattr(django_cache, 'delete_many'):
django_cache.delete_many(all_keys)
else:
for key in all_keys:
django_cache.delete(key)
stats["cleared"] = len(all_keys)
logger.info(f"Удалено {len(all_keys)} ключей")
except Exception as e:
error_msg = f"Ошибка при удалении ключей: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
else:
logger.info("Ключи для удаления не найдены")
elif hasattr(django_cache, 'delete_pattern'):
# Fallback на delete_pattern
patterns = [
f"{cls.REGION_CACHE_PREFIX}:*",
f"{cls.SATELLITE_CACHE_PREFIX}:*",
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
]
for pattern in patterns:
try:
deleted = django_cache.delete_pattern(pattern)
if deleted and isinstance(deleted, int):
stats["cleared"] += deleted
logger.info(f"Очищено {deleted} ключей по паттерну: {pattern}")
except Exception as e:
error_msg = f"Ошибка при очистке паттерна {pattern}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
else:
# Fallback для других бэкендов кеша
logger.warning("Бэкенд кеша не поддерживает keys() или delete_pattern()")
return cls.clear_cache("all")
except Exception as e:
error_msg = f"Критическая ошибка при полной очистке кеша: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
return stats
def parse_metadata(self, metadata: str) -> dict:
"""Парсит метаданные транспондера."""
if not metadata or not metadata.strip():
return {
"standard": None,
"modulation": None,
"symbol_rate": None,
"fec": None,
}
normalized = re.sub(r"\s+", "", metadata.strip())
fec_match = re.search(r"([1-9]/[1-9])$", normalized)
fec = fec_match.group(1) if fec_match else None
if fec_match:
core = normalized[: fec_match.start()]
else:
core = normalized
std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core)
standard = std_match.group(1) if std_match else None
rest = core[len(standard) :] if standard else core
modulation = None
mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest)
if mod_match:
modulation = mod_match.group(1)
rest = rest[len(modulation) :]
symbol_rate = None
sr_match = re.search(r"(\d+)$", rest)
if sr_match:
try:
symbol_rate = int(sr_match.group(1))
except ValueError:
pass
return {
"standard": standard,
"modulation": modulation,
"symbol_rate": symbol_rate,
"fec": fec,
}
def extract_date(self, s: str) -> datetime | None:
"""Извлекает дату из строки формата YYMMDD."""
s = s.strip()
match = re.search(r"(\d{6})$", s)
if not match:
return None
yymmdd = match.group(1)
try:
return datetime.strptime(yymmdd, "%y%m%d").date()
except ValueError:
return None
def convert_polarization(self, polarization: str) -> str:
"""Преобразует код поляризации в понятное название на русском."""
polarization_map = {
"V": "Вертикальная",
"H": "Горизонтальная",
"R": "Правая",
"L": "Левая",
}
return polarization_map.get(polarization.upper(), polarization)
def fetch_region_page(self, region: str, force_refresh: bool = False) -> Optional[str]:
"""
Получает HTML страницу региона с кешированием.
Args:
region: Название региона
force_refresh: Принудительно обновить кеш
Returns:
HTML содержимое страницы или None при ошибке
"""
cache_key = self._get_cache_key(self.REGION_CACHE_PREFIX, region)
# Проверяем кеш
if not force_refresh:
cached_html = self._get_from_cache(cache_key)
if cached_html:
logger.info(f"Страница региона {region} получена из кеша")
return cached_html
# Запрашиваем страницу
url = f"{self.BASE_URL}/{region}.html"
logger.info(f"Запрос страницы региона: {url}")
try:
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
if response.status_code != 200:
logger.error(f"Ошибка при запросе {url}: статус {response.status_code}")
return None
html_content = response.json().get("solution", {}).get("response", "")
if html_content:
# Сохраняем в кеш
self._set_to_cache(cache_key, html_content, self.REGION_CACHE_TTL)
logger.info(f"Страница региона {region} получена и закеширована")
return html_content
except Exception as e:
logger.error(f"Ошибка при получении страницы {url}: {e}", exc_info=True)
return None
def get_satellite_list_from_region(self, region: str, force_refresh: bool = False) -> list[dict]:
"""
Получает список спутников из региона.
Args:
region: Название региона
force_refresh: Принудительно обновить кеш
Returns:
Список словарей с информацией о спутниках
"""
# Создаем уникальный ключ кеша с учетом целевых спутников
# Если target_sats не указаны, используем "all"
sats_key = "all" if not self.target_sats else "_".join(sorted(self.target_sats))
cache_key = self._get_cache_key(self.SATELLITE_LIST_CACHE_PREFIX, f"{region}_{sats_key}")
# Проверяем кеш
if not force_refresh:
cached_list = self._get_from_cache(cache_key)
if cached_list:
logger.info(f"Список спутников региона {region} (фильтр: {sats_key[:50]}) получен из кеша")
return cached_list
# Получаем HTML страницы
html_content = self.fetch_region_page(region, force_refresh)
if not html_content:
return []
# Парсим список спутников
satellites = []
try:
soup = BeautifulSoup(html_content, "html.parser")
col_table = soup.find_all("div", class_="desktab")[0]
tables = col_table.find_next_sibling("table").find_all("table")
trs = []
for table in tables:
trs.extend(table.find_all("tr"))
for tr in trs:
sat_name = tr.find("span").text.replace("ü", "u").strip().lower()
# Фильтруем по целевым спутникам
if self.target_sats is not None:
names = parse_satellite_names(sat_name)
if len(names) == 1:
sat_name = names[0]
else:
for name in names:
if name in self.target_sats:
sat_name = name
break
if sat_name not in self.target_sats:
continue
try:
sat_url = tr.find_all("a")[2]["href"]
except IndexError:
sat_url = tr.find_all("a")[0]["href"]
update_date_str = tr.find_all("td")[-1].text
try:
update_date = datetime.strptime(update_date_str, "%y%m%d").date()
except (ValueError, TypeError):
update_date = None
satellites.append({
"name": sat_name,
"url": sat_url,
"update_date": update_date,
"region": region
})
# Сохраняем в кеш
self._set_to_cache(cache_key, satellites, self.REGION_CACHE_TTL)
sats_filter = "все" if not self.target_sats else f"{len(self.target_sats)} целевых"
logger.info(f"Найдено {len(satellites)} спутников в регионе {region} (фильтр: {sats_filter})")
except Exception as e:
logger.error(f"Ошибка при парсинге списка спутников региона {region}: {e}", exc_info=True)
return satellites
def fetch_satellite_data(self, sat_name: str, sat_url: str, force_refresh: bool = False) -> Optional[dict]:
"""
Получает данные одного спутника с кешированием.
Args:
sat_name: Название спутника
sat_url: URL страницы спутника
force_refresh: Принудительно обновить кеш
Returns:
Словарь с данными спутника или None при ошибке
"""
cache_key = self._get_cache_key(self.SATELLITE_CACHE_PREFIX, sat_name)
# Проверяем кеш
if not force_refresh:
cached_data = self._get_from_cache(cache_key)
if cached_data:
logger.info(f"Данные спутника {sat_name} получены из кеша")
return cached_data
# Запрашиваем данные
full_url = f"{self.BASE_URL}/{sat_url}"
logger.info(f"Запрос данных спутника {sat_name}: {full_url}")
try:
payload = {"cmd": "request.get", "url": full_url, "maxTimeout": 60000}
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
if response.status_code != 200:
logger.error(f"Ошибка при запросе {full_url}: статус {response.status_code}")
return None
html_content = response.json().get("solution", {}).get("response", "")
if not html_content:
logger.warning(f"Пустой ответ для спутника {sat_name}")
return None
# Парсим данные
sources = self.parse_satellite_content(html_content)
satellite_data = {
"name": sat_name,
"url": full_url,
"sources": sources,
"fetched_at": datetime.now().isoformat()
}
# Сохраняем в кеш
self._set_to_cache(cache_key, satellite_data, self.SATELLITE_CACHE_TTL)
logger.info(f"Данные спутника {sat_name} получены и закешированы ({len(sources)} источников)")
return satellite_data
except Exception as e:
logger.error(f"Ошибка при получении данных спутника {sat_name}: {e}", exc_info=True)
return None
def parse_satellite_content(self, html_content: str) -> list[dict]:
"""Парсит содержимое страницы спутника."""
data = []
try:
sat_soup = BeautifulSoup(html_content, "html.parser")
big_table = sat_soup.find("table", class_="bigtable")
if not big_table:
logger.warning("Таблица bigtable не найдена")
return data
all_tables = big_table.find_all("div", class_="desktab")[:-1]
for table in all_tables:
trs = table.find_next_sibling("table").find_all("tr")
for idx, tr in enumerate(trs):
tds = tr.find_all("td")
if len(tds) < 9 or idx < 2:
continue
try:
freq, polarization = tds[0].find("b").text.strip().split("\xa0")
polarization = self.convert_polarization(polarization)
meta = self.parse_metadata(tds[1].text)
provider_name = tds[3].text
last_update = self.extract_date(tds[-1].text)
data.append({
"freq": freq,
"pol": polarization,
"metadata": meta,
"provider_name": provider_name,
"last_update": last_update,
})
except Exception as e:
logger.debug(f"Ошибка при парсинге строки транспондера: {e}")
continue
except Exception as e:
logger.error(f"Ошибка при парсинге содержимого спутника: {e}", exc_info=True)
return data
def get_all_satellites_list(self, force_refresh: bool = False) -> list[dict]:
"""
Получает список всех спутников из всех регионов.
Args:
force_refresh: Принудительно обновить кеш
Returns:
Список словарей с информацией о спутниках
"""
all_satellites = []
for region in self.regions:
logger.info(f"Получение списка спутников из региона: {region}")
satellites = self.get_satellite_list_from_region(region, force_refresh)
all_satellites.extend(satellites)
logger.info(f"Всего найдено спутников: {len(all_satellites)}")
return all_satellites

View File

@@ -0,0 +1,292 @@
"""
Утилиты для асинхронной обработки данных LyngSat с кешированием.
"""
import logging
from typing import Callable, Optional
from .async_parser import AsyncLyngSatParser
from .models import LyngSat
from mainapp.models import Polarization, Standard, Modulation, Satellite
from dbapp.settings.base import FLARESOLVERR_URL
logger = logging.getLogger(__name__)
def process_single_satellite(
parser: AsyncLyngSatParser,
satellite_info: dict,
force_refresh: bool = False
) -> dict:
"""
Обрабатывает один спутник и сохраняет данные в БД.
Args:
parser: Экземпляр парсера
satellite_info: Информация о спутнике (name, url, update_date)
force_refresh: Принудительно обновить кеш
Returns:
dict: Статистика обработки спутника
"""
sat_name = satellite_info["name"]
sat_url = satellite_info["url"]
stats = {
"satellite_name": sat_name,
"sources_found": 0,
"created": 0,
"updated": 0,
"errors": []
}
logger.info(f"Обработка спутника: {sat_name}")
# Получаем данные спутника (из кеша или с сайта)
satellite_data = parser.fetch_satellite_data(sat_name, sat_url, force_refresh)
if not satellite_data:
error_msg = f"Не удалось получить данные для спутника {sat_name}"
logger.error(error_msg)
stats["errors"].append(error_msg)
return stats
sources = satellite_data.get("sources", [])
stats["sources_found"] = len(sources)
logger.info(f"Найдено {len(sources)} источников для {sat_name}")
# Находим спутник в базе по имени или альтернативному имени (lowercase)
from django.db.models import Q
sat_name_lower = sat_name.lower()
try:
sat_obj = Satellite.objects.get(
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
)
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
except Satellite.DoesNotExist:
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
logger.warning(error_msg)
stats["errors"].append(error_msg)
return stats
except Satellite.MultipleObjectsReturned:
error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
logger.warning(error_msg)
stats["errors"].append(error_msg)
return stats
# Обрабатываем каждый источник
for source_idx, source in enumerate(sources, 1):
try:
# Парсим частоту
try:
freq = float(source['freq'])
except (ValueError, TypeError):
freq = -1.0
logger.debug(f"Некорректная частота для {sat_name}: {source.get('freq')}")
last_update = source['last_update']
fec = source['metadata'].get('fec')
modulation_name = source['metadata'].get('modulation')
standard_name = source['metadata'].get('standard')
symbol_velocity = source['metadata'].get('symbol_rate')
polarization_name = source['pol']
channel_info = source['provider_name']
# Создаем или получаем связанные объекты
pol_obj, _ = Polarization.objects.get_or_create(
name=polarization_name if polarization_name else "-"
)
mod_obj, _ = Modulation.objects.get_or_create(
name=modulation_name if modulation_name else "-"
)
standard_obj, _ = Standard.objects.get_or_create(
name=standard_name if standard_name else "-"
)
# Создаем или обновляем запись Lyngsat
lyng_obj, created = LyngSat.objects.update_or_create(
id_satellite=sat_obj,
frequency=freq,
polarization=pol_obj,
defaults={
"modulation": mod_obj,
"standard": standard_obj,
"sym_velocity": symbol_velocity if symbol_velocity else 0,
"channel_info": channel_info[:20] if channel_info else "",
"last_update": last_update,
"fec": fec[:30] if fec else "",
"url": satellite_data["url"]
}
)
if created:
stats['created'] += 1
logger.debug(f"Создана запись для {sat_name} {freq} МГц")
else:
stats['updated'] += 1
logger.debug(f"Обновлена запись для {sat_name} {freq} МГц")
# Логируем прогресс каждые 10 источников
if source_idx % 10 == 0:
logger.info(f"Обработано {source_idx}/{len(sources)} источников для {sat_name}")
except Exception as e:
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
logger.error(error_msg, exc_info=True)
stats['errors'].append(error_msg)
continue
logger.info(f"Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
return stats
def fill_lyngsat_data_async(
target_sats: list[str],
regions: list[str] = None,
task_id: str = None,
update_progress: Optional[Callable] = None,
force_refresh: bool = False,
use_cache: bool = True
) -> dict:
"""
Асинхронно заполняет данные Lyngsat для указанных спутников.
Обрабатывает спутники по одному с кешированием.
Args:
target_sats: Список названий спутников для обработки
regions: Список регионов для парсинга (по умолчанию все)
task_id: ID задачи Celery для логирования
update_progress: Функция для обновления прогресса (current, total, status, details)
force_refresh: Принудительно обновить кеш
use_cache: Использовать ли кеширование
Returns:
dict: Статистика обработки
"""
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat Async]"
overall_stats = {
'total_satellites': 0,
'processed_satellites': 0,
'total_sources': 0,
'created': 0,
'updated': 0,
'errors': [],
'satellites_details': []
}
if regions is None:
regions = ["europe", "asia", "america", "atlantic"]
logger.info(f"{log_prefix} Начало асинхронной обработки данных")
logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}")
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}")
logger.info(f"{log_prefix} Использование кеша: {use_cache}, Принудительное обновление: {force_refresh}")
if update_progress:
update_progress(0, len(target_sats), "Инициализация парсера...", {})
try:
# Создаем парсер
parser = AsyncLyngSatParser(
flaresolver_url=FLARESOLVERR_URL,
target_sats=target_sats,
regions=regions,
use_cache=use_cache
)
logger.info(f"{log_prefix} Получение списка спутников...")
if update_progress:
update_progress(0, len(target_sats), "Получение списка спутников...", {})
# Получаем список всех спутников
all_satellites = parser.get_all_satellites_list(force_refresh)
overall_stats['total_satellites'] = len(all_satellites)
logger.info(f"{log_prefix} Найдено {len(all_satellites)} спутников для обработки")
# Обрабатываем каждый спутник по отдельности
for idx, satellite_info in enumerate(all_satellites, 1):
sat_name = satellite_info["name"]
logger.info(f"{log_prefix} Обработка спутника {idx}/{len(all_satellites)}: {sat_name}")
if update_progress:
update_progress(
idx - 1,
len(all_satellites),
f"Обработка {sat_name}...",
{
"current_satellite": sat_name,
"created": overall_stats['created'],
"updated": overall_stats['updated']
}
)
# Обрабатываем спутник
sat_stats = process_single_satellite(parser, satellite_info, force_refresh)
# Обновляем общую статистику
overall_stats['processed_satellites'] += 1
overall_stats['total_sources'] += sat_stats['sources_found']
overall_stats['created'] += sat_stats['created']
overall_stats['updated'] += sat_stats['updated']
overall_stats['errors'].extend(sat_stats['errors'])
overall_stats['satellites_details'].append(sat_stats)
logger.info(
f"{log_prefix} Спутник {sat_name} обработан: "
f"источников {sat_stats['sources_found']}, "
f"создано {sat_stats['created']}, "
f"обновлено {sat_stats['updated']}"
)
logger.info(
f"{log_prefix} Обработка завершена. "
f"Спутников: {overall_stats['processed_satellites']}/{overall_stats['total_satellites']}, "
f"Источников: {overall_stats['total_sources']}, "
f"Создано: {overall_stats['created']}, "
f"Обновлено: {overall_stats['updated']}, "
f"Ошибок: {len(overall_stats['errors'])}"
)
if update_progress:
update_progress(
overall_stats['processed_satellites'],
overall_stats['total_satellites'],
"Завершено",
{
"created": overall_stats['created'],
"updated": overall_stats['updated'],
"errors_count": len(overall_stats['errors'])
}
)
except Exception as e:
error_msg = f"Критическая ошибка: {str(e)}"
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
overall_stats['errors'].append(error_msg)
return overall_stats
def clear_lyngsat_cache(cache_type: str = "all") -> dict:
"""
Очищает кеш LyngSat.
Args:
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
Returns:
dict: Статистика очистки
"""
logger.info(f"Очистка кеша LyngSat: {cache_type}")
if cache_type == "all":
stats = AsyncLyngSatParser.clear_all_cache()
else:
stats = AsyncLyngSatParser.clear_cache(cache_type)
logger.info(f"Кеш очищен: {stats}")
return stats

View File

View File

@@ -0,0 +1,40 @@
"""
Management команда для очистки кеша LyngSat.
"""
from django.core.management.base import BaseCommand
from lyngsatapp.async_utils import clear_lyngsat_cache
class Command(BaseCommand):
help = 'Очищает кеш данных LyngSat'
def add_arguments(self, parser):
parser.add_argument(
'--type',
type=str,
default='all',
choices=['regions', 'satellites', 'all'],
help='Тип кеша для очистки (regions, satellites, all)'
)
def handle(self, *args, **options):
cache_type = options['type']
self.stdout.write(f'Очистка кеша LyngSat: {cache_type}...')
stats = clear_lyngsat_cache(cache_type)
self.stdout.write(
self.style.SUCCESS(
f'Кеш очищен успешно! Удалено записей: {stats["cleared"]}'
)
)
if stats['errors']:
self.stdout.write(
self.style.WARNING(
f'Ошибок при очистке: {len(stats["errors"])}'
)
)
for error in stats['errors']:
self.stdout.write(self.style.ERROR(f' - {error}'))

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2025-11-12 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='LyngSat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')),
('sym_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Дата посленего обновления')),
('channel_info', models.CharField(blank=True, max_length=20, null=True, verbose_name='Описание источника')),
('fec', models.CharField(blank=True, max_length=30, null=True, verbose_name='Коэффициент коррекции ошибок')),
('url', models.URLField(blank=True, null=True, verbose_name='Ссылка на страницу')),
],
options={
'verbose_name': 'Источник LyngSat',
'verbose_name_plural': 'Источники LyngSat',
},
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-11-12 14:21
import django.db.models.deletion
import mainapp.models
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('lyngsatapp', '0001_initial'),
('mainapp', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='lyngsat',
name='id_satellite',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lyngsat', to='mainapp.satellite', verbose_name='Спутник'),
),
migrations.AddField(
model_name='lyngsat',
name='modulation',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.modulation', verbose_name='Модуляция'),
),
migrations.AddField(
model_name='lyngsat',
name='polarization',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.polarization', verbose_name='Поляризация'),
),
migrations.AddField(
model_name='lyngsat',
name='standard',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.standard', verbose_name='Стандарт'),
),
]

View File

@@ -1,36 +1,37 @@
from django.db import models from django.db import models
from mainapp.models import ( from mainapp.models import (
Satellite, Satellite,
Polarization, Polarization,
Modulation, Modulation,
Standard, Standard,
get_default_polarization, get_default_polarization,
get_default_modulation, get_default_modulation,
get_default_standard get_default_standard
) )
class LyngSat(models.Model): class LyngSat(models.Model):
id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="lyngsat", verbose_name="Спутник", null=True) id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="lyngsat", verbose_name="Спутник", null=True)
polarization = models.ForeignKey( polarization = models.ForeignKey(
Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Поляризация" Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Поляризация"
) )
modulation = models.ForeignKey( modulation = models.ForeignKey(
Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Модуляция" Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Модуляция"
) )
standard = models.ForeignKey( standard = models.ForeignKey(
Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Стандарт" Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Стандарт"
) )
frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц") frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц")
sym_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД") sym_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД")
last_update = models.DateTimeField(null=True, blank=True, verbose_name="Время") last_update = models.DateTimeField(null=True, blank=True, verbose_name="Дата посленего обновления")
channel_info = models.CharField(max_length=20, blank=True, null=True, verbose_name="Описание источника") channel_info = models.CharField(max_length=20, blank=True, null=True, verbose_name="Описание источника")
# url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу") fec = models.CharField(max_length=30, blank=True, null=True, verbose_name="Коэффициент коррекции ошибок")
url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу")
def __str__(self):
return f"Ист {self.frequency}, {self.polarization}" def __str__(self):
return f"Ист {self.frequency}, {self.polarization}"
class Meta:
verbose_name = "Источник LyngSat" class Meta:
verbose_name_plural = "Источники LyngSat" verbose_name = "Источник LyngSat"
verbose_name_plural = "Источники LyngSat"

View File

@@ -1,371 +1,437 @@
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from datetime import datetime from datetime import datetime
import re import re
import time import time
class LyngSatParser: def parse_satellite_names(satellite_string: str) -> list[str]:
"""Парсер данных для LyngSat(Для работы нужен flaresolver)""" slash_parts = [part.strip() for part in satellite_string.split('/')]
def __init__( all_names = []
self, for part in slash_parts:
flaresolver_url: str = "http://localhost:8191/v1", main_match = re.match(r'^([^(]+)', part)
regions: list[str] | None = None, if main_match:
target_sats: list[str] | None = None, main_name = main_match.group(1).strip()
): if main_name:
self.flaresolver_url = flaresolver_url all_names.append(main_name)
self.regions = regions bracket_match = re.search(r'\(([^)]+)\)', part)
self.target_sats = list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None if bracket_match:
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"] bracket_name = bracket_match.group(1).strip()
self.BASE_URL = "https://www.lyngsat.com" if bracket_name:
all_names.append(bracket_name)
def parse_metadata(self, metadata: str) -> dict: seen = set()
if not metadata or not metadata.strip(): result = []
return { for name in all_names:
'standard': None, if name not in seen:
'modulation': None, seen.add(name)
'symbol_rate': None, result.append(name.strip().lower())
'fec': None return result
}
normalized = re.sub(r'\s+', '', metadata.strip())
fec_match = re.search(r'([1-9]/[1-9])$', normalized) class LyngSatParser:
fec = fec_match.group(1) if fec_match else None """Парсер данных для LyngSat(Для работы нужен flaresolver)"""
if fec_match:
core = normalized[:fec_match.start()] def __init__(
else: self,
core = normalized flaresolver_url: str = "http://localhost:8191/v1",
std_match = re.match(r'(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)', core) regions: list[str] | None = None,
standard = std_match.group(1) if std_match else None target_sats: list[str] | None = None,
rest = core[len(standard):] if standard else core ):
modulation = None self.flaresolver_url = flaresolver_url
mod_match = re.match(r'(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)', rest) self.regions = regions
if mod_match: self.target_sats = (
modulation = mod_match.group(1) list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None
rest = rest[len(modulation):] )
symbol_rate = None self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
sr_match = re.search(r'(\d+)$', rest) self.BASE_URL = "https://www.lyngsat.com"
if sr_match:
try: def parse_metadata(self, metadata: str) -> dict:
symbol_rate = int(sr_match.group(1)) if not metadata or not metadata.strip():
except ValueError: return {
pass "standard": None,
"modulation": None,
return { "symbol_rate": None,
'standard': standard, "fec": None,
'modulation': modulation, }
'symbol_rate': symbol_rate, normalized = re.sub(r"\s+", "", metadata.strip())
'fec': fec fec_match = re.search(r"([1-9]/[1-9])$", normalized)
} fec = fec_match.group(1) if fec_match else None
if fec_match:
def extract_date(self, s: str) -> datetime | None: core = normalized[: fec_match.start()]
s = s.strip() else:
match = re.search(r'(\d{6})$', s) core = normalized
if not match: std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core)
return None standard = std_match.group(1) if std_match else None
yymmdd = match.group(1) rest = core[len(standard) :] if standard else core
try: modulation = None
return datetime.strptime(yymmdd, '%y%m%d').date() mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest)
except ValueError: if mod_match:
return None modulation = mod_match.group(1)
rest = rest[len(modulation) :]
def convert_polarization(self, polarization: str) -> str: symbol_rate = None
"""Преобразовать код поляризации в понятное название на русском""" sr_match = re.search(r"(\d+)$", rest)
polarization_map = { if sr_match:
'V': 'Вертикальная', try:
'H': 'Горизонтальная', symbol_rate = int(sr_match.group(1))
'R': 'Правая', except ValueError:
'L': 'Левая' pass
}
return polarization_map.get(polarization.upper(), polarization) return {
"standard": standard,
def get_region_pages(self) -> list[str]: "modulation": modulation,
html_regions = [] "symbol_rate": symbol_rate,
for region in self.regions: "fec": fec,
url = f"{self.BASE_URL}/{region}.html" }
payload = {
"cmd": "request.get", def extract_date(self, s: str) -> datetime | None:
"url": url, s = s.strip()
"maxTimeout": 60000 match = re.search(r"(\d{6})$", s)
} if not match:
response = requests.post(self.flaresolver_url, json=payload) return None
if response.status_code != 200: yymmdd = match.group(1)
continue try:
html_content = response.json().get("solution", {}).get("response", "") return datetime.strptime(yymmdd, "%y%m%d").date()
html_regions.append(html_content) except ValueError:
print(f"Обработал страницу по {region}") return None
return html_regions
def convert_polarization(self, polarization: str) -> str:
def get_satellites_data(self) -> dict[dict]: """Преобразовать код поляризации в понятное название на русском"""
sat_data = {} polarization_map = {
for region_page in self.get_region_pages(): "V": "Вертикальная",
soup = BeautifulSoup(region_page, "html.parser") "H": "Горизонтальная",
"R": "Правая",
col_table = soup.find_all("div", class_="desktab")[0] "L": "Левая",
}
tables = col_table.find_next_sibling('table').find_all('table') return polarization_map.get(polarization.upper(), polarization)
trs = []
for table in tables: def get_region_pages(self, regions: list[str] | None = None) -> list[str]:
trs.extend(table.find_all('tr')) html_regions = []
for tr in trs: if regions is None:
sat_name = tr.find('span').text regions = self.regions
if self.target_sats is not None: for region in regions:
if sat_name.strip().lower() not in self.target_sats: url = f"{self.BASE_URL}/{region}.html"
continue payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
try: response = requests.post(self.flaresolver_url, json=payload)
sat_url = tr.find_all('a')[2]['href'] if response.status_code != 200:
except IndexError: continue
sat_url = tr.find_all('a')[0]['href'] html_content = response.json().get("solution", {}).get("response", "")
html_regions.append(html_content)
update_date = tr.find_all('td')[-1].text print(f"Обработал страницу по {region}")
sat_response = requests.post(self.flaresolver_url, json={ return html_regions
"cmd": "request.get",
"url": f"{self.BASE_URL}/{sat_url}", def get_satellite_urls(self, html_regions: list[str]):
"maxTimeout": 60000 sat_names = []
}) sat_urls = []
html_content = sat_response.json().get("solution", {}).get("response", "") for region_page in html_regions:
sat_page_data = self.get_satellite_content(html_content) soup = BeautifulSoup(region_page, "html.parser")
sat_data[sat_name] = {
"url": f"{self.BASE_URL}/{sat_url}", col_table = soup.find_all("div", class_="desktab")[0]
"update_date": datetime.strptime(update_date, "%y%m%d").date(),
"sources": sat_page_data tables = col_table.find_next_sibling("table").find_all("table")
} trs = []
return sat_data for table in tables:
trs.extend(table.find_all("tr"))
def get_satellite_content(self, html_content: str) -> dict: for tr in trs:
sat_soup = BeautifulSoup(html_content, "html.parser") sat_name = tr.find("span").text
big_table = sat_soup.find('table', class_='bigtable') if self.target_sats is not None:
all_tables = big_table.find_all("div", class_="desktab")[:-1] if sat_name.strip().lower() not in self.target_sats:
data = [] continue
for table in all_tables: try:
trs = table.find_next_sibling('table').find_all('tr') sat_url = tr.find_all("a")[2]["href"]
for idx, tr in enumerate(trs): except IndexError:
tds = tr.find_all('td') sat_url = tr.find_all("a")[0]["href"]
if len(tds) < 9 or idx < 2: sat_names.append(sat_name)
continue sat_urls.append(sat_url)
freq, polarization = tds[0].find('b').text.strip().split('\xa0') return sat_names, sat_urls
polarization = self.convert_polarization(polarization)
meta = self.parse_metadata(tds[1].text) def get_satellites_data(self) -> dict[dict]:
provider_name = tds[3].text sat_data = {}
last_update = self.extract_date(tds[-1].text) for region_page in self.get_region_pages(self.regions):
data.append({ soup = BeautifulSoup(region_page, "html.parser")
"freq": freq,
"pol": polarization, col_table = soup.find_all("div", class_="desktab")[0]
"metadata": meta,
"provider_name": provider_name, tables = col_table.find_next_sibling("table").find_all("table")
"last_update": last_update trs = []
}) for table in tables:
return data trs.extend(table.find_all("tr"))
for tr in trs:
sat_name = tr.find("span").text.replace("ü", "u").strip().lower()
class KingOfSatParser: if self.target_sats is not None:
def __init__(self, base_url="https://ru.kingofsat.net", max_satellites=0): names = parse_satellite_names(sat_name)
""" if len(names) == 1:
Инициализация парсера sat_name = names[0]
:param base_url: Базовый URL сайта else:
:param max_satellites: Максимальное количество спутников для парсинга (0 - все) for name in names:
""" if name in self.target_sats:
self.base_url = base_url sat_name = name
self.max_satellites = max_satellites if sat_name not in self.target_sats:
self.session = requests.Session() continue
self.session.headers.update({ try:
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' sat_url = tr.find_all("a")[2]["href"]
}) except IndexError:
sat_url = tr.find_all("a")[0]["href"]
def convert_polarization(self, polarization):
"""Преобразовать код поляризации в понятное название на русском""" update_date = tr.find_all("td")[-1].text
polarization_map = { sat_response = requests.post(
'V': 'Вертикальная', self.flaresolver_url,
'H': 'Горизонтальная', json={
'R': 'Правая', "cmd": "request.get",
'L': 'Левая' "url": f"{self.BASE_URL}/{sat_url}",
} "maxTimeout": 60000,
return polarization_map.get(polarization.upper(), polarization) },
)
def fetch_page(self, url): html_content = (
"""Получить HTML страницу""" sat_response.json().get("solution", {}).get("response", "")
try: )
response = self.session.get(url, timeout=30) sat_page_data = self.get_satellite_content(html_content)
response.raise_for_status() sat_data[sat_name] = {
return response.text "url": f"{self.BASE_URL}/{sat_url}",
except Exception as e: "update_date": datetime.strptime(update_date, "%y%m%d").date(),
print(f"Ошибка при получении страницы {url}: {e}") "sources": sat_page_data,
return None }
return sat_data
def parse_satellite_table(self, html_content):
"""Распарсить таблицу со спутниками""" def get_satellite_content(self, html_content: str) -> list[dict]:
soup = BeautifulSoup(html_content, 'html.parser') data = []
satellites = [] sat_soup = BeautifulSoup(html_content, "html.parser")
table = soup.find('table') try:
if not table: big_table = sat_soup.find("table", class_="bigtable")
print("Таблица не найдена") all_tables = big_table.find_all("div", class_="desktab")[:-1]
return satellites for table in all_tables:
trs = table.find_next_sibling("table").find_all("tr")
rows = table.find_all('tr')[1:] for idx, tr in enumerate(trs):
tds = tr.find_all("td")
for row in rows: if len(tds) < 9 or idx < 2:
cols = row.find_all('td') continue
if len(cols) < 13: freq, polarization = tds[0].find("b").text.strip().split("\xa0")
continue polarization = self.convert_polarization(polarization)
meta = self.parse_metadata(tds[1].text)
try: provider_name = tds[3].text
position_cell = cols[0].text.strip() last_update = self.extract_date(tds[-1].text)
position_match = re.search(r'([\d\.]+)°([EW])', position_cell) data.append(
if position_match: {
position_value = position_match.group(1) "freq": freq,
position_direction = position_match.group(2) "pol": polarization,
position = f"{position_value}{position_direction}" "metadata": meta,
else: "provider_name": provider_name,
position = None "last_update": last_update,
}
# Название спутника (2-я колонка) )
satellite_cell = cols[1] except Exception as e:
satellite_name = satellite_cell.get_text(strip=True) print(e)
# Удаляем возможные лишние символы или пробелы return data if data else data[{}]
satellite_name = re.sub(r'\s+', ' ', satellite_name).strip()
# NORAD (3-я колонка) class KingOfSatParser:
norad = cols[2].text.strip() def __init__(self, base_url="https://ru.kingofsat.net", max_satellites=0):
if not norad or norad == "-": """
norad = None Инициализация парсера
:param base_url: Базовый URL сайта
ini_link = None :param max_satellites: Максимальное количество спутников для парсинга (0 - все)
ini_cell = cols[3] """
ini_img = ini_cell.find('img', src=lambda x: x and 'disquette.gif' in x) self.base_url = base_url
if ini_img and position: self.max_satellites = max_satellites
ini_link = f"https://ru.kingofsat.net/dl.php?pos={position}&fkhz=0" self.session = requests.Session()
self.session.headers.update(
update_date = cols[12].text.strip() if len(cols) > 12 else None {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
if satellite_name and ini_link and position: }
satellites.append({ )
'position': position,
'name': satellite_name, def convert_polarization(self, polarization):
'norad': norad, """Преобразовать код поляризации в понятное название на русском"""
'ini_url': ini_link, polarization_map = {
'update_date': update_date "V": "Вертикальная",
}) "H": "Горизонтальная",
"R": "Правая",
except Exception as e: "L": "Левая",
print(f"Ошибка при обработке строки таблицы: {e}") }
continue return polarization_map.get(polarization.upper(), polarization)
return satellites def fetch_page(self, url):
"""Получить HTML страницу"""
def parse_ini_file(self, ini_content): try:
"""Распарсить содержимое .ini файла""" response = self.session.get(url, timeout=30)
data = { response.raise_for_status()
'metadata': {}, return response.text
'sattype': {}, except Exception as e:
'dvb': {} print(f"Ошибка при получении страницы {url}: {e}")
} return None
# # Извлекаем метаданные из комментариев def parse_satellite_table(self, html_content):
# metadata_match = re.search(r'\[ downloaded from www\.kingofsat\.net \(c\) (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \]', ini_content) """Распарсить таблицу со спутниками"""
# if metadata_match: soup = BeautifulSoup(html_content, "html.parser")
# data['metadata']['downloaded'] = metadata_match.group(1) satellites = []
table = soup.find("table")
# Парсим секцию [SATTYPE] if not table:
sattype_match = re.search(r'\[SATTYPE\](.*?)\n\[', ini_content, re.DOTALL) print("Таблица не найдена")
if sattype_match: return satellites
sattype_content = sattype_match.group(1).strip()
for line in sattype_content.split('\n'): rows = table.find_all("tr")[1:]
line = line.strip()
if '=' in line: for row in rows:
key, value = line.split('=', 1) cols = row.find_all("td")
data['sattype'][key.strip()] = value.strip() if len(cols) < 13:
continue
# Парсим секцию [DVB]
dvb_match = re.search(r'\[DVB\](.*?)(?:\n\[|$)', ini_content, re.DOTALL) try:
if dvb_match: position_cell = cols[0].text.strip()
dvb_content = dvb_match.group(1).strip() position_match = re.search(r"([\d\.]+)°([EW])", position_cell)
for line in dvb_content.split('\n'): if position_match:
line = line.strip() position_value = position_match.group(1)
if '=' in line: position_direction = position_match.group(2)
key, value = line.split('=', 1) position = f"{position_value}{position_direction}"
params = [p.strip() for p in value.split(',')] else:
polarization = params[1] if len(params) > 1 else '' position = None
if polarization:
polarization = self.convert_polarization(polarization) # Название спутника (2-я колонка)
satellite_cell = cols[1]
data['dvb'][key.strip()] = { satellite_name = satellite_cell.get_text(strip=True)
'frequency': params[0] if len(params) > 0 else '', # Удаляем возможные лишние символы или пробелы
'polarization': polarization, satellite_name = re.sub(r"\s+", " ", satellite_name).strip()
'symbol_rate': params[2] if len(params) > 2 else '',
'fec': params[3] if len(params) > 3 else '', # NORAD (3-я колонка)
'standard': params[4] if len(params) > 4 else '', norad = cols[2].text.strip()
'modulation': params[5] if len(params) > 5 else '' if not norad or norad == "-":
} norad = None
return data ini_link = None
ini_cell = cols[3]
def download_ini_file(self, url): ini_img = ini_cell.find("img", src=lambda x: x and "disquette.gif" in x)
"""Скачать содержимое .ini файла""" if ini_img and position:
try: ini_link = f"https://ru.kingofsat.net/dl.php?pos={position}&fkhz=0"
response = self.session.get(url, timeout=30)
response.raise_for_status() update_date = cols[12].text.strip() if len(cols) > 12 else None
return response.text
except Exception as e: if satellite_name and ini_link and position:
print(f"Ошибка при скачивании .ini файла {url}: {e}") satellites.append(
return None {
"position": position,
def get_all_satellites_data(self): "name": satellite_name,
"""Получить данные всех спутников с учетом ограничения max_satellites""" "norad": norad,
html_content = self.fetch_page(self.base_url + '/satellites') "ini_url": ini_link,
if not html_content: "update_date": update_date,
return [] }
)
satellites = self.parse_satellite_table(html_content)
except Exception as e:
if self.max_satellites > 0 and len(satellites) > self.max_satellites: print(f"Ошибка при обработке строки таблицы: {e}")
satellites = satellites[:self.max_satellites] continue
results = [] return satellites
processed_count = 0
def parse_ini_file(self, ini_content):
for satellite in satellites: """Распарсить содержимое .ini файла"""
print(f"Обработка спутника: {satellite['name']} ({satellite['position']})") data = {"metadata": {}, "sattype": {}, "dvb": {}}
ini_content = self.download_ini_file(satellite['ini_url']) # # Извлекаем метаданные из комментариев
if not ini_content: # metadata_match = re.search(r'\[ downloaded from www\.kingofsat\.net \(c\) (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \]', ini_content)
print(f"Не удалось скачать .ini файл для {satellite['name']}") # if metadata_match:
continue # data['metadata']['downloaded'] = metadata_match.group(1)
parsed_ini = self.parse_ini_file(ini_content) # Парсим секцию [SATTYPE]
sattype_match = re.search(r"\[SATTYPE\](.*?)\n\[", ini_content, re.DOTALL)
result = { if sattype_match:
'satellite_name': satellite['name'], sattype_content = sattype_match.group(1).strip()
'position': satellite['position'], for line in sattype_content.split("\n"):
'norad': satellite['norad'], line = line.strip()
'update_date': satellite['update_date'], if "=" in line:
'ini_url': satellite['ini_url'], key, value = line.split("=", 1)
'ini_data': parsed_ini data["sattype"][key.strip()] = value.strip()
}
# Парсим секцию [DVB]
results.append(result) dvb_match = re.search(r"\[DVB\](.*?)(?:\n\[|$)", ini_content, re.DOTALL)
processed_count += 1 if dvb_match:
dvb_content = dvb_match.group(1).strip()
if self.max_satellites > 0 and processed_count >= self.max_satellites: for line in dvb_content.split("\n"):
break line = line.strip()
if "=" in line:
time.sleep(1) key, value = line.split("=", 1)
params = [p.strip() for p in value.split(",")]
return results polarization = params[1] if len(params) > 1 else ""
if polarization:
def create_satellite_dict(self, satellites_data): polarization = self.convert_polarization(polarization)
"""Создать словарь с данными спутников"""
satellite_dict = {} data["dvb"][key.strip()] = {
"frequency": params[0] if len(params) > 0 else "",
for data in satellites_data: "polarization": polarization,
key = f"{data['position']}_{data['satellite_name'].replace(' ', '_').replace('/', '_')}" "symbol_rate": params[2] if len(params) > 2 else "",
satellite_dict[key] = { "fec": params[3] if len(params) > 3 else "",
'name': data['satellite_name'], "standard": params[4] if len(params) > 4 else "",
'position': data['position'], "modulation": params[5] if len(params) > 5 else "",
'norad': data['norad'], }
'update_date': data['update_date'],
'ini_url': data['ini_url'], return data
'transponders_count': len(data['ini_data']['dvb']),
'transponders': data['ini_data']['dvb'], def download_ini_file(self, url):
'sattype_info': data['ini_data']['sattype'], """Скачать содержимое .ini файла"""
'metadata': data['ini_data']['metadata'] try:
} response = self.session.get(url, timeout=30)
response.raise_for_status()
return satellite_dict return response.text
except Exception as e:
print(f"Ошибка при скачивании .ini файла {url}: {e}")
return None
def get_all_satellites_data(self):
"""Получить данные всех спутников с учетом ограничения max_satellites"""
html_content = self.fetch_page(self.base_url + "/satellites")
if not html_content:
return []
satellites = self.parse_satellite_table(html_content)
if self.max_satellites > 0 and len(satellites) > self.max_satellites:
satellites = satellites[: self.max_satellites]
results = []
processed_count = 0
for satellite in satellites:
print(f"Обработка спутника: {satellite['name']} ({satellite['position']})")
ini_content = self.download_ini_file(satellite["ini_url"])
if not ini_content:
print(f"Не удалось скачать .ini файл для {satellite['name']}")
continue
parsed_ini = self.parse_ini_file(ini_content)
result = {
"satellite_name": satellite["name"],
"position": satellite["position"],
"norad": satellite["norad"],
"update_date": satellite["update_date"],
"ini_url": satellite["ini_url"],
"ini_data": parsed_ini,
}
results.append(result)
processed_count += 1
if self.max_satellites > 0 and processed_count >= self.max_satellites:
break
time.sleep(1)
return results
def create_satellite_dict(self, satellites_data):
"""Создать словарь с данными спутников"""
satellite_dict = {}
for data in satellites_data:
key = f"{data['position']}_{data['satellite_name'].replace(' ', '_').replace('/', '_')}"
satellite_dict[key] = {
"name": data["satellite_name"],
"position": data["position"],
"norad": data["norad"],
"update_date": data["update_date"],
"ini_url": data["ini_url"],
"transponders_count": len(data["ini_data"]["dvb"]),
"transponders": data["ini_data"]["dvb"],
"sattype_info": data["ini_data"]["sattype"],
"metadata": data["ini_data"]["metadata"],
}
return satellite_dict

201
dbapp/lyngsatapp/tasks.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Celery tasks for Lyngsat data processing.
"""
import logging
from celery import shared_task
from django.core.cache import cache
from .utils import fill_lyngsat_data
from .async_utils import fill_lyngsat_data_async, clear_lyngsat_cache
logger = logging.getLogger(__name__)
@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async')
def fill_lyngsat_data_task(self, target_sats, regions=None, force_refresh=False, use_cache=True):
"""
Асинхронная задача для заполнения данных Lyngsat с кешированием.
Обрабатывает спутники по одному.
Args:
target_sats: Список названий спутников для обработки
regions: Список регионов для парсинга (по умолчанию все)
force_refresh: Принудительно обновить кеш
use_cache: Использовать ли кеширование
Returns:
dict: Статистика обработки
"""
task_id = self.request.id
logger.info(f"[Task {task_id}] Начало обработки данных Lyngsat")
logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}")
logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}")
logger.info(f"[Task {task_id}] Кеширование: {use_cache}, Принудительное обновление: {force_refresh}")
# Обновляем статус задачи
self.update_state(
state='PROGRESS',
meta={
'current': 0,
'total': len(target_sats),
'status': 'Инициализация...',
'details': {}
}
)
try:
# Вызываем асинхронную функцию заполнения данных
stats = fill_lyngsat_data_async(
target_sats=target_sats,
regions=regions,
task_id=task_id,
force_refresh=force_refresh,
use_cache=use_cache,
update_progress=lambda current, total, status, details: self.update_state(
state='PROGRESS',
meta={
'current': current,
'total': total,
'status': status,
'details': details
}
)
)
logger.info(f"[Task {task_id}] Обработка завершена успешно")
logger.info(f"[Task {task_id}] Статистика: {stats}")
# Сохраняем результат в кеш для отображения на странице
cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600)
return stats
except Exception as e:
logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True)
error_message = f"{type(e).__name__}: {str(e)}"
self.update_state(
state='FAILURE',
meta={
'error': error_message,
'status': 'Ошибка при обработке',
'details': {},
'exc_type': type(e).__name__,
'exc_message': str(e)
}
)
# Возвращаем словарь с ошибкой вместо raise для корректной сериализации
return {
'error': error_message,
'status': 'FAILURE',
'total_satellites': 0,
'processed_satellites': 0,
'total_sources': 0,
'created': 0,
'updated': 0,
'errors': [error_message]
}
@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_sync')
def fill_lyngsat_data_task_sync(self, target_sats, regions=None):
"""
Синхронная задача для заполнения данных Lyngsat (старая версия без кеширования).
Используется для обратной совместимости.
Args:
target_sats: Список названий спутников для обработки
regions: Список регионов для парсинга (по умолчанию все)
Returns:
dict: Статистика обработки
"""
task_id = self.request.id
logger.info(f"[Task {task_id}] Начало синхронной обработки данных Lyngsat")
logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}")
logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}")
# Обновляем статус задачи
self.update_state(
state='PROGRESS',
meta={
'current': 0,
'total': len(target_sats),
'status': 'Инициализация...'
}
)
try:
# Вызываем старую функцию заполнения данных
stats = fill_lyngsat_data(
target_sats=target_sats,
regions=regions,
task_id=task_id,
update_progress=lambda current, total, status: self.update_state(
state='PROGRESS',
meta={
'current': current,
'total': total,
'status': status
}
)
)
logger.info(f"[Task {task_id}] Обработка завершена успешно")
logger.info(f"[Task {task_id}] Статистика: {stats}")
# Сохраняем результат в кеш для отображения на странице
cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600)
return stats
except Exception as e:
logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True)
error_message = f"{type(e).__name__}: {str(e)}"
self.update_state(
state='FAILURE',
meta={
'error': error_message,
'status': 'Ошибка при обработке',
'exc_type': type(e).__name__,
'exc_message': str(e)
}
)
# Возвращаем словарь с ошибкой вместо raise для корректной сериализации
return {
'error': error_message,
'status': 'FAILURE',
'total_satellites': 0,
'total_sources': 0,
'created': 0,
'updated': 0,
'errors': [error_message]
}
@shared_task(bind=True, name='lyngsatapp.clear_cache')
def clear_cache_task(self, cache_type='all'):
"""
Задача для очистки кеша LyngSat.
Args:
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
Returns:
dict: Статистика очистки
"""
task_id = self.request.id
logger.info(f"[Task {task_id}] Запуск задачи очистки кеша: {cache_type}")
try:
stats = clear_lyngsat_cache(cache_type)
logger.info(f"[Task {task_id}] Кеш очищен успешно: {stats}")
return stats
except Exception as e:
logger.error(f"[Task {task_id}] Ошибка при очистке кеша: {str(e)}", exc_info=True)
error_message = f"{type(e).__name__}: {str(e)}"
return {
'error': error_message,
'status': 'FAILURE',
'cleared': 0,
'errors': [error_message]
}

View File

@@ -0,0 +1,151 @@
{% extends 'mainapp/base.html' %}
{% block title %}Источники LyngSat{% endblock %}
{% block extra_css %}
<style>
.table-responsive tr.selected {
background-color: #d4edff;
}
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<!-- Page Header -->
<div class="row mb-3">
<div class="col-12">
<h2>Данные по ИРИ с ресурса LyngSat</h2>
</div>
</div>
<!-- Toolbar Component -->
<div class="row mb-3">
<div class="col-12">
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True search_placeholder="Поиск по ID..." action_buttons=action_buttons_html %}
</div>
</div>
<!-- Filter Panel Component -->
{% include 'mainapp/components/_filter_panel.html' with filters=filter_html_list %}
<!-- Main Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="min-width: 60px;">
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
</th>
<th scope="col" style="min-width: 120px;">Спутник</th>
<th scope="col" style="min-width: 100px;">
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
</th>
<th scope="col" style="min-width: 100px;">Поляризация</th>
<th scope="col" style="min-width: 120px;">
{% include 'mainapp/components/_sort_header.html' with field='sym_velocity' label='Сим. скорость, БОД' current_sort=sort %}
</th>
<th scope="col" style="min-width: 100px;">Модуляция</th>
<th scope="col" style="min-width: 100px;">Стандарт</th>
<th scope="col" style="min-width: 80px;">FEC</th>
<th scope="col" style="min-width: 150px;">Описание</th>
<th scope="col" style="min-width: 120px;">
{% include 'mainapp/components/_sort_header.html' with field='last_update' label='Обновлено' current_sort=sort %}
</th>
<th scope="col" style="min-width: 100px;">Ссылка</th>
</tr>
</thead>
<tbody>
{% for item in lyngsat_items %}
<tr>
<td class="text-center">{{ item.id }}</td>
<td>
{% if item.id_satellite %}
<a href="#" class="text-decoration-underline"
onclick="showSatelliteModal({{ item.id_satellite.id }}); return false;">
{{ item.id_satellite.name }}
</a>
{% else %}
-
{% endif %}
</td>
<td>{{ item.frequency|floatformat:3|default:"-" }}</td>
<td>{{ item.polarization.name|default:"-" }}</td>
<td>{{ item.sym_velocity|floatformat:0|default:"-" }}</td>
<td>{{ item.modulation.name|default:"-" }}</td>
<td>{{ item.standard.name|default:"-" }}</td>
<td>{{ item.fec|default:"-" }}</td>
<td>{{ item.channel_info|default:"-" }}</td>
<td>{{ item.last_update|date:"d.m.Y"|default:"-" }}</td>
<td>
{% if item.url %}
<a href="{{ item.url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Открыть ссылку">
<i class="bi bi-link-45deg"></i>
</a>
{% else %}
-
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% load static %}
<!-- Include sorting functionality -->
<script src="{% static 'js/sorting.js' %}"></script>
<script>
// Function to select/deselect all options in a select element
function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
}
// Enhanced filter counter for multi-select fields
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('filter-form');
if (form) {
// Add event listeners to multi-select fields
const selectFields = form.querySelectorAll('select[multiple]');
selectFields.forEach(select => {
select.addEventListener('change', function() {
// Trigger the filter counter update from _filter_panel.html
const event = new Event('change', { bubbles: true });
form.dispatchEvent(event);
});
});
}
});
</script>
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
{% endblock %}

View File

@@ -1,3 +1,3 @@
from django.test import TestCase from django.test import TestCase
# Create your tests here. # Create your tests here.

8
dbapp/lyngsatapp/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'lyngsatapp'
urlpatterns = [
path('', views.LyngSatListView.as_view(), name='lyngsat_list'),
]

180
dbapp/lyngsatapp/utils.py Normal file
View File

@@ -0,0 +1,180 @@
import logging
from .parser import LyngSatParser
from .models import LyngSat
from mainapp.models import Polarization, Standard, Modulation, Satellite
from dbapp.settings.base import FLARESOLVERR_URL
logger = logging.getLogger(__name__)
def fill_lyngsat_data(
target_sats: list[str],
regions: list[str] = None,
task_id: str = None,
update_progress=None
):
"""
Заполняет данные Lyngsat для указанных спутников и регионов.
Args:
target_sats: Список названий спутников для обработки
regions: Список регионов для парсинга (по умолчанию все)
task_id: ID задачи Celery для логирования
update_progress: Функция для обновления прогресса (current, total, status)
Returns:
dict: Статистика обработки с ключами:
- total_satellites: общее количество спутников
- total_sources: общее количество источников
- created: количество созданных записей
- updated: количество обновленных записей
- errors: список ошибок
"""
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]"
stats = {
'total_satellites': 0,
'total_sources': 0,
'created': 0,
'updated': 0,
'errors': []
}
if regions is None:
regions = ["europe", "asia", "america", "atlantic"]
logger.info(f"{log_prefix} Начало парсинга данных")
logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}")
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}")
if update_progress:
update_progress(0, len(target_sats), "Инициализация парсера...")
try:
parser = LyngSatParser(
flaresolver_url=FLARESOLVERR_URL,
target_sats=target_sats,
regions=regions
)
logger.info(f"{log_prefix} Получение данных со спутников...")
if update_progress:
update_progress(0, len(target_sats), "Получение данных со спутников...")
lyngsat_data = parser.get_satellites_data()
stats['total_satellites'] = len(lyngsat_data)
logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам")
for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1):
logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}")
if update_progress:
update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...")
url = data['url']
sources = data['sources']
stats['total_sources'] += len(sources)
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
# Находим спутник в базе по имени или альтернативному имени (lowercase)
from django.db.models import Q
sat_name_lower = sat_name.lower()
try:
sat_obj = Satellite.objects.get(
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
)
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
except Satellite.DoesNotExist:
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
logger.warning(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg)
continue
except Satellite.MultipleObjectsReturned:
error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
logger.warning(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg)
continue
for source_idx, source in enumerate(sources, 1):
try:
# Парсим частоту
try:
freq = float(source['freq'])
except (ValueError, TypeError):
freq = -1.0
error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}"
logger.debug(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg)
last_update = source['last_update']
fec = source['metadata'].get('fec')
modulation_name = source['metadata'].get('modulation')
standard_name = source['metadata'].get('standard')
symbol_velocity = source['metadata'].get('symbol_rate')
polarization_name = source['pol']
channel_info = source['provider_name']
# Создаем или получаем связанные объекты
pol_obj, _ = Polarization.objects.get_or_create(
name=polarization_name if polarization_name else "-"
)
mod_obj, _ = Modulation.objects.get_or_create(
name=modulation_name if modulation_name else "-"
)
standard_obj, _ = Standard.objects.get_or_create(
name=standard_name if standard_name else "-"
)
# Создаем или обновляем запись Lyngsat
lyng_obj, created = LyngSat.objects.update_or_create(
id_satellite=sat_obj,
frequency=freq,
polarization=pol_obj,
defaults={
"modulation": mod_obj,
"standard": standard_obj,
"sym_velocity": symbol_velocity if symbol_velocity else 0,
"channel_info": channel_info[:20] if channel_info else "",
"last_update": last_update,
"fec": fec[:30] if fec else "",
"url": url
}
)
if created:
stats['created'] += 1
logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц")
else:
stats['updated'] += 1
logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц")
# Логируем прогресс каждые 10 источников
if source_idx % 10 == 0:
logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}")
except Exception as e:
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
stats['errors'].append(error_msg)
continue
logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
except Exception as e:
error_msg = f"Критическая ошибка: {str(e)}"
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
stats['errors'].append(error_msg)
logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}")
if update_progress:
update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено")
return stats
def link_lyngsat_to_sources():
pass

View File

@@ -1,3 +1,285 @@
from django.shortcuts import render from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Q
from django.views.generic import ListView
# Create your views here. from .models import LyngSat
from mainapp.models import Satellite, Polarization, Modulation, Standard
from mainapp.utils import parse_pagination_params
class LyngSatListView(LoginRequiredMixin, ListView):
"""
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
"""
model = LyngSat
template_name = 'lyngsatapp/lyngsat_list.html'
context_object_name = 'lyngsat_items'
paginate_by = 50
def get_queryset(self):
"""
Возвращает отфильтрованный и отсортированный queryset.
"""
queryset = LyngSat.objects.select_related(
'id_satellite',
'polarization',
'modulation',
'standard'
).all()
# Поиск по ID
search_query = self.request.GET.get('search', '').strip()
if search_query:
try:
search_id = int(search_query)
queryset = queryset.filter(id=search_id)
except ValueError:
queryset = queryset.none()
# Фильтр по спутнику
satellite_ids = self.request.GET.getlist('satellite_id')
if satellite_ids:
queryset = queryset.filter(id_satellite_id__in=satellite_ids)
# Фильтр по поляризации
polarization_ids = self.request.GET.getlist('polarization_id')
if polarization_ids:
queryset = queryset.filter(polarization_id__in=polarization_ids)
# Фильтр по модуляции
modulation_ids = self.request.GET.getlist('modulation_id')
if modulation_ids:
queryset = queryset.filter(modulation_id__in=modulation_ids)
# Фильтр по стандарту
standard_ids = self.request.GET.getlist('standard_id')
if standard_ids:
queryset = queryset.filter(standard_id__in=standard_ids)
# Фильтр по частоте
freq_min = self.request.GET.get('freq_min', '').strip()
freq_max = self.request.GET.get('freq_max', '').strip()
if freq_min:
try:
queryset = queryset.filter(frequency__gte=float(freq_min))
except ValueError:
pass
if freq_max:
try:
queryset = queryset.filter(frequency__lte=float(freq_max))
except ValueError:
pass
# Фильтр по символьной скорости
sym_min = self.request.GET.get('sym_min', '').strip()
sym_max = self.request.GET.get('sym_max', '').strip()
if sym_min:
try:
queryset = queryset.filter(sym_velocity__gte=float(sym_min))
except ValueError:
pass
if sym_max:
try:
queryset = queryset.filter(sym_velocity__lte=float(sym_max))
except ValueError:
pass
# Фильтр по дате обновления
date_from = self.request.GET.get('date_from', '').strip()
date_to = self.request.GET.get('date_to', '').strip()
if date_from:
queryset = queryset.filter(last_update__gte=date_from)
if date_to:
queryset = queryset.filter(last_update__lte=date_to)
# Сортировка
sort = self.request.GET.get('sort', '-id')
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
if sort in valid_sort_fields:
queryset = queryset.order_by(sort)
else:
queryset = queryset.order_by('-id')
return queryset
def get_context_data(self, **kwargs):
"""
Добавляет дополнительный контекст для шаблона.
"""
context = super().get_context_data(**kwargs)
# Параметры пагинации
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
context['items_per_page'] = items_per_page
context['available_items_per_page'] = [25, 50, 100, 200, 500]
# Пагинация
paginator = Paginator(self.get_queryset(), items_per_page)
page_obj = paginator.get_page(page_number)
context['page_obj'] = page_obj
context['lyngsat_items'] = page_obj.object_list
# Параметры поиска и фильтрации
context['search_query'] = self.request.GET.get('search', '')
context['sort'] = self.request.GET.get('sort', '-id')
# Данные для фильтров - только спутники с существующими записями LyngSat
satellites = Satellite.objects.filter(
lyngsat__isnull=False
).distinct().order_by('name')
polarizations = Polarization.objects.all().order_by('name')
modulations = Modulation.objects.all().order_by('name')
standards = Standard.objects.all().order_by('name')
# Выбранные фильтры
selected_satellites = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
selected_polarizations = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()]
selected_modulations = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()]
selected_standards = [int(x) for x in self.request.GET.getlist('standard_id') if x.isdigit()]
# Параметры фильтров
freq_min = self.request.GET.get('freq_min', '')
freq_max = self.request.GET.get('freq_max', '')
sym_min = self.request.GET.get('sym_min', '')
sym_max = self.request.GET.get('sym_max', '')
date_from = self.request.GET.get('date_from', '')
date_to = self.request.GET.get('date_to', '')
# Action buttons HTML for toolbar component
from django.urls import reverse
action_buttons_html = f'''
<a href="{reverse('mainapp:fill_lyngsat_data')}" class="btn btn-secondary btn-sm" title="Заполнить данные Lyngsat">
<i class="bi bi-cloud-download"></i> Добавить данные
</a>
<a href="{reverse('mainapp:link_lyngsat')}" class="btn btn-primary btn-sm" title="Привязать источники LyngSat">
<i class="bi bi-link-45deg"></i> Привязать
</a>
<a href="{reverse('mainapp:unlink_all_lyngsat')}" class="btn btn-warning btn-sm" title="Отвязать все источники LyngSat">
<i class="bi bi-x-circle"></i> Отвязать
</a>
'''
context['action_buttons_html'] = action_buttons_html
# Build filter HTML list for filter_panel component
filter_html_list = []
# Satellite filter
satellite_options = ''.join([
f'<option value="{sat.id}" {"selected" if sat.id in selected_satellites else ""}>{sat.name}</option>'
for sat in satellites
])
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button>
</div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{satellite_options}
</select>
</div>
''')
# Polarization filter
polarization_options = ''.join([
f'<option value="{pol.id}" {"selected" if pol.id in selected_polarizations else ""}>{pol.name}</option>'
for pol in polarizations
])
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Поляризация:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization_id', false)">Снять</button>
</div>
<select name="polarization_id" class="form-select form-select-sm mb-2" multiple size="4">
{polarization_options}
</select>
</div>
''')
# Modulation filter
modulation_options = ''.join([
f'<option value="{mod.id}" {"selected" if mod.id in selected_modulations else ""}>{mod.name}</option>'
for mod in modulations
])
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Модуляция:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation_id', false)">Снять</button>
</div>
<select name="modulation_id" class="form-select form-select-sm mb-2" multiple size="4">
{modulation_options}
</select>
</div>
''')
# Standard filter
standard_options = ''.join([
f'<option value="{std.id}" {"selected" if std.id in selected_standards else ""}>{std.name}</option>'
for std in standards
])
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Стандарт:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('standard_id', false)">Снять</button>
</div>
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
{standard_options}
</select>
</div>
''')
# Frequency filter
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{freq_min}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
placeholder="До" value="{freq_max}">
</div>
''')
# Symbol rate filter
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Символьная скорость, БОД:</label>
<input type="number" step="0.001" name="sym_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{sym_min}">
<input type="number" step="0.001" name="sym_max" class="form-control form-control-sm"
placeholder="До" value="{sym_max}">
</div>
''')
# Date filter
filter_html_list.append(f'''
<div class="mb-2">
<label class="form-label">Дата обновления:</label>
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{date_from}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
placeholder="До" value="{date_to}">
</div>
''')
context['filter_html_list'] = filter_html_list
# Enable full width layout
context['full_width_page'] = True
return context

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
from .models import ObjItem
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
import numpy as np
import matplotlib.pyplot as plt
def get_clusters(coords: list[tuple[float, float]]):
coords = np.radians(coords)
lat, lon = coords[:, 0], coords[:, 1]
db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine')
# db = HDBSCAN()
cluster_labels = db.fit_predict(coords)
plt.figure(figsize=(10, 8))
unique_labels = set(cluster_labels)
colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
for label, color in zip(unique_labels, colors):
if label == -1:
color = 'k'
label_name = 'Шум'
else:
label_name = f'Кластер {label}'
mask = cluster_labels == label
plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30)
plt.xlabel('Долгота')
plt.ylabel('Широта')
plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)')
plt.legend()
plt.grid(True)
plt.show()

View File

@@ -1,73 +1,76 @@
from django.contrib.admin import SimpleListFilter # Django imports
from .models import ObjItem from django.contrib.admin import SimpleListFilter
class GeoKupDistanceFilter(SimpleListFilter): # Local imports
title = 'Расстояние между гео и кубсатом' from .models import ObjItem
parameter_name = 'distance_geo_kup'
class GeoKupDistanceFilter(SimpleListFilter):
def lookups(self, request, model_admin): title = 'Расстояние между гео и кубсатом'
return ( parameter_name = 'distance_geo_kup'
('small', 'Меньше 100 км'),
('medium', '100-500 км'), def lookups(self, request, model_admin):
('large', 'Больше 500 км'), return (
) ('small', 'Меньше 100 км'),
('medium', '100-500 км'),
def queryset(self, request, queryset): ('large', 'Больше 500 км'),
if self.value() == 'small': )
return queryset.filter(distance_coords_kup__lt=100)
if self.value() == 'medium': def queryset(self, request, queryset):
return queryset.filter(distance_coords_kup__gte=100, distance_coords_kup__lte=500) if self.value() == 'small':
if self.value() == 'large': return queryset.filter(distance_coords_kup__lt=100)
return queryset.filter(distance_coords_kup__gt=500) if self.value() == 'medium':
return queryset.filter(distance_coords_kup__gte=100, distance_coords_kup__lte=500)
if self.value() == 'large':
class GeoValidDistanceFilter(SimpleListFilter): return queryset.filter(distance_coords_kup__gt=500)
title = 'Расстояние между гео и оперативным отделом'
parameter_name = 'distance_geo_valid'
class GeoValidDistanceFilter(SimpleListFilter):
def lookups(self, request, model_admin): title = 'Расстояние между гео и оперативным отделом'
return ( parameter_name = 'distance_geo_valid'
('small', 'Меньше 100 км'),
('medium', '100-500 км'), def lookups(self, request, model_admin):
('large', 'Больше 500 км'), return (
) ('small', 'Меньше 100 км'),
('medium', '100-500 км'),
def queryset(self, request, queryset): ('large', 'Больше 500 км'),
if self.value() == 'small': )
return queryset.filter(distance_coords_valid__lt=100)
if self.value() == 'medium': def queryset(self, request, queryset):
return queryset.filter(distance_coords_valid__gte=100, distance_coords_valid__lte=500) if self.value() == 'small':
if self.value() == 'large': return queryset.filter(distance_coords_valid__lt=100)
return queryset.filter(distance_coords_valid__gt=500) if self.value() == 'medium':
return queryset.filter(distance_coords_valid__gte=100, distance_coords_valid__lte=500)
class UniqueToggleFilter(SimpleListFilter): if self.value() == 'large':
title = 'Уникальность по имени' return queryset.filter(distance_coords_valid__gt=500)
parameter_name = 'name'
class UniqueToggleFilter(SimpleListFilter):
def lookups(self, request, model_admin): title = 'Уникальность по имени'
return ( parameter_name = 'name'
('unique', 'Только уникальные'),
('all', 'Все'), def lookups(self, request, model_admin):
) return (
('unique', 'Только уникальные'),
def queryset(self, request, queryset): ('all', 'Все'),
if self.value() == 'unique': )
return queryset.order_by('name').distinct('name')
return queryset def queryset(self, request, queryset):
if self.value() == 'unique':
class HasSigmaParameterFilter(SimpleListFilter): return queryset.order_by('name').distinct('name')
title = 'ВЧ sigma' return queryset
parameter_name = 'has_sigma'
class HasSigmaParameterFilter(SimpleListFilter):
def lookups(self, request, model_admin): title = 'ВЧ sigma'
return ( parameter_name = 'has_sigma'
('yes', 'Заполнено'),
('no', 'Пусто'), def lookups(self, request, model_admin):
) return (
('yes', 'Заполнено'),
def queryset(self, request, queryset): ('no', 'Пусто'),
if self.value() == 'yes': )
return queryset.filter(sigma_parameter__isnull=False)
if self.value() == 'no': def queryset(self, request, queryset):
return queryset.filter(sigma_parameter__isnull=True) if self.value() == 'yes':
return queryset.filter(sigma_parameter__isnull=False)
if self.value() == 'no':
return queryset.filter(sigma_parameter__isnull=True)
return queryset return queryset

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
# Management commands package

View File

@@ -0,0 +1 @@
# Commands package

View File

@@ -0,0 +1,169 @@
"""
Management command для генерации тестовых отметок сигналов.
Использование:
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
Параметры:
--satellite_id: ID спутника (обязательный)
--user_id: ID пользователя CustomUser (обязательный)
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
--clear: Удалить существующие отметки перед генерацией
Особенности:
- Генерирует отметки только в будние дни (пн-пт)
- Время отметок: утро с 8:00 до 11:00
- Одна отметка в день для всех сигналов спутника
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
- Все отметки имеют значение True (сигнал присутствует)
"""
import random
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
class Command(BaseCommand):
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
def add_arguments(self, parser):
parser.add_argument(
'--satellite_id',
type=int,
required=True,
help='ID спутника для генерации отметок'
)
parser.add_argument(
'--user_id',
type=int,
required=True,
help='ID пользователя CustomUser - автор всех отметок'
)
parser.add_argument(
'--date_range',
type=str,
required=True,
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
)
parser.add_argument(
'--clear',
action='store_true',
help='Удалить существующие отметки перед генерацией'
)
def handle(self, *args, **options):
satellite_id = options['satellite_id']
user_id = options['user_id']
date_range = options['date_range']
clear = options['clear']
# Проверяем существование пользователя
try:
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
except CustomUser.DoesNotExist:
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
# Парсим диапазон дат
try:
start_str, end_str = date_range.split('-')
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
# Делаем timezone-aware
start_date = timezone.make_aware(start_date)
end_date = timezone.make_aware(end_date)
if start_date > end_date:
raise CommandError('Начальная дата должна быть раньше конечной')
except ValueError as e:
raise CommandError(
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
)
# Проверяем существование спутника
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
raise CommandError(f'Спутник с ID {satellite_id} не найден')
# Получаем теханализы для спутника
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
ta_count = len(tech_analyzes)
if ta_count == 0:
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
self.stdout.write(f'Спутник: {satellite.name}')
self.stdout.write(f'Теханализов: {ta_count}')
self.stdout.write(f'Пользователь: {custom_user}')
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
self.stdout.write(f'Время: 8:00 - 11:00')
# Удаляем существующие отметки если указан флаг
if clear:
deleted_count = ObjectMark.objects.filter(
tech_analyze__satellite=satellite
).delete()[0]
self.stdout.write(
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
)
# Генерируем отметки
total_marks = 0
marks_to_create = []
workdays_count = 0
current_date = start_date
# Включаем конечную дату в диапазон
end_date_inclusive = end_date + timedelta(days=1)
while current_date < end_date_inclusive:
# Проверяем, что это будний день (0=пн, 4=пт)
if current_date.weekday() < 5:
workdays_count += 1
# Генерируем случайное время в диапазоне 8:00-11:00
random_hour = random.randint(8, 10)
random_minute = random.randint(0, 59)
random_second = random.randint(0, 59)
mark_time = current_date.replace(
hour=random_hour,
minute=random_minute,
second=random_second,
microsecond=0
)
# Создаём отметки для всех теханализов с одинаковым timestamp
for ta in tech_analyzes:
marks_to_create.append(ObjectMark(
tech_analyze=ta,
mark=True, # Всегда True
timestamp=mark_time,
created_by=custom_user,
))
total_marks += 1
current_date += timedelta(days=1)
# Bulk create для производительности
self.stdout.write(f'Рабочих дней: {workdays_count}')
self.stdout.write(f'Создание {total_marks} отметок...')
# Создаём партиями по 1000
batch_size = 1000
for i in range(0, len(marks_to_create), batch_size):
batch = marks_to_create[i:i + batch_size]
ObjectMark.objects.bulk_create(batch)
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
self.stdout.write(
self.style.SUCCESS(
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
)
)

View File

@@ -0,0 +1,24 @@
from django.core.management.base import BaseCommand
from mainapp.tasks import test_celery_connection, add_numbers
class Command(BaseCommand):
help = 'Test Celery functionality'
def handle(self, *args, **options):
self.stdout.write('Testing Celery connection...')
# Test simple task
result = test_celery_connection.delay("Hello from test command!")
self.stdout.write(f'Task ID: {result.id}')
# Wait for result
task_result = result.get(timeout=10)
self.stdout.write(self.style.SUCCESS(f'Task result: {task_result}'))
# Test math task
math_result = add_numbers.delay(10, 20)
sum_result = math_result.get(timeout=10)
self.stdout.write(self.style.SUCCESS(f'10 + 20 = {sum_result}'))
self.stdout.write(self.style.SUCCESS('All tests passed!'))

View File

@@ -1,10 +1,9 @@
# Generated by Django 5.2.7 on 2025-10-31 13:36 # Generated by Django 5.2.7 on 2025-11-12 14:21
import django.contrib.gis.db.models.fields import django.contrib.gis.db.models.fields
import django.contrib.gis.db.models.functions import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import django.db.models.expressions import django.db.models.expressions
import mainapp.models
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -14,116 +13,57 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('lyngsatapp', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel(
name='Band',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Название диапазона', max_length=50, unique=True, verbose_name='Название')),
('border_start', models.FloatField(blank=True, null=True, verbose_name='Нижняя граница диапазона, МГц')),
('border_end', models.FloatField(blank=True, null=True, verbose_name='Верхняя граница диапазона, МГц')),
],
options={
'verbose_name': 'Диапазон',
'verbose_name_plural': 'Диапазоны',
'ordering': ['name'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Mirror', name='Mirror',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30, unique=True, verbose_name='Имя зеркала')), ('name', models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала')),
], ],
options={ options={
'verbose_name': 'Зеркало', 'verbose_name': 'Зеркало',
'verbose_name_plural': 'Зеркала', 'verbose_name_plural': 'Зеркала',
'ordering': ['name'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Modulation', name='Modulation',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Модуляция')), ('name', models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция')),
], ],
options={ options={
'verbose_name': 'Модуляция', 'verbose_name': 'Модуляция',
'verbose_name_plural': 'Модуляции', 'verbose_name_plural': 'Модуляции',
}, 'ordering': ['name'],
),
migrations.CreateModel(
name='Polarization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True, verbose_name='Поляризация')),
],
options={
'verbose_name': 'Поляризация',
'verbose_name_plural': 'Поляризация',
},
),
migrations.CreateModel(
name='Satellite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Имя спутника')),
('norad', models.IntegerField(blank=True, null=True, verbose_name='NORAD ID')),
],
options={
'verbose_name': 'Спутник',
'verbose_name_plural': 'Спутники',
},
),
migrations.CreateModel(
name='SigmaParMark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mark', models.BooleanField(blank=True, null=True, verbose_name='Наличие сигнала')),
('timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Время')),
],
options={
'verbose_name': 'Отметка',
'verbose_name_plural': 'Отметки',
},
),
migrations.CreateModel(
name='Standard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True, verbose_name='Стандарт')),
],
options={
'verbose_name': 'Стандарт',
'verbose_name_plural': 'Стандарты',
},
),
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], default='user', max_length=20, verbose_name='Роль пользователя')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Пользователь',
'verbose_name_plural': 'Пользователи',
},
),
migrations.CreateModel(
name='ObjItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Имя объекта')),
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='mainapp.customuser', verbose_name='Пользователь')),
],
options={
'verbose_name': 'Объект',
'verbose_name_plural': 'Объекты',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Parameter', name='Parameter',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')), ('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')), ('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), ('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД')),
('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ')), ('snr', models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум', null=True, verbose_name='ОСШ')),
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parameter_added', to='mainapp.customuser', verbose_name='Пользователь')),
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция')),
('objitems', models.ManyToManyField(blank=True, related_name='parameters_obj', to='mainapp.objitem', verbose_name='Источники')),
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник')),
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт')),
], ],
options={ options={
'verbose_name': 'ВЧ загрузка', 'verbose_name': 'ВЧ загрузка',
@@ -131,74 +71,141 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SourceType', name='Polarization',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True, verbose_name='Тип источника')), ('name', models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация')),
('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Гео')),
], ],
options={ options={
'verbose_name': 'Тип источника', 'verbose_name': 'Поляризация',
'verbose_name_plural': 'Типы источников', 'verbose_name_plural': 'Поляризация',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Satellite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника')),
('norad', models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID')),
('undersat_point', models.FloatField(blank=True, help_text='Подспутниковая точка в градусах. Восточное полушарие с +, западное с -', null=True, verbose_name='Подспутниковая точка, градусы')),
('url', models.URLField(blank=True, help_text='Ссылка на сайт, где можно проверить информацию', null=True, verbose_name='Ссылка на источник')),
('comment', models.TextField(blank=True, help_text='Любой возможный комменатрий', null=True, verbose_name='Комментарий')),
('launch_date', models.DateField(blank=True, help_text='Дата запуска спутника', null=True, verbose_name='Дата запуска')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
],
options={
'verbose_name': 'Спутник',
'verbose_name_plural': 'Спутники',
'ordering': ['name'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SigmaParameter', name='SigmaParameter',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, verbose_name='Перенос по частоте')), ('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте')),
('status', models.CharField(blank=True, max_length=20, null=True, verbose_name='Статус')), ('status', models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус')),
('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')), ('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
('transfer_frequency', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.expressions.CombinedExpression(models.F('frequency'), '+', models.F('transfer')), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Частота в Ku, МГц')), ('transfer_frequency', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.expressions.CombinedExpression(models.F('frequency'), '+', models.F('transfer')), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Частота в Ku, МГц')),
('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')), ('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот', null=True, verbose_name='Полоса частот, МГц')),
('power', models.FloatField(blank=True, default=0, null=True, verbose_name='Мощность, дБм')), ('power', models.FloatField(blank=True, default=0, help_text='Мощность сигнала', null=True, verbose_name='Мощность, дБм')),
('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), ('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД')),
('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ, Дб')), ('snr', models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ, Дб')),
('packets', models.BooleanField(blank=True, null=True, verbose_name='Пакетность')), ('packets', models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность')),
('datetime_begin', models.DateTimeField(blank=True, null=True, verbose_name='Время начала измерения')), ('datetime_begin', models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения')),
('datetime_end', models.DateTimeField(blank=True, null=True, verbose_name='Время окончания измерения')), ('datetime_end', models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения')),
('id_satellite', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник')),
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция')),
('parameter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ')),
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация')),
('mark', models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка')),
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт')),
], ],
options={ options={
'verbose_name': 'ВЧ sigma', 'verbose_name': 'ВЧ sigma',
'verbose_name_plural': 'ВЧ sigma', 'verbose_name_plural': 'ВЧ sigma',
}, },
), ),
migrations.CreateModel(
name='SigmaParMark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mark', models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала')),
('timestamp', models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время')),
],
options={
'verbose_name': 'Отметка',
'verbose_name_plural': 'Отметки',
'ordering': ['-timestamp'],
},
),
migrations.CreateModel(
name='Source',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата')),
('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников')),
('coords_reference', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, ещё кем-то проверенные (WGS84)', null=True, srid=4326, verbose_name='Координаты справочные')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
],
options={
'verbose_name': 'Источник',
'verbose_name_plural': 'Источники',
},
),
migrations.CreateModel(
name='Standard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт')),
],
options={
'verbose_name': 'Стандарт',
'verbose_name_plural': 'Стандарты',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя')),
('user', models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Пользователь',
'verbose_name_plural': 'Пользователи',
'ordering': ['user__username'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Geo', name='Geo',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Время')), ('timestamp', models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время')),
('coords', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координата геолокации')), ('location', models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение')),
('location', models.CharField(blank=True, max_length=255, null=True, verbose_name='Метоположение')), ('comment', models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий')),
('comment', models.CharField(blank=True, max_length=255, verbose_name='Комментарий')), ('is_average', models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое')),
('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты Кубсата')), ('coords', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации')),
('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты оперативников')), ('mirrors', models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')),
('is_average', models.BooleanField(blank=True, null=True, verbose_name='Усреднённое')),
('distance_coords_kup', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и гео, км')),
('distance_coords_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_valid'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между гео и оперативным отделом, км')),
('distance_kup_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и оперативным отделом, км')),
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geos_added', to='mainapp.customuser', verbose_name='Пользователь')),
('mirrors', models.ManyToManyField(related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')),
('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео')),
], ],
options={ options={
'verbose_name': 'Гео', 'verbose_name': 'Гео',
'verbose_name_plural': 'Гео', 'verbose_name_plural': 'Гео',
'constraints': [models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination')], 'ordering': ['-timestamp'],
}, },
), ),
migrations.AddIndex( migrations.CreateModel(
model_name='parameter', name='ObjItem',
index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'), fields=[
), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
migrations.AddIndex( ('name', models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта')),
model_name='parameter', ('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'), ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('lyngsat_source', models.ForeignKey(blank=True, help_text='Связанный источник из базы LyngSat (ТВ)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='lyngsatapp.lyngsat', verbose_name='Источник LyngSat')),
],
options={
'verbose_name': 'Объект',
'verbose_name_plural': 'Объекты',
'ordering': ['-updated_at'],
},
), ),
] ]

View File

@@ -0,0 +1,150 @@
# Generated by Django 5.2.7 on 2025-11-12 14:21
import django.db.models.deletion
import mainapp.models
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('mainapp', '0001_initial'),
('mapsapp', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='objitem',
name='transponder',
field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder', to='mapsapp.transponders', verbose_name='Транспондер'),
),
migrations.AddField(
model_name='objitem',
name='updated_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
),
migrations.AddField(
model_name='geo',
name='objitem',
field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Объект'),
),
migrations.AddField(
model_name='parameter',
name='modulation',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция'),
),
migrations.AddField(
model_name='parameter',
name='objitem',
field=models.OneToOneField(blank=True, help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parameter_obj', to='mainapp.objitem', verbose_name='Объект'),
),
migrations.AddField(
model_name='parameter',
name='polarization',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация'),
),
migrations.AddField(
model_name='satellite',
name='band',
field=models.ManyToManyField(blank=True, help_text='Диапазоны работы спутника', related_name='bands', to='mainapp.band', verbose_name='Диапазоны'),
),
migrations.AddField(
model_name='satellite',
name='created_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
),
migrations.AddField(
model_name='satellite',
name='updated_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
),
migrations.AddField(
model_name='parameter',
name='id_satellite',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник'),
),
migrations.AddField(
model_name='sigmaparameter',
name='id_satellite',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник'),
),
migrations.AddField(
model_name='sigmaparameter',
name='modulation',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция'),
),
migrations.AddField(
model_name='sigmaparameter',
name='parameter',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ'),
),
migrations.AddField(
model_name='sigmaparameter',
name='polarization',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация'),
),
migrations.AddField(
model_name='sigmaparameter',
name='mark',
field=models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка'),
),
migrations.AddField(
model_name='source',
name='created_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
),
migrations.AddField(
model_name='source',
name='updated_by',
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
),
migrations.AddField(
model_name='objitem',
name='source',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source', to='mainapp.source', verbose_name='ИРИ'),
),
migrations.AddField(
model_name='sigmaparameter',
name='standard',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт'),
),
migrations.AddField(
model_name='parameter',
name='standard',
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт'),
),
migrations.AddIndex(
model_name='geo',
index=models.Index(fields=['-timestamp'], name='mainapp_geo_timesta_58a605_idx'),
),
migrations.AddIndex(
model_name='geo',
index=models.Index(fields=['location'], name='mainapp_geo_locatio_b855c9_idx'),
),
migrations.AddConstraint(
model_name='geo',
constraint=models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination'),
),
migrations.AddIndex(
model_name='objitem',
index=models.Index(fields=['name'], name='mainapp_obj_name_e4f1e1_idx'),
),
migrations.AddIndex(
model_name='objitem',
index=models.Index(fields=['-updated_at'], name='mainapp_obj_updated_f46b0e_idx'),
),
migrations.AddIndex(
model_name='objitem',
index=models.Index(fields=['-created_at'], name='mainapp_obj_created_cba553_idx'),
),
migrations.AddIndex(
model_name='parameter',
index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'),
),
migrations.AddIndex(
model_name='parameter',
index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'),
),
]

View File

@@ -1,35 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-31 13:56
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='objitem',
name='created_at',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата создания'),
),
migrations.AddField(
model_name='objitem',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
),
migrations.AddField(
model_name='objitem',
name='updated_at',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата последнего изменения'),
),
migrations.AddField(
model_name='objitem',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-31 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0002_objitem_created_at_objitem_created_by_and_more'),
]
operations = [
migrations.AlterField(
model_name='objitem',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'),
),
migrations.AlterField(
model_name='objitem',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Дата последнего изменения'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2025-11-12 19:41
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0002_initial'),
('mapsapp', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='source',
name='coords_average',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты, полученные от в ходе геолокации (WGS84)', null=True, srid=4326, verbose_name='Координаты ГЛ'),
),
migrations.AlterField(
model_name='objitem',
name='source',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_objitems', to='mainapp.source', verbose_name='ИРИ'),
),
migrations.AlterField(
model_name='objitem',
name='transponder',
field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder_objitems', to='mapsapp.transponders', verbose_name='Транспондер'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-13 14:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0003_source_coords_average_alter_objitem_source_and_more'),
]
operations = [
migrations.AlterField(
model_name='geo',
name='mirrors',
field=models.ManyToManyField(blank=True, help_text='Спутники-зеркала, использованные для приема', related_name='geo_mirrors', to='mainapp.satellite', verbose_name='Зеркала'),
),
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-01 07:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0003_alter_objitem_created_at_alter_objitem_updated_at'),
]
operations = [
migrations.RemoveField(
model_name='geo',
name='id_user_add',
),
migrations.RemoveField(
model_name='objitem',
name='id_user_add',
),
migrations.RemoveField(
model_name='parameter',
name='id_user_add',
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2025-11-16 10:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0004_change_geo_mirrors_to_satellites'),
]
operations = [
migrations.AlterModelOptions(
name='sigmaparmark',
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка сигнала', 'verbose_name_plural': 'Отметки сигналов'},
),
migrations.CreateModel(
name='ObjectMark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mark', models.BooleanField(blank=True, help_text='True - объект обнаружен, False - объект отсутствует', null=True, verbose_name='Наличие объекта')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Время фиксации отметки', verbose_name='Время')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший отметку', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='marks_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('objitem', models.ForeignKey(help_text='Связанный объект', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.objitem', verbose_name='Объект')),
],
options={
'verbose_name': 'Отметка объекта',
'verbose_name_plural': 'Отметки объектов',
'ordering': ['-timestamp'],
},
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.7 on 2025-11-16 15:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0005_alter_sigmaparmark_options_objectmark'),
]
operations = [
migrations.AlterModelOptions(
name='objectmark',
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка источника', 'verbose_name_plural': 'Отметки источников'},
),
migrations.RemoveField(
model_name='objectmark',
name='objitem',
),
migrations.AddField(
model_name='objectmark',
name='source',
field=models.ForeignKey(help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-16 15:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0006_change_objectmark_to_source'),
]
operations = [
migrations.AlterField(
model_name='objectmark',
name='source',
field=models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2025-11-17 12:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0007_make_source_required'),
]
operations = [
migrations.CreateModel(
name='ObjectInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Информация о типе объекта', max_length=255, unique=True, verbose_name='Тип объекта')),
],
options={
'verbose_name': 'Тип объекта',
'verbose_name_plural': 'Типы объектов',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='source',
name='info',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.7 on 2025-11-20 11:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0008_objectinfo_source_info'),
]
operations = [
migrations.CreateModel(
name='ObjectOwnership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Принадлежность объекта (страна, организация и т.д.)', max_length=255, unique=True, verbose_name='Принадлежность')),
],
options={
'verbose_name': 'Принадлежность объекта',
'verbose_name_plural': 'Принадлежности объектов',
'ordering': ['name'],
},
),
migrations.AlterField(
model_name='source',
name='info',
field=models.ForeignKey(blank=True, help_text='Тип объекта', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
),
migrations.AddField(
model_name='source',
name='ownership',
field=models.ForeignKey(blank=True, help_text='Принадлежность объекта (страна, организация и т.д.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_ownership', to='mainapp.objectownership', verbose_name='Принадлежность объекта'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-11-21 07:35
from django.db import migrations
def set_default_source_type(apps, schema_editor):
"""
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
"""
Source = apps.get_model('mainapp', 'Source')
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
# Создаем или получаем тип "Стационарные"
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
# Обновляем все Source без типа
sources_without_type = Source.objects.filter(info__isnull=True)
count = sources_without_type.update(info=stationary_info)
print(f"Обновлено {count} источников с типом 'Стационарные'")
def reverse_set_default_source_type(apps, schema_editor):
"""
Обратная миграция - ничего не делаем, так как это безопасная операция.
"""
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
]
operations = [
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
]

View File

@@ -0,0 +1,74 @@
# Generated by Django 5.2.7 on 2025-11-21 07:42
from django.db import migrations
def fix_capitalization(apps, schema_editor):
"""
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
"""
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
Source = apps.get_model('mainapp', 'Source')
# Создаем правильные типы с большой буквы
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
# Находим старые типы с маленькой буквы
try:
stationary_old = ObjectInfo.objects.get(name="стационарные")
# Обновляем все Source, которые используют старый тип
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
# Удаляем старый тип
stationary_old.delete()
except ObjectInfo.DoesNotExist:
pass
try:
mobile_old = ObjectInfo.objects.get(name="подвижные")
# Обновляем все Source, которые используют старый тип
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
# Удаляем старый тип
mobile_old.delete()
except ObjectInfo.DoesNotExist:
pass
def reverse_fix_capitalization(apps, schema_editor):
"""
Обратная миграция - возвращаем маленькие буквы
"""
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
Source = apps.get_model('mainapp', 'Source')
# Создаем типы с маленькой буквы
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
# Находим типы с большой буквы
try:
stationary_new = ObjectInfo.objects.get(name="Стационарные")
Source.objects.filter(info=stationary_new).update(info=stationary_old)
stationary_new.delete()
except ObjectInfo.DoesNotExist:
pass
try:
mobile_new = ObjectInfo.objects.get(name="Подвижные")
Source.objects.filter(info=mobile_new).update(info=mobile_old)
mobile_new.delete()
except ObjectInfo.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0010_set_default_source_type'),
]
operations = [
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-11-21 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0011_fix_source_type_capitalization'),
]
operations = [
migrations.AddField(
model_name='source',
name='confirm_at',
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
),
migrations.AddField(
model_name='source',
name='last_signal_at',
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-24 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
]
operations = [
migrations.DeleteModel(
name='Mirror',
),
migrations.RemoveField(
model_name='sigmaparameter',
name='mark',
),
migrations.AddField(
model_name='objitem',
name='is_automatic',
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
),
migrations.DeleteModel(
name='SigmaParMark',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-25 12:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0013_add_is_automatic_to_objitem'),
]
operations = [
migrations.AddField(
model_name='source',
name='note',
field=models.TextField(blank=True, help_text='Дополнительное описание объекта', null=True, verbose_name='Примечание'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-26 20:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0014_source_note'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='international_code',
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.7 on 2025-11-27 07:10
import django.db.models.deletion
import mainapp.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0015_add_international_code_to_satellite'),
]
operations = [
migrations.AlterField(
model_name='satellite',
name='international_code',
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
),
migrations.CreateModel(
name='TechAnalyze',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
],
options={
'verbose_name': 'Тех. анализ',
'verbose_name_plural': 'Тех. анализы',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-12-01 08:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='alternative_name',
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
),
migrations.AlterField(
model_name='standard',
name='name',
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
),
]

View File

@@ -0,0 +1,87 @@
# Generated by Django 5.2.7 on 2025-12-08 08:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0017_add_satellite_alternative_name'),
]
operations = [
migrations.AlterField(
model_name='objectownership',
name='name',
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
),
migrations.AlterField(
model_name='satellite',
name='alternative_name',
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
),
migrations.CreateModel(
name='SourceRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
],
options={
'verbose_name': 'Заявка на источник',
'verbose_name_plural': 'Заявки на источники',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SourceRequestStatusHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
],
options={
'verbose_name': 'История статуса заявки',
'verbose_name_plural': 'История статусов заявок',
'ordering': ['-changed_at'],
},
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
),
migrations.AddIndex(
model_name='sourcerequeststatushistory',
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
),
migrations.AddIndex(
model_name='sourcerequeststatushistory',
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-12-08 09:24
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0018_add_source_request_models'),
]
operations = [
migrations.AddField(
model_name='sourcerequest',
name='coords',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
),
migrations.AddField(
model_name='sourcerequest',
name='points_count',
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-12-08 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0019_add_coords_to_source_request'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='location_place',
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
),
]

View File

@@ -0,0 +1,60 @@
# Generated by Django 5.2.7 on 2025-12-09 12:39
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0020_satellite_location_place'),
]
operations = [
migrations.AddField(
model_name='sourcerequest',
name='card_date',
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
),
migrations.AddField(
model_name='sourcerequest',
name='coords_source',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
),
migrations.AddField(
model_name='sourcerequest',
name='downlink',
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
),
migrations.AddField(
model_name='sourcerequest',
name='region',
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
),
migrations.AddField(
model_name='sourcerequest',
name='satellite',
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
),
migrations.AddField(
model_name='sourcerequest',
name='transfer',
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
),
migrations.AddField(
model_name='sourcerequest',
name='uplink',
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
),
migrations.AlterField(
model_name='sourcerequest',
name='coords',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
),
migrations.AlterField(
model_name='sourcerequest',
name='source',
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
),
]

View File

@@ -0,0 +1,73 @@
"""
Миграция для изменения модели ObjectMark:
- Удаление всех существующих отметок
- Удаление поля source
- Добавление поля tech_analyze
"""
from django.db import migrations, models
import django.db.models.deletion
def delete_all_marks(apps, schema_editor):
"""Удаляем все существующие отметки перед изменением структуры."""
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
count = ObjectMark.objects.count()
ObjectMark.objects.all().delete()
print(f"Удалено {count} отметок ObjectMark")
def noop(apps, schema_editor):
"""Обратная операция - ничего не делаем."""
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0021_add_source_request_fields'),
]
operations = [
# Сначала удаляем все отметки
migrations.RunPython(delete_all_marks, noop),
# Удаляем старое поле source
migrations.RemoveField(
model_name='objectmark',
name='source',
),
# Добавляем новое поле tech_analyze
migrations.AddField(
model_name='objectmark',
name='tech_analyze',
field=models.ForeignKey(
help_text='Связанный технический анализ',
on_delete=django.db.models.deletion.CASCADE,
related_name='marks',
to='mainapp.techanalyze',
verbose_name='Тех. анализ',
),
preserve_default=False,
),
# Обновляем метаданные модели
migrations.AlterModelOptions(
name='objectmark',
options={
'ordering': ['-timestamp'],
'verbose_name': 'Отметка сигнала',
'verbose_name_plural': 'Отметки сигналов'
},
),
# Добавляем индекс для оптимизации запросов
migrations.AddIndex(
model_name='objectmark',
index=models.Index(
fields=['tech_analyze', '-timestamp'],
name='mainapp_obj_tech_an_idx'
),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-12-11 12:08
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0022_change_objectmark_to_techanalyze'),
]
operations = [
migrations.RenameIndex(
model_name='objectmark',
new_name='mainapp_obj_tech_an_b0c804_idx',
old_name='mainapp_obj_tech_an_idx',
),
migrations.AddField(
model_name='sourcerequest',
name='coords_object',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
),
migrations.AlterField(
model_name='objectmark',
name='mark',
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2025-12-12 12:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0023_add_coords_object_to_sourcerequest'),
]
operations = [
migrations.AlterField(
model_name='objectmark',
name='timestamp',
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
),
migrations.AlterField(
model_name='sourcerequest',
name='status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
),
migrations.AlterField(
model_name='sourcerequeststatushistory',
name='new_status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
),
migrations.AlterField(
model_name='sourcerequeststatushistory',
name='old_status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
),
]

209
dbapp/mainapp/mixins.py Normal file
View File

@@ -0,0 +1,209 @@
"""
Переиспользуемые миксины для представлений mainapp.
Этот модуль содержит миксины для стандартизации общей логики в представлениях,
включая проверку прав доступа, обработку координат и сообщений.
"""
# Standard library imports
from datetime import datetime
from typing import Optional, Tuple
# Django imports
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.gis.geos import Point
class RoleRequiredMixin(UserPassesTestMixin):
"""
Mixin для проверки роли пользователя.
Проверяет, что пользователь имеет одну из требуемых ролей для доступа к представлению.
Attributes:
required_roles (list): Список допустимых ролей для доступа.
По умолчанию ['admin', 'moderator'].
Example:
class MyView(RoleRequiredMixin, View):
required_roles = ['admin', 'moderator']
def get(self, request):
# Только пользователи с ролью admin или moderator могут получить доступ
return render(request, 'template.html')
"""
required_roles = ["admin", "moderator"]
def test_func(self) -> bool:
"""
Проверяет, имеет ли пользователь требуемую роль.
Returns:
bool: True если пользователь имеет одну из требуемых ролей, иначе False.
"""
if not self.request.user.is_authenticated:
return False
if not hasattr(self.request.user, "customuser"):
return False
return self.request.user.customuser.role in self.required_roles
class CoordinateProcessingMixin:
"""
Mixin для обработки координат из POST данных форм.
Предоставляет методы для извлечения и обработки координат различных типов
(геолокация, кубсат, оперативники) из POST запроса и применения их к объекту Geo.
Note: Координаты Кубсата и оперативников теперь хранятся в модели Source,
а не в модели Geo, но для совместимости в форме все еще могут быть поля
для этих координат.
"""
def process_coordinates(self, geo_instance, prefix: str = "geo") -> None:
"""
Обрабатывает координаты из POST данных и применяет их к объекту Geo.
Извлекает координаты геолокации из POST запроса
и устанавливает соответствующие поля объекта Geo.
Args:
geo_instance: Экземпляр модели Geo для обновления координат.
prefix (str): Префикс для полей формы (по умолчанию 'geo').
Note:
Метод ожидает следующие поля в request.POST:
- geo_longitude, geo_latitude: координаты геолокации
"""
# Обрабатываем координаты геолокации
geo_coords = self._extract_coordinates("geo")
if geo_coords:
geo_instance.coords = Point(geo_coords[0], geo_coords[1], srid=4326)
def _extract_coordinates(self, coord_type: str) -> Optional[Tuple[float, float]]:
"""
Извлекает координаты указанного типа из POST данных.
Args:
coord_type (str): Тип координат ('geo', 'kupsat', 'valid').
Returns:
Optional[Tuple[float, float]]: Кортеж (longitude, latitude) или None,
если координаты не найдены или невалидны.
"""
longitude_key = f"{coord_type}_longitude"
latitude_key = f"{coord_type}_latitude"
longitude = self.request.POST.get(longitude_key)
latitude = self.request.POST.get(latitude_key)
if longitude and latitude:
try:
return (float(longitude), float(latitude))
except (ValueError, TypeError):
return None
return None
def process_timestamp(self, geo_instance) -> None:
"""
Обрабатывает дату и время из POST данных и применяет к объекту Geo.
Args:
geo_instance: Экземпляр модели Geo для обновления timestamp.
Note:
Метод ожидает следующие поля в request.POST:
- timestamp_date: дата в формате YYYY-MM-DD
- timestamp_time: время в формате HH:MM
"""
timestamp_date = self.request.POST.get("timestamp_date")
timestamp_time = self.request.POST.get("timestamp_time")
if timestamp_date and timestamp_time:
try:
naive_datetime = datetime.strptime(
f"{timestamp_date} {timestamp_time}", "%Y-%m-%d %H:%M"
)
geo_instance.timestamp = naive_datetime
except ValueError:
# Если формат даты/времени неверный, пропускаем
pass
class FormMessageMixin:
"""
Mixin для стандартизации сообщений об успехе и ошибках в формах.
Автоматически добавляет сообщения пользователю при успешной или неуспешной
обработке формы.
Attributes:
success_message (str): Сообщение при успешной обработке формы.
error_message (str): Сообщение при ошибке обработки формы.
Example:
class MyFormView(FormMessageMixin, FormView):
success_message = "Данные успешно сохранены!"
error_message = "Ошибка при сохранении данных"
def form_valid(self, form):
# Автоматически добавит success_message
return super().form_valid(form)
"""
success_message = "Операция выполнена успешно"
error_message = "Произошла ошибка при обработке формы"
def form_valid(self, form):
"""
Обрабатывает валидную форму и добавляет сообщение об успехе.
Args:
form: Валидная форма Django.
Returns:
HttpResponse: Результат обработки родительского метода form_valid.
"""
if self.success_message:
messages.success(self.request, self.success_message)
return super().form_valid(form)
def form_invalid(self, form):
"""
Обрабатывает невалидную форму и добавляет сообщение об ошибке.
Args:
form: Невалидная форма Django.
Returns:
HttpResponse: Результат обработки родительского метода form_invalid.
"""
if self.error_message:
messages.error(self.request, self.error_message)
return super().form_invalid(form)
def get_success_message(self) -> str:
"""
Возвращает сообщение об успехе.
Может быть переопределен в подклассах для динамического формирования сообщения.
Returns:
str: Сообщение об успехе.
"""
return self.success_message
def get_error_message(self) -> str:
"""
Возвращает сообщение об ошибке.
Может быть переопределен в подклассах для динамического формирования сообщения.
Returns:
str: Сообщение об ошибке.
"""
return self.error_message

File diff suppressed because it is too large Load Diff

View File

@@ -1,75 +1,76 @@
from django.contrib.admin.filters import ChoicesFieldListFilter # Django imports
from django.forms import Media from django.contrib.admin.filters import ChoicesFieldListFilter
from django.forms import Media
class PopupCompatibleMultiSelectRelatedDropdownFilter(ChoicesFieldListFilter):
""" class PopupCompatibleMultiSelectRelatedDropdownFilter(ChoicesFieldListFilter):
A custom filter that maintains popup context when used in raw_id_fields modals. """
""" A custom filter that maintains popup context when used in raw_id_fields modals.
"""
def __init__(self, field, request, params, model, model_admin, field_path):
super().__init__(field, request, params, model, model_admin, field_path) def __init__(self, field, request, params, model, model_admin, field_path):
super().__init__(field, request, params, model, model_admin, field_path)
# Check if we're in a popup context
self.is_popup = '_popup' in request.GET or 'pop' in request.GET or 'admin' not in request.path # Check if we're in a popup context
self.is_popup = '_popup' in request.GET or 'pop' in request.GET or 'admin' not in request.path
# Get all choices (related objects)
self.lookup_choices = field.get_choices(include_blank=False) # Get all choices (related objects)
self.lookup_choices = field.get_choices(include_blank=False)
def has_output(self):
return len(self.lookup_choices) > 1 def has_output(self):
return len(self.lookup_choices) > 1
def value(self):
return self.lookup_val def value(self):
return self.lookup_val
def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg_isnull] def expected_parameters(self):
return [self.lookup_kwarg, self.lookup_kwarg_isnull]
def choices(self, changelist):
# If in popup, preserve the popup parameters in the filter URL def choices(self, changelist):
popup_params = {} # If in popup, preserve the popup parameters in the filter URL
if self.is_popup: popup_params = {}
# Preserve popup parameters if self.is_popup:
if '_popup' in changelist.params: # Preserve popup parameters
popup_params['_popup'] = 1 if '_popup' in changelist.params:
if 'pop' in changelist.params: popup_params['_popup'] = 1
popup_params['pop'] = changelist.params['pop'] if 'pop' in changelist.params:
if '_to_field' in changelist.params: popup_params['pop'] = changelist.params['pop']
popup_params['_to_field'] = changelist.params['_to_field'] if '_to_field' in changelist.params:
popup_params['_to_field'] = changelist.params['_to_field']
# Create the base URL with popup parameters
all_params = changelist.get_filters_params() # Create the base URL with popup parameters
all_params.update(popup_params) all_params = changelist.get_filters_params()
all_params.update(popup_params)
# Generate the URL for the filter
url = changelist.get_query_string(all_params, [self.lookup_kwarg]) # Generate the URL for the filter
url = changelist.get_query_string(all_params, [self.lookup_kwarg])
yield {
'selected': self.lookup_val is None, yield {
'query_string': url, 'selected': self.lookup_val is None,
'display': 'All', 'query_string': url,
} 'display': 'All',
}
# Add choices
for lookup, title in self.lookup_choices: # Add choices
params = dict(all_params) for lookup, title in self.lookup_choices:
params[self.lookup_kwarg] = lookup params = dict(all_params)
params[self.lookup_kwarg] = lookup
# Remove the parameter if it's being set to the same value (for unselecting)
if self.lookup_val == str(lookup): # Remove the parameter if it's being set to the same value (for unselecting)
params.pop(self.lookup_kwarg, None) if self.lookup_val == str(lookup):
params.pop(self.lookup_kwarg, None)
# Add popup parameters to each choice URL
choice_params = params.copy() # Add popup parameters to each choice URL
choice_params.update(popup_params) choice_params = params.copy()
choice_params.update(popup_params)
yield {
'selected': str(lookup) == self.lookup_val, yield {
'query_string': changelist.get_query_string(choice_params, [self.lookup_kwarg_isnull]), 'selected': str(lookup) == self.lookup_val,
'display': title, 'query_string': changelist.get_query_string(choice_params, [self.lookup_kwarg_isnull]),
} 'display': title,
}
@property
def media(self): @property
# Include necessary CSS/JS for dropdown functionality if needed def media(self):
# Include necessary CSS/JS for dropdown functionality if needed
return Media() return Media()

View File

@@ -1,11 +1,17 @@
from django.db.models.signals import post_save # Django imports
from django.dispatch import receiver # from django.contrib.auth.models import User
from django.contrib.auth.models import User # from django.db.models.signals import post_save
from .models import CustomUser # from django.dispatch import receiver
# # Local imports
# from .models import CustomUser
@receiver(post_save, sender=User) # @receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs): # def create_or_update_user_profile(sender, instance, created, **kwargs):
if created: # if created:
CustomUser.objects.create(user=instance) # CustomUser.objects.get_or_create(user=instance)
instance.customuser.save() # else:
# # Only save if customuser exists (avoid error if it doesn't)
# if hasattr(instance, 'customuser'):
# instance.customuser.save()

View File

@@ -0,0 +1,161 @@
.checkbox-multiselect-wrapper {
position: relative;
width: 100%;
}
.multiselect-input-container {
position: relative;
display: flex;
align-items: flex-start;
min-height: 38px;
border: 1px solid #ced4da;
border-radius: 0.25rem;
background-color: #fff;
cursor: text;
padding: 4px 30px 4px 4px;
flex-wrap: wrap;
gap: 4px;
}
.multiselect-input-container:focus-within {
border-color: #86b7fe;
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.multiselect-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1 1 auto;
max-width: calc(100% - 150px);
}
.multiselect-tag {
display: inline-flex;
align-items: center;
background-color: #e9ecef;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 2px 8px;
font-size: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
.multiselect-tag-remove {
margin-left: 6px;
cursor: pointer;
color: #6c757d;
font-weight: bold;
border: none;
background: none;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.multiselect-tag-remove:hover {
color: #dc3545;
}
.multiselect-search {
flex: 1 1 auto;
min-width: 120px;
border: none;
outline: none;
padding: 4px;
font-size: 0.875rem;
}
.multiselect-search:focus {
box-shadow: none;
}
.multiselect-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6c757d;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: none;
}
.multiselect-clear:hover {
color: #dc3545;
}
.multiselect-input-container.has-selections .multiselect-clear {
display: block;
}
.multiselect-dropdown {
position: absolute;
left: 0;
right: 0;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
/* Открытие вверх (по умолчанию) */
.multiselect-dropdown {
bottom: 100%;
margin-bottom: 2px;
}
/* Открытие вниз (если места сверху недостаточно) */
.multiselect-dropdown.dropdown-below {
bottom: auto;
top: 100%;
margin-top: 2px;
margin-bottom: 0;
}
.multiselect-dropdown.show {
display: block;
}
.multiselect-options {
padding: 4px 0;
}
.multiselect-option {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
margin: 0;
transition: background-color 0.15s ease-in-out;
}
.multiselect-option:hover {
background-color: #f8f9fa;
}
.multiselect-option input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
.multiselect-option .option-label {
flex: 1;
user-select: none;
}
.multiselect-option.hidden {
display: none;
}

View File

@@ -0,0 +1,148 @@
# Sorting Functionality Documentation
## Overview
This document describes the centralized sorting functionality implemented for table columns across the Django application.
## Files Created/Modified
### Created Files:
1. **`dbapp/mainapp/static/js/sorting.js`** - Main sorting JavaScript library
2. **`dbapp/mainapp/static/js/sorting-test.html`** - Test page for manual verification
### Modified Files:
1. **`dbapp/mainapp/templates/mainapp/base.html`** - Added sorting.js script include
2. **`dbapp/mainapp/templates/mainapp/components/_sort_header.html`** - Removed inline script, added data attributes
## Features
### 1. Sort Toggle Logic
- **First click**: Sort ascending (field)
- **Second click**: Sort descending (-field)
- **Third click**: Sort ascending again (cycles back)
### 2. URL Parameter Management
- Preserves all existing GET parameters (search, filters, etc.)
- Automatically resets page number to 1 when sorting changes
- Updates the `sort` parameter in the URL
### 3. Visual Indicators
- Shows up arrow (↑) for ascending sort
- Shows down arrow (↓) for descending sort
- Automatically initializes indicators on page load
- Adds `sort-active` class to currently sorted column
## Usage
### In Templates
Use the `_sort_header.html` component in your table headers:
```django
<thead class="table-dark sticky-top">
<tr>
<th>{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}</th>
<th>{% include 'mainapp/components/_sort_header.html' with field='name' label='Название' current_sort=sort %}</th>
<th>{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}</th>
</tr>
</thead>
```
### In Views
Pass the current sort parameter to the template context:
```python
def get(self, request):
sort = request.GET.get('sort', '-id') # Default sort
# Validate allowed sorts
allowed_sorts = ['id', '-id', 'name', '-name', 'created_at', '-created_at']
if sort not in allowed_sorts:
sort = '-id'
# Apply sorting
queryset = Model.objects.all().order_by(sort)
context = {
'sort': sort,
'objects': queryset,
# ... other context
}
return render(request, 'template.html', context)
```
## JavaScript API
### Functions
#### `updateSort(field)`
Updates the sort parameter and reloads the page.
**Parameters:**
- `field` (string): The field name to sort by
**Example:**
```javascript
updateSort('created_at'); // Sort by created_at ascending
```
#### `getCurrentSort()`
Gets the current sort field and direction from URL.
**Returns:**
- Object with `field` and `direction` properties
- `direction` can be 'asc', 'desc', or null
**Example:**
```javascript
const sort = getCurrentSort();
console.log(sort.field); // 'created_at'
console.log(sort.direction); // 'asc' or 'desc'
```
#### `initializeSortIndicators()`
Automatically called on page load to show current sort state.
## Requirements Satisfied
This implementation satisfies the following requirements from the specification:
- **5.1**: Supports ascending and descending order for sortable columns
- **5.2**: Toggles between ascending, descending when clicking column headers
- **5.3**: Displays visual indicators (arrow icons) showing sort direction
- **5.5**: Preserves sort state in URL parameters during navigation
- **5.6**: Preserves other active filters and resets pagination when sorting
## Testing
### Manual Testing
1. Open `dbapp/mainapp/static/js/sorting-test.html` in a browser
2. Click column headers to test sorting
3. Verify URL updates correctly
4. Add query parameters (e.g., ?page=5&search=test) and verify they're preserved
### Integration Testing
Test in actual Django views:
1. Navigate to any list view (sources, objitems, transponders)
2. Click column headers to sort
3. Verify data is sorted correctly
4. Apply filters and verify they're preserved when sorting
5. Navigate to page 2+, then sort - verify it resets to page 1
## Browser Compatibility
- Modern browsers supporting ES6 (URLSearchParams)
- Chrome 49+
- Firefox 44+
- Safari 10.1+
- Edge 17+
## Notes
- The sorting.js file is loaded with `defer` attribute for better performance
- All GET parameters are preserved except `page` which is reset to 1
- The function is globally available and can be called from any template
- Sort indicators are automatically initialized on page load

View File

@@ -0,0 +1,120 @@
/**
* Checkbox Select Multiple Widget
* Provides a multi-select dropdown with checkboxes and tag display
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize all checkbox multiselect widgets
document.querySelectorAll('.checkbox-multiselect-wrapper').forEach(function(wrapper) {
initCheckboxMultiselect(wrapper);
});
});
function initCheckboxMultiselect(wrapper) {
const widgetId = wrapper.dataset.widgetId;
const inputContainer = wrapper.querySelector('.multiselect-input-container');
const searchInput = wrapper.querySelector('.multiselect-search');
const dropdown = wrapper.querySelector('.multiselect-dropdown');
const tagsContainer = wrapper.querySelector('.multiselect-tags');
const clearButton = wrapper.querySelector('.multiselect-clear');
const checkboxes = wrapper.querySelectorAll('input[type="checkbox"]');
// Show dropdown when clicking on input container
inputContainer.addEventListener('click', function(e) {
if (e.target !== clearButton) {
positionDropdown();
dropdown.classList.add('show');
searchInput.focus();
}
});
// Position dropdown (up or down based on available space)
function positionDropdown() {
const rect = inputContainer.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = 300; // max-height from CSS
// If more space below and enough space, open downward
if (spaceBelow > spaceAbove && spaceBelow >= dropdownHeight) {
dropdown.classList.add('dropdown-below');
} else {
dropdown.classList.remove('dropdown-below');
}
}
// Hide dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!wrapper.contains(e.target)) {
dropdown.classList.remove('show');
}
});
// Search functionality
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const options = wrapper.querySelectorAll('.multiselect-option');
options.forEach(function(option) {
const label = option.querySelector('.option-label').textContent.toLowerCase();
if (label.includes(searchTerm)) {
option.classList.remove('hidden');
} else {
option.classList.add('hidden');
}
});
});
// Handle checkbox changes
checkboxes.forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
updateTags();
});
});
// Clear all button
clearButton.addEventListener('click', function(e) {
e.stopPropagation();
checkboxes.forEach(function(checkbox) {
checkbox.checked = false;
});
updateTags();
});
// Update tags display
function updateTags() {
tagsContainer.innerHTML = '';
let hasSelections = false;
checkboxes.forEach(function(checkbox) {
if (checkbox.checked) {
hasSelections = true;
const tag = document.createElement('div');
tag.className = 'multiselect-tag';
tag.innerHTML = `
<span>${checkbox.dataset.label}</span>
<button type="button" class="multiselect-tag-remove" data-value="${checkbox.value}">×</button>
`;
// Remove tag on click
tag.querySelector('.multiselect-tag-remove').addEventListener('click', function(e) {
e.stopPropagation();
checkbox.checked = false;
updateTags();
});
tagsContainer.appendChild(tag);
}
});
// Show/hide clear button
if (hasSelections) {
inputContainer.classList.add('has-selections');
} else {
inputContainer.classList.remove('has-selections');
}
}
// Initialize tags on load
updateTags();
}

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sorting Test</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Sorting Functionality Test</h1>
<div class="alert alert-info">
<strong>Current URL:</strong> <span id="currentUrl"></span>
</div>
<table class="table table-striped">
<thead class="table-dark">
<tr>
<th>
<a href="javascript:void(0)"
onclick="updateSort('id')"
class="text-white text-decoration-none"
data-sort-field="id">
ID
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
</a>
</th>
<th>
<a href="javascript:void(0)"
onclick="updateSort('name')"
class="text-white text-decoration-none"
data-sort-field="name">
Name
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
</a>
</th>
<th>
<a href="javascript:void(0)"
onclick="updateSort('created_at')"
class="text-white text-decoration-none"
data-sort-field="created_at">
Created At
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Test Item 1</td>
<td>2024-01-01</td>
</tr>
<tr>
<td>2</td>
<td>Test Item 2</td>
<td>2024-01-02</td>
</tr>
</tbody>
</table>
<div class="card">
<div class="card-body">
<h5>Test Instructions:</h5>
<ol>
<li>Click on any column header (ID, Name, or Created At)</li>
<li>The URL should update with ?sort=field_name</li>
<li>Click again to toggle to descending (?sort=-field_name)</li>
<li>Click a third time to toggle back to ascending</li>
<li>Add ?page=5 to the URL and click a header - page should reset to 1</li>
<li>Add ?search=test to the URL and click a header - search should be preserved</li>
</ol>
</div>
</div>
</div>
<script src="sorting.js"></script>
<script>
// Display current URL
function updateUrlDisplay() {
document.getElementById('currentUrl').textContent = window.location.href;
}
updateUrlDisplay();
// Update URL display on page load
window.addEventListener('load', updateUrlDisplay);
</script>
</body>
</html>

View File

@@ -0,0 +1,106 @@
/**
* Sorting functionality for table columns
* Handles toggling between ascending, descending, and no sort
* Preserves other GET parameters and resets pagination
*/
/**
* Updates the sort parameter in the URL and reloads the page
* @param {string} field - The field name to sort by
*/
function updateSort(field) {
// Get current URL parameters
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
let newSort;
// Toggle sort direction logic:
// 1. If not sorted by this field -> sort ascending (field)
// 2. If sorted ascending -> sort descending (-field)
// 3. If sorted descending -> sort ascending (field)
if (currentSort === field) {
// Currently ascending, switch to descending
newSort = '-' + field;
} else if (currentSort === '-' + field) {
// Currently descending, switch to ascending
newSort = field;
} else {
// Not sorted by this field, start with ascending
newSort = field;
}
// Update sort parameter
urlParams.set('sort', newSort);
// Reset to first page when sorting changes
urlParams.delete('page');
// Reload page with new parameters
window.location.search = urlParams.toString();
}
/**
* Gets the current sort field and direction
* @returns {Object} Object with field and direction properties
*/
function getCurrentSort() {
const urlParams = new URLSearchParams(window.location.search);
const sort = urlParams.get('sort');
if (!sort) {
return { field: null, direction: null };
}
if (sort.startsWith('-')) {
return {
field: sort.substring(1),
direction: 'desc'
};
}
return {
field: sort,
direction: 'asc'
};
}
/**
* Initializes sort indicators on page load
* Adds visual indicators to show current sort state
*/
function initializeSortIndicators() {
const currentSort = getCurrentSort();
if (!currentSort.field) {
return;
}
// Find all sort headers and update their indicators
const sortHeaders = document.querySelectorAll('[data-sort-field]');
sortHeaders.forEach(header => {
const field = header.getAttribute('data-sort-field');
if (field === currentSort.field) {
// Add active class or update icon
header.classList.add('sort-active');
// Update icon if present
const icon = header.querySelector('.sort-icon');
if (icon) {
if (currentSort.direction === 'asc') {
icon.className = 'bi bi-arrow-up sort-icon';
} else {
icon.className = 'bi bi-arrow-down sort-icon';
}
}
}
});
}
// Initialize sort indicators when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeSortIndicators);
} else {
initializeSortIndicators();
}

65
dbapp/mainapp/tasks.py Normal file
View File

@@ -0,0 +1,65 @@
"""
Simple test tasks for Celery functionality.
"""
import time
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(name='mainapp.test_celery_connection')
def test_celery_connection(message="Hello from Celery!"):
"""
A simple test task to verify Celery is working.
Args:
message (str): Message to return
Returns:
str: Confirmation message with task completion time
"""
logger.info(f"Test task started with message: {message}")
time.sleep(2) # Simulate some work
result = f"Task completed! Received message: {message}"
logger.info(f"Test task completed: {result}")
return result
@shared_task(name='mainapp.add_numbers')
def add_numbers(x, y):
"""
A simple addition task to test Celery functionality.
Args:
x (int): First number
y (int): Second number
Returns:
int: Sum of x and y
"""
logger.info(f"Adding {x} + {y}")
result = x + y
logger.info(f"Addition completed: {x} + {y} = {result}")
return result
@shared_task(name='mainapp.long_running_task')
def long_running_task(duration=10):
"""
A task that runs for a specified duration to test long-running tasks.
Args:
duration (int): Duration in seconds
Returns:
str: Completion message
"""
logger.info(f"Starting long running task for {duration} seconds")
for i in range(duration):
time.sleep(1)
logger.info(f"Long task progress: {i+1}/{duration}")
result = f"Long running task completed after {duration} seconds"
logger.info(result)
return result

View File

@@ -1,60 +1,60 @@
{% extends "mapsapp/map2d_base.html" %} {% extends "mapsapp/map2d_base.html" %}
{% load static %} {% load static %}
{% block title %}Вынос точек{% endblock title %} {% block title %}Вынос точек{% endblock title %}
{% block extra_js %} {% block extra_js %}
<script> <script>
// Цвета для стандартных маркеров (из leaflet-color-markers) // Цвета для стандартных маркеров (из leaflet-color-markers)
var markerColors = ['red', 'green', 'orange', 'violet', 'grey', 'black', 'blue']; var markerColors = ['red', 'green', 'orange', 'violet', 'grey', 'black', 'blue'];
var getColorIcon = function(color) { var getColorIcon = function(color) {
return L.icon({ return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png', iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}', shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41], iconSize: [25, 41],
iconAnchor: [12, 41], iconAnchor: [12, 41],
popupAnchor: [1, -34], popupAnchor: [1, -34],
shadowSize: [41, 41] shadowSize: [41, 41]
}); });
}; };
var overlays = []; var overlays = [];
{% for group in groups %} {% for group in groups %}
var groupIndex = {{ forloop.counter0 }}; var groupIndex = {{ forloop.counter0 }};
var colorName = markerColors[groupIndex % markerColors.length]; var colorName = markerColors[groupIndex % markerColors.length];
var groupIcon = getColorIcon(colorName); var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup(); var groupLayer = L.layerGroup();
var subgroup = []; var subgroup = [];
{% for point_data in group.points %} {% for point_data in group.points %}
var pointName = "{{ group.name|escapejs }}"; var pointName = "{{ group.name|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], { var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon icon: groupIcon
}).bindPopup(pointName); }).bindPopup(pointName);
groupLayer.addLayer(marker); groupLayer.addLayer(marker);
subgroup.push({ subgroup.push({
label: "{{ forloop.counter }} - {{ point_data.frequency }}", label: "{{ forloop.counter }} - {{ point_data.frequency }}",
layer: marker layer: marker
}); });
{% endfor %} {% endfor %}
overlays.push({ overlays.push({
label: '{{ group.name|escapejs }}', label: '{{ group.name|escapejs }}',
selectAllCheckbox: true, selectAllCheckbox: true,
children: subgroup, children: subgroup,
layer: groupLayer layer: groupLayer
}); });
{% endfor %} {% endfor %}
// Используем именно tree-контрол // Используем именно tree-контрол
L.control.layers.tree(baseLayers, overlays, { L.control.layers.tree(baseLayers, overlays, {
collapsed: false, collapsed: false,
autoZIndex: true autoZIndex: true
}).addTo(map); }).addTo(map);
</script> </script>
{% endblock extra_js %} {% endblock extra_js %}

View File

@@ -1,196 +1,210 @@
{% extends 'mainapp/base.html' %} {% extends 'mainapp/base.html' %}
{% block title %}Действия{% endblock %} {% block title %}Действия{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="text-center mb-5"> <div class="text-center mb-5">
<h1 class="display-4 fw-bold">Действия</h1> <h1 class="display-4 fw-bold">Действия</h1>
<p class="lead">Управление данными спутников</p> <p class="lead">Управление данными спутников</p>
</div> </div>
<!-- Alert messages --> <!-- Main feature cards -->
{% if messages %} <div class="row g-4">
{% for message in messages %} <!-- Excel Data Upload Card -->
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert"> <div class="col-lg-6">
{{ message }} <div class="card h-100 shadow-sm border-0">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <div class="card-body">
</div> <div class="d-flex align-items-center mb-3">
{% endfor %} <div class="bg-primary bg-opacity-10 rounded-circle p-2 me-3">
{% endif %} <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-excel text-primary" viewBox="0 0 16 16">
<path d="M5.884 6.68a.5.5 0 1 0-.768.64L7.349 10l-2.233 2.68a.5.5 0 0 0 .768.64L8 10.781l2.116 2.54a.5.5 0 0 0 .768-.641L8.651 10l2.233-2.68a.5.5 0 0 0-.768-.64L8 9.219z"/>
<!-- Main feature cards --> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
<div class="row g-4"> </svg>
<!-- Excel Data Upload Card --> </div>
<div class="col-lg-6"> <h3 class="card-title mb-0">Загрузка данных из Excel</h3>
<div class="card h-100 shadow-sm border-0"> </div>
<div class="card-body"> <p class="card-text">Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.</p>
<div class="d-flex align-items-center mb-3"> <a href="{% url 'mainapp:load_excel_data' %}" class="btn btn-primary">
<div class="bg-primary bg-opacity-10 rounded-circle p-2 me-3"> Перейти к загрузке данных
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-excel text-primary" viewBox="0 0 16 16"> </a>
<path d="M5.884 6.68a.5.5 0 1 0-.768.64L7.349 10l-2.233 2.68a.5.5 0 0 0 .768.64L8 10.781l2.116 2.54a.5.5 0 0 0 .768-.641L8.651 10l2.233-2.68a.5.5 0 0 0-.768-.64L8 9.219z"/> </div>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> </div>
</svg> </div>
</div>
<h3 class="card-title mb-0">Загрузка данных из Excel</h3> <!-- CSV Data Upload Card -->
</div> <div class="col-lg-6">
<p class="card-text">Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.</p> <div class="card h-100 shadow-sm border-0">
<a href="{% url 'load_excel_data' %}" class="btn btn-primary"> <div class="card-body">
Перейти к загрузке данных <div class="d-flex align-items-center mb-3">
</a> <div class="bg-success bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-text text-success" viewBox="0 0 16 16">
</div> <path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1z"/>
</div> <path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0m0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
</svg>
<!-- CSV Data Upload Card --> </div>
<div class="col-lg-6"> <h3 class="card-title mb-0">Загрузка данных из CSV</h3>
<div class="card h-100 shadow-sm border-0"> </div>
<div class="card-body"> <p class="card-text">Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.</p>
<div class="d-flex align-items-center mb-3"> <a href="{% url 'mainapp:load_csv_data' %}" class="btn btn-success">
<div class="bg-success bg-opacity-10 rounded-circle p-2 me-3"> Перейти к загрузке данных
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-text text-success" viewBox="0 0 16 16"> </a>
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1z"/> </div>
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0m0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/> </div>
</svg> </div>
</div>
<h3 class="card-title mb-0">Загрузка данных из CSV</h3> <!-- Satellite List Card -->
</div> <div class="col-lg-6">
<p class="card-text">Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.</p> <div class="card h-100 shadow-sm border-0">
<a href="{% url 'load_csv_data' %}" class="btn btn-success"> <div class="card-body">
Перейти к загрузке данных <div class="d-flex align-items-center mb-3">
</a> <div class="bg-info bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-satellite text-info" viewBox="0 0 16 16">
</div> <path d="M13.37 1.37c-2.75 0-5.4 1.13-7.29 3.02C4.13 6.33 3 8.98 3 11.73c0 2.75 1.13 5.4 3.02 7.29 1.94 1.94 4.54 3.02 7.29 3.02 2.75 0 5.4-1.13 7.29-3.02 1.94-1.94 3.02-4.54 3.02-7.29 0-2.75-1.13-5.4-3.02-7.29C18.77 2.5-2.75 1.37-5.5 1.37m-5.5 8.26c0-1.52.62-3.02 1.73-4.13 1.11-1.11 2.61-1.73 4.13-1.73 1.52 0 3.02.62 4.13 1.73 1.11 1.11 1.73 2.61 1.73 4.13 0 1.52-.62 3.02-1.73 4.13-1.11 1.11-2.61 1.73-4.13 1.73-1.52 0-3.02-.62-4.13-1.73-1.11-1.11-1.73-2.61-1.73-4.13"/>
</div> <path d="M6.63 6.63c.62-.62 1.45-.98 2.27-.98.82 0 1.65.36 2.27.98.62.62.98 1.45.98 2.27 0 .82-.36 1.65-.98 2.27-.62.62-1.45.98-2.27.98-.82 0-1.65-.36-2.27-.98-.62-.62-.98-1.45-.98-2.27 0-.82.36-1.65.98-2.27m2.27 1.02c-.26 0-.52.1-.71.29-.2.2-.29.46-.29.71 0 .26.1.52.29.71.2.2.46.29.71.29.26 0 .52-.1.71-.29.2-.2.29-.46.29-.71 0-.26-.1-.52-.29-.71-.19-.19-.45-.29-.71-.29"/>
<path d="M5.13 5.13c.46-.46 1.08-.73 1.73-.73.65 0 1.27.27 1.73.73.46.46.73 1.08.73 1.73 0 .65-.27 1.27-.73 1.73-.46.46-1.08.73-1.73.73-.65 0-1.27-.27-1.73-.73-.46-.46-.73-1.08-.73-1.73 0-.65.27-1.27.73-1.73m1.73.58c-.15 0-.3.06-.42.18-.12.12-.18.27-.18.42 0 .15.06.3.18.42.12.12.27.18.42.18.15 0 .3-.06.42-.18.12-.12.18-.27.18-.42 0-.15-.06-.3-.18-.42-.12-.12-.27-.18-.42-.18"/>
<!-- Satellite List Card --> <path d="M8 3.5c.28 0 .5.22.5.5v1c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-1c0-.28.22-.5.5-.5"/>
<div class="col-lg-6"> <path d="M10.5 8c0-.28.22-.5.5-.5h1c.28 0 .5.22.5.5s-.22.5-.5.5h-1c-.28 0-.5-.22-.5-.5"/>
<div class="card h-100 shadow-sm border-0"> <path d="M8 12.5c-.28 0-.5.22-.5.5v1c0 .28.22.5.5.5s.5-.22.5-.5v-1c0-.28-.22-.5-.5-.5"/>
<div class="card-body"> <path d="M3.5 8c0 .28-.22.5-.5.5h-1c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h1c.28 0 .5.22.5.5"/>
<div class="d-flex align-items-center mb-3"> </svg>
<div class="bg-info bg-opacity-10 rounded-circle p-2 me-3"> </div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-satellite text-info" viewBox="0 0 16 16"> <h3 class="card-title mb-0">Добавление списка спутников</h3>
<path d="M13.37 1.37c-2.75 0-5.4 1.13-7.29 3.02C4.13 6.33 3 8.98 3 11.73c0 2.75 1.13 5.4 3.02 7.29 1.94 1.94 4.54 3.02 7.29 3.02 2.75 0 5.4-1.13 7.29-3.02 1.94-1.94 3.02-4.54 3.02-7.29 0-2.75-1.13-5.4-3.02-7.29C18.77 2.5-2.75 1.37-5.5 1.37m-5.5 8.26c0-1.52.62-3.02 1.73-4.13 1.11-1.11 2.61-1.73 4.13-1.73 1.52 0 3.02.62 4.13 1.73 1.11 1.11 1.73 2.61 1.73 4.13 0 1.52-.62 3.02-1.73 4.13-1.11 1.11-2.61 1.73-4.13 1.73-1.52 0-3.02-.62-4.13-1.73-1.11-1.11-1.73-2.61-1.73-4.13"/> </div>
<path d="M6.63 6.63c.62-.62 1.45-.98 2.27-.98.82 0 1.65.36 2.27.98.62.62.98 1.45.98 2.27 0 .82-.36 1.65-.98 2.27-.62.62-1.45.98-2.27.98-.82 0-1.65-.36-2.27-.98-.62-.62-.98-1.45-.98-2.27 0-.82.36-1.65.98-2.27m2.27 1.02c-.26 0-.52.1-.71.29-.2.2-.29.46-.29.71 0 .26.1.52.29.71.2.2.46.29.71.29.26 0 .52-.1.71-.29.2-.2.29-.46.29-.71 0-.26-.1-.52-.29-.71-.19-.19-.45-.29-.71-.29"/> <p class="card-text">Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.</p>
<path d="M5.13 5.13c.46-.46 1.08-.73 1.73-.73.65 0 1.27.27 1.73.73.46.46.73 1.08.73 1.73 0 .65-.27 1.27-.73 1.73-.46.46-1.08.73-1.73.73-.65 0-1.27-.27-1.73-.73-.46-.46-.73-1.08-.73-1.73 0-.65.27-1.27.73-1.73m1.73.58c-.15 0-.3.06-.42.18-.12.12-.18.27-.18.42 0 .15.06.3.18.42.12.12.27.18.42.18.15 0 .3-.06.42-.18.12-.12.18-.27.18-.42 0-.15-.06-.3-.18-.42-.12-.12-.27-.18-.42-.18"/> <a href="{% url 'mainapp:add_sats' %}" class="btn btn-info disabled">
<path d="M8 3.5c.28 0 .5.22.5.5v1c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-1c0-.28.22-.5.5-.5"/> Добавить список спутников
<path d="M10.5 8c0-.28.22-.5.5-.5h1c.28 0 .5.22.5.5s-.22.5-.5.5h-1c-.28 0-.5-.22-.5-.5"/> </a>
<path d="M8 12.5c-.28 0-.5.22-.5.5v1c0 .28.22.5.5.5s.5-.22.5-.5v-1c0-.28-.22-.5-.5-.5"/> </div>
<path d="M3.5 8c0 .28-.22.5-.5.5h-1c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h1c.28 0 .5.22.5.5"/> </div>
</svg> </div>
</div>
<h3 class="card-title mb-0">Добавление списка спутников</h3>
</div>
<p class="card-text">Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.</p> <!-- VCH Load Data Card -->
<a href="{% url 'add_sats' %}" class="btn btn-info"> <div class="col-lg-6">
Добавить список спутников <div class="card h-100 shadow-sm border-0">
</a> <div class="card-body">
</div> <div class="d-flex align-items-center mb-3">
</div> <div class="bg-danger bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-upload text-danger" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<!-- Transponders Card --> <path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
<div class="col-lg-6"> </svg>
<div class="card h-100 shadow-sm border-0"> </div>
<div class="card-body"> <h3 class="card-title mb-0">Добавление данных ВЧ загрузки</h3>
<div class="d-flex align-items-center mb-3"> </div>
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3"> <p class="card-text">Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.</p>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-wifi text-warning" viewBox="0 0 16 16"> <a href="{% url 'mainapp:vch_load' %}" class="btn btn-danger">
<path d="M6.002 3.5a5.5 5.5 0 1 1 3.996 9.5H10A5.5 5.5 0 0 1 6.002 3.5M6.002 5.5a3.5 3.5 0 1 0 3.996 5.5H10A3.5 3.5 0 0 0 6.002 5.5"/> Добавить данные ВЧ загрузки
<path d="M10.5 12.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5 3.5 3.5 0 0 1 7 0"/> </a>
</svg> </div>
</div> </div>
<h3 class="card-title mb-0">Добавление транспондеров</h3> </div>
</div>
<p class="card-text">Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.</p> <!-- Lyngsat Data Fill Card -->
<a href="{% url 'add_trans' %}" class="btn btn-warning"> <div class="col-lg-6">
Добавить транспондеры <div class="card h-100 shadow-sm border-0">
</a> <div class="card-body">
</div> <div class="d-flex align-items-center mb-3">
</div> <div class="bg-secondary bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-cloud-download text-secondary" viewBox="0 0 16 16">
<path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773 16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318 1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383"/>
<!-- VCH Load Data Card --> <path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708z"/>
<div class="col-lg-6"> </svg>
<div class="card h-100 shadow-sm border-0"> </div>
<div class="card-body"> <h3 class="card-title mb-0">Заполнение данных Lyngsat</h3>
<div class="d-flex align-items-center mb-3"> </div>
<div class="bg-danger bg-opacity-10 rounded-circle p-2 me-3"> <p class="card-text">Загрузите данные о транспондерах спутников с сайта Lyngsat. Выберите спутники и регионы для парсинга данных.</p>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-upload text-danger" viewBox="0 0 16 16"> <a href="{% url 'mainapp:fill_lyngsat_data' %}" class="btn btn-secondary">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/> Заполнить данные Lyngsat
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/> </a>
</svg> </div>
</div> </div>
<h3 class="card-title mb-0">Добавление данных ВЧ загрузки</h3> </div>
</div>
<p class="card-text">Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.</p> <!-- Calculation Card -->
<a href="{% url 'vch_load' %}" class="btn btn-danger"> <div class="col-lg-6">
Добавить данные ВЧ загрузки <div class="card h-100 shadow-sm border-0">
</a> <div class="card-body">
</div> <div class="d-flex align-items-center mb-3">
</div> <div class="bg-info bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-calculator text-info" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v4h2V2a1 1 0 0 0-1-1M5 6v1h1V6zm2 0v1h1V6zm2 0v1h1V6zm2 0v1h1V6zm1 2v1h1V8zm0 2v1h1v-1zm0 2v1h1v-1zm-8-6v8H3V8zm2 0v8h1V8zm2 0v8h1V8zm2 0v8h1V8z"/>
<!-- Map Views Card --> </svg>
<div class="col-lg-6"> </div>
<div class="card h-100 shadow-sm border-0"> <h3 class="card-title mb-0">Привязка ВЧ загрузки</h3>
<div class="card-body"> </div>
<div class="d-flex align-items-center mb-3"> <p class="card-text">Привязка ВЧ загрузки с sigma</p>
<div class="bg-secondary bg-opacity-10 rounded-circle p-2 me-3"> <a href="{% url 'mainapp:link_vch_sigma' %}" class="btn btn-info">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-map text-secondary" viewBox="0 0 16 16"> Открыть форму
<path d="M15.817.113A.5.5 0 0 1 16 .5v14a.5.5 0 0 1-.402.49l-5 1a.502.502 0 0 1-.196 0L5.5 15.01l-4.902.98A.5.5 0 0 1 0 15.5v-14a.5.5 0 0 1 .402-.49l5-1a.5.5 0 0 1 .196 0L10.5.99l4.902-.98a.5.5 0 0 1 .415.103M10 1.91l-4-.8v12.98l4 .8zM1.61 2.22l4.39.88v10.88l-4.39-.88zm9.18 10.88 4-.8V2.34l-4 .8z"/> </a>
</svg> </div>
</div> </div>
<h3 class="card-title mb-0">Карты</h3> </div>
</div>
<p class="card-text">Просматривайте данные на 2D и 3D картах для визуализации геолокации спутников.</p> <!-- New Event Card -->
<div class="mt-2"> <div class="col-lg-6">
<a href="{% url '2dmap' %}" class="btn btn-secondary me-2">2D Карта</a> <div class="card h-100 shadow-sm border-0">
<a href="{% url '3dmap' %}" class="btn btn-outline-secondary">3D Карта</a> <div class="card-body">
</div> <div class="d-flex align-items-center mb-3">
</div> <div class="bg-success bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-plus-circle text-success" viewBox="0 0 16 16">
</div> <path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0M4.5 7.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5M7.5 4.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 0 1h-1a.5.5 0 0 1-.5-.5m1 3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5"/>
</svg>
<!-- Calculation Card --> </div>
<div class="col-lg-6"> <h3 class="card-title mb-0">Формирование таблицы для Кубсатов</h3>
<div class="card h-100 shadow-sm border-0"> </div>
<div class="card-body"> <p class="card-text">Добавьте новое событие с помощью выбора спутника и загрузки файла данных.</p>
<div class="d-flex align-items-center mb-3"> <a href="{% url 'mainapp:kubsat_excel' %}" class="btn btn-success">
<div class="bg-info bg-opacity-10 rounded-circle p-2 me-3"> Добавить событие
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-calculator text-info" viewBox="0 0 16 16"> </a>
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v4h2V2a1 1 0 0 0-1-1M5 6v1h1V6zm2 0v1h1V6zm2 0v1h1V6zm2 0v1h1V6zm1 2v1h1V8zm0 2v1h1v-1zm0 2v1h1v-1zm-8-6v8H3V8zm2 0v8h1V8zm2 0v8h1V8zm2 0v8h1V8z"/> </div>
</svg> </div>
</div> </div>
<h3 class="card-title mb-0">Привязка ВЧ загрузки</h3>
</div> <!-- Link LyngSat Sources Card -->
<p class="card-text">Привязка ВЧ загрузки с sigma</p> <div class="col-lg-6">
<a href="{% url 'link_vch_sigma' %}" class="btn btn-info"> <div class="card h-100 shadow-sm border-0">
Открыть форму <div class="card-body">
</a> <div class="d-flex align-items-center mb-3">
</div> <div class="bg-primary bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-link-45deg text-primary" viewBox="0 0 16 16">
</div> <path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/>
<!-- New Event Card --> </svg>
<div class="col-lg-6"> </div>
<div class="card h-100 shadow-sm border-0"> <h3 class="card-title mb-0">Привязка источников LyngSat</h3>
<div class="card-body"> </div>
<div class="d-flex align-items-center mb-3"> <p class="card-text">Автоматическая привязка источников из базы LyngSat к объектам по частоте и поляризации. Объекты с привязанными источниками отображаются как "ТВ".</p>
<div class="bg-success bg-opacity-10 rounded-circle p-2 me-3"> <a href="{% url 'mainapp:link_lyngsat' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-plus-circle text-success" viewBox="0 0 16 16"> Привязать источники
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0M4.5 7.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5M7.5 4.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 0 1h-1a.5.5 0 0 1-.5-.5m1 3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5"/> </a>
</svg> </div>
</div> </div>
<h3 class="card-title mb-0">Формирование таблицы для Кубсатов</h3> </div>
</div>
<p class="card-text">Добавьте новое событие с помощью выбора спутника и загрузки файла данных.</p> <!-- Unlink All LyngSat Sources Card -->
<a href="{% url 'kubsat_excel' %}" class="btn btn-success"> <div class="col-lg-6">
Добавить событие <div class="card h-100 shadow-sm border-0">
</a> <div class="card-body">
</div> <div class="d-flex align-items-center mb-3">
</div> <div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-unlink text-warning" viewBox="0 0 16 16">
</div> <path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4H9q-.13 0-.25.031A2 2 0 0 1 7 10.5H4a2 2 0 1 1 0-4h1.535c.218-.376.495-.714.82-1z"/>
</div> <path d="M9 5.5a3 3 0 0 0-2.83 4h1.098A2 2 0 0 1 9 6.5h3a2 2 0 1 1 0 4h-1.535a4 4 0 0 1-.82 1H12a3 3 0 1 0 0-6z"/>
<path d="M1 1l14 14"/>
</svg>
</div>
<h3 class="card-title mb-0">Отвязка всех источников LyngSat</h3>
</div>
<p class="card-text">Отвязать все источники LyngSat от объектов. Все объекты перестанут отображаться как "ТВ" источники. Операция обратима через повторную привязку.</p>
<a href="{% url 'mainapp:unlink_all_lyngsat' %}" class="btn btn-warning">
Отвязать все источники
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,48 +1,45 @@
{% extends 'mainapp/base.html' %} {% extends 'mainapp/base.html' %}
{% block title %}Загрузка данных из CSV{% endblock %} {% block title %}Загрузка данных из CSV{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<h2 class="mb-0">Загрузка данных из CSV</h2> <h2 class="mb-0">Загрузка данных из CSV</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if messages %} <p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p>
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert"> <form method="post" enctype="multipart/form-data">
{{ message }} {% csrf_token %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> <!-- Form fields with Bootstrap styling -->
{% endfor %} {% include 'mainapp/components/_form_field.html' with field=form.file %}
{% endif %}
<!-- Automatic checkbox -->
<p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p> <div class="mb-3">
<div class="form-check">
<form method="post" enctype="multipart/form-data"> {{ form.is_automatic }}
{% csrf_token %} <label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
{{ form.is_automatic.label }}
<!-- Form fields with Bootstrap styling --> </label>
<div class="mb-3"> {% if form.is_automatic.help_text %}
<label for="{{ form.file.id_for_label }}" class="form-label">Выберите CSV файл:</label> <div class="form-text">{{ form.is_automatic.help_text }}</div>
{{ form.file }} {% endif %}
{% if form.file.errors %} </div>
<div class="text-danger mt-1">{{ form.file.errors }}</div> </div>
{% endif %}
<div class="form-text">Загрузите CSV-файл с данными для обработки</div> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
</div> <a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-success">Добавить в базу</button>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> </div>
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a> </form>
<button type="submit" class="btn btn-success">Добавить в базу</button> </div>
</div> </div>
</form> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,65 +1,47 @@
{% extends 'mainapp/base.html' %} {% extends 'mainapp/base.html' %}
{% block title %}Загрузка данных из Excel{% endblock %} {% block title %}Загрузка данных из Excel{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
<h2 class="mb-0">Загрузка данных из Excel</h2> <h2 class="mb-0">Загрузка данных из Excel</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if messages %} <p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert"> <form method="post" enctype="multipart/form-data">
{{ message }} {% csrf_token %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> <!-- Form fields with Bootstrap styling -->
{% endfor %} {% include 'mainapp/components/_form_field.html' with field=form.file %}
{% endif %} {% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
<p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
<!-- Automatic checkbox -->
<form method="post" enctype="multipart/form-data"> <div class="mb-3">
{% csrf_token %} <div class="form-check">
{{ form.is_automatic }}
<!-- Form fields with Bootstrap styling --> <label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
<div class="mb-3"> {{ form.is_automatic.label }}
<label for="{{ form.file.id_for_label }}" class="form-label">Выберите Excel файл:</label> </label>
{{ form.file }} {% if form.is_automatic.help_text %}
{% if form.file.errors %} <div class="form-text">{{ form.is_automatic.help_text }}</div>
<div class="text-danger mt-1">{{ form.file.errors }}</div> {% endif %}
{% endif %} </div>
<div class="form-text">Загрузите Excel-файл (.xlsx или .xls) с данными для обработки</div> </div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<div class="mb-3"> <a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<label for="{{ form.sat_choice.id_for_label }}" class="form-label">Выберите спутник:</label> <button type="submit" class="btn btn-primary">Добавить в базу</button>
{{ form.sat_choice }} </div>
{% if form.sat_choice.errors %} </form>
<div class="text-danger mt-1">{{ form.sat_choice.errors }}</div> </div>
{% endif %} </div>
</div> </div>
</div>
<div class="mb-3"> </div>
<label for="{{ form.number_input.id_for_label }}" class="form-label">Количество строк для обработки:</label>
{{ form.number_input }}
{% if form.number_input.errors %}
<div class="text-danger mt-1">{{ form.number_input.errors }}</div>
{% endif %}
<div class="form-text">Оставьте пустым или введите 0 для обработки всех строк</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-primary">Добавить в базу</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,79 +1,45 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head>
<meta charset="UTF-8"> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Геолокация{% endblock %}</title> <link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet"> <title>{% block title %}Геолокация{% endblock %}</title>
<link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<!-- Bootstrap Icons -->
<!-- Дополнительные стили (если нужно) --> <link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head> <!-- Bootstrap CSS -->
<body> <link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<!-- Навигационная панель --> <!-- Дополнительные стили -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> {% block extra_css %}{% endblock %}
<div class="container"> </head>
<a class="navbar-brand" href="{% url 'home' %}">Геолокация</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <body>
<span class="navbar-toggler-icon"></span> <!-- Навигационная панель -->
</button> {% include 'mainapp/components/_navbar.html' %}
<div class="collapse navbar-collapse" id="navbarNav">
{% if user.is_authenticated %} <!-- Сообщения -->
<ul class="navbar-nav me-auto"> <div class="container mt-3">
<li class="nav-item"> {% include 'mainapp/components/_messages.html' %}
<a class="nav-link" href="{% url 'home' %}">Объекты</a> </div>
</li>
<li class="nav-item"> <!-- Основной контент -->
<a class="nav-link" href="{% url 'actions' %}">Действия</a> <main class="{% if full_width_page %}container-fluid p-0{% else %}container mt-4{% endif %}">
</li> {% block content %}{% endblock %}
<li class="nav-item"> </main>
<a class="nav-link" href="{% url '3dmap' %}">3D карта</a>
</li> <!-- Bootstrap JS -->
<li class="nav-item"> <script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}" defer></script>
<a class="nav-link" href="{% url '2dmap' %}">2D карта</a>
</li> <!-- Common sorting functionality -->
<li class="nav-item"> <script src="{% static 'js/sorting.js' %}" defer></script>
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
</li> <!-- Дополнительные скрипты -->
</ul> {% block extra_js %}{% endblock %}
<ul class="navbar-nav"> </body>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
{% if user.first_name and user.last_name %}
{{ user.first_name }} {{ user.last_name }}
{% elif user.get_full_name %}
{{ user.get_full_name }}
{% else %}
{{ user.username }}
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'logout' %}">Выйти</a></li>
</ul>
</li>
</ul>
{% else %}
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Войти</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>
<!-- Основной контент -->
<main class="{% if full_width_page %}container-fluid p-0{% else %}container mt-4{% endif %}">
{% block content %}{% endblock %}
</main>
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html> </html>

View File

@@ -0,0 +1,113 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Управление кешем LyngSat{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi bi-database"></i> Управление кешем LyngSat
</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
<h5><i class="bi bi-info-circle"></i> Информация о кешировании</h5>
<ul class="mb-0">
<li><strong>Страницы регионов:</strong> кешируются на 7 дней</li>
<li><strong>Данные спутников:</strong> кешируются на 1 день</li>
<li><strong>Списки спутников:</strong> кешируются на 7 дней</li>
</ul>
</div>
<h5 class="mt-4">Очистка кеша</h5>
<p class="text-muted">
Выберите тип кеша для очистки. Это полезно, если нужно принудительно обновить данные.
</p>
<form method="post" class="mt-3">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">Тип кеша для очистки:</label>
<div class="list-group">
<button type="submit" name="cache_type" value="all"
class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<i class="bi bi-trash"></i> Очистить весь кеш
</h6>
</div>
<p class="mb-1 text-muted small">
Удалить все кешированные данные LyngSat (регионы, спутники, списки)
</p>
</button>
<button type="submit" name="cache_type" value="regions"
class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<i class="bi bi-globe"></i> Очистить кеш регионов
</h6>
</div>
<p class="mb-1 text-muted small">
Удалить кешированные страницы регионов (Europe, Asia, America, Atlantic)
</p>
</button>
<button type="submit" name="cache_type" value="satellites"
class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<i class="bi bi-satellite"></i> Очистить кеш спутников
</h6>
</div>
<p class="mb-1 text-muted small">
Удалить кешированные данные отдельных спутников
</p>
</button>
</div>
</div>
</form>
<hr>
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle"></i> Внимание</h6>
<p class="mb-0 small">
После очистки кеша следующий запрос данных будет выполняться дольше,
так как данные будут загружаться заново с сайта LyngSat.
</p>
</div>
<div class="mt-3">
<a href="{% url 'mainapp:fill_lyngsat_data' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад к заполнению данных
</a>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-terminal"></i> Альтернативные способы очистки
</h5>
</div>
<div class="card-body">
<h6>Через Django Management команду:</h6>
<pre class="bg-dark text-light p-3 rounded"><code>python manage.py clear_lyngsat_cache --type all</code></pre>
<h6 class="mt-3">Через Redis CLI:</h6>
<pre class="bg-dark text-light p-3 rounded"><code>redis-cli keys "dbapp:lyngsat*"
redis-cli del "dbapp:lyngsat_region:europe"</code></pre>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% comment %}
Компонент для элемента переключения видимости столбца
Использование:
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
{% endcomment %}
<li>
<label class="dropdown-item">
<input type="checkbox" class="column-toggle" data-column="{{ column_index }}" {% if checked %}checked{% endif %}
onchange="toggleColumn(this)"> {{ column_label }}
</label>
</li>

View File

@@ -0,0 +1,45 @@
{% comment %}
Компонент для выпадающего списка видимости столбцов
Использование:
{% include 'mainapp/components/_column_visibility_dropdown.html' %}
{% endcomment %}
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
id="columnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear"></i> Колонки
</button>
<ul class="dropdown-menu" aria-labelledby="columnVisibilityDropdown" style="z-index: 1050; max-height: 300px; overflow-y: auto;">
<li>
<label class="dropdown-item">
<input type="checkbox" id="select-all-columns" unchecked
onchange="toggleAllColumns(this)"> Выбрать всё
</label>
</li>
<li>
<hr class="dropdown-divider">
</li>
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=2 column_label="Спутник" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=3 column_label="Транспондер" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=4 column_label="Част, МГц" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=5 column_label="Полоса, МГц" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=6 column_label="Поляризация" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=7 column_label="Сим. V" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=8 column_label="Модул" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=9 column_label="ОСШ" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=10 column_label="Время ГЛ" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=11 column_label="Местоположение" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=12 column_label="Геолокация" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=13 column_label="Обновлено" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=14 column_label="Кем (обновление)" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=15 column_label="Создано" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=16 column_label="Кем (создание)" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=17 column_label="Комментарий" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %}
</ul>
</div>

View File

@@ -0,0 +1,99 @@
{% comment %}
Переиспользуемый компонент панели фильтров (Offcanvas)
Параметры:
- filters: список HTML-кода фильтров для отображения (опционально)
- filter_form: объект формы Django для фильтров (опционально)
- reset_url: URL для сброса фильтров (по умолчанию: текущая страница без параметров)
Использование:
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list %}
{% include 'mainapp/components/_filter_panel.html' with filter_form=form %}
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list reset_url='/sources/' %}
Примечание:
- Можно передать либо список HTML-кода фильтров через 'filters', либо форму Django через 'filter_form'
- Форма отправляется методом GET для сохранения параметров в URL
- Кнопка "Сбросить" очищает все параметры фильтрации
{% endcomment %}
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
{% if filter_form %}
{# Если передана форма Django, отображаем её поля #}
{% for field in filter_form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
{% elif filters %}
{# Если переданы готовые HTML-блоки фильтров #}
{% for filter in filters %}
{{ filter|safe }}
{% endfor %}
{% endif %}
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
{% if request.GET.sort %}
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
{% endif %}
{% if request.GET.search %}
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% endif %}
{% if request.GET.items_per_page %}
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
{% endif %}
<div class="d-grid gap-2 mt-3">
<button type="submit" class="btn btn-primary btn-sm">
Применить
</button>
<a href="{{ reset_url|default:'?' }}" class="btn btn-secondary btn-sm">
Сбросить
</a>
</div>
</form>
</div>
</div>
<script>
// Update filter counter badge when filters are active
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const filterCounter = document.getElementById('filterCounter');
if (filterCounter) {
// Count active filters (excluding pagination, sort, search, and items_per_page)
const excludedParams = ['page', 'sort', 'search', 'items_per_page'];
let activeFilters = 0;
for (const [key, value] of urlParams.entries()) {
if (!excludedParams.includes(key) && value) {
activeFilters++;
}
}
if (activeFilters > 0) {
filterCounter.textContent = activeFilters;
filterCounter.style.display = 'inline-block';
} else {
filterCounter.style.display = 'none';
}
}
});
</script>

View File

@@ -0,0 +1,33 @@
{% comment %}
Переиспользуемый компонент для отображения полей формы
Использование:
{% include 'mainapp/components/_form_field.html' with field=form.field_name %}
{% include 'mainapp/components/_form_field.html' with field=form.field_name label_class="custom-label" %}
{% endcomment %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label {% if label_class %}{{ label_class }}{% endif %}">
{{ field.label }}
{% if field.field.required %}<span class="text-danger">*</span>{% endif %}
</label>
{% if field.field.widget.input_type == 'checkbox' %}
<div class="form-check">
{{ field }}
</div>
{% else %}
{{ field }}
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>

View File

@@ -0,0 +1,71 @@
<!-- Frequency Plan Modal -->
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="modalLoadingSpinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
<div id="modalFrequencyContent" style="display: none;">
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;"></span> Downlink (синий), <span style="color: #fd7e14;"></span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
<div class="frequency-plan">
<div class="chart-controls">
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
</button>
</div>
<div class="frequency-chart-container">
<canvas id="modalFrequencyChart"></canvas>
</div>
<div class="mt-3">
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
</div>
</div>
</div>
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
<p>Нет данных о транспондерах для этого спутника</p>
</div>
</div>
</div>
</div>
</div>
<style>
.frequency-plan {
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.frequency-chart-container {
position: relative;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 20px;
height: 400px;
}
.chart-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.chart-controls button {
padding: 5px 15px;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,833 @@
{% load l10n %}
<!-- Вкладка фильтров и экспорта -->
<form method="get" id="filterForm" class="mb-4">
<input type="hidden" name="tab" value="filters">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Фильтры</h5>
</div>
<div class="card-body">
<div class="row">
<!-- Спутники -->
<div class="col-md-3 mb-3">
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellites', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellites', false)">Снять</button>
</div>
{{ form.satellites }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Полоса спутника -->
<div class="col-md-3 mb-3">
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('band', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('band', false)">Снять</button>
</div>
{{ form.band }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Поляризация -->
<div class="col-md-3 mb-3">
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', false)">Снять</button>
</div>
{{ form.polarization }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Модуляция -->
<div class="col-md-3 mb-3">
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', false)">Снять</button>
</div>
{{ form.modulation }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
</div>
<div class="row">
<!-- Центральная частота -->
<div class="col-md-3 mb-3">
<label class="form-label">Центральная частота (МГц)</label>
<div class="input-group">
{{ form.frequency_min }}
<span class="input-group-text"></span>
{{ form.frequency_max }}
</div>
</div>
<!-- Полоса -->
<div class="col-md-3 mb-3">
<label class="form-label">Полоса (МГц)</label>
<div class="input-group">
{{ form.freq_range_min }}
<span class="input-group-text"></span>
{{ form.freq_range_max }}
</div>
</div>
<!-- Тип объекта -->
<div class="col-md-3 mb-3">
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_type', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_type', false)">Снять</button>
</div>
{{ form.object_type }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Принадлежность объекта -->
<div class="col-md-3 mb-3">
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_ownership', false)">Снять</button>
</div>
{{ form.object_ownership }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
</div>
<div class="row">
<!-- Количество ObjItem -->
<div class="col-md-3 mb-3">
<label class="form-label">Количество привязанных точек ГЛ</label>
<div class="input-group mb-2">
{{ form.objitem_count_min }}
</div>
<div class="input-group">
{{ form.objitem_count_max }}
</div>
</div>
<!-- Планы на Кубсат -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.has_plans.label }}</label>
<div>
{% for radio in form.has_plans %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- ГСО успешно -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_1.label }}</label>
<div>
{% for radio in form.success_1 %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Кубсат успешно -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_2.label }}</label>
<div>
{% for radio in form.success_2 %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row">
<!-- Диапазон дат -->
<div class="col-md-6 mb-3">
<label class="form-label">Диапазон дат ГЛ:</label>
<div class="input-group">
{{ form.date_from }}
<span class="input-group-text"></span>
{{ form.date_to }}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary">Применить фильтры</button>
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
</div>
</div>
</div>
</div>
</form>
<!-- Кнопка экспорта и статистика -->
{% if sources_with_date_info %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
<!-- Поиск по имени точки -->
<div class="input-group" style="max-width: 350px;">
<input type="text" id="searchObjitemName" class="form-control"
placeholder="Поиск по имени точки..."
oninput="filterTableByName()">
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<button type="button" class="btn btn-success" onclick="exportToExcel()">
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
</button>
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
<i class="bi bi-plus-circle"></i> Создать заявки
</button>
<span class="text-muted" id="statsCounter">
Найдено объектов: {{ sources_with_date_info|length }},
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
</span>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Таблица результатов -->
{% if sources_with_date_info %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
<thead class="table-dark sticky-top">
<tr>
<th style="min-width: 80px;">ID объекта</th>
<th style="min-width: 120px;">Тип объекта</th>
<th style="min-width: 150px;">Принадлежность объекта</th>
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
<th class="text-center" style="min-width: 80px;">ГСО</th>
<th class="text-center" style="min-width: 80px;">Кубсат</th>
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
<th style="min-width: 150px;">Усреднённая координата</th>
<th style="min-width: 120px;">Имя точки</th>
<th style="min-width: 150px;">Спутник</th>
<th style="min-width: 100px;">Частота (МГц)</th>
<th style="min-width: 100px;">Полоса (МГц)</th>
<th style="min-width: 100px;">Поляризация</th>
<th style="min-width: 100px;">Модуляция</th>
<th style="min-width: 150px;">Координаты ГЛ</th>
<th style="min-width: 100px;">Дата ГЛ</th>
<th style="min-width: 150px;">Действия</th>
</tr>
</thead>
<tbody>
{% for source_data in sources_with_date_info %}
{% for objitem_data in source_data.objitems_data %}
<tr data-source-id="{{ source_data.source.id }}"
data-objitem-id="{{ objitem_data.objitem.id }}"
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
{% if source_data.source.ownership %}
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
<a href="#" class="text-primary text-decoration-none"
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
</a>
{% else %}
{{ source_data.source.ownership.name }}
{% endif %}
{% else %}
-
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
{% if source_data.requests_count > 0 %}
<span class="badge bg-info">{{ source_data.requests_count }}</span>
{% else %}
<span class="text-muted">0</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
{% if source_data.gso_success == True %}
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
{% elif source_data.gso_success == False %}
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
{% if source_data.kubsat_success == True %}
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
{% elif source_data.kubsat_success == False %}
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
{% if source_data.request_status %}
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
<span class="badge bg-success">{{ source_data.request_status }}</span>
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
<span class="badge bg-danger">{{ source_data.request_status }}</span>
{% elif source_data.request_status_raw == 'planned' %}
<span class="badge bg-primary">{{ source_data.request_status }}</span>
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
{% else %}
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
{% if source_data.avg_lat and source_data.avg_lon %}
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
{% else %}
-
{% endif %}
</td>
{% endif %}
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
{% endif %}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj %}
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj %}
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
{{ objitem_data.objitem.parameter_obj.polarization.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
{{ objitem_data.objitem.parameter_obj.modulation.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.geo_date %}
{{ objitem_data.geo_date|date:"d.m.Y" }}
{% else %}
-
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
<i class="bi bi-trash"></i>
</button>
{% if forloop.first %}
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
<i class="bi bi-trash-fill"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% elif request.GET %}
<div class="alert alert-info">
По заданным критериям ничего не найдено.
</div>
{% endif %}
<script>
// Функция для пересчёта усреднённых координат источника через Python API
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
function recalculateAverageCoords(sourceId) {
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
if (sourceRows.length === 0) return;
// Собираем ID всех оставшихся точек для этого источника
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
if (objitemIds.length === 0) {
// Нет точек - очищаем координаты
updateAvgCoordsCell(sourceId, null, null);
return;
}
// Вызываем Python API для пересчёта координат
const formData = new FormData();
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
formData.append('csrfmiddlewaretoken', csrfToken.value);
}
objitemIds.forEach(id => formData.append('objitem_ids', id));
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken ? csrfToken.value : ''
},
body: formData
})
.then(response => response.json())
.then(result => {
if (result.success && result.results[sourceId]) {
const coords = result.results[sourceId];
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
}
})
.catch(error => {
console.error('Error recalculating coords:', error);
});
}
// Обновляет ячейку с усреднёнными координатами
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
if (sourceRows.length === 0) return;
const firstRow = sourceRows[0];
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
if (avgCoordsCell) {
if (avgLat !== null && avgLon !== null) {
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
avgCoordsCell.dataset.avgLat = avgLat;
avgCoordsCell.dataset.avgLon = avgLon;
} else {
avgCoordsCell.textContent = '-';
avgCoordsCell.dataset.avgLat = '';
avgCoordsCell.dataset.avgLon = '';
}
}
}
function removeObjItem(button) {
const row = button.closest('tr');
const sourceId = row.dataset.sourceId;
const isFirstInSource = row.dataset.isFirstInSource === 'true';
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
// All rowspan cells that need to be handled
const rowspanCellClasses = [
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
];
if (sourceRows.length === 1) {
row.remove();
} else if (isFirstInSource) {
const nextRow = sourceRows[1];
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
if (cells.length > 0) {
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
const newRowspan = currentRowspan - 1;
// Clone and update all rowspan cells
const newCells = cells.map(cell => {
const newCell = cell.cloneNode(true);
newCell.setAttribute('rowspan', newRowspan);
if (newCell.classList.contains('source-count-cell')) {
newCell.textContent = newRowspan;
}
return newCell;
});
// Insert cells in reverse order to maintain correct order
newCells.reverse().forEach(cell => {
nextRow.insertBefore(cell, nextRow.firstChild);
});
const actionsCell = nextRow.querySelector('td:last-child');
if (actionsCell) {
const btnGroup = actionsCell.querySelector('.btn-group');
if (btnGroup && btnGroup.children.length === 1) {
const deleteSourceBtn = document.createElement('button');
deleteSourceBtn.type = 'button';
deleteSourceBtn.className = 'btn btn-sm btn-warning';
deleteSourceBtn.onclick = function() { removeSource(this); };
deleteSourceBtn.title = 'Удалить весь объект';
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
btnGroup.appendChild(deleteSourceBtn);
}
}
}
nextRow.dataset.isFirstInSource = 'true';
row.remove();
// Пересчитываем усреднённые координаты после удаления точки
recalculateAverageCoords(sourceId);
} else {
const firstRow = sourceRows[0];
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
if (cells.length > 0) {
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
const newRowspan = currentRowspan - 1;
cells.forEach(cell => {
cell.setAttribute('rowspan', newRowspan);
if (cell.classList.contains('source-count-cell')) {
cell.textContent = newRowspan;
}
});
}
row.remove();
// Пересчитываем усреднённые координаты после удаления точки
recalculateAverageCoords(sourceId);
}
updateCounter();
}
function removeSource(button) {
const row = button.closest('tr');
const sourceId = row.dataset.sourceId;
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
rows.forEach(r => r.remove());
updateCounter();
}
function updateCounter() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const counter = document.getElementById('statsCounter');
if (counter) {
// Подсчитываем уникальные источники и точки (только видимые)
const uniqueSources = new Set();
let visibleRowsCount = 0;
rows.forEach(row => {
if (row.style.display !== 'none') {
uniqueSources.add(row.dataset.sourceId);
visibleRowsCount++;
}
});
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
}
}
function exportToExcel() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
if (objitemIds.length === 0) {
alert('Нет данных для экспорта');
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '{% url "mainapp:kubsat_export" %}';
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken.value;
form.appendChild(csrfInput);
}
objitemIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'objitem_ids';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
}
function createRequestsFromTable() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
if (objitemIds.length === 0) {
alert('Нет данных для создания заявок');
return;
}
// Подсчитываем уникальные источники
const uniqueSources = new Set();
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
return;
}
// Показываем индикатор загрузки
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
const formData = new FormData();
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
formData.append('csrfmiddlewaretoken', csrfToken.value);
}
objitemIds.forEach(id => {
formData.append('objitem_ids', id);
});
fetch('{% url "mainapp:kubsat_create_requests" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken ? csrfToken.value : ''
},
body: formData
})
.then(response => response.json())
.then(result => {
btn.disabled = false;
btn.innerHTML = originalText;
if (result.success) {
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
if (result.errors && result.errors.length > 0) {
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
}
alert(message);
// Перезагружаем страницу для обновления данных
location.reload();
} else {
alert('Ошибка: ' + result.error);
}
})
.catch(error => {
btn.disabled = false;
btn.innerHTML = originalText;
console.error('Error creating requests:', error);
alert('Ошибка создания заявок');
});
}
// Фильтрация таблицы по имени точки
function filterTableByName() {
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
const rows = document.querySelectorAll('#resultsTable tbody tr');
if (!searchValue) {
// Показываем все строки
rows.forEach(row => {
row.style.display = '';
});
// Восстанавливаем rowspan
recalculateRowspans();
updateCounter();
return;
}
// Группируем строки по source_id
const sourceGroups = {};
rows.forEach(row => {
const sourceId = row.dataset.sourceId;
if (!sourceGroups[sourceId]) {
sourceGroups[sourceId] = [];
}
sourceGroups[sourceId].push(row);
});
// Фильтруем по имени точки используя data-атрибут
Object.keys(sourceGroups).forEach(sourceId => {
const sourceRows = sourceGroups[sourceId];
let hasVisibleRows = false;
sourceRows.forEach(row => {
// Используем data-атрибут для получения имени точки
const name = (row.dataset.objitemName || '').toLowerCase();
if (name.includes(searchValue)) {
row.style.display = '';
hasVisibleRows = true;
} else {
row.style.display = 'none';
}
});
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
if (!hasVisibleRows) {
sourceRows.forEach(row => {
row.style.display = 'none';
});
}
});
// Пересчитываем rowspan для видимых строк
recalculateRowspans();
updateCounter();
}
// Пересчет rowspan для видимых строк
function recalculateRowspans() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
// Группируем видимые строки по source_id
const sourceGroups = {};
rows.forEach(row => {
if (row.style.display !== 'none') {
const sourceId = row.dataset.sourceId;
if (!sourceGroups[sourceId]) {
sourceGroups[sourceId] = [];
}
sourceGroups[sourceId].push(row);
}
});
// All rowspan cell classes
const rowspanCellClasses = [
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
];
// Обновляем rowspan для каждой группы
Object.keys(sourceGroups).forEach(sourceId => {
const visibleRows = sourceGroups[sourceId];
const newRowspan = visibleRows.length;
if (visibleRows.length > 0) {
const firstRow = visibleRows[0];
rowspanCellClasses.forEach(cls => {
const cell = firstRow.querySelector(cls);
if (cell) {
cell.setAttribute('rowspan', newRowspan);
// Обновляем отображаемое количество точек
if (cell.classList.contains('source-count-cell')) {
cell.textContent = newRowspan;
}
}
});
}
});
}
// Очистка поиска
function clearSearch() {
document.getElementById('searchObjitemName').value = '';
filterTableByName();
}
document.addEventListener('DOMContentLoaded', function() {
updateCounter();
});
</script>

View File

@@ -0,0 +1,41 @@
{% comment %}
Переиспользуемый компонент для отображения сообщений Django
Использование:
{% include 'mainapp/components/_messages.html' %}
Для отключения автоскрытия добавьте extra_tags='persistent':
messages.success(request, "Сообщение", extra_tags='persistent')
{% endcomment %}
{% if messages %}
<div class="messages-container">
{% for message in messages %}
<div class="alert alert-{% if 'error' in message.tags %}danger{% elif 'success' in message.tags %}success{% elif 'warning' in message.tags %}warning{% else %}info{% endif %} alert-dismissible fade show {% if 'persistent' not in message.tags %}auto-dismiss{% endif %}" role="alert">
{% if 'error' in message.tags %}
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{% elif 'success' in message.tags %}
<i class="bi bi-check-circle-fill me-2"></i>
{% elif 'warning' in message.tags %}
<i class="bi bi-exclamation-circle-fill me-2"></i>
{% elif 'info' in message.tags %}
<i class="bi bi-info-circle-fill me-2"></i>
{% endif %}
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
<script>
// Автоматическое скрытие уведомлений через 5 секунд (кроме persistent)
document.addEventListener('DOMContentLoaded', function() {
const alerts = document.querySelectorAll('.alert.auto-dismiss');
alerts.forEach(function(alert) {
setTimeout(function() {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}, 5000);
});
});
</script>
{% endif %}

View File

@@ -0,0 +1,78 @@
{% comment %}
Переиспользуемый компонент навигационной панели
Использование:
{% include 'mainapp/components/_navbar.html' %}
{% endcomment %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'mainapp:source_list' %}">Геолокация</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% if user.is_authenticated %}
<ul class="navbar-nav me-auto">
<!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Главная</a>
</li> -->
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Объекты</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:satellite_list' %}">Спутники</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
</li>
<!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li> -->
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
</li>
<!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li> -->
<li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:2dmap' %}">Карта</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
{% if user.first_name and user.last_name %}
{{ user.first_name }} {{ user.last_name }}
{% elif user.get_full_name %}
{{ user.get_full_name }}
{% else %}
{{ user.username }}
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'logout' %}">Выйти</a></li>
</ul>
</li>
</ul>
{% else %}
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Войти</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>

View File

@@ -0,0 +1,126 @@
<!-- ObjItems Table Component -->
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">Объекты (ObjItems)</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.9rem;">
<thead class="table-dark sticky-top">
<tr>
<th>ID</th>
<th>Имя</th>
<th>Спутник</th>
<th>Частота, МГц</th>
<th>Полоса, МГц</th>
<th>Поляризация</th>
<th>Модуляция</th>
<th>Сим. v</th>
<th>ОСШ</th>
<th>Геолокация</th>
<th>Дата гео</th>
<th>Объект</th>
<th>LyngSat</th>
{% if show_marks == '1' %}
<th>Отметки</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for item in processed_objitems %}
<tr>
<td><a href="{% url 'mainapp:objitem_detail' item.id %}">{{ item.id }}</a></td>
<td>{{ item.name }}</td>
<td>{{ item.satellite }}</td>
<td>{{ item.frequency }}</td>
<td>{{ item.freq_range }}</td>
<td>{{ item.polarization }}</td>
<td>{{ item.modulation }}</td>
<td>{{ item.bod_velocity }}</td>
<td>{{ item.snr }}</td>
<td>{{ item.geo_coords }}</td>
<td>{{ item.geo_date }}</td>
<td>
{% if item.source_id %}
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if item.lyngsat_id %}
<a href="{% url 'admin:lyngsatapp_lyngsat_change' item.lyngsat_id %}" target="_blank">
<i class="bi bi-link-45deg"></i>
</a>
{% else %}
-
{% endif %}
</td>
{% if show_marks == '1' %}
<td>
{% if item.marks %}
<div style="max-height: 150px; overflow-y: auto;">
{% for mark in item.marks %}
<div class="mb-1">
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
<br>
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
<br>
<small class="text-muted">{{ mark.created_by }}</small>
</div>
{% endfor %}
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if show_marks == '1' %}14{% else %}13{% endif %}" class="text-center py-4 text-muted">
Нет данных для выбранных фильтров
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if page_obj.paginator.num_pages > 1 %}
<div class="card-footer">
<nav>
<ul class="pagination justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
<div class="text-center mt-2">
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
</div>
</div>
{% endif %}
</div>

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