diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py index 2af93da..27850c9 100644 --- a/dbapp/mainapp/forms.py +++ b/dbapp/mainapp/forms.py @@ -2,57 +2,54 @@ from django import forms # Local imports -from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard -from .widgets import TagSelectWidget +from .models import ( + Geo, + Modulation, + ObjItem, + Parameter, + Polarization, + Satellite, + Standard, +) +from .widgets import CheckboxSelectMultipleWidget + class UploadFileForm(forms.Form): file = forms.FileField( - label="Выберите файл", - widget=forms.FileInput(attrs={ - 'class': 'form-file-input' - }) + label="Выберите файл", + widget=forms.FileInput(attrs={"class": "form-file-input"}), ) class LoadExcelData(forms.Form): file = forms.FileField( label="Выберите Excel файл", - widget=forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.xlsx,.xls' - }) + widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}), ) sat_choice = forms.ModelChoiceField( queryset=Satellite.objects.all(), label="Выберите спутник", - widget=forms.Select(attrs={ - 'class': 'form-select' - }) + widget=forms.Select(attrs={"class": "form-select"}), ) number_input = forms.IntegerField( label="Введите число объектов", min_value=0, - widget=forms.NumberInput(attrs={ - 'class': 'form-control' - }) + widget=forms.NumberInput(attrs={"class": "form-control"}), ) + class LoadCsvData(forms.Form): file = forms.FileField( label="Выберите CSV файл", - widget=forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.csv' - }) + widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}), ) + class UploadVchLoad(UploadFileForm): sat_choice = forms.ModelChoiceField( queryset=Satellite.objects.all(), label="Выберите спутник", - widget=forms.Select(attrs={ - 'class': 'form-select' - }) + widget=forms.Select(attrs={"class": "form-select"}), ) @@ -60,9 +57,7 @@ class VchLinkForm(forms.Form): sat_choice = forms.ModelChoiceField( queryset=Satellite.objects.all(), label="Выберите спутник", - widget=forms.Select(attrs={ - 'class': 'form-select' - }) + widget=forms.Select(attrs={"class": "form-select"}), ) # ku_range = forms.ChoiceField( # choices=[(9750.0, '9750'), (10750.0, '10750')], @@ -74,18 +69,22 @@ class VchLinkForm(forms.Form): label="Разброс по частоте (не используется)", required=False, initial=0.0, - widget=forms.NumberInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Не используется - погрешность определяется автоматически' - }) + widget=forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": "Не используется - погрешность определяется автоматически", + } + ), ) value2 = forms.FloatField( - label="Разброс по полосе (в %)", - widget=forms.NumberInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Введите погрешность полосы в процентах', - 'step': '0.1' - }) + label="Разброс по полосе (в %)", + widget=forms.NumberInput( + attrs={ + "class": "form-control", + "placeholder": "Введите погрешность полосы в процентах", + "step": "0.1", + } + ), ) @@ -106,256 +105,270 @@ class NewEventForm(forms.Form): # ) file = forms.FileField( label="Выберите файл", - widget=forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.xlsx,.xls' - }) + widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}), ) class FillLyngsatDataForm(forms.Form): """Форма для заполнения данных из Lyngsat с поддержкой кеширования""" - + REGION_CHOICES = [ - ('europe', 'Европа'), - ('asia', 'Азия'), - ('america', 'Америка'), - ('atlantic', 'Атлантика'), + ("europe", "Европа"), + ("asia", "Азия"), + ("america", "Америка"), + ("atlantic", "Атлантика"), ] - + satellites = forms.ModelMultipleChoiceField( - queryset=Satellite.objects.all().order_by('name'), + queryset=Satellite.objects.all().order_by("name"), label="Выберите спутники", - widget=forms.SelectMultiple(attrs={ - 'class': 'form-select', - 'size': '10' - }), + widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}), required=True, - help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников" + help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников", ) - + regions = forms.MultipleChoiceField( choices=REGION_CHOICES, label="Выберите регионы", - widget=forms.SelectMultiple(attrs={ - 'class': 'form-select', - 'size': '4' - }), + widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "4"}), required=True, - initial=['europe', 'asia', 'america', 'atlantic'], - help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов" + initial=["europe", "asia", "america", "atlantic"], + help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов", ) - + use_cache = forms.BooleanField( label="Использовать кеширование", required=False, initial=True, - widget=forms.CheckboxInput(attrs={ - 'class': 'form-check-input' - }), - help_text="Использовать кешированные данные (ускоряет повторные запросы)" + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + help_text="Использовать кешированные данные (ускоряет повторные запросы)", ) - + force_refresh = forms.BooleanField( label="Принудительно обновить данные", required=False, initial=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'form-check-input' - }), - help_text="Игнорировать кеш и получить свежие данные с сайта" + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + help_text="Игнорировать кеш и получить свежие данные с сайта", ) class LinkLyngsatForm(forms.Form): """Форма для привязки источников LyngSat к объектам""" - + satellites = forms.ModelMultipleChoiceField( - queryset=Satellite.objects.all().order_by('name'), + queryset=Satellite.objects.all().order_by("name"), label="Выберите спутники", - widget=forms.SelectMultiple(attrs={ - 'class': 'form-select', - 'size': '10' - }), + widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}), required=False, - help_text="Оставьте пустым для обработки всех спутников" + help_text="Оставьте пустым для обработки всех спутников", ) - + frequency_tolerance = forms.FloatField( label="Допуск по частоте (МГц)", initial=0.5, min_value=0, - widget=forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.1' - }), - help_text="Допустимое отклонение частоты при сравнении" + widget=forms.NumberInput(attrs={"class": "form-control", "step": "0.1"}), + help_text="Допустимое отклонение частоты при сравнении", ) + + class ParameterForm(forms.ModelForm): """ Форма для создания и редактирования параметров ВЧ загрузки. - + Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь. """ - + class Meta: model = Parameter fields = [ - 'id_satellite', 'frequency', 'freq_range', 'polarization', - 'bod_velocity', 'modulation', 'snr', 'standard' + "id_satellite", + "frequency", + "freq_range", + "polarization", + "bod_velocity", + "modulation", + "snr", + "standard", ] widgets = { - 'id_satellite': forms.Select(attrs={ - 'class': 'form-select', - 'required': True - }), - 'frequency': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.000001', - 'min': '0', - 'max': '50000', - 'placeholder': 'Введите частоту в МГц' - }), - 'freq_range': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.000001', - 'min': '0', - 'max': '1000', - 'placeholder': 'Введите полосу частот в МГц' - }), - 'bod_velocity': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.001', - 'min': '0', - 'placeholder': 'Введите символьную скорость в БОД' - }), - 'snr': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.001', - 'min': '-50', - 'max': '100', - 'placeholder': 'Введите ОСШ в дБ' - }), - 'polarization': forms.Select(attrs={'class': 'form-select'}), - 'modulation': forms.Select(attrs={'class': 'form-select'}), - 'standard': forms.Select(attrs={'class': 'form-select'}), + "id_satellite": forms.Select( + attrs={"class": "form-select", "required": True} + ), + "frequency": forms.NumberInput( + attrs={ + "class": "form-control", + "step": "0.000001", + "min": "0", + "max": "50000", + "placeholder": "Введите частоту в МГц", + } + ), + "freq_range": forms.NumberInput( + attrs={ + "class": "form-control", + "step": "0.000001", + "min": "0", + "max": "1000", + "placeholder": "Введите полосу частот в МГц", + } + ), + "bod_velocity": forms.NumberInput( + attrs={ + "class": "form-control", + "step": "0.001", + "min": "0", + "placeholder": "Введите символьную скорость в БОД", + } + ), + "snr": forms.NumberInput( + attrs={ + "class": "form-control", + "step": "0.001", + "min": "-50", + "max": "100", + "placeholder": "Введите ОСШ в дБ", + } + ), + "polarization": forms.Select(attrs={"class": "form-select"}), + "modulation": forms.Select(attrs={"class": "form-select"}), + "standard": forms.Select(attrs={"class": "form-select"}), } labels = { - 'id_satellite': 'Спутник', - 'frequency': 'Частота (МГц)', - 'freq_range': 'Полоса частот (МГц)', - 'polarization': 'Поляризация', - 'bod_velocity': 'Символьная скорость (БОД)', - 'modulation': 'Модуляция', - 'snr': 'ОСШ (дБ)', - 'standard': 'Стандарт', + "id_satellite": "Спутник", + "frequency": "Частота (МГц)", + "freq_range": "Полоса частот (МГц)", + "polarization": "Поляризация", + "bod_velocity": "Символьная скорость (БОД)", + "modulation": "Модуляция", + "snr": "ОСШ (дБ)", + "standard": "Стандарт", } help_texts = { - 'frequency': 'Частота в диапазоне от 0 до 50000 МГц', - 'freq_range': 'Полоса частот в диапазоне от 0 до 1000 МГц', - 'bod_velocity': 'Символьная скорость должна быть положительной', - 'snr': 'Отношение сигнал/шум в диапазоне от -50 до 100 дБ', + "frequency": "Частота в диапазоне от 0 до 50000 МГц", + "freq_range": "Полоса частот в диапазоне от 0 до 1000 МГц", + "bod_velocity": "Символьная скорость должна быть положительной", + "snr": "Отношение сигнал/шум в диапазоне от -50 до 100 дБ", } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + # Динамически загружаем choices для select полей - self.fields['id_satellite'].queryset = Satellite.objects.all().order_by('name') - self.fields['polarization'].queryset = Polarization.objects.all().order_by('name') - self.fields['modulation'].queryset = Modulation.objects.all().order_by('name') - self.fields['standard'].queryset = Standard.objects.all().order_by('name') - + self.fields["id_satellite"].queryset = Satellite.objects.all().order_by("name") + self.fields["polarization"].queryset = Polarization.objects.all().order_by( + "name" + ) + self.fields["modulation"].queryset = Modulation.objects.all().order_by("name") + self.fields["standard"].queryset = Standard.objects.all().order_by("name") + # Делаем спутник обязательным полем - self.fields['id_satellite'].required = True - + self.fields["id_satellite"].required = True + def clean(self): """ Дополнительная валидация формы. - + Проверяет соотношение между частотой, полосой частот и символьной скоростью. """ cleaned_data = super().clean() - frequency = cleaned_data.get('frequency') - freq_range = cleaned_data.get('freq_range') - bod_velocity = cleaned_data.get('bod_velocity') - + frequency = cleaned_data.get("frequency") + freq_range = cleaned_data.get("freq_range") + bod_velocity = cleaned_data.get("bod_velocity") + # Проверка что частота больше полосы частот if frequency and freq_range: if freq_range > frequency: - self.add_error('freq_range', 'Полоса частот не может быть больше частоты') - + self.add_error( + "freq_range", "Полоса частот не может быть больше частоты" + ) + # Проверка что символьная скорость соответствует полосе частот if bod_velocity and freq_range: if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц - self.add_error('bod_velocity', 'Символьная скорость не может превышать полосу частот') - + self.add_error( + "bod_velocity", + "Символьная скорость не может превышать полосу частот", + ) + return cleaned_data + class GeoForm(forms.ModelForm): class Meta: model = Geo - fields = ['location', 'comment', 'is_average', 'mirrors'] + fields = ["location", "comment", "is_average", "mirrors"] widgets = { - 'location': forms.TextInput(attrs={'class': 'form-control'}), - 'comment': forms.TextInput(attrs={'class': 'form-control'}), - 'is_average': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'mirrors': TagSelectWidget(attrs={'id': 'id_geo-mirrors'}), + "location": forms.TextInput(attrs={"class": "form-control"}), + "comment": forms.TextInput(attrs={"class": "form-control"}), + "is_average": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "mirrors": CheckboxSelectMultipleWidget( + attrs={ + 'id': 'id_geo-mirrors', + 'placeholder': 'Выберите спутники...', + } + ), } labels = { - 'location': 'Местоположение', - 'comment': 'Комментарий', - 'is_average': 'Усреднённое', - 'mirrors': 'Спутники-зеркала, использованные для приёма', + "location": "Местоположение", + "comment": "Комментарий", + "is_average": "Усреднённое", + "mirrors": "Спутники-зеркала, использованные для приёма", } help_texts = { - 'mirrors': 'Начните вводить название спутника для поиска', + "mirrors": "Выберите спутники из списка", } + class ObjItemForm(forms.ModelForm): """ Форма для создания и редактирования объектов (источников сигнала). - + Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно через ParameterForm с использованием OneToOne связи. """ - + class Meta: model = ObjItem - fields = ['name'] + fields = ["name"] widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Введите название объекта', - 'maxlength': '100' - }), + "name": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Введите название объекта", + "maxlength": "100", + } + ), } labels = { - 'name': 'Название объекта', + "name": "Название объекта", } help_texts = { - 'name': 'Уникальное название объекта/источника сигнала', + "name": "Уникальное название объекта/источника сигнала", } - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Делаем поле name необязательным, так как оно может быть пустым - self.fields['name'].required = False - + self.fields["name"].required = False + def clean_name(self): """ Валидация поля name. - + Проверяет что название не состоит только из пробелов. """ - name = self.cleaned_data.get('name') - + name = self.cleaned_data.get("name") + if name: # Удаляем лишние пробелы name = name.strip() - + # Проверяем что после удаления пробелов что-то осталось if not name: - raise forms.ValidationError('Название не может состоять только из пробелов') - - return name \ No newline at end of file + raise forms.ValidationError( + "Название не может состоять только из пробелов" + ) + + return name diff --git a/dbapp/mainapp/static/css/checkbox-select-multiple.css b/dbapp/mainapp/static/css/checkbox-select-multiple.css new file mode 100644 index 0000000..c88edb7 --- /dev/null +++ b/dbapp/mainapp/static/css/checkbox-select-multiple.css @@ -0,0 +1,160 @@ +.checkbox-multiselect-wrapper { + position: relative; + width: 100%; +} + +.multiselect-input-container { + position: relative; + display: flex; + align-items: center; + min-height: 38px; + border: 1px solid #ced4da; + border-radius: 0.25rem; + background-color: #fff; + cursor: text; + padding: 4px 30px 4px 4px; + flex-wrap: wrap; + gap: 4px; +} + +.multiselect-input-container:focus-within { + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +.multiselect-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + flex: 0 0 auto; +} + +.multiselect-tag { + display: inline-flex; + align-items: center; + background-color: #e9ecef; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + padding: 2px 8px; + font-size: 0.875rem; + line-height: 1.5; + white-space: nowrap; +} + +.multiselect-tag-remove { + margin-left: 6px; + cursor: pointer; + color: #6c757d; + font-weight: bold; + border: none; + background: none; + padding: 0; + font-size: 1rem; + line-height: 1; +} + +.multiselect-tag-remove:hover { + color: #dc3545; +} + +.multiselect-search { + flex: 1 1 auto; + min-width: 120px; + border: none; + outline: none; + padding: 4px; + font-size: 0.875rem; +} + +.multiselect-search:focus { + box-shadow: none; +} + +.multiselect-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + color: #6c757d; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: none; +} + +.multiselect-clear:hover { + color: #dc3545; +} + +.multiselect-input-container.has-selections .multiselect-clear { + display: block; +} + +.multiselect-dropdown { + position: absolute; + left: 0; + right: 0; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 0.25rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow-y: auto; + z-index: 1000; + display: none; +} + +/* Открытие вверх (по умолчанию) */ +.multiselect-dropdown { + bottom: 100%; + margin-bottom: 2px; +} + +/* Открытие вниз (если места сверху недостаточно) */ +.multiselect-dropdown.dropdown-below { + bottom: auto; + top: 100%; + margin-top: 2px; + margin-bottom: 0; +} + +.multiselect-dropdown.show { + display: block; +} + +.multiselect-options { + padding: 4px 0; +} + +.multiselect-option { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + margin: 0; + transition: background-color 0.15s ease-in-out; +} + +.multiselect-option:hover { + background-color: #f8f9fa; +} + +.multiselect-option input[type="checkbox"] { + margin-right: 8px; + cursor: pointer; +} + +.multiselect-option .option-label { + flex: 1; + user-select: none; +} + +.multiselect-option.hidden { + display: none; +} diff --git a/dbapp/mainapp/static/js/checkbox-select-multiple.js b/dbapp/mainapp/static/js/checkbox-select-multiple.js new file mode 100644 index 0000000..7d0b116 --- /dev/null +++ b/dbapp/mainapp/static/js/checkbox-select-multiple.js @@ -0,0 +1,120 @@ +/** + * Checkbox Select Multiple Widget + * Provides a multi-select dropdown with checkboxes and tag display + */ + +document.addEventListener('DOMContentLoaded', function() { + // Initialize all checkbox multiselect widgets + document.querySelectorAll('.checkbox-multiselect-wrapper').forEach(function(wrapper) { + initCheckboxMultiselect(wrapper); + }); +}); + +function initCheckboxMultiselect(wrapper) { + const widgetId = wrapper.dataset.widgetId; + const inputContainer = wrapper.querySelector('.multiselect-input-container'); + const searchInput = wrapper.querySelector('.multiselect-search'); + const dropdown = wrapper.querySelector('.multiselect-dropdown'); + const tagsContainer = wrapper.querySelector('.multiselect-tags'); + const clearButton = wrapper.querySelector('.multiselect-clear'); + const checkboxes = wrapper.querySelectorAll('input[type="checkbox"]'); + + // Show dropdown when clicking on input container + inputContainer.addEventListener('click', function(e) { + if (e.target !== clearButton) { + positionDropdown(); + dropdown.classList.add('show'); + searchInput.focus(); + } + }); + + // Position dropdown (up or down based on available space) + function positionDropdown() { + const rect = inputContainer.getBoundingClientRect(); + const spaceAbove = rect.top; + const spaceBelow = window.innerHeight - rect.bottom; + const dropdownHeight = 300; // max-height from CSS + + // If more space below and enough space, open downward + if (spaceBelow > spaceAbove && spaceBelow >= dropdownHeight) { + dropdown.classList.add('dropdown-below'); + } else { + dropdown.classList.remove('dropdown-below'); + } + } + + // Hide dropdown when clicking outside + document.addEventListener('click', function(e) { + if (!wrapper.contains(e.target)) { + dropdown.classList.remove('show'); + } + }); + + // Search functionality + searchInput.addEventListener('input', function() { + const searchTerm = this.value.toLowerCase(); + const options = wrapper.querySelectorAll('.multiselect-option'); + + options.forEach(function(option) { + const label = option.querySelector('.option-label').textContent.toLowerCase(); + if (label.includes(searchTerm)) { + option.classList.remove('hidden'); + } else { + option.classList.add('hidden'); + } + }); + }); + + // Handle checkbox changes + checkboxes.forEach(function(checkbox) { + checkbox.addEventListener('change', function() { + updateTags(); + }); + }); + + // Clear all button + clearButton.addEventListener('click', function(e) { + e.stopPropagation(); + checkboxes.forEach(function(checkbox) { + checkbox.checked = false; + }); + updateTags(); + }); + + // Update tags display + function updateTags() { + tagsContainer.innerHTML = ''; + let hasSelections = false; + + checkboxes.forEach(function(checkbox) { + if (checkbox.checked) { + hasSelections = true; + const tag = document.createElement('div'); + tag.className = 'multiselect-tag'; + tag.innerHTML = ` + ${checkbox.dataset.label} + + `; + + // Remove tag on click + tag.querySelector('.multiselect-tag-remove').addEventListener('click', function(e) { + e.stopPropagation(); + checkbox.checked = false; + updateTags(); + }); + + tagsContainer.appendChild(tag); + } + }); + + // Show/hide clear button + if (hasSelections) { + inputContainer.classList.add('has-selections'); + } else { + inputContainer.classList.remove('has-selections'); + } + } + + // Initialize tags on load + updateTags(); +} diff --git a/dbapp/mainapp/templates/mainapp/objitem_form.html b/dbapp/mainapp/templates/mainapp/objitem_form.html index a7ce7d7..08ec1ff 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_form.html +++ b/dbapp/mainapp/templates/mainapp/objitem_form.html @@ -6,27 +6,93 @@ {% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %} {% block extra_css %} + {% endblock %} @@ -65,10 +149,12 @@ {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} {% if object %} - Удалить + Удалить {% endif %} {% endif %} - Назад + Назад @@ -186,16 +272,16 @@