Compare commits

...

2 Commits

16 changed files with 4512 additions and 4342 deletions

View File

@@ -1,25 +1,28 @@
# Django Settings
DEBUG=False DEBUG=False
ENVIRONMENT=production ENVIRONMENT=production
DJANGO_ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.production DJANGO_SETTINGS_MODULE=dbapp.settings.production
SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production SECRET_KEY=django-insecure-dev-key-only-for-production
# Database Configuration # Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb DB_NAME=geodb
DB_USER=geralt DB_USER=geralt
DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD DB_PASSWORD=123456
DB_HOST=db DB_HOST=db
DB_PORT=5432 DB_PORT=5432
# Allowed Hosts (comma-separated) # Allowed Hosts
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com 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 # PostgreSQL Configuration
POSTGRES_DB=geodb POSTGRES_DB=geodb
POSTGRES_USER=geralt POSTGRES_USER=geralt
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD POSTGRES_PASSWORD=123456
# Gunicorn Configuration # Redis Configuration
GUNICORN_WORKERS=3 REDIS_URL=redis://redis:6379/1
GUNICORN_TIMEOUT=120 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

View File

@@ -1,57 +1,53 @@
FROM python:3.13-slim FROM python:3.13.7-slim AS builder
# Install system dependencies # Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
gdal-bin \
libgdal-dev \
proj-bin \
proj-data \
libproj-dev \
libproj25 \
libgeos-dev \
libgeos-c1v5 \
build-essential \ build-essential \
postgresql-client \ gdal-bin libgdal-dev \
libproj-dev proj-bin \
libpq-dev \ libpq-dev \
libpq5 \
netcat-openbsd \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set work directory
WORKDIR /app WORKDIR /app
# Upgrade pip # Устанавливаем uv пакетно-менеджер глобально
RUN pip install --upgrade pip RUN pip install --no-cache-dir uv
# Copy requirements file # Копируем зависимости
COPY requirements.txt ./ COPY pyproject.toml uv.lock ./
# Install dependencies # Синхронизируем зависимости (включая prod + dev), чтобы билдить
RUN pip install --no-cache-dir -r requirements.txt RUN uv sync --locked
# Copy project files # Копируем весь код приложения
COPY . . COPY . .
# Create directories # --- рантайм-стадия — минимальный образ для продакшена ---
RUN mkdir -p /app/staticfiles /app/logs /app/media FROM python:3.13.7-slim
# Set permissions for entrypoint 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.sh исполняемым
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# Create non-root user
RUN useradd --create-home --shell /bin/bash app && \
chown -R app:app /app
USER app
# Expose port
EXPOSE 8000 EXPOSE 8000
# Run entrypoint script # Используем entrypoint для инициализации (миграции, статика)
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -19,23 +19,29 @@ DEBUG = False
# In production, specify allowed hosts explicitly from environment variable # In production, specify allowed hosts explicitly from environment variable
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") 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 # SECURITY SETTINGS
# ============================================================================ # ============================================================================
# SSL/HTTPS settings # SSL/HTTPS settings (disable for local testing without SSL)
SECURE_SSL_REDIRECT = True SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
# Security headers # Security headers
SECURE_BROWSER_XSS_FILTER = True SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
# HSTS settings # HSTS settings (disable for local testing)
SECURE_HSTS_SECONDS = 31536000 # 1 year SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "False") == "True"
SECURE_HSTS_PRELOAD = True SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "False") == "True"
# Additional security settings # Additional security settings
SECURE_REDIRECT_EXEMPT = [] SECURE_REDIRECT_EXEMPT = []
@@ -51,7 +57,7 @@ TEMPLATES = [
"DIRS": [ "DIRS": [
BASE_DIR / "templates", BASE_DIR / "templates",
], ],
"APP_DIRS": True, "APP_DIRS": False, # Must be False when using custom loaders
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
"django.template.context_processors.debug", "django.template.context_processors.debug",

View File

@@ -14,11 +14,11 @@ 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.views import custom_logout 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/', admin.site.urls, name='admin'), path('admin/', admin.site.urls, name='admin'),
@@ -28,4 +28,9 @@ urlpatterns = [
# Authentication URLs # Authentication URLs
path('login/', auth_views.LoginView.as_view(), name='login'), path('login/', auth_views.LoginView.as_view(), name='login'),
path('logout/', 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()

14
dbapp/entrypoint.sh Executable file → Normal file
View File

@@ -8,30 +8,30 @@ echo "Starting in $ENVIRONMENT mode..."
# Ждем PostgreSQL # Ждем PostgreSQL
echo "Waiting for PostgreSQL..." echo "Waiting for PostgreSQL..."
while ! nc -z $DB_HOST $DB_PORT; do until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
sleep 0.1 echo "PostgreSQL is unavailable - sleeping"
sleep 1
done done
echo "PostgreSQL started" echo "PostgreSQL started"
# Выполняем миграции # Выполняем миграции
echo "Running migrations..." echo "Running migrations..."
python manage.py migrate --noinput uv run python manage.py migrate --noinput
# Собираем статику (только для production) # Собираем статику (только для production)
if [ "$ENVIRONMENT" = "production" ]; then if [ "$ENVIRONMENT" = "production" ]; then
echo "Collecting static files..." echo "Collecting static files..."
python manage.py collectstatic --noinput uv run python manage.py collectstatic --noinput
fi fi
# Запускаем сервер в зависимости от окружения # Запускаем сервер в зависимости от окружения
if [ "$ENVIRONMENT" = "development" ]; then if [ "$ENVIRONMENT" = "development" ]; then
echo "Starting Django development server..." echo "Starting Django development server..."
exec python manage.py runserver 0.0.0.0:8000 exec uv run python manage.py runserver 0.0.0.0:8000
else else
echo "Starting Gunicorn..." echo "Starting Gunicorn..."
exec gunicorn --bind 0.0.0.0:8000 \ exec uv run gunicorn --bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \ --workers ${GUNICORN_WORKERS:-3} \
--timeout ${GUNICORN_TIMEOUT:-120} \ --timeout ${GUNICORN_TIMEOUT:-120} \
--reload \
dbapp.wsgi:application dbapp.wsgi:application
fi fi

View File

@@ -17,12 +17,7 @@
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 4px; border-radius: 4px;
padding: 20px; padding: 20px;
overflow-x: auto; height: 400px;
}
#frequencyCanvas {
display: block;
cursor: crosshair;
} }
.legend { .legend {
@@ -44,18 +39,16 @@
border-radius: 3px; border-radius: 3px;
} }
.transponder-tooltip { .chart-controls {
position: absolute; display: flex;
background: rgba(0, 0, 0, 0.9); gap: 10px;
color: white; margin-bottom: 15px;
padding: 10px; flex-wrap: wrap;
border-radius: 4px; }
font-size: 0.85rem;
pointer-events: none; .chart-controls button {
z-index: 1000; padding: 5px 15px;
display: none; font-size: 0.9rem;
max-width: 300px;
white-space: pre-line;
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -214,16 +207,28 @@
{% if action == 'update' and transponders %} {% if action == 'update' and transponders %}
<!-- Frequency Plan Visualization --> <!-- Frequency Plan Visualization -->
<!-- <div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h4>Частотный план</h4> <h4>Частотный план</h4>
<p class="text-muted">Визуализация транспондеров спутника по частотам (Downlink). Наведите курсор на транспондер для подробной информации.</p> <p class="text-muted">Визуализация транспондеров спутника по частотам (Downlink). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации.</p>
<div class="frequency-plan"> <div class="frequency-plan">
<div class="chart-controls">
<button type="button" class="btn btn-sm btn-outline-primary" id="resetZoom">
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="zoomIn">
<i class="bi bi-zoom-in"></i> Увеличить
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="zoomOut">
<i class="bi bi-zoom-out"></i> Уменьшить
</button>
</div>
<div class="frequency-chart-container"> <div class="frequency-chart-container">
<canvas id="frequencyCanvas"></canvas> <canvas id="frequencyChart"></canvas>
</div> </div>
<div class="legend"> <div class="legend">
@@ -256,9 +261,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> --> </div>
<div class="transponder-tooltip" id="transponderTooltip"></div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
@@ -267,7 +270,7 @@
{% if action == 'update' and transponders %} {% if action == 'update' and transponders %}
<script> <script>
// Transponder data from Django // Transponder data from Django
const transponders = {{ transponders|safe }}; const transpondersData = {{ transponders|safe }};
// Color mapping for polarizations // Color mapping for polarizations
const polarizationColors = { const polarizationColors = {
@@ -278,88 +281,129 @@ const polarizationColors = {
'default': '#6c757d' 'default': '#6c757d'
}; };
let canvas, ctx, tooltip;
let hoveredTransponder = null;
function getColor(polarization) { function getColor(polarization) {
return polarizationColors[polarization] || polarizationColors['default']; return polarizationColors[polarization] || polarizationColors['default'];
} }
function renderFrequencyPlan() { // Chart state
if (!transponders || transponders.length === 0) { let canvas, ctx, container;
let zoomLevel = 1;
let panOffset = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartOffset = 0;
let hoveredTransponder = null;
let transponderRects = [];
// Frequency range
let minFreq, maxFreq, freqRange;
let originalMinFreq, originalMaxFreq, originalFreqRange;
function initializeFrequencyChart() {
if (!transpondersData || transpondersData.length === 0) {
return; return;
} }
canvas = document.getElementById('frequencyCanvas'); canvas = document.getElementById('frequencyChart');
if (!canvas) return;
container = canvas.parentElement;
ctx = canvas.getContext('2d'); ctx = canvas.getContext('2d');
tooltip = document.getElementById('transponderTooltip');
// Find min and max frequencies // Calculate frequency range
let minFreq = Infinity; minFreq = Infinity;
let maxFreq = -Infinity; maxFreq = -Infinity;
transponders.forEach(t => { transpondersData.forEach(t => {
const startFreq = t.downlink - (t.frequency_range / 2); const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2); const endFreq = t.downlink + (t.frequency_range / 2);
minFreq = Math.min(minFreq, startFreq); minFreq = Math.min(minFreq, startFreq);
maxFreq = Math.max(maxFreq, endFreq); maxFreq = Math.max(maxFreq, endFreq);
}); });
// Add padding (5%) // Add 2% padding
const padding = (maxFreq - minFreq) * 0.05; const padding = (maxFreq - minFreq) * 0.02;
minFreq -= padding; minFreq -= padding;
maxFreq += padding; maxFreq += padding;
const freqRange = maxFreq - minFreq; // Store original values
originalMinFreq = minFreq;
originalMaxFreq = maxFreq;
originalFreqRange = maxFreq - minFreq;
freqRange = originalFreqRange;
// Setup event listeners
canvas.addEventListener('wheel', handleWheel, { passive: false });
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseLeave);
renderChart();
}
function renderChart() {
if (!canvas || !ctx) return;
// Set canvas size // Set canvas size
const container = canvas.parentElement; const dpr = window.devicePixelRatio || 1;
const canvasWidth = Math.max(container.clientWidth - 40, 800); const rect = container.getBoundingClientRect();
const rowHeight = 50; const width = rect.width;
const topMargin = 40; const height = rect.height;
const bottomMargin = 60;
// Group transponders by polarization to stack them canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Layout constants
const leftMargin = 60;
const rightMargin = 20;
const topMargin = 40;
const bottomMargin = 40;
const chartWidth = width - leftMargin - rightMargin;
const chartHeight = height - topMargin - bottomMargin;
// Group transponders by polarization
const polarizationGroups = {}; const polarizationGroups = {};
transponders.forEach(t => { transpondersData.forEach(t => {
const pol = t.polarization || 'default'; const pol = t.polarization || 'Другая';
if (!polarizationGroups[pol]) { if (!polarizationGroups[pol]) {
polarizationGroups[pol] = []; polarizationGroups[pol] = [];
} }
polarizationGroups[pol].push(t); polarizationGroups[pol].push(t);
}); });
const numRows = Object.keys(polarizationGroups).length; const polarizations = Object.keys(polarizationGroups);
const canvasHeight = topMargin + (numRows * rowHeight) + bottomMargin; const rowHeight = chartHeight / polarizations.length;
// Set canvas dimensions (use device pixel ratio for sharp rendering) // Calculate visible frequency range with zoom and pan
const dpr = window.devicePixelRatio || 1; const visibleFreqRange = freqRange / zoomLevel;
canvas.width = canvasWidth * dpr; const centerFreq = (minFreq + maxFreq) / 2;
canvas.height = canvasHeight * dpr; const visibleMinFreq = centerFreq - visibleFreqRange / 2 + panOffset;
canvas.style.width = canvasWidth + 'px'; const visibleMaxFreq = centerFreq + visibleFreqRange / 2 + panOffset;
canvas.style.height = canvasHeight + 'px';
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw frequency axis // Draw frequency axis
ctx.strokeStyle = '#dee2e6'; ctx.strokeStyle = '#dee2e6';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, topMargin); ctx.moveTo(leftMargin, topMargin);
ctx.lineTo(canvasWidth, topMargin); ctx.lineTo(width - rightMargin, topMargin);
ctx.stroke(); ctx.stroke();
// Draw frequency labels // Draw frequency labels and grid
ctx.fillStyle = '#6c757d'; ctx.fillStyle = '#6c757d';
ctx.font = '12px sans-serif'; ctx.font = '11px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const numLabels = 10; const numTicks = 10;
for (let i = 0; i <= numLabels; i++) { for (let i = 0; i <= numTicks; i++) {
const freq = minFreq + (freqRange * i / numLabels); const freq = visibleMinFreq + (visibleMaxFreq - visibleMinFreq) * i / numTicks;
const x = (canvasWidth * i / numLabels); const x = leftMargin + chartWidth * i / numTicks;
// Draw tick // Draw tick
ctx.beginPath(); ctx.beginPath();
@@ -367,114 +411,266 @@ function renderFrequencyPlan() {
ctx.lineTo(x, topMargin - 5); ctx.lineTo(x, topMargin - 5);
ctx.stroke(); ctx.stroke();
// Draw grid line
ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
ctx.beginPath();
ctx.moveTo(x, topMargin);
ctx.lineTo(x, height - bottomMargin);
ctx.stroke();
ctx.strokeStyle = '#dee2e6';
// Draw label // Draw label
ctx.fillText(freq.toFixed(1), x, topMargin - 10); ctx.fillText(freq.toFixed(1), x, topMargin - 10);
} }
// Draw "МГц" label // Draw axis title
ctx.textAlign = 'right'; ctx.fillStyle = '#000';
ctx.fillText('МГц', canvasWidth, topMargin - 25); ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Частота (МГц)', width / 2, topMargin - 25);
// Store transponder positions for hover detection // Draw polarization label
const transponderRects = []; ctx.save();
ctx.translate(15, height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.fillText('Поляризация', 0, 0);
ctx.restore();
// Clear transponder rects for hover detection
transponderRects = [];
// Draw transponders // Draw transponders
let yOffset = topMargin + 10; polarizations.forEach((pol, index) => {
Object.keys(polarizationGroups).forEach((pol, groupIndex) => {
const group = polarizationGroups[pol]; const group = polarizationGroups[pol];
const color = getColor(pol); const color = getColor(pol);
const y = topMargin + index * rowHeight;
const barHeight = rowHeight * 0.7;
const barY = y + (rowHeight - barHeight) / 2;
// Draw polarization label // Draw polarization label
ctx.fillStyle = '#000'; ctx.fillStyle = '#000';
ctx.font = 'bold 14px sans-serif'; ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'right';
ctx.fillText(`${pol}:`, 5, yOffset + 20); ctx.fillText(pol, leftMargin - 10, barY + barHeight / 2 + 4);
// Draw transponders
group.forEach(t => { group.forEach(t => {
const startFreq = t.downlink - (t.frequency_range / 2); const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2); const endFreq = t.downlink + (t.frequency_range / 2);
const x = ((startFreq - minFreq) / freqRange) * canvasWidth; // Check if transponder is visible
const width = ((endFreq - startFreq) / freqRange) * canvasWidth; if (endFreq < visibleMinFreq || startFreq > visibleMaxFreq) {
const y = yOffset; return;
const height = 30; }
// Draw transponder bar // Calculate position
const x1 = leftMargin + ((startFreq - visibleMinFreq) / (visibleMaxFreq - visibleMinFreq)) * chartWidth;
const x2 = leftMargin + ((endFreq - visibleMinFreq) / (visibleMaxFreq - visibleMinFreq)) * chartWidth;
const barWidth = x2 - x1;
// Skip if too small
if (barWidth < 1) return;
// Draw bar
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(x, y, width, height); ctx.fillRect(x1, barY, barWidth, barHeight);
// Draw border // Draw border
ctx.strokeStyle = '#fff'; ctx.strokeStyle = '#fff';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height); ctx.strokeRect(x1, barY, barWidth, barHeight);
// Draw transponder name if there's enough space // Draw name if there's space
if (width > 50) { if (barWidth > 40) {
ctx.fillStyle = pol === 'R' ? '#000' : '#fff'; ctx.fillStyle = (pol === 'R') ? '#000' : '#fff';
ctx.font = '11px sans-serif'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(t.name, x + width / 2, y + height / 2 + 4); ctx.fillText(t.name, x1 + barWidth / 2, barY + barHeight / 2 + 3);
} }
// Store for hover detection // Store for hover detection
transponderRects.push({ transponderRects.push({
x, y, width, height, x: x1,
data: t y: barY,
width: barWidth,
height: barHeight,
transponder: t
});
}); });
}); });
yOffset += rowHeight; // Draw hover tooltip
if (hoveredTransponder) {
drawTooltip(hoveredTransponder);
}
}
function drawTooltip(t) {
const startFreq = t.downlink - (t.frequency_range / 2);
const endFreq = t.downlink + (t.frequency_range / 2);
const lines = [
t.name,
'Диапазон: ' + startFreq.toFixed(3) + ' - ' + endFreq.toFixed(3) + ' МГц',
'Downlink: ' + t.downlink.toFixed(3) + ' МГц',
'Полоса: ' + t.frequency_range.toFixed(3) + ' МГц',
'Поляризация: ' + t.polarization,
'Зона: ' + t.zone_name
];
// Calculate tooltip size
ctx.font = '12px sans-serif';
const padding = 10;
const lineHeight = 16;
let maxWidth = 0;
lines.forEach(line => {
const width = ctx.measureText(line).width;
maxWidth = Math.max(maxWidth, width);
}); });
// Add mouse move event for tooltip const tooltipWidth = maxWidth + padding * 2;
canvas.addEventListener('mousemove', (e) => { const tooltipHeight = lines.length * lineHeight + padding * 2;
// Position tooltip
const mouseX = hoveredTransponder._mouseX || canvas.width / 2;
const mouseY = hoveredTransponder._mouseY || canvas.height / 2;
let tooltipX = mouseX + 15;
let tooltipY = mouseY + 15;
// Keep tooltip in bounds
if (tooltipX + tooltipWidth > canvas.width) {
tooltipX = mouseX - tooltipWidth - 15;
}
if (tooltipY + tooltipHeight > canvas.height) {
tooltipY = mouseY - tooltipHeight - 15;
}
// Draw tooltip background
ctx.fillStyle = 'rgba(0, 0, 0, 0.9)';
ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
// Draw tooltip text
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(lines[0], tooltipX + padding, tooltipY + padding + 12);
ctx.font = '11px sans-serif';
for (let i = 1; i < lines.length; i++) {
ctx.fillText(lines[i], tooltipX + padding, tooltipY + padding + 12 + i * lineHeight);
}
}
function handleWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(1, Math.min(20, zoomLevel * delta));
if (newZoom !== zoomLevel) {
zoomLevel = newZoom;
// Adjust pan to keep center
const maxPan = (originalFreqRange * (zoomLevel - 1)) / (2 * zoomLevel);
panOffset = Math.max(-maxPan, Math.min(maxPan, panOffset));
renderChart();
}
}
function handleMouseDown(e) {
isDragging = true;
dragStartX = e.clientX;
dragStartOffset = panOffset;
canvas.style.cursor = 'grabbing';
}
function handleMouseMove(e) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mouseY = e.clientY - rect.top;
hoveredTransponder = null; if (isDragging) {
const dx = e.clientX - dragStartX;
const freqPerPixel = (freqRange / zoomLevel) / (rect.width - 80);
panOffset = dragStartOffset - dx * freqPerPixel;
// Limit pan
const maxPan = (originalFreqRange * (zoomLevel - 1)) / (2 * zoomLevel);
panOffset = Math.max(-maxPan, Math.min(maxPan, panOffset));
renderChart();
} else {
// Check hover
let found = null;
for (const tr of transponderRects) { for (const tr of transponderRects) {
if (mouseX >= tr.x && mouseX <= tr.x + tr.width && if (mouseX >= tr.x && mouseX <= tr.x + tr.width &&
mouseY >= tr.y && mouseY <= tr.y + tr.height) { mouseY >= tr.y && mouseY <= tr.y + tr.height) {
hoveredTransponder = tr.data; found = tr.transponder;
found._mouseX = mouseX;
found._mouseY = mouseY;
break; break;
} }
} }
if (hoveredTransponder) { if (found !== hoveredTransponder) {
const startFreq = hoveredTransponder.downlink - (hoveredTransponder.frequency_range / 2); hoveredTransponder = found;
const endFreq = hoveredTransponder.downlink + (hoveredTransponder.frequency_range / 2); canvas.style.cursor = found ? 'pointer' : 'default';
renderChart();
tooltip.innerHTML = `<strong>${hoveredTransponder.name}</strong> } else if (found) {
Downlink: ${hoveredTransponder.downlink.toFixed(3)} МГц found._mouseX = mouseX;
Полоса: ${hoveredTransponder.frequency_range.toFixed(3)} МГц found._mouseY = mouseY;
Диапазон: ${startFreq.toFixed(3)} - ${endFreq.toFixed(3)} МГц }
Поляризация: ${hoveredTransponder.polarization}
Зона: ${hoveredTransponder.zone_name}`;
tooltip.style.display = 'block';
tooltip.style.left = (e.pageX + 15) + 'px';
tooltip.style.top = (e.pageY + 15) + 'px';
} else {
tooltip.style.display = 'none';
} }
});
canvas.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
hoveredTransponder = null;
});
} }
// Render on page load function handleMouseUp() {
document.addEventListener('DOMContentLoaded', renderFrequencyPlan); isDragging = false;
canvas.style.cursor = hoveredTransponder ? 'pointer' : 'default';
}
function handleMouseLeave() {
isDragging = false;
hoveredTransponder = null;
canvas.style.cursor = 'default';
renderChart();
}
function resetZoom() {
zoomLevel = 1;
panOffset = 0;
renderChart();
}
function zoomIn() {
zoomLevel = Math.min(20, zoomLevel * 1.2);
renderChart();
}
function zoomOut() {
zoomLevel = Math.max(1, zoomLevel / 1.2);
if (zoomLevel === 1) {
panOffset = 0;
}
renderChart();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initializeFrequencyChart();
// Control buttons
document.getElementById('resetZoom').addEventListener('click', resetZoom);
document.getElementById('zoomIn').addEventListener('click', zoomIn);
document.getElementById('zoomOut').addEventListener('click', zoomOut);
});
// Re-render on window resize // Re-render on window resize
let resizeTimeout; let resizeTimeout;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(renderFrequencyPlan, 250); resizeTimeout = setTimeout(renderChart, 250);
}); });
</script> </script>
{% endif %} {% endif %}

