После рефакторинга

This commit is contained in:
2025-11-18 14:44:32 +03:00
parent 55759ec705
commit c8bcd1adf0
56 changed files with 204454 additions and 683 deletions

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,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();
}

View File

@@ -9,9 +9,6 @@
<p class="lead">Управление данными спутников</p>
</div>
<!-- Alert messages -->
{% include 'mainapp/components/_messages.html' %}
<!-- Main feature cards -->
<div class="row g-4">
<!-- Excel Data Upload Card -->

View File

@@ -11,8 +11,6 @@
<h2 class="mb-0">Загрузка данных из CSV</h2>
</div>
<div class="card-body">
{% include 'mainapp/components/_messages.html' %}
<p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p>
<form method="post" enctype="multipart/form-data">
@@ -22,7 +20,7 @@
{% include 'mainapp/components/_form_field.html' with field=form.file %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-success">Добавить в базу</button>
</div>
</form>

View File

@@ -11,8 +11,6 @@
<h2 class="mb-0">Загрузка данных из Excel</h2>
</div>
<div class="card-body">
{% include 'mainapp/components/_messages.html' %}
<p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
<form method="post" enctype="multipart/form-data">
@@ -24,7 +22,7 @@
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-primary">Добавить в базу</button>
</div>
</form>

View File

@@ -35,6 +35,9 @@
<!-- Bootstrap JS -->
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}" defer></script>
<!-- Common sorting functionality -->
<script src="{% static 'js/sorting.js' %}" defer></script>
<!-- Дополнительные скрипты -->
{% block extra_js %}{% endblock %}
</body>

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

@@ -7,7 +7,7 @@
{% if messages %}
<div class="messages-container">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
<div class="alert alert-{{ message.tags }} alert-dismissible fade show auto-dismiss" role="alert">
{% if message.tags == 'error' %}
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{% elif message.tags == 'success' %}
@@ -22,4 +22,17 @@
</div>
{% endfor %}
</div>
<script>
// Автоматическое скрытие уведомлений через 5 секунд
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

@@ -6,7 +6,7 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'mainapp:home' %}">Геолокация</a>
<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>
@@ -14,7 +14,7 @@
{% if user.is_authenticated %}
<ul class="navbar-nav me-auto">
<!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:home' %}">Главная</a>
<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>

View File

@@ -0,0 +1,27 @@
{% comment %}
Переиспользуемый компонент заголовка таблицы с сортировкой
Параметры:
- field: имя поля для сортировки (обязательный)
- label: отображаемый текст заголовка (обязательный)
- current_sort: текущее значение сортировки из контекста (обязательный)
Использование:
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}
Примечание:
Функция updateSort() определена в static/js/sorting.js и загружается через base.html
{% endcomment %}
<a href="javascript:void(0)"
onclick="updateSort('{{ field }}')"
class="text-white text-decoration-none"
data-sort-field="{{ field }}">
{{ label }}
{% if current_sort == field %}
<i class="bi bi-arrow-up sort-icon"></i>
{% elif current_sort == '-'|add:field %}
<i class="bi bi-arrow-down sort-icon"></i>
{% endif %}
</a>

View File

