diff --git a/cdip_admin/api/v2/serializers.py b/cdip_admin/api/v2/serializers.py index 8224b696f..fedcb39e8 100644 --- a/cdip_admin/api/v2/serializers.py +++ b/cdip_admin/api/v2/serializers.py @@ -1347,7 +1347,7 @@ class Meta: ) def get_unique_together_validators(self): - # Overriden to disable unique together check as it's handled in the create method + # Overridden to disable unique together check as it's handled in the create method return [] def create(self, validated_data): diff --git a/cdip_admin/api/v2/views.py b/cdip_admin/api/v2/views.py index 784264097..c2b443df1 100644 --- a/cdip_admin/api/v2/views.py +++ b/cdip_admin/api/v2/views.py @@ -54,7 +54,7 @@ def get_serializer_class(self): def get_object(self): return EULA.objects.get_active_eula() - # Overriden to return a single object (the active eula) + # Overridden to return a single object (the active eula) def list(self, request, *args, **kwargs): try: instance = self.get_object() diff --git a/cdip_admin/cdip_admin/auth/middleware.py b/cdip_admin/cdip_admin/auth/middleware.py index 9115c69c3..b5b0b5e52 100644 --- a/cdip_admin/cdip_admin/auth/middleware.py +++ b/cdip_admin/cdip_admin/auth/middleware.py @@ -71,6 +71,14 @@ def process_request(self, request): # If specified header doesn't exist then remove any existing # authenticated remote-user, or return (leaving request.user set to # AnonymousUser by the AuthenticationMiddleware). + + # Don't force logout for form submissions or HTMX requests if user is already authenticated + # This prevents issues with regular form submissions and HTMX requests that don't include the header + if (self.force_logout_if_no_header and request.user.is_authenticated and + ((request.method == 'POST' and 'csrfmiddlewaretoken' in request.POST) or + (request.method == 'GET' and (request.headers.get('HX-Request') or request.path.startswith('/integrations/'))))): + return + if self.force_logout_if_no_header and request.user.is_authenticated: self._remove_invalid_user(request) return diff --git a/cdip_admin/integrations/filters.py b/cdip_admin/integrations/filters.py index e9ef4babd..03e0dfe01 100644 --- a/cdip_admin/integrations/filters.py +++ b/cdip_admin/integrations/filters.py @@ -178,14 +178,51 @@ class DeviceFilter(django_filters.FilterSet): empty_label=_("Types"), ) + search = django_filters.CharFilter(method='filter_search', label='Search') + class Meta: model = Device fields = ( "organization", "inbound_config_type", "external_id", + "search", ) + def filter_search(self, queryset, name, value): + """ + Search across inbound_configuration.name, name, external_id, owner.name, + inbound_configuration.default_devicegroup.name, and inbound_configuration.type.name fields + """ + if not value: + return queryset + + # Create a Q object for OR conditions + from django.db.models import Q + + # Search in inbound_configuration.name + config_name_q = Q(inbound_configuration__name__icontains=value) + + # Search in device name + name_q = Q(name__icontains=value) + + # Search in external_id + external_id_q = Q(external_id__icontains=value) + + # Search in owner.name + owner_q = Q(inbound_configuration__owner__name__icontains=value) + + # Search in inbound_configuration.default_devicegroup.name + devicegroup_q = Q(inbound_configuration__default_devicegroup__name__icontains=value) + + # Search in inbound_configuration.type.name + type_name_q = Q(inbound_configuration__type__name__icontains=value) + + # Combine all search conditions with OR + search_q = config_name_q | name_q | external_id_q | owner_q | devicegroup_q | type_name_q + + return queryset.filter(search_q).distinct() + def __init__(self, *args, **kwargs): # this can appropriately update the ui filter elements # check for stored values and set form values accordingly @@ -203,7 +240,7 @@ def qs(self): self.request.session["owner_filter"] = self.data["organization"] if not IsGlobalAdmin.has_permission(None, self.request, None): return IsOrganizationMember.filter_queryset_for_user( - qs, self.request.user, "owner__name" + qs, self.request.user, "inbound_configuration__owner__name" ) if "owner_filter" in self.request.session: return qs.filter( @@ -344,10 +381,38 @@ def qs(self): class BridgeIntegrationFilter(django_filters.FilterSet): enabled = django_filters.BooleanFilter(widget=CustomBooleanWidget) + search = django_filters.CharFilter(method='filter_search', label='Search') class Meta: model = BridgeIntegration - fields = ("enabled",) + fields = ("enabled", "search") + + def filter_search(self, queryset, name, value): + """ + Search across owner.name, name, state, and additional fields + """ + if not value: + return queryset + + # Create a Q object for OR conditions + from django.db.models import Q + + # Search in owner.name + owner_q = Q(owner__name__icontains=value) + + # Search in name field + name_q = Q(name__icontains=value) + + # Search in state JSON field (as text) + state_q = Q(state__icontains=value) + + # Search in additional JSON field (as text) + additional_q = Q(additional__icontains=value) + + # Combine all search conditions with OR + search_q = owner_q | name_q | state_q | additional_q + + return queryset.filter(search_q).distinct() class CharInFilter(django_filters_rest.BaseInFilter, django_filters_rest.CharFilter): diff --git a/cdip_admin/integrations/forms.py b/cdip_admin/integrations/forms.py index 48d087f4e..d903de34c 100644 --- a/cdip_admin/integrations/forms.py +++ b/cdip_admin/integrations/forms.py @@ -18,10 +18,12 @@ InboundIntegrationConfiguration, InboundIntegrationType, DeviceGroup, + SubjectType, BridgeIntegrationType, BridgeIntegration, Device ) +from .widgets import SearchableMultiSelectField, SubjectTypeAutocompleteField, DeviceSearchableMultiSelectField, DeviceGroupSelectWithLinkField, DeviceGroupAutoCreateField, DeviceGroupDisplayWidget from django.urls import reverse from django.core.exceptions import ValidationError import json @@ -30,10 +32,17 @@ def tooltip_labels(text): return f""" """ + title="{text}" tabindex="-1">?""" class InboundIntegrationConfigurationForm(forms.ModelForm): + # Override the default_devicegroup field to use our custom widget + default_devicegroup = DeviceGroupSelectWithLinkField( + queryset=DeviceGroup.objects.all(), + required=False, + label="Default Device Group" + ) + class Meta: model = InboundIntegrationConfiguration exclude = [ @@ -86,6 +95,22 @@ def __init__(self, *args, request=None, **kwargs): ) else: self.fields["owner"].queryset = qs + + # Filter device groups based on user permissions + device_group_qs = DeviceGroup.objects.all() + if not IsGlobalAdmin.has_permission(None, request, None): + self.fields[ + "default_devicegroup" + ].queryset = IsOrganizationMember.filter_queryset_for_user( + device_group_qs, request.user, "owner__name" + ) + else: + self.fields["default_devicegroup"].queryset = device_group_qs + + # Replace default_devicegroup field with display widget for existing integrations + if self.instance.pk and self.instance.default_devicegroup: # If this is an existing integration with a default device group + self.fields["default_devicegroup"].widget = DeviceGroupDisplayWidget() + self.fields["default_devicegroup"].help_text = "Default Device Group cannot be changed for existing integrations." # TODO: review how we trigger the warning modal self.fields['type'].widget.attrs['hx-get'] = reverse("inboundconfigurations/type_modal", kwargs={"integration_id": self.instance.id}) @@ -99,6 +124,135 @@ def __init__(self, *args, request=None, **kwargs): request.session["integration_type"] = str(self.instance.type.id) self.fields['state'].widget.instance = self.instance.type.id + def save(self, commit=True): + """Override save to preserve default_devicegroup value when using display widget.""" + instance = super().save(commit=False) + + # If using DeviceGroupDisplayWidget, preserve the original value + if self.instance.pk and isinstance(self.fields['default_devicegroup'].widget, DeviceGroupDisplayWidget): + instance.default_devicegroup = self.instance.default_devicegroup + + if commit: + instance.save() + return instance + + helper = FormHelper() + helper.add_input(Submit("submit", "Save", css_class="btn-primary")) + helper.form_method = "POST" + + helper.layout = Layout( + Row( + Column(Field("name", autocomplete="off"), css_class="form-group col-lg-3 mb-0"), + Column("owner", css_class="form-group col-lg-3 mb-0"), + css_class="form-row", + ), + Row( + Column("type", css_class="form-group col-lg-3 mb-0"), + Column( + Field("provider", autocomplete="off"), css_class="form-group col-lg-3 mb-0" + ), + css_class="form-row", + ), + "enabled", + Row( + Column("default_devicegroup", css_class="form-group col-lg-3 mb-0"), + css_class="form-row", + ), + Row( + Column( + Field("endpoint", autocomplete="off"), + css_class="form-group col-lg-3 mb-0", + ), + Column(Field("token", autocomplete="off"), css_class="form-group col-lg-3 mb-0"), + css_class="form-row", + ), + Row( + Column(Field("login", autocomplete="off"), css_class="form-group col-md-3"), + Column( + Field("password", autocomplete="off"), css_class="form-group col-md-3" + ), + css_class="form-row", + ), + Row(Column("state", css_class="form-group col-lg-6 mb-0")), + ) + + +class InboundIntegrationConfigurationAddForm(forms.ModelForm): + # Override the default_devicegroup field to show auto-create message + default_devicegroup = DeviceGroupAutoCreateField( + queryset=DeviceGroup.objects.none(), + required=False, + label="Default Device Group" + ) + + class Meta: + model = InboundIntegrationConfiguration + exclude = [ + "id", + ] + fields = ("name", "owner", "type", "provider", "enabled", + "default_devicegroup", "endpoint", "token", + "login", "password", "state") + widgets = { + "password": PeekabooTextInput(), + "token": PeekabooTextInput(), + # "state": FormattedJsonFieldWidget(), + "apikey": PeekabooTextInput(), + "type": forms.Select( + attrs={ + 'name': "type", + 'id': 'id_type', + 'hx-trigger': 'change', + 'hx-target': 'body', + 'hx-swap': 'beforeend' + }), + "state": JSONFormWidget( + schema=InboundIntegrationType.objects.configuration_schema, + ), + "owner": forms.Select( + attrs={ + 'name': "owner", + 'hx-trigger': 'load', + 'hx-target': '#div_id_state', + 'hx-swap': 'outerHTML' + }, + ) + } + + def __init__(self, *args, request=None, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + for field_name in self.fields: + if self.fields[field_name].help_text != "": + self.fields[ + field_name + ].label += tooltip_labels(self.fields[field_name].help_text) + self.fields[field_name].help_text = None + if request: + qs = Organization.objects.all() + if not IsGlobalAdmin.has_permission(None, request, None): + self.fields[ + "owner" + ].queryset = IsOrganizationMember.filter_queryset_for_user( + qs, request.user, "name", admin_only=True + ) + else: + self.fields["owner"].queryset = qs + # For Add Integration: Allow free type changes without warning modal + # The type field will trigger schema updates directly + if hasattr(self.instance, 'type'): + # TODO: review how we trigger the schema view + self.fields['owner'].widget.attrs['hx-get'] = reverse("inboundconfigurations/schema", + kwargs={"integration_type": self.instance.type.id, + "integration_id": self.instance.id, + "update": "false"}) + if hasattr(request, 'session'): + request.session["integration_type"] = str(self.instance.type.id) + self.fields['state'].widget.instance = self.instance.type.id + + # Configure type field to use the new add_type_modal endpoint + self.fields['type'].widget.attrs['hx-get'] = reverse("inboundconfigurations/add_type_modal") + helper = FormHelper() helper.add_input(Submit("submit", "Save", css_class="btn-primary")) helper.form_method = "POST" @@ -141,6 +295,13 @@ def __init__(self, *args, request=None, **kwargs): class DeviceGroupForm(forms.ModelForm): + # Override the destinations field to use our custom widget + destinations = SearchableMultiSelectField( + queryset=OutboundIntegrationConfiguration.objects.all(), + required=False, + label="Destinations" + ) + class Meta: model = DeviceGroup exclude = [ @@ -179,9 +340,86 @@ def __init__(self, *args, request=None, **kwargs): class DeviceGroupManagementForm(forms.ModelForm): + # Override the destinations field to use our custom widget + destinations = SearchableMultiSelectField( + queryset=OutboundIntegrationConfiguration.objects.all(), + required=False, + label="Destinations" + ) + + + # Override the default_subject_type field to use our custom widget + default_subject_type = SubjectTypeAutocompleteField( + required=False, + label="Default Subject Type", + empty_label="Select or create a subject type..." + ) + + class Meta: + model = DeviceGroup + exclude = ["id", "devices"] + + def __init__(self, *args, request=None, **kwargs): + super(DeviceGroupManagementForm, self).__init__(*args, **kwargs) + for field_name in self.fields: + if self.fields[field_name].help_text != "": + self.fields[ + field_name + ].label += tooltip_labels(self.fields[field_name].help_text) + self.fields[field_name].help_text = None + if self.instance and request: + qs = Organization.objects.all() + if not IsGlobalAdmin.has_permission(None, request, None): + self.fields[ + "owner" + ].queryset = IsOrganizationMember.filter_queryset_for_user( + qs, request.user, "name" + ) + else: + self.fields["owner"].queryset = qs + + field_order = [ + "name", + "owner", + "default_subject_type", + "destinations", + ] + + helper = FormHelper() + helper.add_input(Submit("submit", "Save", css_class="btn-primary")) + helper.form_method = "POST" + + +class DeviceGroupDevicesManagementForm(forms.ModelForm): + # Use a simple multiple choice field for devices + devices = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label="Devices", + widget=forms.CheckboxSelectMultiple + ) + class Meta: model = DeviceGroup - exclude = ["id", "name", "destinations", "owner"] + fields = ["devices"] + + def __init__(self, *args, request=None, **kwargs): + super(DeviceGroupDevicesManagementForm, self).__init__(*args, **kwargs) + if self.instance and request: + # Filter devices to only show devices from the inbound integration associated with this device group + try: + inbound_integration = self.instance.inbound_integration_configuration.first() + if inbound_integration: + # Limit devices to only those from the associated inbound integration + self.fields["devices"].queryset = Device.objects.filter( + inbound_configuration=inbound_integration + ).select_related('inbound_configuration__owner', 'inbound_configuration__type') + else: + # If no inbound integration is associated, show no devices + self.fields["devices"].queryset = Device.objects.none() + except Exception: + # If there's any error, show no devices + self.fields["devices"].queryset = Device.objects.none() helper = FormHelper() helper.add_input(Submit("submit", "Save", css_class="btn-primary")) @@ -430,14 +668,7 @@ class Meta: "additional": JSONFormWidget( schema=BridgeIntegrationType.objects.configuration_schema, ), - "owner": forms.Select( - attrs={ - 'name': "owner", - 'hx-trigger': 'load', - 'hx-target': '#div_id_additional', - 'hx-swap': 'outerHTML' - }, - ), + "owner": forms.Select(), "state": FormattedJsonFieldWidget(), } @@ -460,16 +691,12 @@ def __init__(self, *args, request=None, **kwargs): ) else: self.fields["owner"].queryset = qs - # TODO: review how we trigger the warning modal - self.fields['type'].widget.attrs['hx-get'] = reverse("bridges/type_modal", - kwargs={"integration_id": self.instance.id}) + # Set up HTMX attributes for type field to trigger modal + if self.instance and self.instance.id: + self.fields['type'].widget.attrs['hx-get'] = reverse("bridges/type_modal", + kwargs={"integration_id": self.instance.id}) - if hasattr(self.instance, 'type'): - # TODO: review how we trigger the schema view - self.fields['owner'].widget.attrs['hx-get'] = reverse("bridges/schema", - kwargs={"integration_type": self.instance.type.id, - "integration_id": self.instance.id, - "update": "false"}) + if hasattr(self.instance, 'type') and self.instance.type: if hasattr(request, 'session'): request.session["integration_type"] = str(self.instance.type.id) self.fields['additional'].widget.instance = self.instance.type.id diff --git a/cdip_admin/integrations/models/v1/models.py b/cdip_admin/integrations/models/v1/models.py index b001c64ec..07f262da8 100644 --- a/cdip_admin/integrations/models/v1/models.py +++ b/cdip_admin/integrations/models/v1/models.py @@ -433,7 +433,7 @@ class Device(TimestampedModel): on_delete=models.PROTECT, blank=True, null=True, - help_text="Default subject type. Can be overriden by the integration or data provider", + help_text="Default subject type. Can be overridden by the integration or data provider", ) additional = models.JSONField( blank=True, @@ -525,7 +525,7 @@ class DeviceGroup(TimestampedModel): on_delete=models.PROTECT, blank=True, null=True, - help_text="Subject type to be used unless overriden by the integration or data provider.", + help_text="Subject type to be used unless overridden by the integration or data provider.", ) history = HistoricalRecords() diff --git a/cdip_admin/integrations/static/integrations/js/README.md b/cdip_admin/integrations/static/integrations/js/README.md new file mode 100644 index 000000000..94cf6b5f2 --- /dev/null +++ b/cdip_admin/integrations/static/integrations/js/README.md @@ -0,0 +1,37 @@ +# Searchable MultiSelect Widget + +This directory contains the JavaScript implementation for the searchable multiselect widget used in device group management forms. + +## Files + +- `searchable-multiselect.js` - Main JavaScript file that provides the searchable multiselect functionality + +## Features + +- **Search functionality**: Users can type to filter available destinations +- **Visual selection**: Clear visual distinction between selected and available items +- **Easy management**: Add/remove items with intuitive buttons +- **Responsive design**: Works well on different screen sizes +- **Bootstrap integration**: Uses Bootstrap classes for consistent styling + +## Usage + +The widget is automatically initialized when the form loads. It provides: + +1. A search input to filter available destinations +2. A "Selected Destinations" section showing currently selected items +3. An "Available Destinations" section showing items that can be added +4. Add/remove buttons for each item + +## Dependencies + +- Font Awesome icons (loaded via CDN) +- Bootstrap CSS classes +- Modern JavaScript (ES6+ features) + +## Browser Support + +- Chrome 60+ +- Firefox 55+ +- Safari 12+ +- Edge 79+ diff --git a/cdip_admin/integrations/static/integrations/js/device-searchable-multiselect.js b/cdip_admin/integrations/static/integrations/js/device-searchable-multiselect.js new file mode 100644 index 000000000..0aa35ab41 --- /dev/null +++ b/cdip_admin/integrations/static/integrations/js/device-searchable-multiselect.js @@ -0,0 +1,228 @@ +/** + * Device Searchable Multi-Select Widget + * Provides a searchable multiselect interface specifically for devices + */ + +document.addEventListener('DOMContentLoaded', function() { + // Initialize all device searchable multiselect widgets + const deviceWidgets = document.querySelectorAll('[data-toggle="searchable-multiselect"]'); + deviceWidgets.forEach(initDeviceWidget); +}); + +function initDeviceWidget(container) { + const widgetId = container.id; + const searchInput = container.querySelector('.search-input'); + const availableList = container.querySelector('.available-list'); + const selectedList = container.querySelector('.selected-list'); + const hiddenInputsContainer = container.querySelector('.hidden-inputs'); + + // Get options from data attributes or use defaults + const options = { + choices: JSON.parse(container.dataset.choices || '[]'), + selected: JSON.parse(container.dataset.selected || '[]') + }; + + console.log('Initializing device widget with options:', options); + + let allChoices = options.choices || []; + let selectedValues = new Set(options.selected || []); + let filteredChoices = [...allChoices]; + + // Initialize the widget + function init() { + renderSelectedItems(); + renderAvailableItems(); + setupEventListeners(); + } + + // Render selected items + function renderSelectedItems() { + selectedList.innerHTML = ''; + + if (selectedValues.size === 0) { + selectedList.innerHTML = `
No devices selected
`; + } else { + selectedValues.forEach(value => { + const choice = allChoices.find(c => c[0] == value); + if (choice) { + // For devices: choice[0]=id, choice[1]=name, choice[2]=external_id, choice[3]=owner, choice[4]=type, choice[5]=config + const item = createSelectedItem(choice[0], choice[1], choice[2], choice[3], choice[4], choice[5]); + selectedList.appendChild(item); + } + }); + } + + // Always update hidden inputs, even when no items are selected + updateHiddenInputs(); + } + + // Render available items (devices only) + function renderAvailableItems() { + availableList.innerHTML = ''; + + const availableChoices = filteredChoices.filter(choice => !selectedValues.has(choice[0])); + + if (availableChoices.length === 0) { + availableList.innerHTML = `
No devices available
`; + return; + } + + availableChoices.forEach(choice => { + // For devices: choice[0]=id, choice[1]=name, choice[2]=external_id, choice[3]=owner, choice[4]=type, choice[5]=config + const item = createAvailableItem(choice[0], choice[1], choice[2], choice[3], choice[4], choice[5]); + availableList.appendChild(item); + }); + } + + // Create a selected item element as a list item (devices only) + function createSelectedItem(value, name, externalId, owner, type, config) { + const item = document.createElement('div'); + item.className = 'list-group-item list-group-item-action list-group-item-success d-flex justify-content-between align-items-center mb-1'; + + const infoHtml = ` +
+
+
+
+ ${name || 'N/A'} +
+
+ ${externalId} +
+
+ ${type} +
+
+ ${owner} +
+
+
+
+ `; + + item.innerHTML = ` + ${infoHtml} + + `; + + // Add remove functionality + item.querySelector('.remove-item').addEventListener('click', (e) => { + e.stopPropagation(); + selectedValues.delete(value); + renderSelectedItems(); + renderAvailableItems(); + }); + + return item; + } + + // Create an available item element (devices only) + function createAvailableItem(value, name, externalId, owner, type, config) { + const item = document.createElement('div'); + item.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center mb-1'; + item.style.cursor = 'pointer'; + + const infoHtml = ` +
+
+
+
+ ${name || 'N/A'} +
+
+ ${externalId} +
+
+ ${type} +
+
+ ${owner} +
+
+
+
+ `; + + item.innerHTML = ` + ${infoHtml} + + `; + + // Add click functionality to add item + item.addEventListener('click', (e) => { + if (!e.target.closest('.add-item')) { + addItem(value); + } + }); + + // Add button click functionality + item.querySelector('.add-item').addEventListener('click', (e) => { + e.stopPropagation(); + addItem(value); + }); + + return item; + } + + // Add an item to selected + function addItem(value) { + selectedValues.add(value); + renderSelectedItems(); + renderAvailableItems(); + } + + // Update hidden inputs + function updateHiddenInputs() { + // Clear existing hidden inputs + hiddenInputsContainer.innerHTML = ''; + + // Add hidden inputs for each selected value + selectedValues.forEach(value => { + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = container.dataset.name || 'devices'; + hiddenInput.value = value; + hiddenInputsContainer.appendChild(hiddenInput); + }); + + console.log('Updated hidden inputs:', hiddenInputsContainer.innerHTML); + } + + // Setup event listeners + function setupEventListeners() { + // Search functionality - filters devices only + searchInput.addEventListener('input', (e) => { + const searchTerm = e.target.value.toLowerCase(); + filteredChoices = allChoices.filter(choice => { + // For devices: choice[0]=id, choice[1]=name, choice[2]=external_id, choice[3]=owner, choice[4]=type, choice[5]=config + let matches = choice[1].toLowerCase().includes(searchTerm); // name + matches = matches || + choice[2].toLowerCase().includes(searchTerm) || // external_id + choice[3].toLowerCase().includes(searchTerm) || // owner + choice[4].toLowerCase().includes(searchTerm) || // type + choice[5].toLowerCase().includes(searchTerm); // config + + return matches; + }); + renderSelectedItems(); + renderAvailableItems(); + }); + + // Clear search when clicking outside + document.addEventListener('click', (e) => { + if (!container.contains(e.target)) { + searchInput.value = ''; + filteredChoices = [...allChoices]; + renderSelectedItems(); + renderAvailableItems(); + } + }); + } + + // Initialize the widget + init(); +} diff --git a/cdip_admin/integrations/static/integrations/js/integration-type-warning.js b/cdip_admin/integrations/static/integrations/js/integration-type-warning.js new file mode 100644 index 000000000..182b43d74 --- /dev/null +++ b/cdip_admin/integrations/static/integrations/js/integration-type-warning.js @@ -0,0 +1,31 @@ +// JavaScript for handling Integration Type change warnings +// Tracks state field changes and modifies HTMX requests accordingly + +let stateFieldEdited = false; + +// Track when the state field is edited +function trackStateChanges() { + const stateField = document.querySelector('#div_id_state input, #div_id_state textarea, #div_id_state select'); + if (stateField) { + stateField.addEventListener('input', function() { + stateFieldEdited = true; + }); + stateField.addEventListener('change', function() { + stateFieldEdited = true; + }); + } +} + +// Handle Integration Type changes +document.addEventListener('DOMContentLoaded', function() { + // Set up state change tracking + trackStateChanges(); + + // Re-setup state change tracking after HTMX updates + document.body.addEventListener('htmx:afterRequest', function(event) { + if (event.target.id === 'id_type' && event.detail.xhr.status === 200) { + // Re-setup state change tracking for any new state fields + trackStateChanges(); + } + }); +}); diff --git a/cdip_admin/integrations/static/integrations/js/searchable-multiselect.js b/cdip_admin/integrations/static/integrations/js/searchable-multiselect.js new file mode 100644 index 000000000..2d6fa6857 --- /dev/null +++ b/cdip_admin/integrations/static/integrations/js/searchable-multiselect.js @@ -0,0 +1,306 @@ +/** + * Searchable MultiSelect Widget + * Provides a user-friendly interface for selecting multiple items from a searchable list + * Updated: Fixed empty selection bug - v1.1 + */ + +function initSearchableMultiSelect(widgetId, options) { + const container = document.getElementById(widgetId + '_container'); + const searchInput = document.getElementById(widgetId + '_search'); + const selectedList = document.getElementById(widgetId + '_selected'); + const availableList = document.getElementById(widgetId + '_available'); + const hiddenInputsContainer = container.querySelector('.hidden-inputs'); + + let allChoices = options.choices || []; + let selectedValues = new Set(options.selected || []); + let filteredChoices = [...allChoices]; + + // Initialize the widget + function init() { + renderSelectedItems(); + renderAvailableItems(); + setupEventListeners(); + } + + // Render selected items + function renderSelectedItems() { + selectedList.innerHTML = ''; + + if (selectedValues.size === 0) { + selectedList.innerHTML = `
No destinations selected
`; + } else { + selectedValues.forEach(value => { + const choice = allChoices.find(c => c[0] == value); + if (choice) { + // For destinations: choice[0]=id, choice[1]=name, choice[2]=owner, choice[3]=type, choice[4]=endpoint + const item = createSelectedItem(choice[0], choice[1], choice[2], choice[3], choice[4], undefined); + selectedList.appendChild(item); + } + }); + } + + // Always update hidden inputs, even when no items are selected + updateHiddenInputs(); + } + + // Render available items (destinations only) + function renderAvailableItems() { + availableList.innerHTML = ''; + + const availableChoices = filteredChoices.filter(choice => !selectedValues.has(choice[0])); + + if (availableChoices.length === 0) { + availableList.innerHTML = `
No destinations available
`; + return; + } + + availableChoices.forEach(choice => { + // For destinations: choice[0]=id, choice[1]=name, choice[2]=owner, choice[3]=type, choice[4]=endpoint + const item = createAvailableItem(choice[0], choice[1], choice[2], choice[3], choice[4], undefined); + availableList.appendChild(item); + }); + } + + // Create a selected item element as a list item (destinations only) + function createSelectedItem(value, label, owner, type, endpoint, externalId) { + const item = document.createElement('div'); + item.className = 'list-group-item list-group-item-action list-group-item-success d-flex justify-content-between align-items-center mb-1'; + + const infoHtml = ` +
+
${label}
+ ${owner} • ${type} +
+ ${endpoint} +
+ `; + + item.innerHTML = ` + ${infoHtml} + + `; + + // Add remove functionality + item.querySelector('.remove-item').addEventListener('click', (e) => { + e.stopPropagation(); + selectedValues.delete(value); + renderSelectedItems(); + renderAvailableItems(); + }); + + return item; + } + + // Create an available item element (destinations only) + function createAvailableItem(value, label, owner, type, endpoint, externalId) { + const item = document.createElement('div'); + item.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center mb-1'; + item.style.cursor = 'pointer'; + + // This function is only used for destinations now + const infoHtml = ` +
+
${label}
+ ${owner} • ${type} +
+ ${endpoint} +
+ `; + + item.innerHTML = ` + ${infoHtml} + + `; + + // Add click functionality to the entire item + item.addEventListener('click', (e) => { + if (!e.target.closest('.add-item')) { + addItem(value); + } + }); + + // Add button click functionality + item.querySelector('.add-item').addEventListener('click', (e) => { + e.stopPropagation(); + addItem(value); + }); + + return item; + } + + // Add an item to selected + function addItem(value) { + selectedValues.add(value); + renderSelectedItems(); + renderAvailableItems(); + } + + // Update hidden inputs for form submission + function updateHiddenInputs() { + hiddenInputsContainer.innerHTML = ''; + + // Convert Set to Array and use sequential indices + const selectedArray = Array.from(selectedValues); + + if (selectedArray.length === 0) { + // When no items are selected, create a single hidden input with empty value + // This ensures Django knows the field should be cleared + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = `${options.name}_0`; + input.value = ''; + hiddenInputsContainer.appendChild(input); + console.log('Empty selection: created single hidden input with empty value'); + } else { + // Create inputs for each selected item + selectedArray.forEach((value, index) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = `${options.name}_${index}`; + input.value = value; + hiddenInputsContainer.appendChild(input); + }); + } + + // Debug: log the hidden inputs being created + console.log('Hidden inputs created:'); + const allInputs = hiddenInputsContainer.querySelectorAll('input'); + allInputs.forEach((input, index) => { + console.log(` ${input.name} = ${input.value}`); + }); + } + + // Setup event listeners + function setupEventListeners() { + // Search functionality - filters destinations only + searchInput.addEventListener('input', (e) => { + const searchTerm = e.target.value.toLowerCase(); + filteredChoices = allChoices.filter(choice => { + // For destinations: choice[0]=id, choice[1]=name, choice[2]=owner, choice[3]=type, choice[4]=endpoint + let matches = choice[1].toLowerCase().includes(searchTerm); // name + matches = matches || + choice[2].toLowerCase().includes(searchTerm) || // owner + choice[3].toLowerCase().includes(searchTerm) || // type + choice[4].toLowerCase().includes(searchTerm); // endpoint + + return matches; + }); + renderSelectedItems(); + renderAvailableItems(); + }); + + // Clear search when clicking outside + document.addEventListener('click', (e) => { + if (!container.contains(e.target)) { + searchInput.value = ''; + filteredChoices = [...allChoices]; + renderSelectedItems(); + renderAvailableItems(); + } + }); + } + + // Initialize the widget + init(); +} + +// CSS styles (injected dynamically) - only inject once +(function() { + if (!document.getElementById('searchable-multiselect-styles')) { + const styles = ` + .searchable-multiselect-container { + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 1rem; + background-color: #fff; + } + + .search-input-container .search-input { + border-radius: 0.375rem; + border: 1px solid #ced4da; + padding: 0.5rem 0.75rem; + } + + .search-input-container .search-input:focus { + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + } + + .destination-card { + transition: all 0.2s ease-in-out; + } + + .available-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + } + + .selected-list, .available-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 0; + background-color: #f8f9fa; + } + + .selected-list .list-group-item, .available-list .list-group-item { + border: none; + border-bottom: 1px solid #dee2e6; + border-radius: 0; + } + + .selected-list .list-group-item:last-child, .available-list .list-group-item:last-child { + border-bottom: none; + } + + .info-label { + color: #6c757d; + font-size: 0.875rem; + margin-right: 0.5rem; + } + + .info-value { + color: #495057; + font-size: 0.875rem; + } + + .info-row { + display: flex; + align-items: flex-start; + } + + .cursor-pointer { + cursor: pointer; + } + + .btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: 0.25rem; + } + + .text-break { + word-break: break-all; + } + + .card { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .card:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + } + `; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'searchable-multiselect-styles'; + styleSheet.textContent = styles; + document.head.appendChild(styleSheet); + } +})(); diff --git a/cdip_admin/integrations/static/integrations/js/subject-type-autocomplete.js b/cdip_admin/integrations/static/integrations/js/subject-type-autocomplete.js new file mode 100644 index 000000000..fa4bd05cb --- /dev/null +++ b/cdip_admin/integrations/static/integrations/js/subject-type-autocomplete.js @@ -0,0 +1,532 @@ +/** + * Subject Type Autocomplete Widget + * Provides auto-complete functionality for subject type selection with ability to create new ones + * Version: 1.0 + */ + +function initSubjectTypeAutocomplete(widgetId, options) { + console.log('=== INITIALIZING SUBJECT TYPE WIDGET ==='); + console.log('Widget ID:', widgetId); + console.log('Options:', options); + + const container = document.getElementById(widgetId + '_container'); + const input = document.getElementById(widgetId + '_input'); + const hiddenInput = document.getElementById(widgetId + '_hidden'); + const dropdown = document.getElementById(widgetId + '_dropdown'); + const dropdownMenu = document.getElementById(widgetId + '_dropdown_menu'); + const selectedSection = document.getElementById(widgetId + '_selected'); + const createNewSection = document.getElementById(widgetId + '_create_new'); + + console.log('Found elements:', { + container: !!container, + input: !!input, + hiddenInput: !!hiddenInput, + dropdown: !!dropdown, + dropdownMenu: !!dropdownMenu, + selectedSection: !!selectedSection, + createNewSection: !!createNewSection + }); + + if (!container || !input || !hiddenInput) { + console.error('Required elements not found for widget:', widgetId); + return; + } + + // Check if this widget is already initialized + if (container.dataset.initialized === 'true') { + console.warn('Widget already initialized:', widgetId); + return; + } + container.dataset.initialized = 'true'; + + let allChoices = options.choices || []; + let currentValue = options.currentValue || ''; + let selectedChoice = null; + + // Initialize the widget + function init() { + console.log('Subject Type Widget: Initializing...'); + console.log('Subject Type Widget: allChoices =', allChoices); + console.log('Subject Type Widget: currentValue =', currentValue); + + setupEventListeners(); + + // Add form submission debugging + const form = container.closest('form'); + if (form) { + form.addEventListener('submit', function(e) { + console.log('Form submitted!'); + console.log('Hidden input value before submit:', hiddenInput.value); + console.log('Hidden input name:', hiddenInput.name); + + // Log all form data + const formData = new FormData(form); + console.log('All form data:'); + for (let [key, value] of formData.entries()) { + console.log(`${key}: ${value}`); + } + }); + } + + if (currentValue) { + // Find and set the current selection + const choice = allChoices.find(c => c[0] === currentValue); + if (choice) { + selectChoice(choice); + } + } + + console.log('Subject Type Widget: Initialization complete'); + } + + // Setup event listeners + function setupEventListeners() { + // Input typing for autocomplete + input.addEventListener('input', handleInput); + input.addEventListener('focus', () => { + showDropdown(); + if (dropdownMenu.innerHTML === '') { + showAllChoices(); + } + }); + input.addEventListener('blur', hideDropdown); + + // Dropdown toggle + dropdown.addEventListener('click', toggleDropdown); + + // Click outside to close dropdown + document.addEventListener('click', (e) => { + if (!container.contains(e.target)) { + hideDropdown(); + } + }); + + // Clear selection + container.addEventListener('click', (e) => { + if (e.target.closest('.clear-selection')) { + clearSelection(); + } + }); + + // Show create new section + container.addEventListener('click', (e) => { + if (e.target.closest('.show-create-new')) { + // Get the search term from the input field + const currentSearchTerm = input.value.toLowerCase(); + showCreateNewSection(currentSearchTerm); + } + }); + + // Cancel create new + container.addEventListener('click', (e) => { + if (e.target.closest('.cancel-create')) { + hideCreateNewSection(); + } + }); + + // Create new subject type + container.addEventListener('click', (e) => { + if (e.target.closest('.create-subject-type')) { + createNewSubjectType(); + } + }); + + // Auto-generate slug from display name + const displayNameInput = container.querySelector('.new-display-name'); + const valueInput = container.querySelector('.new-value'); + + if (displayNameInput && valueInput) { + displayNameInput.addEventListener('input', (e) => { + const slug = e.target.value.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); + valueInput.value = slug; + // Trigger validation after auto-filling + validateValueField(); + }); + + // Add validation for the value field + valueInput.addEventListener('input', validateValueField); + valueInput.addEventListener('blur', validateValueField); + } + } + + // Handle input typing + function handleInput(e) { + const searchTerm = e.target.value.toLowerCase(); + + if (searchTerm.length === 0) { + showAllChoices(); + hideCreateNewSection(); + } else { + const filteredChoices = allChoices.filter(choice => + choice[1].toLowerCase().includes(searchTerm) || + choice[2].toLowerCase().includes(searchTerm) + ); + + // Always render filtered choices (or empty state) + renderChoices(filteredChoices); + + // Always show create new option when there's a search term + showCreateNewOption(searchTerm); + } + + showDropdown(); + } + + // Show all choices + function showAllChoices() { + console.log('Subject Type Widget: showAllChoices called, choices =', allChoices); + renderChoices(allChoices); + } + + // Render choices in dropdown + function renderChoices(choices) { + console.log('Subject Type Widget: renderChoices called with', choices); + dropdownMenu.innerHTML = ''; + + if (choices.length === 0) { + dropdownMenu.innerHTML = ''; + console.log('Subject Type Widget: No choices to render'); + return; + } + + choices.forEach(choice => { + const item = document.createElement('div'); + item.className = 'dropdown-item choice-item'; + item.innerHTML = ` +
+ ${choice[1]} + ${choice[2]} +
+ `; + + item.addEventListener('click', () => { + selectChoice(choice); + hideDropdown(); + }); + + dropdownMenu.appendChild(item); + }); + } + + // Show create new option + function showCreateNewOption(searchTerm) { + // Check if a create new item already exists and remove it + const existingCreateNew = dropdownMenu.querySelector('.show-create-new'); + if (existingCreateNew) { + existingCreateNew.remove(); + } + + const createNewItem = document.createElement('div'); + createNewItem.className = 'dropdown-item show-create-new'; + createNewItem.innerHTML = ` +
+ Create new: "${searchTerm}" +
+ `; + + createNewItem.addEventListener('click', (e) => { + e.stopPropagation(); + showCreateNewSection(searchTerm); + hideDropdown(); + }); + + dropdownMenu.appendChild(createNewItem); + } + + // Select a choice + function selectChoice(choice) { + console.log('selectChoice called with:', choice); + console.log('Current hidden input value before update:', hiddenInput.value); + console.log('Current hidden input name:', hiddenInput.name); + + selectedChoice = choice; + hiddenInput.value = choice[0]; + input.value = ''; + + // Show selected section + selectedSection.querySelector('.selected-name').textContent = choice[1]; + selectedSection.querySelector('.selected-value').textContent = choice[2]; + selectedSection.style.display = 'block'; + + console.log('Selected subject type:', choice); + console.log('Hidden input value set to:', hiddenInput.value); + console.log('Hidden input name:', hiddenInput.name); + console.log('Hidden input element:', hiddenInput); + + // Verify the value was actually set + setTimeout(() => { + console.log('Verification - hidden input value after 100ms:', hiddenInput.value); + }, 100); + } + + // Clear selection + function clearSelection() { + selectedChoice = null; + hiddenInput.value = ''; + input.value = ''; + selectedSection.style.display = 'none'; + console.log('Cleared subject type selection'); + } + + // Show dropdown + function showDropdown() { + console.log('Subject Type Widget: showDropdown called'); + dropdownMenu.style.display = 'block'; + dropdown.setAttribute('aria-expanded', 'true'); + } + + // Hide dropdown + function hideDropdown() { + // Delay hiding to allow clicks on dropdown items + setTimeout(() => { + dropdownMenu.style.display = 'none'; + dropdown.setAttribute('aria-expanded', 'false'); + }, 150); + } + + // Toggle dropdown + function toggleDropdown() { + if (dropdownMenu.style.display === 'block') { + hideDropdown(); + } else { + showDropdown(); + if (dropdownMenu.innerHTML === '') { + showAllChoices(); + } + } + } + + // Show create new section + function showCreateNewSection(searchTerm = '') { + createNewSection.style.display = 'block'; + input.value = ''; + hideDropdown(); + + // Pre-populate the fields with the search term + if (searchTerm) { + const displayNameInput = container.querySelector('.new-display-name'); + const valueInput = container.querySelector('.new-value'); + + if (displayNameInput) { + // Convert search term to a more user-friendly display name + // Handle both underscore-separated slugs and space-separated text + let displayName; + if (searchTerm.includes('_') || searchTerm.includes('-')) { + // If it contains underscores or hyphens, treat as slug and convert to title case + const separator = searchTerm.includes('_') ? '_' : '-'; + displayName = searchTerm + .split(separator) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } else { + // If it's regular text, just capitalize each word + displayName = searchTerm + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + displayNameInput.value = displayName; + } + + if (valueInput) { + // Generate a proper slug from the search term + const slug = searchTerm.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); + valueInput.value = slug; + } + } + + // Initial validation + validateValueField(); + } + + // Validate the value field + function validateValueField() { + const valueInput = container.querySelector('.new-value'); + const createButton = container.querySelector('.create-subject-type'); + const validationMessage = container.querySelector('.validation-message'); + + if (!valueInput || !createButton) return; + + const value = valueInput.value.trim(); + const isValid = isValidValue(value); + + // Update button state + createButton.disabled = !isValid; + + // Update visual feedback + if (value.length === 0) { + // Empty field - neutral state + valueInput.classList.remove('is-valid', 'is-invalid'); + if (validationMessage) { + validationMessage.style.display = 'none'; + } + } else if (isValid) { + // Valid field + valueInput.classList.remove('is-invalid'); + valueInput.classList.add('is-valid'); + if (validationMessage) { + validationMessage.style.display = 'none'; + } + } else { + // Invalid field + valueInput.classList.remove('is-valid'); + valueInput.classList.add('is-invalid'); + if (validationMessage) { + validationMessage.style.display = 'block'; + validationMessage.textContent = getValidationMessage(value); + } + } + } + + // Check if value is valid + function isValidValue(value) { + if (!value || value.length === 0) { + return false; + } + + // Check length (up to 32 characters) + if (value.length > 32) { + return false; + } + + // Check characters (lowercase letters, digits, underscores, and hyphens) + const validPattern = /^[a-z0-9_-]+$/; + return validPattern.test(value); + } + + // Get validation error message + function getValidationMessage(value) { + if (value.length > 32) { + return 'Value must be 32 characters or less'; + } + + const invalidChars = value.match(/[^a-z0-9_-]/g); + if (invalidChars) { + return `Invalid characters: ${invalidChars.join(', ')}. Only lowercase letters, digits, underscores, and hyphens are allowed.`; + } + + return 'Invalid value format'; + } + + // Hide create new section + function hideCreateNewSection() { + createNewSection.style.display = 'none'; + } + + // Create new subject type + async function createNewSubjectType() { + const displayNameInput = container.querySelector('.new-display-name'); + const valueInput = container.querySelector('.new-value'); + + const displayName = displayNameInput.value.trim(); + const value = valueInput.value.trim(); + + if (!displayName || !value) { + alert('Please fill in both Display Name and Value fields.'); + return; + } + + try { + // Create new subject type via AJAX + const response = await fetch('/integrations/api/create-subject-type/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value, + }, + body: JSON.stringify({ + display_name: displayName, + value: value + }) + }); + + if (response.ok) { + const data = await response.json(); + const newChoice = [data.id, data.display_name, data.value]; + + // Add to choices list + allChoices.push(newChoice); + allChoices.sort((a, b) => a[1].localeCompare(b[1])); + + // Select the new choice + selectChoice(newChoice); + + // Hide create new section + hideCreateNewSection(); + + console.log('Created new subject type:', newChoice); + } else { + const error = await response.json(); + alert('Error creating subject type: ' + (error.message || 'Unknown error')); + } + } catch (error) { + console.error('Error creating subject type:', error); + alert('Error creating subject type. Please try again.'); + } + } + + // Initialize + init(); +} + +// CSS styles (injected dynamically) - only inject once +(function() { + if (!document.getElementById('subject-type-autocomplete-styles')) { + const styles = ` + .subject-type-autocomplete-container .autocomplete-dropdown { + width: 100%; + max-height: 200px; + overflow-y: auto; + border: 1px solid #ced4da; + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + } + + .subject-type-autocomplete-container .choice-item { + cursor: pointer; + padding: 0.5rem 0.75rem; + } + + .subject-type-autocomplete-container .choice-item:hover { + background-color: #f8f9fa; + } + + .subject-type-autocomplete-container .show-create-new { + cursor: pointer; + padding: 0.5rem 0.75rem; + border-top: 1px solid #dee2e6; + } + + .subject-type-autocomplete-container .show-create-new:hover { + background-color: #e3f2fd; + } + + .subject-type-autocomplete-container .dropdown-toggle::after { + display: none; + } + + .subject-type-autocomplete-container .input-group .form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .subject-type-autocomplete-container .input-group .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + `; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'subject-type-autocomplete-styles'; + styleSheet.textContent = styles; + document.head.appendChild(styleSheet); + } +})(); diff --git a/cdip_admin/integrations/templates/integrations/bridge_integration_list.html b/cdip_admin/integrations/templates/integrations/bridge_integration_list.html index 182cc9f3b..0fbf8e75b 100644 --- a/cdip_admin/integrations/templates/integrations/bridge_integration_list.html +++ b/cdip_admin/integrations/templates/integrations/bridge_integration_list.html @@ -19,11 +19,21 @@

Bridge Integrations

{% if filter %} -
+
- {% bootstrap_form filter.form layout='inline' form_group_class='mr-2' %} +
+ + {{ filter.form.search }} +
+
+ + {{ filter.form.enabled }} +
{% bootstrap_button 'Filter' extra_classes='' %} + {% if request.GET.search or request.GET.enabled %} + Clear + {% endif %}
diff --git a/cdip_admin/integrations/templates/integrations/device_group_detail.html b/cdip_admin/integrations/templates/integrations/device_group_detail.html deleted file mode 100644 index 50ea29c6a..000000000 --- a/cdip_admin/integrations/templates/integrations/device_group_detail.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% load render_table from django_tables2 %} -{% load bootstrap4 %} -{% load static %} - -{% block title %}Device Group: {{object.name}}{% endblock %} - -{% block content %} -

{{object.name}}

-

Organization: {{object.owner.name}}

- -
-
-

Devices in Group

-
- -
- - - {% render_table table %} - -{% endblock %} \ No newline at end of file diff --git a/cdip_admin/integrations/templates/integrations/device_group_devices_manage.html b/cdip_admin/integrations/templates/integrations/device_group_devices_manage.html new file mode 100644 index 000000000..675ed18e5 --- /dev/null +++ b/cdip_admin/integrations/templates/integrations/device_group_devices_manage.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% load bootstrap4 %} +{% load static %} + +{% block title %}Manage Devices - {{device_group.name}}{% endblock %} + +{% block content %} +
+
+