View File

@@ -21,7 +21,6 @@ dependencies = [
"django-dynamic-raw-id>=4.4", "django-dynamic-raw-id>=4.4",
"django-import-export>=4.3.10", "django-import-export>=4.3.10",
"django-leaflet>=0.32.0", "django-leaflet>=0.32.0",
"django-map-widgets>=0.5.1",
"django-more-admin-filters>=1.13", "django-more-admin-filters>=1.13",
"dotenv>=0.9.9", "dotenv>=0.9.9",
"flower>=2.0.1", "flower>=2.0.1",

11
dbapp/uv.lock generated
View File

@@ -366,7 +366,6 @@ dependencies = [
{ name = "django-dynamic-raw-id" }, { name = "django-dynamic-raw-id" },
{ name = "django-import-export" }, { name = "django-import-export" },
{ name = "django-leaflet" }, { name = "django-leaflet" },
{ name = "django-map-widgets" },
{ name = "django-more-admin-filters" }, { name = "django-more-admin-filters" },
{ name = "django-redis" }, { name = "django-redis" },
{ name = "dotenv" }, { name = "dotenv" },
@@ -407,7 +406,6 @@ requires-dist = [
{ name = "django-dynamic-raw-id", specifier = ">=4.4" }, { name = "django-dynamic-raw-id", specifier = ">=4.4" },
{ name = "django-import-export", specifier = ">=4.3.10" }, { name = "django-import-export", specifier = ">=4.3.10" },
{ name = "django-leaflet", specifier = ">=0.32.0" }, { name = "django-leaflet", specifier = ">=0.32.0" },
{ name = "django-map-widgets", specifier = ">=0.5.1" },
{ name = "django-more-admin-filters", specifier = ">=1.13" }, { name = "django-more-admin-filters", specifier = ">=1.13" },
{ name = "django-redis", specifier = ">=5.4.0" }, { name = "django-redis", specifier = ">=5.4.0" },
{ name = "dotenv", specifier = ">=0.9.9" }, { name = "dotenv", specifier = ">=0.9.9" },
@@ -598,15 +596,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/d3/bf4a46eff75a5a804fc32588696d2dcd04370008041114009f0f35a3fb42/django_leaflet-0.32.0-py3-none-any.whl", hash = "sha256:a17d8e6cc05dd98e8e543fbf198b81dabbf9f195c222e786d1686aeda91c1aa8", size = 582439, upload-time = "2025-05-14T12:49:34.151Z" }, { url = "https://files.pythonhosted.org/packages/ec/d3/bf4a46eff75a5a804fc32588696d2dcd04370008041114009f0f35a3fb42/django_leaflet-0.32.0-py3-none-any.whl", hash = "sha256:a17d8e6cc05dd98e8e543fbf198b81dabbf9f195c222e786d1686aeda91c1aa8", size = 582439, upload-time = "2025-05-14T12:49:34.151Z" },
] ]
[[package]]
name = "django-map-widgets"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/50/651dae7335fc9c6df7b1ab27c49b1cc98245ac0d61750538a192da19e671/django_map_widgets-0.5.1.tar.gz", hash = "sha256:68e81f9c58c1cd6d180421220a4d100a185c8062ae0ca7be790658fcfd4eda1d", size = 160819, upload-time = "2024-07-09T17:37:50.717Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/75/7f1782c9fa3c07c2ca63ce7b65c4838afb568a5ea71aa119aaa9dc456d8b/django_map_widgets-0.5.1-py3-none-any.whl", hash = "sha256:7307935163b46c6a2a225c85c91c7262a8b47a5c3aefbbc6d8fc7a5fda53b7cd", size = 256008, upload-time = "2024-07-09T17:37:48.941Z" },
]
[[package]] [[package]]
name = "django-more-admin-filters" name = "django-more-admin-filters"
version = "1.13" version = "1.13"