@@ -0,0 +1,146 @@
{% comment %}
Переиспользуемый компонент панели инструментов
Параметры:
- show_search: показывать ли поиск (по умолчанию: True)
- show_filters: показывать ли кнопку фильтров (по умолчанию: True)
- show_actions: показывать ли кнопки действий (по умолчанию: True)
- search_placeholder: текст placeholder для поиска (по умолчанию: "Поиск...")
- search_query: текущее значение поиска
- items_per_page: текущее количество элементов на странице
- available_items_per_page: список доступных значений для выбора
- action_buttons: HTML-код кнопок действий (опционально)
- page_obj: объект пагинации Django
- show_pagination_info: показывать ли информацию о количестве элементов (по умолчанию: True)
- extra_buttons: дополнительные кнопки между фильтрами и пагинацией (опционально)
Использование:
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True %}
{% include 'mainapp/components/_toolbar.html' with show_search=False show_actions=False %}
{% endcomment %}
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
{% if show_search|default:True %}
<!-- Search bar -->
<div style="min-width: 200px; flex-grow: 1; max-width: 400px;">
<div class="input-group">
<input type="text" id="toolbar-search" class="form-control"
placeholder="{{ search_placeholder|default:'Поиск...' }}"
value="{{ search_query|default:'' }}">
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">
Найти
</button>
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
Очистить
</button>
</div>
</div>
{% endif %}
<!-- Items per page select -->
<div>
<label for="items-per-page" class="form-label mb-0">Показать:</label>
<select name="items_per_page" id="items-per-page"
class="form-select form-select-sm d-inline-block" style="width: auto;"
onchange="updateItemsPerPage()">
{% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
{% if show_actions|default:True %}
<!-- Action buttons -->
<div class="d-flex gap-2">
{% if action_buttons %}
{{ action_buttons|safe }}
{% endif %}
</div>
{% endif %}
{% if show_filters|default:True %}
<!-- Filter Toggle Button -->
<div>
<button class="btn btn-outline-primary btn-sm" type="button"
data-bs-toggle="offcanvas" data-bs-target="#offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
</div>
{% endif %}
{% if extra_buttons %}
<!-- Extra buttons (e.g., polygon filter) -->
<div class="d-flex gap-2">
{{ extra_buttons|safe }}
</div>
{% endif %}
<!-- Pagination -->
<div class="ms-auto">
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=show_pagination_info|default:True %}
</div>
</div>
</div>
</div>
<script>
// Search functionality
function performSearch() {
const searchInput = document.getElementById('toolbar-search');
const searchValue = searchInput.value.trim();
const urlParams = new URLSearchParams(window.location.search);
if (searchValue) {
urlParams.set('search', searchValue);
} else {
urlParams.delete('search');
}
// Reset to first page when searching
urlParams.delete('page');
window.location.search = urlParams.toString();
}
function clearSearch() {
const searchInput = document.getElementById('toolbar-search');
searchInput.value = '';
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Allow Enter key to trigger search
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('toolbar-search');
if (searchInput) {
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
}
});
// Items per page functionality
function updateItemsPerPage() {
const select = document.getElementById('items-per-page');
const itemsPerPage = select.value;
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('items_per_page', itemsPerPage);
// Reset to first page when changing items per page
urlParams.delete('page');
window.location.search = urlParams.toString();
}
</script>

View File

@@ -17,9 +17,6 @@
</h3>
</div>
<div class="card-body">
<!-- Alert messages -->
{% include 'mainapp/components/_messages.html' %}
<div class="alert alert-info" role="alert">
<strong>Внимание!</strong> Процесс заполнения данных может занять продолжительное время,
так как выполняются запросы к внешнему сайту Lyngsat. Пожалуйста, дождитесь завершения операции.

View File

@@ -13,9 +13,6 @@
</h3>
</div>
<div class="card-body">
<!-- Alert messages -->
{% include 'mainapp/components/_messages.html' %}
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i>
<strong>Информация:</strong> Эта функция автоматически привязывает источники из базы LyngSat к объектам

View File

@@ -11,15 +11,6 @@
<h2 class="mb-0">Привязка ВЧ загрузки</h2>
</div>
<div class="card-body">
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<div class="alert alert-info" role="alert">
<strong>Информация о привязке:</strong>
<p class="mb-2">Погрешность центральной частоты определяется автоматически в зависимости от полосы:</p>
@@ -67,8 +58,8 @@
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
{% comment %} <a href="{% url 'mainapp:home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
{% comment %} <a href="{% url 'mainapp:source_list' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
<button type="submit" class="btn btn-info">Выполнить привязку</button>
</div>
</form>

View File