Manage Devices in {{device_group.name}}

+

Organization: {{device_group.owner.name}}

+
+ +
+ + {% if inbound_integration %} +
+
+ +
+ Default Device Group for: + + {{ inbound_integration.name|default:inbound_integration.type.name }} + +
+ Only devices from this integration configuration can be added to this device group. +
+
+
+ {% else %} +
+
+ +
+ No Integration Configuration Found +
+ This device group is not associated with any inbound integration configuration. No devices can be added. +
+
+
+ {% endif %} + +
+ {% csrf_token %} + +
+
+
+ + Device Management +
+
+
+ {% if inbound_integration %} +

+ Select devices from the {{ inbound_integration.type.name }} integration to include in this device group. +

+ + {% if form.devices.field.queryset %} +
+ + + + + + + + + + + + + {% for device in form.devices.field.queryset %} + + + + + + + + + {% endfor %} + +
SelectNameExternal IDTypeOwnerConfiguration
+
+ + +
+
+ {{ device.name|default:"N/A" }} + + {{ device.external_id|default:"N/A" }} + + {{ device.inbound_configuration.type.name }} + + {{ device.inbound_configuration.owner.name }} + + {{ device.inbound_configuration.name|default:"N/A" }} +
+
+ {% else %} +
+ + No devices available from the {{ inbound_integration.type.name }} integration. +
+ {% endif %} + {% else %} +
+ + Cannot manage devices: No integration configuration associated with this device group. +
+ {% endif %} +
+
+ +
+ {% if inbound_integration and form.devices.field.queryset %} + + {% endif %} + + + Back to Device Group + +
+
+{% endblock %} + diff --git a/cdip_admin/integrations/templates/integrations/device_group_management_update.html b/cdip_admin/integrations/templates/integrations/device_group_management_update.html index 7e2f02b40..aef8607c5 100644 --- a/cdip_admin/integrations/templates/integrations/device_group_management_update.html +++ b/cdip_admin/integrations/templates/integrations/device_group_management_update.html @@ -1,10 +1,13 @@ {% extends "base.html" %} {% load crispy_forms_tags %} +{% load static %} -{% block title %}Manage Devices{% endblock %} +{% block title %}Manage Device Group{% endblock %} {% block content %} + +
{% csrf_token %} {% crispy form %} diff --git a/cdip_admin/integrations/templates/integrations/device_group_update.html b/cdip_admin/integrations/templates/integrations/device_group_update.html index 0bf9b6adb..6434bcf92 100644 --- a/cdip_admin/integrations/templates/integrations/device_group_update.html +++ b/cdip_admin/integrations/templates/integrations/device_group_update.html @@ -1,13 +1,123 @@ {% extends "base.html" %} {% load crispy_forms_tags %} +{% load static %} -{% block title %}Update Device Group{% endblock %} +{% block title %}Device Group: {{object.name}}{% endblock %} {% block content %} - -