View File

@@ -1,95 +1,60 @@
services: services:
db:
image: postgis/postgis:17-3.4
container_name: postgres-postgis-prod
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-geodb}
POSTGRES_USER: ${POSTGRES_USER:-geralt}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
ports:
- "5432:5432"
volumes:
- postgres_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-geralt} -d ${POSTGRES_DB:-geodb}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
web: web:
build: build:
context: ./dbapp context: ./dbapp
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: django-app-prod env_file:
restart: always - .env.prod
environment:
- DEBUG=False
- ENVIRONMENT=production
- DJANGO_SETTINGS_MODULE=dbapp.settings.production
- SECRET_KEY=${SECRET_KEY}
- DB_ENGINE=django.contrib.gis.db.backends.postgis
- DB_NAME=${DB_NAME:-geodb}
- DB_USER=${DB_USER:-geralt}
- DB_PASSWORD=${DB_PASSWORD:-123456}
- DB_HOST=db
- DB_PORT=5432
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost,127.0.0.1}
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-3}
- GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-120}
ports:
- "8000:8000"
volumes:
- static_volume_prod:/app/staticfiles
- media_volume_prod:/app/media
- logs_volume_prod:/app/logs
depends_on: depends_on:
db: - db
condition: service_healthy
networks:
- app-network
tileserver:
image: maptiler/tileserver-gl:latest
container_name: tileserver-gl-prod
restart: always
ports:
- "8080:8080"
volumes: volumes:
- ./tiles:/data - static_volume:/app/staticfiles
- tileserver_config_prod:/config expose:
environment: - 8000
- VERBOSE=false
networks: worker:
- app-network build:
context: ./dbapp
dockerfile: Dockerfile
env_file:
- .env.prod
entrypoint: []
command: ["uv", "run", "celery", "-A", "dbapp", "worker", "--loglevel=INFO"]
depends_on:
- db
- redis
- web
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- 6379:6379
db:
image: postgis/postgis:18-3.6
container_name: postgres-postgis
restart: unless-stopped
env_file:
- .env.prod
ports:
- 5432:5432
volumes:
- pgdata:/var/lib/postgresql
# networks:
# - app-network
nginx: nginx:
image: nginx:alpine image: nginx:alpine
container_name: nginx-prod
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- static_volume_prod:/app/staticfiles:ro
- media_volume_prod:/app/media:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on: depends_on:
- web - web
networks: ports:
- app-network - 8080:80
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- static_volume:/usr/share/nginx/html/static
# если у тебя медиа — можно замонтировать том media
volumes: volumes:
postgres_data_prod: pgdata:
static_volume_prod: static_volume:
media_volume_prod:
logs_volume_prod:
tileserver_config_prod:
networks:
app-network:
driver: bridge