@@ -5,22 +5,10 @@
{% block extra_css %}
<style>
.marks-table {
width: 100%;
border-collapse: collapse;
}
.marks-table th,
.marks-table td {
border: 1px solid #dee2e6;
padding: 8px;
vertical-align: middle;
}
.marks-table th {
background-color: #f8f9fa;
font-weight: 600;
text-align: center;
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
.source-info-cell {
@@ -70,13 +58,6 @@
font-size: 0.75rem;
}
.filter-section {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.no-marks {
color: #6c757d;
font-style: italic;
@@ -105,195 +86,286 @@
{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Наличие сигнала объектов</h2>
<div class="container-fluid px-3">
<!-- Page Header -->
<div class="row mb-3">
<div class="col-12">
<h2>Наличие сигнала объектов</h2>
</div>
</div>
<!-- Фильтры -->
<div class="filter-section">
<form method="get" class="row g-3">
<div class="col-md-4">
<label for="search" class="form-label">Поиск по имени объекта</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Введите имя объекта..."
value="{{ request.GET.search|default:'' }}">
<!-- Toolbar with search, pagination, and filters -->
<div class="row mb-3">
<div class="col-12">
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=False search_placeholder='Поиск по ID или имени объекта...' %}
</div>
</div>
<!-- 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">
<thead class="table-dark sticky-top">
<tr>
<th class="source-info-cell">
{% include 'mainapp/components/_sort_header.html' with field='id' label='Информация об объекте' current_sort=sort %}
</th>
<th class="marks-cell">Наличие</th>
<th class="marks-cell">
{% include 'mainapp/components/_sort_header.html' with field='last_mark_date' label='Дата и время' current_sort=sort %}
</th>
<th class="actions-cell">Действия</th>
</tr>
</thead>
<tbody>
{% for source in sources %}
{% with marks=source.marks.all %}
{% if marks %}
<!-- Первая строка с информацией об объекте и первой отметкой -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell" rowspan="{{ marks.count }}">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя объекта:</strong> {{ source.objitem_name }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
</td>
{% with first_mark=marks.0 %}
<td class="marks-cell" data-mark-id="{{ first_mark.id }}">
<span class="mark-status {% if first_mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if first_mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ first_mark.id }}, {{ first_mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ first_mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ first_mark.created_by|default:"—" }}</small>
</td>
<td class="actions-cell" rowspan="{{ marks.count }}">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
{% endwith %}
</tr>
<!-- Остальные отметки -->
{% for mark in marks|slice:"1:" %}
<tr data-source-id="{{ source.id }}">
<td class="marks-cell" data-mark-id="{{ mark.id }}">
<span class="mark-status {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ mark.id }}, {{ mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ mark.created_by|default:"—" }}</small>
</td>
</tr>
{% endfor %}
{% else %}
<!-- Объект без отметок -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя объекта:</strong> {{ source.objitem_name }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
</td>
<td colspan="2" class="no-marks">Отметок нет</td>
<td class="actions-cell">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
</tr>
{% endif %}
{% endwith %}
{% empty %}
<tr>
<td colspan="4" class="text-center py-4">
<p class="text-muted mb-0">Объекты не найдены</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<label for="satellite" class="form-label">Спутник</label>
<select class="form-select" id="satellite" name="satellite">
<option value="">Все спутники</option>
{% for sat in satellites %}
<option value="{{ sat.id }}" {% if request.GET.satellite == sat.id|stringformat:"s" %}selected{% endif %}>
{{ sat.name }}
</div>
</div>
</div>
<!-- Offcanvas Filter Panel -->
<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">
<!-- Satellite Selection - Multi-select -->
<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">
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">Применить</button>
<a href="{% url 'mainapp:object_marks' %}" class="btn btn-secondary">Сбросить</a>
<!-- Mark Status Filter -->
<div class="mb-2">
<label class="form-label">Статус отметок:</label>
<select name="mark_status" class="form-select form-select-sm">
<option value="">Все</option>
<option value="with_marks" {% if filter_mark_status == 'with_marks' %}selected{% endif %}>С отметками</option>
<option value="without_marks" {% if filter_mark_status == 'without_marks' %}selected{% endif %}>Без отметок</option>
</select>
</div>
<!-- Date Range Filters -->
<div class="mb-2">
<label class="form-label">Дата отметки от:</label>
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ filter_date_from }}">
</div>
<div class="mb-2">
<label class="form-label">Дата отметки до:</label>
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ filter_date_to }}">
</div>
<!-- User Selection - Multi-select -->
<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('user_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('user_id', false)">Снять</button>
</div>
<select name="user_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for user in users %}
<option value="{{ user.id }}" {% if user.id in selected_users %}selected{% endif %}>
{{ user.user.username }}
</option>
{% endfor %}
</select>
</div>
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
{% 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="?" class="btn btn-secondary btn-sm">
Сбросить
</a>
</div>
</form>
</div>
<!-- Таблица с наличие сигналами -->
<div class="table-responsive">
<table class="marks-table table table-bordered">
<thead>
<tr>
<th class="source-info-cell">Информация об объекте</th>
<th class="marks-cell">Наличие</th>
<th class="marks-cell">Дата и время</th>
<th class="actions-cell">Действия</th>
</tr>
</thead>
<tbody>
{% for source in sources %}
{% with marks=source.marks.all %}
{% if marks %}
<!-- Первая строка с информацией об объекте и первой отметкой -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell" rowspan="{{ marks.count }}">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя объекта:</strong> {{ source.objitem_name }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
</td>
{% with first_mark=marks.0 %}
<td class="marks-cell" data-mark-id="{{ first_mark.id }}">
<span class="mark-status {% if first_mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if first_mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ first_mark.id }}, {{ first_mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ first_mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ first_mark.created_by|default:"—" }}</small>
</td>
<td class="actions-cell" rowspan="{{ marks.count }}">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
{% endwith %}
</tr>
<!-- Остальные наличие сигнала -->
{% for mark in marks|slice:"1:" %}
<tr data-source-id="{{ source.id }}">
<td class="marks-cell" data-mark-id="{{ mark.id }}">
<span class="mark-status {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ mark.id }}, {{ mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ mark.created_by|default:"—" }}</small>
</td>
</tr>
{% endfor %}
{% else %}
<!-- Объект без отметок -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Имя объекта:</strong> {{ source.objitem_name }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
</td>
<td colspan="2" class="no-marks">Отметок нет</td>
<td class="actions-cell">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
</tr>
{% endif %}
{% endwith %}
{% empty %}
<tr>
<td colspan="4" class="text-center py-4">
<p class="text-muted mb-0">Объекти не найдены</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Навигация по страницам" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Предыдущая</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="?page={{ page_obj.next_page_number }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
<script>
// Multi-select helper function
function selectAllOptions(selectName, select) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let option of selectElement.options) {
option.selected = select;
}
}
}
// 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>
{% endblock %}
{% block extra_js %}

View File

@@ -16,7 +16,7 @@
<p>Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.</p>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-danger">Удалить</button>
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary ms-2">Отмена</a>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary ms-2">Отмена</a>
</div>
</form>
</div>

View File

@@ -1,4 +1,5 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Список объектов{% endblock %}
{% block extra_css %}
@@ -8,6 +9,9 @@
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/sorting.js' %}"></script>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">

View File

@@ -40,7 +40,7 @@
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary">Назад</a>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary">Назад</a>
<button type="submit" class="btn btn-success">Выполнить</button>
</div>
</form>

View File

@@ -67,7 +67,7 @@
<input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary">
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Отмена
</a>
<button type="submit" class="btn btn-danger" id="confirmDeleteBtn">
@@ -119,7 +119,7 @@ document.getElementById('deleteForm').addEventListener('submit', function(e) {
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{% url "mainapp:home" %}';
window.location.href = '{% url "mainapp:source_list" %}';
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
btn.disabled = false;

View File

@@ -136,7 +136,7 @@
<a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-danger btn-action">Удалить</a>
{% endif %}
<a href="{% url 'mainapp:home' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
<a href="{% url 'mainapp:source_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-secondary btn-action">Назад</a>
</div>
</div>

View File

@@ -430,14 +430,7 @@
<input type="checkbox" id="select-all" class="form-check-input">
</th>
<th scope="col" class="text-center" style="min-width: 60px;">
<a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none">
ID
{% if sort == 'id' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-id' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
</th>
<th scope="col" style="min-width: 150px;">Имя</th>
<th scope="col" style="min-width: 120px;">Спутник</th>
@@ -449,34 +442,13 @@
<th scope="col" style="min-width: 180px;">Наличие сигнала</th>
<th scope="col" class="text-center" style="min-width: 80px;">ТВ или нет</th>
<th scope="col" class="text-center" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('objitem_count')" class="text-white text-decoration-none">
Кол-во точек
{% if sort == 'objitem_count' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-objitem_count' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
{% include 'mainapp/components/_sort_header.html' with field='objitem_count' label='Кол-во точек' current_sort=sort %}
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('created_at')" class="text-white text-decoration-none">
Создано
{% if sort == 'created_at' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-created_at' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Создано' current_sort=sort %}
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('updated_at')" class="text-white text-decoration-none">
Обновлено
{% if sort == 'updated_at' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-updated_at' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
{% include 'mainapp/components/_sort_header.html' with field='updated_at' label='Обновлено' current_sort=sort %}
</th>
<th scope="col" class="text-center" style="min-width: 150px;">Действия</th>
</tr>
@@ -1016,26 +988,7 @@ function updateItemsPerPage() {
window.location.search = urlParams.toString();
}
// Sorting functionality
function updateSort(field) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
let newSort;
if (currentSort === field) {
newSort = '-' + field;
} else if (currentSort === '-' + field) {
newSort = field;
} else {
newSort = field;
}
urlParams.set('sort', newSort);
urlParams.delete('page');
// Preserve polygon filter
// (already in urlParams from window.location.search)
window.location.search = urlParams.toString();
}
// Sorting functionality is now handled by sorting.js (loaded via base.html)
// Setup radio-like behavior for filter checkboxes
function setupRadioLikeCheckboxes(name) {

View File

@@ -11,15 +11,6 @@
<h2 class="mb-0">Загрузка данных транспондеров из CellNet</h2>
</div>
<div class="card-body">
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<p class="card-text">Загрузите xml-файл и выберите спутник для загрузки данных в базу.</p>
<form method="post" enctype="multipart/form-data">
@@ -41,7 +32,7 @@
{% endif %}
</div> {% endcomment %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-warning">Добавить в базу</button>
</div>
</form>

View File

@@ -11,15 +11,6 @@
<h2 class="mb-0">Загрузка данных ВЧ загрузки</h2>
</div>
<div class="card-body">
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<p class="card-text">Загрузите HTML-файл с таблицами данных ВЧ загрузки и выберите спутник для привязки данных.</p>
<form method="post" enctype="multipart/form-data">
@@ -44,7 +35,7 @@
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
<button type="submit" class="btn btn-danger">Обработать файл</button>
</div>
</form>

View File

@@ -13,11 +13,11 @@
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.username.id_for_label }}" class="form-label">Имя пользователя</label>
{{ form.username }}
<input type="text" name="username" class="form-control" id="{{ form.username.id_for_label }}" required>
</div>
<div class="mb-3">
<label for="{{ form.password.id_for_label }}" class="form-label">Пароль</label>
{{ form.password }}
<input type="password" name="password" class="form-control" id="{{ form.password.id_for_label }}" required>
</div>
{% if form.errors %}
<div class="alert alert-danger">

View File

@@ -1,6 +1,7 @@
from django.conf import settings
from django.conf.urls.static import static
from django.urls import path
from django.views.generic import RedirectView
from .views import (
ActionsPageView,
AddSatellitesView,
@@ -51,7 +52,11 @@ from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMar
app_name = 'mainapp'
urlpatterns = [
path('', HomeView.as_view(), name='home'),
# Root URL now points to SourceListView (Requirement 1.1)
path('', SourceListView.as_view(), name='home'),
# Redirect old /home/ URL to source_list for backward compatibility (Requirement 1.2)
path('home/', RedirectView.as_view(pattern_name='mainapp:source_list', permanent=True), name='home_redirect'),
# Keep /sources/ as an alias (Requirement 1.2)
path('sources/', SourceListView.as_view(), name='source_list'),
path('source/<int:pk>/edit/', SourceUpdateView.as_view(), name='source_update'),
path('source/<int:pk>/delete/', SourceDeleteView.as_view(), name='source_delete'),

View File

@@ -500,4 +500,4 @@ class ActionsPageView(View):
def custom_logout(request):
"""Custom logout view."""
logout(request)
return redirect("mainapp:home")
return redirect("mainapp:source_list")

View File

@@ -38,7 +38,7 @@ class AddSatellitesView(LoginRequiredMixin, View):
def get(self, request):
add_satellite_list()
return redirect("mainapp:home")
return redirect("mainapp:source_list")
class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView):

View File

@@ -162,7 +162,7 @@ class ClearLyngsatCacheView(LoginRequiredMixin, View):
except Exception as e:
messages.error(request, f"Ошибка при запуске задачи очистки кеша: {str(e)}")
return redirect(request.META.get('HTTP_REFERER', 'mainapp:home'))
return redirect(request.META.get('HTTP_REFERER', 'mainapp:source_list'))
def get(self, request):
"""Cache management page."""

View File

@@ -167,7 +167,7 @@ class ShowSourcesMapView(LoginRequiredMixin, View):
}
)
else:
return redirect("mainapp:home")
return redirect("mainapp:source_list")
# Get polygon filter from URL if present
polygon_coords_str = request.GET.get("polygon", "").strip()
@@ -198,7 +198,7 @@ class ShowSourceWithPointsMapView(LoginRequiredMixin, View):
"source_objitems__geo_obj",
).get(id=source_id)
except Source.DoesNotExist:
return redirect("mainapp:home")
return redirect("mainapp:source_list")
groups = []
@@ -280,7 +280,7 @@ class ShowSourceAveragingStepsMapView(LoginRequiredMixin, View):
"source_objitems__geo_obj",
).get(id=source_id)
except Source.DoesNotExist:
return redirect("mainapp:home")
return redirect("mainapp:source_list")
# Получаем все ObjItem, отсортированные по ID (порядок добавления)
objitems = source.source_objitems.select_related(

View File

@@ -18,10 +18,17 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
model = Source
template_name = "mainapp/object_marks.html"
context_object_name = "sources"
paginate_by = 50
def get_paginate_by(self, queryset):
"""Получить количество элементов на странице из параметров запроса"""
from mainapp.utils import parse_pagination_params
_, items_per_page = parse_pagination_params(self.request, default_per_page=50)
return items_per_page
def get_queryset(self):
"""Получить queryset с предзагруженными связанными данными"""
from django.db.models import Count, Max
queryset = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
@@ -30,17 +37,69 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
'marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
).order_by('-updated_at')
).annotate(
mark_count=Count('marks'),
last_mark_date=Max('marks__timestamp')
)
# Фильтрация по спутнику
satellite_id = self.request.GET.get('satellite')
if satellite_id:
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id=satellite_id).distinct()
# Фильтрация по спутникам (мультивыбор)
satellite_ids = self.request.GET.getlist('satellite_id')
if satellite_ids:
queryset = queryset.filter(source_objitems__parameter_obj__id_satellite_id__in=satellite_ids).distinct()
# Поиск по имени объекта
# Фильтрация по статусу (есть/нет отметок)
mark_status = self.request.GET.get('mark_status')
if mark_status == 'with_marks':
queryset = queryset.filter(mark_count__gt=0)
elif mark_status == 'without_marks':
queryset = queryset.filter(mark_count=0)
# Фильтрация по дате отметки
date_from = self.request.GET.get('date_from')
date_to = self.request.GET.get('date_to')
if date_from:
from django.utils.dateparse import parse_date
parsed_date = parse_date(date_from)
if parsed_date:
queryset = queryset.filter(marks__timestamp__date__gte=parsed_date).distinct()
if date_to:
from django.utils.dateparse import parse_date
parsed_date = parse_date(date_to)
if parsed_date:
queryset = queryset.filter(marks__timestamp__date__lte=parsed_date).distinct()
# Фильтрация по пользователям (мультивыбор)
user_ids = self.request.GET.getlist('user_id')
if user_ids:
queryset = queryset.filter(marks__created_by_id__in=user_ids).distinct()
# Поиск по имени объекта или ID
search_query = self.request.GET.get('search', '').strip()
if search_query:
queryset = queryset.filter(source_objitems__name__icontains=search_query).distinct()
from django.db.models import Q
try:
# Попытка поиска по ID
source_id = int(search_query)
queryset = queryset.filter(Q(id=source_id) | Q(source_objitems__name__icontains=search_query)).distinct()
except ValueError:
# Поиск только по имени
queryset = queryset.filter(source_objitems__name__icontains=search_query).distinct()
# Сортировка
sort = self.request.GET.get('sort', '-id')
allowed_sorts = ['id', '-id', 'created_at', '-created_at', 'last_mark_date', '-last_mark_date', 'mark_count', '-mark_count']
if sort in allowed_sorts:
# Для сортировки по last_mark_date нужно обработать NULL значения
if 'last_mark_date' in sort:
from django.db.models import F
from django.db.models.functions import Coalesce
queryset = queryset.order_by(
Coalesce(F('last_mark_date'), F('created_at')).desc() if sort.startswith('-') else Coalesce(F('last_mark_date'), F('created_at')).asc()
)
else:
queryset = queryset.order_by(sort)
else:
queryset = queryset.order_by('-id')
return queryset
@@ -48,7 +107,32 @@ class ObjectMarksListView(LoginRequiredMixin, ListView):
"""Добавить дополнительные данные в контекст"""
context = super().get_context_data(**kwargs)
from mainapp.models import Satellite
context['satellites'] = Satellite.objects.all().order_by('name')
from mainapp.utils import parse_pagination_params
# Данные для фильтров - только спутники, у которых есть источники
context['satellites'] = Satellite.objects.filter(
parameters__objitem__source__isnull=False
).distinct().order_by('name')
context['users'] = CustomUser.objects.select_related('user').filter(
marks_created__isnull=False
).distinct().order_by('user__username')
# Параметры пагинации
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]
# Параметры поиска и сортировки
context['search_query'] = self.request.GET.get('search', '')
context['sort'] = self.request.GET.get('sort', '-id')
# Параметры фильтров для отображения в UI (мультивыбор)
context['selected_satellites'] = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
context['selected_users'] = [int(x) for x in self.request.GET.getlist('user_id') if x.isdigit()]
context['filter_mark_status'] = self.request.GET.get('mark_status', '')
context['filter_date_from'] = self.request.GET.get('date_from', '')
context['filter_date_to'] = self.request.GET.get('date_to', '')
# Добавить информацию о возможности редактирования для каждой отметки
# и получить имя первого объекта для каждого источника