Device Group

- {% csrf_token %} - {% crispy form %} -
+ + +
+
+

{{object.name}}

+

Organization: {{object.owner.name}}

+
+ +
+ + {% if inbound_integration %} +
+
+ +
+ Default Device Group for: + + {{ inbound_integration.name|default:inbound_integration.type.name }} + +
+ This device group is used as the default for the {{ inbound_integration.type.name }} integration configuration. +
+
+
+ {% endif %} + + + + + +
+ +
+
+
+
+ {% csrf_token %} + {% crispy form %} +
+
+
+
+ + +
+
+
+ {% if devices %} +
+ + + + + + + + + + + + {% for device in devices %} + + + + + + + + {% endfor %} + +
NameExternal IDTypeOwnerConfiguration
+ + {{ device.name|default:"N/A" }} + + {{ device.external_id|default:"N/A" }} + {{ device.inbound_configuration.type.name }} + {{ device.inbound_configuration.owner.name }}{{ device.inbound_configuration.name|default:"N/A" }}
+
+ + {% else %} +
+ +
No devices in this group
+

Add devices to this group to start managing them.

+ + + Add Devices + +
+ {% endif %} +
+
+
+
{% endblock %} \ No newline at end of file diff --git a/cdip_admin/integrations/templates/integrations/device_list.html b/cdip_admin/integrations/templates/integrations/device_list.html index e68ca9d8c..60261da38 100644 --- a/cdip_admin/integrations/templates/integrations/device_list.html +++ b/cdip_admin/integrations/templates/integrations/device_list.html @@ -16,11 +16,23 @@