View File

@@ -1,39 +1,39 @@
events { upstream django {
worker_connections 1024; server web:8000;
} }
http { server {
include /etc/nginx/mime.types; listen 80;
default_type application/octet-stream; server_name _;
# Log format proxy_connect_timeout 300s;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' proxy_send_timeout 300s;
'$status $body_bytes_sent "$http_referer" ' proxy_read_timeout 300s;
'"$http_user_agent" "$http_x_forwarded_for"'; send_timeout 300s;
# Максимальный размер тела запроса, например для загрузки файлов
client_max_body_size 200m;
access_log /var/log/nginx/access.log main; # Статические файлы (статика Django)
error_log /var/log/nginx/error.log; location /static/ {
alias /usr/share/nginx/html/static/; # ← тут путь в контейнере nginx, куда монтируется том со static
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Security headers # Медиа-файлы, если есть MEDIA_URL
add_header X-Frame-Options "SAMEORIGIN" always; location /media/ {
add_header X-Content-Type-Options "nosniff" always; alias /usr/share/nginx/media/; # путь, куда монтируется media-том
add_header Referrer-Policy "no-referrer-when-downgrade" always; expires 30d;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; add_header Cache-Control "public, max-age=2592000";
}
# Proxy settings # Прокси для всех остальных запросов на Django (асинхронный / uvicorn или gunicorn)
proxy_set_header Host $http_host; location / {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name; proxy_redirect off;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
# gzip_proxied expired no-cache no-store private must-revalidate auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Include server blocks
include /etc/nginx/conf.d/*.conf;
} }