View File

@@ -5,7 +5,7 @@ from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db import models
from django.db.models import F
from django.db.models import F, Prefetch
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.urls import reverse_lazy
@@ -14,7 +14,7 @@ from django.views.generic import CreateView, DeleteView, UpdateView
from ..forms import GeoForm, ObjItemForm, ParameterForm
from ..mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin
from ..models import Geo, Modulation, ObjItem, Polarization, Satellite
from ..models import Geo, Modulation, ObjItem, ObjectMark, Polarization, Satellite
from ..utils import (
format_coordinate,
format_coords_display,
@@ -69,7 +69,7 @@ class ObjItemListView(LoginRequiredMixin, View):
selected_sat_id = str(first_satellite.id)
page_number, items_per_page = parse_pagination_params(request)
sort_param = request.GET.get("sort", "")
sort_param = request.GET.get("sort", "-id")
freq_min = request.GET.get("freq_min")
freq_max = request.GET.get("freq_max")
@@ -99,6 +99,18 @@ class ObjItemListView(LoginRequiredMixin, View):
selected_satellites = []
if selected_satellites:
# Create optimized prefetch for mirrors through geo_obj
mirrors_prefetch = Prefetch(
'geo_obj__mirrors',
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
# Create optimized prefetch for marks (through source)
marks_prefetch = Prefetch(
'source__marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
objects = (
ObjItem.objects.select_related(
"geo_obj",
@@ -111,14 +123,31 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__polarization",
"parameter_obj__modulation",
"parameter_obj__standard",
"transponder",
"transponder__sat_id",
"transponder__polarization",
)
.prefetch_related(
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
marks_prefetch,
)
.filter(parameter_obj__id_satellite_id__in=selected_satellites)
)
else:
# Create optimized prefetch for mirrors through geo_obj
mirrors_prefetch = Prefetch(
'geo_obj__mirrors',
queryset=Satellite.objects.only('id', 'name').order_by('id')
)
# Create optimized prefetch for marks (through source)
marks_prefetch = Prefetch(
'source__marks',
queryset=ObjectMark.objects.select_related('created_by__user').order_by('-timestamp')
)
objects = ObjItem.objects.select_related(
"geo_obj",
"source",
@@ -130,9 +159,14 @@ class ObjItemListView(LoginRequiredMixin, View):
"parameter_obj__polarization",
"parameter_obj__modulation",
"parameter_obj__standard",
"transponder",
"transponder__sat_id",
"transponder__polarization",
).prefetch_related(
"parameter_obj__sigma_parameter",
"parameter_obj__sigma_parameter__polarization",
mirrors_prefetch,
marks_prefetch,
)
if freq_min is not None and freq_min.strip() != "":
@@ -272,7 +306,10 @@ class ObjItemListView(LoginRequiredMixin, View):
first_param_mod_name=F("parameter_obj__modulation__name"),
)
# Define valid sort fields with their database mappings
valid_sort_fields = {
"id": "id",
"-id": "-id",
"name": "name",
"-name": "-name",
"updated_at": "updated_at",
@@ -301,8 +338,12 @@ class ObjItemListView(LoginRequiredMixin, View):
"-modulation": "-first_param_mod_name",
}
# Apply sorting if valid, otherwise use default
if sort_param in valid_sort_fields:
objects = objects.order_by(valid_sort_fields[sort_param])
else:
# Default sort by id descending
objects = objects.order_by("-id")
paginator = Paginator(objects, items_per_page)
page_obj = paginator.get_page(page_number)
@@ -325,8 +366,8 @@ class ObjItemListView(LoginRequiredMixin, View):
geo_timestamp = obj.geo_obj.timestamp
geo_location = obj.geo_obj.location
# Get mirrors
mirrors_list = list(obj.geo_obj.mirrors.values_list('name', flat=True))
# Get mirrors - use prefetched data
mirrors_list = [mirror.name for mirror in obj.geo_obj.mirrors.all()]
if obj.geo_obj.coords:
geo_coords = format_coords_display(obj.geo_obj.coords)
@@ -489,7 +530,7 @@ class ObjItemFormView(
model = ObjItem
form_class = ObjItemForm
template_name = "mainapp/objitem_form.html"
success_url = reverse_lazy("mainapp:home")
success_url = reverse_lazy("mainapp:source_list")
required_roles = ["admin", "moderator"]
def get_success_url(self):

View File

@@ -236,17 +236,40 @@ class SourceListView(LoginRequiredMixin, View):
# Get all Source objects with query optimization
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
# Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries
# Using select_related for ForeignKey/OneToOne relationships to avoid N+1 queries
# Using prefetch_related for reverse ForeignKey and ManyToMany relationships
sources = Source.objects.select_related(
'info'
'info', # ForeignKey to ObjectInfo
'created_by', # ForeignKey to CustomUser
'created_by__user', # OneToOne to User
'updated_by', # ForeignKey to CustomUser
'updated_by__user', # OneToOne to User
).prefetch_related(
# Prefetch related objitems with their nested relationships
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__parameter_obj__id_satellite',
'source_objitems__parameter_obj__polarization',
'source_objitems__parameter_obj__modulation',
'source_objitems__parameter_obj__standard',
'source_objitems__geo_obj',
'source_objitems__geo_obj__mirrors',
'source_objitems__lyngsat_source',
'source_objitems__lyngsat_source__id_satellite',
'source_objitems__lyngsat_source__polarization',
'source_objitems__lyngsat_source__modulation',
'source_objitems__lyngsat_source__standard',
'source_objitems__transponder',
'source_objitems__created_by',
'source_objitems__created_by__user',
'source_objitems__updated_by',
'source_objitems__updated_by__user',
# Prefetch marks with their relationships
'marks',
'marks__created_by',
'marks__created_by__user'
).annotate(
# Use annotate for efficient counting in a single query
objitem_count=Count('source_objitems', filter=objitem_filter_q, distinct=True) if has_objitem_filter else Count('source_objitems')
)
@@ -704,7 +727,7 @@ class AdminModeratorMixin(UserPassesTestMixin):
def handle_no_permission(self):
messages.error(self.request, 'У вас нет прав для выполнения этого действия.')
return redirect('mainapp:home')
return redirect('mainapp:source_list')
class SourceUpdateView(LoginRequiredMixin, AdminModeratorMixin, View):
@@ -801,8 +824,8 @@ class SourceDeleteView(LoginRequiredMixin, AdminModeratorMixin, View):
# Redirect to source list
if request.GET.urlencode():
return redirect(f"{reverse('mainapp:home')}?{request.GET.urlencode()}")
return redirect('mainapp:home')
return redirect(f"{reverse('mainapp:source_list')}?{request.GET.urlencode()}")
return redirect('mainapp:source_list')
class DeleteSelectedSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
@@ -813,7 +836,7 @@ class DeleteSelectedSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
ids = request.GET.get("ids", "")
if not ids:
messages.error(request, "Не выбраны источники для удаления")
return redirect('mainapp:home')
return redirect('mainapp:source_list')
try:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
@@ -858,7 +881,7 @@ class DeleteSelectedSourcesView(LoginRequiredMixin, AdminModeratorMixin, View):
except Exception as e:
messages.error(request, f'Ошибка при подготовке удаления: {str(e)}')
return redirect('mainapp:home')
return redirect('mainapp:source_list')
def post(self, request):
"""Actually delete the selected sources."""

View File

@@ -58,7 +58,7 @@ from mapsapp.utils import parse_transponders_from_xml
class AddSatellitesView(LoginRequiredMixin, View):
def get(self, request):
add_satellite_list()
return redirect("mainapp:home")
return redirect("mainapp:source_list")
class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView):
@@ -312,7 +312,7 @@ class ClusterTestView(LoginRequiredMixin, View):
def custom_logout(request):
logout(request)
return redirect("mainapp:home")
return redirect("mainapp:source_list")
class UploadVchLoadView(LoginRequiredMixin, FormMessageMixin, FormView):
@@ -1298,7 +1298,7 @@ class ObjItemFormView(
model = ObjItem
form_class = ObjItemForm
template_name = "mainapp/objitem_form.html"
success_url = reverse_lazy("mainapp:home")
success_url = reverse_lazy("mainapp:source_list")
required_roles = ["admin", "moderator"]
def get_success_url(self):
@@ -1609,7 +1609,7 @@ class ClearLyngsatCacheView(LoginRequiredMixin, View):
except Exception as e:
messages.error(request, f"Ошибка при запуске задачи очистки кеша: {str(e)}")
return redirect(request.META.get('HTTP_REFERER', 'mainapp:home'))
return redirect(request.META.get('HTTP_REFERER', 'mainapp:source_list'))
def get(self, request):
"""Страница управления кешем"""