Devices

{% if filter %} -
-
- {% bootstrap_form filter.form layout='inline' form_group_class='mr-2' %} - {% bootstrap_button 'Filter' extra_classes='' %} -
+
+
+
+
+
+ + Search by device name, external ID, organization, inbound config name, device group name, or inbound config type +
+
+ {% bootstrap_button 'Filter' extra_classes='' %} + {% if request.GET.search %} + Clear + {% endif %} +
+
+
+
{% endif %} {% render_table table %} diff --git a/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html b/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html index 067593b32..15b860860 100644 --- a/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html +++ b/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load crispy_forms_filters %} - {% load crispy_forms_tags %} +{% load static %} {% block title %}Add Inbound Integration Configuration{% endblock %} @@ -12,4 +12,8 @@

Add Inbound Integration

{{form.errors}}
{% crispy form %} +{% endblock %} + +{% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/cdip_admin/integrations/urls.py b/cdip_admin/integrations/urls.py index 49918ed28..df6835ae5 100644 --- a/cdip_admin/integrations/urls.py +++ b/cdip_admin/integrations/urls.py @@ -12,22 +12,22 @@ path("devices", views.DeviceList.as_view(), name="device_list"), path("devices/add", views.DeviceAddView.as_view(), name="device_add"), path( - "devicegroups/", - views.DeviceGroupDetail.as_view(), + "devicegroups/", + views.DeviceGroupManagementUpdateView.as_view(), name="device_group", ), path("devicegroups", views.DeviceGroupListView.as_view(), name="device_group_list"), path("devicegroups/add", views.DeviceGroupAddView.as_view(), name="device_group_add"), - path( - "devicegroups//edit", - views.DeviceGroupUpdateView.as_view(), - name="device_group_update", - ), path( "devicegroups//manage", views.DeviceGroupManagementUpdateView.as_view(), name="device_group_management_update", ), + path( + "devicegroups//devices/manage", + views.DeviceGroupDevicesManagementView.as_view(), + name="device_group_devices_manage", + ), path("devicestates", views.DeviceStateList.as_view(), name="device_state_list"), path( "inboundtypes/", @@ -94,6 +94,21 @@ views.InboundIntegrationConfigurationUpdateView.type_modal, name="inboundconfigurations/type_modal", ), + path( + "inboundconfigurations/add_type_modal/", + views.InboundIntegrationConfigurationAddView.type_modal, + name="inboundconfigurations/add_type_modal", + ), + path( + "inboundconfigurations/add_schema//", + views.InboundIntegrationConfigurationAddView.add_schema, + name="inboundconfigurations/add_schema", + ), + path( + "inboundconfigurations/add_dropdown_restore/", + views.InboundIntegrationConfigurationAddView.add_dropdown_restore, + name="inboundconfigurations/add_dropdown_restore", + ), path( "inboundconfigurations/schema///", views.InboundIntegrationConfigurationUpdateView.schema, @@ -171,5 +186,7 @@ "bridges/dropdown_restore//", views.BridgeIntegrationUpdateView.dropdown_restore, name="bridges/dropdown_restore", - ) + ), + # API endpoints + path("api/create-subject-type/", views.create_subject_type_api, name="create_subject_type_api"), ] diff --git a/cdip_admin/integrations/views.py b/cdip_admin/integrations/views.py index 42f57a0c0..3dc3f0e67 100644 --- a/cdip_admin/integrations/views.py +++ b/cdip_admin/integrations/views.py @@ -1,5 +1,6 @@ import logging import random +import json from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin @@ -10,6 +11,9 @@ from django_filters.views import FilterView from django_tables2.views import SingleTableMixin from django.db.models import Count +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt from cdip_admin import settings from core.permissions import IsGlobalAdmin, IsOrganizationMember @@ -25,9 +29,11 @@ ) from .forms import ( InboundIntegrationConfigurationForm, + InboundIntegrationConfigurationAddForm, OutboundIntegrationConfigurationForm, DeviceGroupForm, DeviceGroupManagementForm, + DeviceGroupDevicesManagementForm, InboundIntegrationTypeForm, OutboundIntegrationTypeForm, BridgeIntegrationForm, @@ -188,23 +194,54 @@ def get_table_data(self): return qs.annotate(device_count=Count("devices")) -class DeviceGroupDetail(PermissionRequiredMixin, SingleTableMixin, DetailView): - template_name = "integrations/device_group_detail.html" + + +class DeviceGroupDevicesManagementView(PermissionRequiredMixin, UpdateView): + template_name = "integrations/device_group_devices_manage.html" + form_class = DeviceGroupDevicesManagementForm model = DeviceGroup - table_class = DeviceTable - paginate_by = default_paginate_by - permission_required = "integrations.view_devicegroup" + permission_required = "integrations.change_devicegroup" def get_object(self): - return get_object_or_404(DeviceGroup, pk=self.kwargs.get("module_id")) + device_group = get_object_or_404( + DeviceGroup, pk=self.kwargs.get("device_group_id") + ) + if not IsGlobalAdmin.has_permission(None, self.request, None): + if not IsOrganizationMember.is_object_owner( + self.request.user, device_group.owner + ): + raise PermissionDenied + return device_group - def get_table_data(self): - return self.get_object().devices + def get(self, request, *args, **kwargs): + form_class = self.get_form_class() + self.object = self.get_object() + form = form_class(instance=self.object, request=request) + return self.render_to_response(self.get_context_data(form=form)) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + form.save() + return redirect("device_group", device_group_id=str(self.object.id)) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - base_url = reverse("device_list") - context["base_url"] = base_url + context["device_group"] = self.object + + # Get the inbound integration configuration that uses this device group as default + try: + inbound_integration = self.object.inbound_integration_configuration.get() + context["inbound_integration"] = inbound_integration + except (InboundIntegrationConfiguration.DoesNotExist, InboundIntegrationConfiguration.MultipleObjectsReturned): + context["inbound_integration"] = None + return context @@ -251,13 +288,13 @@ class DeviceGroupAddView(PermissionRequiredMixin, FormView): permission_required = "integrations.add_devicegroup" def post(self, request, *args, **kwargs): - form = DeviceGroupForm(request.POST) + form = DeviceGroupForm(request.POST, request=request) if form.is_valid(): config = form.save() return redirect("device_group", str(config.id)) def get_form(self, form_class=None): - form = DeviceGroupForm() + form = DeviceGroupForm(request=self.request) if not IsGlobalAdmin.has_permission(None, self.request, None): # can only add if you are an admin of at least one organization if not IsOrganizationMember.filter_queryset_for_user( @@ -269,19 +306,13 @@ def get_form(self, form_class=None): return form -class DeviceGroupUpdateView(PermissionRequiredMixin, UpdateView): + + +class DeviceGroupManagementUpdateView(PermissionRequiredMixin, UpdateView): template_name = "integrations/device_group_update.html" - form_class = DeviceGroupForm + form_class = DeviceGroupManagementForm model = DeviceGroup - permission_required = "integrations.change_devicegroup" - - def get(self, request, *args, **kwargs): - form_class = self.get_form_class() - self.object = self.get_object() - form = form_class(instance=self.object) - if not IsGlobalAdmin.has_permission(None, self.request, None): - form = filter_device_group_form_fields(form, self.request.user) - return self.render_to_response(self.get_context_data(form=form)) + permission_required = "integrations.view_devicegroup" def get_object(self): device_group = get_object_or_404( @@ -294,34 +325,54 @@ def get_object(self): raise PermissionDenied return device_group - def get_success_url(self): - return reverse( - "device_group", kwargs={"module_id": self.kwargs.get("device_group_id")} - ) - - -class DeviceGroupManagementUpdateView(LoginRequiredMixin, UpdateView): - template_name = "integrations/device_group_update.html" - form_class = DeviceGroupManagementForm - model = DeviceGroup - - def get_object(self): - device_group = get_object_or_404( - DeviceGroup, pk=self.kwargs.get("device_group_id") - ) - return device_group - def get(self, request, *args, **kwargs): form_class = self.get_form_class() self.object = self.get_object() - form = form_class(instance=self.object) + form = form_class(instance=self.object, request=request) if not IsGlobalAdmin.has_permission(None, self.request, None): form = filter_device_group_form_fields(form, self.request.user) return self.render_to_response(self.get_context_data(form=form)) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Get the inbound integration configuration that uses this device group as default + try: + inbound_integration = self.object.inbound_integration_configuration.get() + context["inbound_integration"] = inbound_integration + except (InboundIntegrationConfiguration.DoesNotExist, InboundIntegrationConfiguration.MultipleObjectsReturned): + context["inbound_integration"] = None + + # Add device information for display + context["devices"] = self.object.devices.select_related( + 'inbound_configuration__owner', + 'inbound_configuration__type' + ).all() + + return context + + def post(self, request, *args, **kwargs): + """Override post method to ensure many-to-many relationships are saved properly.""" + self.object = self.get_object() + form = self.get_form() + # Pass request to form for proper initialization + form = self.form_class(request.POST, instance=self.object, request=request) + + if form.is_valid(): + # Save the instance + instance = form.save(commit=False) + instance.save() + + # Explicitly save many-to-many relationships + form._save_m2m() + + return self.form_valid(form) + else: + return self.form_invalid(form) + def get_success_url(self): return reverse( - "device_group", kwargs={"module_id": self.kwargs.get("device_group_id")} + "device_group", kwargs={"device_group_id": self.kwargs.get("device_group_id")} ) @@ -503,20 +554,20 @@ def inbound_integration_configuration_detail(request, id): class InboundIntegrationConfigurationAddView(PermissionRequiredMixin, FormView): template_name = "integrations/inbound_integration_configuration_add.html" - form_class = InboundIntegrationConfigurationForm + form_class = InboundIntegrationConfigurationAddForm model = InboundIntegrationConfiguration permission_required = "integrations.add_inboundintegrationconfiguration" def post(self, request, *args, **kwargs): - form = InboundIntegrationConfigurationForm(request.POST) + form = InboundIntegrationConfigurationAddForm(request.POST, request=request) if form.is_valid(): config: InboundIntegrationConfiguration = form.save() device_group = config.default_devicegroup - return redirect("device_group_update", device_group_id=device_group.id) + return redirect("device_group_management_update", device_group_id=device_group.id) return render(request, self.template_name, {'form': form}) def get_form(self, form_class=None): - form = InboundIntegrationConfigurationForm() + form = InboundIntegrationConfigurationAddForm(request=self.request) if not IsGlobalAdmin.has_permission(None, self.request, None): form.fields[ "owner" @@ -525,6 +576,84 @@ def get_form(self, form_class=None): ) return form + @staticmethod + @requires_csrf_token + def type_modal(request): + """ + Type modal for Add Integration form. + Always shows warning modal when changing integration type. + """ + if request.GET.get("type"): + integration_type = request.GET.get("type") + selected_type = InboundIntegrationType.objects.get(id=integration_type) + else: + integration_type = "none" + selected_type = "None" + + # Always show warning modal when changing integration type + rendered = render_to_string('integrations/type_modal.html', { + 'selected_type': selected_type, + 'target': '#div_id_state', + 'proceed_button': reverse("inboundconfigurations/add_schema", + kwargs={ + "integration_type": integration_type, + "update": "true" + }), + 'cancel_button': reverse("inboundconfigurations/add_dropdown_restore") + }) + return HttpResponse(rendered) + + @staticmethod + @requires_csrf_token + def add_schema(request, integration_type, update): + """ + Schema endpoint for Add Integration form. + """ + if integration_type != "none": + integration_type_obj = InboundIntegrationType.objects.get(id=integration_type) + form = InboundIntegrationConfigurationAddForm() + + # Set the session for the integration type + request.session["integration_type"] = integration_type + + # Set up the state field widget based on the schema + if integration_type_obj.configuration_schema != {}: + form.fields['state'].widget = JSONFormWidget( + schema=integration_type_obj.configuration_schema, + ) + else: + form.fields['state'].widget = FormattedJsonFieldWidget() + + return HttpResponse(as_crispy_field(form["state"])) + return HttpResponse("") + + @staticmethod + @requires_csrf_token + def add_dropdown_restore(request): + """ + Dropdown restore for Add Integration form. + """ + response = f"""
+ +
+ +
+
""" + return HttpResponse(response) + class InboundIntegrationConfigurationUpdateView( PermissionRequiredMixin, @@ -535,6 +664,12 @@ class InboundIntegrationConfigurationUpdateView( model = InboundIntegrationConfiguration permission_required = "integrations.change_inboundintegrationconfiguration" + def get_form_kwargs(self): + """Add request to form kwargs.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + @staticmethod @requires_csrf_token def type_modal(request, integration_id): @@ -616,7 +751,7 @@ def dropdown_restore(request, integration_id): *
""" @@ -946,8 +1081,23 @@ class BridgeIntegrationUpdateView(PermissionRequiredMixin, UpdateView): model = BridgeIntegration permission_required = "integrations.change_bridgeintegration" + def get_form_kwargs(self): + """Add request to form kwargs.""" + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def post(self, request, *args, **kwargs): + """Override post method to ensure proper form handling.""" + self.object = self.get_object() + form = self.get_form() + + if not form.is_valid(): + return self.form_invalid(form) + + return self.form_valid(form) + @staticmethod - @requires_csrf_token def type_modal(request, integration_id): if request.GET.get("type"): integration_type = request.GET.get("type") @@ -971,7 +1121,6 @@ def type_modal(request, integration_id): return HttpResponse(rendered) @staticmethod - @requires_csrf_token def dropdown_restore(request, integration_id): type_modal = reverse("bridges/type_modal", kwargs={"integration_id": integration_id}) response = f"""
@@ -984,7 +1133,7 @@ def dropdown_restore(request, integration_id): *
+
+ +
+
+
+
Available Destinations
+
+ +
+
+
+ +
+
+
Selected Destinations
+
+ +
+
+
+
+ + +
+ +
+
+ ''', + widget_id=widget_id + ) + + # Add JavaScript to initialize the widget + choices_json = mark_safe(json.dumps(choices)) + selected_json = mark_safe(json.dumps(selected_values)) + + # Read the JavaScript file content and include it inline + js_file_path = os.path.join(os.path.dirname(__file__), 'static', 'integrations', 'js', 'searchable-multiselect.js') + + js_content = "" + if os.path.exists(js_file_path): + with open(js_file_path, 'r') as f: + js_content = f.read() + + js = format_html( + ''' + + ''', + js_content=mark_safe(js_content), + widget_id=widget_id, + choices_json=choices_json, + selected_json=selected_json, + name=name + ) + + return mark_safe(html + js) + + def value_from_datadict(self, data, files, name): + """Extract selected values from form data.""" + values = [] + + # Look for values with the pattern name_0, name_1, etc. + i = 0 + while True: + key = f"{name}_{i}" + if key in data: + value = data[key] + # Skip empty values (they indicate clearing the selection) + if value and value.strip(): + values.append(value) + i += 1 + else: + break + + # Also check for a single value with the name + if name in data: + values.append(data[name]) + + # Handle the case where field names might include UUIDs + # Look for any keys that start with the field name + for key in data.keys(): + if key.startswith(f"{name}_") and key not in [f"{name}_{i}" for i in range(len(values))]: + # This might be a UUID-based field name + value = data[key] + # Only add non-empty values + if value and value.strip(): + values.append(value) + + return values + + +class SearchableMultiSelectField(forms.ModelMultipleChoiceField): + """ + A custom field that uses the SearchableMultiSelectWidget. + """ + + def __init__(self, *args, **kwargs): + # Create the widget instance first + widget = SearchableMultiSelectWidget() + kwargs['widget'] = widget + super().__init__(*args, **kwargs) + # Store the field reference in the widget after initialization + self.widget.attrs['field'] = self + + def bound_data(self, data, initial): + """Override to ensure initial values are properly handled.""" + if initial is None: + initial = [] + return super().bound_data(data, initial) + + def widget_attrs(self, widget): + """Override to pass choices to the widget.""" + attrs = super().widget_attrs(widget) + # Don't set choices here - let the widget get them during render + return attrs + + +class DeviceSearchableMultiSelectWidget(forms.Widget): + """ + A custom widget that provides a searchable multiselect interface + for Device ManyToManyField relationships. + """ + + def __init__(self, attrs=None): + default_attrs = { + 'class': 'searchable-multiselect', + 'data-toggle': 'searchable-multiselect' + } + if attrs: + default_attrs.update(attrs) + super().__init__(default_attrs) + + def render(self, name, value, attrs=None, renderer=None): + if value is None: + value = [] + + # Convert value to list of strings if it's a QuerySet or list of objects + if hasattr(value, '__iter__') and not isinstance(value, str): + try: + # If it's a QuerySet or list of model instances, extract the primary keys + if hasattr(value, 'values_list'): + # It's a QuerySet + value = list(value.values_list('pk', flat=True)) + elif value and hasattr(value[0], 'pk'): + # It's a list of model instances + value = [str(obj.pk) for obj in value] + else: + # It's already a list of values + value = [str(v) for v in value] + except (AttributeError, IndexError): + # Fallback to string conversion + value = [str(v) for v in value] if value else [] + elif value: + value = [str(value)] + else: + value = [] + + # Ensure attrs is a dictionary + if attrs is None: + attrs = {} + + # Get the field instance to access choices + field = self.attrs.get('field') + if not field: + return format_html('
Field not available
') + + # Get all available choices + choices = [] + # First try to get choices from attrs (set by widget_attrs) + if 'choices' in attrs: + choices = attrs['choices'] + elif hasattr(field, 'queryset') and field.queryset: + try: + queryset = field.queryset.all().select_related('inbound_configuration__owner', 'inbound_configuration__type') + for obj in queryset: + # Create choice tuple with: (id, name, external_id, owner, type, inbound_config) + choices.append(( + str(obj.pk), + obj.name or str(obj), + obj.external_id or 'N/A', + obj.owner.name if obj.owner else 'N/A', + obj.inbound_configuration.type.name if obj.inbound_configuration and obj.inbound_configuration.type else 'N/A', + obj.inbound_configuration.name if obj.inbound_configuration else 'N/A' + )) + except Exception as e: + pass # Silently handle queryset errors + elif hasattr(field, 'choices') and field.choices: + choices = field.choices + + # Convert value to list of strings for comparison + if isinstance(value, (list, tuple)): + selected_values = [str(v) for v in value] + else: + selected_values = [str(value)] if value else [] + + # Render the widget HTML + widget_id = attrs.get('id', f'id_{name}') + + html = format_html( + ''' +
+
+ +
+ +
+
+
+
Available Devices
+
+
+
Name
+
External ID
+
Type
+
Owner
+
+
+
+ +
+
+
+ +
+
+
Selected Devices
+
+
+
Name
+
External ID
+
Type
+
Owner
+
+
+
+ +
+
+
+
+ + +
+ +
+
+ ''', + widget_id=widget_id, + choices_json=json.dumps(choices), + selected_json=json.dumps(selected_values), + name=name + ) + + # Add JavaScript to initialize the widget + import json + from django.utils.safestring import mark_safe + + choices_json = mark_safe(json.dumps(choices)) + selected_json = mark_safe(json.dumps(selected_values)) + + # Read the JavaScript file content and include it inline + js_file_path = os.path.join(os.path.dirname(__file__), 'static', 'integrations', 'js', 'device-searchable-multiselect.js') + + js_content = "" + if os.path.exists(js_file_path): + with open(js_file_path, 'r') as f: + js_content = f.read() + + js = format_html( + ''' + + ''', + js_content=mark_safe(js_content), + widget_id=widget_id, + choices_json=choices_json, + selected_json=selected_json, + name=name + ) + + return mark_safe(html + js) + + def value_from_datadict(self, data, files, name): + """Extract selected values from form data.""" + values = [] + + # Look for values with the pattern name_0, name_1, etc. + i = 0 + while True: + key = f"{name}_{i}" + if key in data: + value = data[key] + # Skip empty values (they indicate clearing the selection) + if value and value.strip(): + values.append(value) + i += 1 + else: + break + + # Also check for a single value with the name + if name in data: + values.append(data[name]) + + # Handle the case where field names might include UUIDs + # Look for any keys that start with the field name + for key in data.keys(): + if key.startswith(f"{name}_") and key not in [f"{name}_{i}" for i in range(len(values))]: + # This might be a UUID-based field name + value = data[key] + # Only add non-empty values + if value and value.strip(): + values.append(value) + + return values + + +class DeviceSearchableMultiSelectField(forms.ModelMultipleChoiceField): + """ + A custom field that uses the DeviceSearchableMultiSelectWidget. + """ + + def __init__(self, *args, **kwargs): + # Create the widget instance first + widget = DeviceSearchableMultiSelectWidget() + kwargs['widget'] = widget + super().__init__(*args, **kwargs) + # Store the field reference in the widget after initialization + self.widget.attrs['field'] = self + + def bound_data(self, data, initial): + """Override to ensure initial values are properly handled.""" + if initial is None: + initial = [] + return super().bound_data(data, initial) + + def widget_attrs(self, widget): + """Override to pass choices to the widget.""" + attrs = super().widget_attrs(widget) + # Don't set choices here - let the widget get them during render + return attrs + + +class SubjectTypeAutocompleteWidget(forms.Widget): + """ + A widget for selecting subject types with auto-complete and the ability to create new ones. + """ + template_name = 'integrations/widgets/subject_type_autocomplete.html' + + def __init__(self, attrs=None): + default_attrs = { + 'class': 'subject-type-autocomplete', + 'data-toggle': 'subject-type-autocomplete', + } + if attrs: + default_attrs.update(attrs) + super().__init__(default_attrs) + + def render(self, name, value, attrs=None, renderer=None): + if value is None: + value = '' + + # Get all available subject types + from integrations.models import SubjectType + subject_types = SubjectType.objects.all().order_by('display_name') + choices = [(str(st.id), st.display_name, st.value) for st in subject_types] + + if attrs is None: + attrs = {} + + widget_id = attrs.get('id', f'id_{name}') + + html = format_html( + ''' +
+
+ + + + +
+ + + + +
+ ''', + widget_id=widget_id, + name=name, + value=value + ) + + # Add JavaScript to initialize the widget using external file reference + # Ensure proper JSON escaping + choices_json = json.dumps(choices) + current_value = value if value else '' + + # Add a script tag that loads the external JavaScript file + js = f""" + + """ + + return mark_safe(html + js) + + +class SubjectTypeAutocompleteField(forms.ModelChoiceField): + """ + A custom field that uses the SubjectTypeAutocompleteWidget. + """ + def __init__(self, *args, **kwargs): + from integrations.models import SubjectType + queryset = kwargs.get('queryset', SubjectType.objects.all()) + kwargs['queryset'] = queryset + widget = SubjectTypeAutocompleteWidget() + kwargs['widget'] = widget + super().__init__(*args, **kwargs) + + +class DeviceGroupSelectWithLinkWidget(forms.Widget): + """ + A widget that displays a device group select field with a link to manage the selected group. + """ + + def __init__(self, attrs=None): + default_attrs = { + 'class': 'form-control device-group-select-with-link' + } + if attrs: + default_attrs.update(attrs) + super().__init__(default_attrs) + + def render(self, name, value, attrs=None, renderer=None): + if attrs is None: + attrs = {} + + # Get the field instance to access choices + field = self.attrs.get('field') + if not field: + return format_html('
Field not available
') + + # Get all available choices + choices = [] + if hasattr(field, 'queryset') and field.queryset: + try: + queryset = field.queryset.all().select_related('owner') + for obj in queryset: + choices.append((str(obj.pk), str(obj))) + except Exception as e: + pass # Silently handle queryset errors + elif hasattr(field, 'choices') and field.choices: + choices = field.choices + + widget_id = attrs.get('id', f'id_{name}') + + # Create the select element + select_html = f'' + + # Create the manage link (only show if a value is selected) + manage_link_html = '' + if value: + manage_link_html = f''' + + ''' + + # Add JavaScript to update the manage link when selection changes + js_html = f''' + + ''' + + html = format_html( + ''' + + {js_html} + ''', + select_html=mark_safe(select_html), + widget_id=widget_id, + manage_link_html=mark_safe(manage_link_html), + js_html=mark_safe(js_html) + ) + + return html + + def value_from_datadict(self, data, files, name): + """Extract the selected value from form data.""" + return data.get(name) + + +class DeviceGroupSelectWithLinkField(forms.ModelChoiceField): + """ + A custom field that uses the DeviceGroupSelectWithLinkWidget. + """ + + def __init__(self, *args, **kwargs): + from integrations.models import DeviceGroup + queryset = kwargs.get('queryset', DeviceGroup.objects.all()) + kwargs['queryset'] = queryset + widget = DeviceGroupSelectWithLinkWidget() + kwargs['widget'] = widget + super().__init__(*args, **kwargs) + # Store the field reference in the widget after initialization + self.widget.attrs['field'] = self + + +class DeviceGroupAutoCreateWidget(forms.Widget): + """ + A widget that displays a message indicating a default device group will be created automatically. + """ + + def __init__(self, attrs=None): + default_attrs = { + 'class': 'form-control device-group-auto-create' + } + if attrs: + default_attrs.update(attrs) + super().__init__(default_attrs) + + def render(self, name, value, attrs=None, renderer=None): + widget_id = attrs.get('id', f'id_{name}') if attrs else f'id_{name}' + + html = format_html( + ''' +
+
+
+ +
+ Default Device Group
+ A default device group will be created automatically when you save this integration configuration. +
+
+
+ +
+ ''', + name=name, + widget_id=widget_id + ) + + return html + + def value_from_datadict(self, data, files, name): + """Return empty value since this field is auto-created.""" + return None + + +class DeviceGroupAutoCreateField(forms.ModelChoiceField): + """ + A custom field that uses the DeviceGroupAutoCreateWidget. + """ + + def __init__(self, *args, **kwargs): + from integrations.models import DeviceGroup + queryset = kwargs.get('queryset', DeviceGroup.objects.none()) # Empty queryset since we don't want to show options + kwargs['queryset'] = queryset + widget = DeviceGroupAutoCreateWidget() + kwargs['widget'] = widget + kwargs['required'] = False + super().__init__(*args, **kwargs) + + +class DeviceGroupDisplayWidget(forms.Widget): + """ + A widget that displays the device group name with a link to its manage page. + """ + + def render(self, name, value, attrs=None, renderer=None): + if not value: + return format_html('
No default device group set
') + + try: + from integrations.models import DeviceGroup + device_group = DeviceGroup.objects.get(id=value) + manage_url = reverse('device_group_management_update', kwargs={'device_group_id': device_group.id}) + + return format_html( + ''' +
+
+ +
+ {device_group_name}
+ Organization: {owner_name}
+ + Manage Device Group + +
+
+
+ + ''', + device_group_name=device_group.name, + owner_name=device_group.owner.name, + manage_url=manage_url, + name=name, + value=value + ) + except DeviceGroup.DoesNotExist: + return format_html('
Device group not found
') + + def value_from_datadict(self, data, files, name): + """Return the hidden input value to preserve the device group ID.""" + return data.get(name)