diff --git a/gateway/.envs/example/django.env b/gateway/.envs/example/django.env index 8be928cc8..1287a26fa 100644 --- a/gateway/.envs/example/django.env +++ b/gateway/.envs/example/django.env @@ -2,6 +2,7 @@ # ====================== LOCAL ENV ====================== # GENERAL # ------------------------------------------------------------------------------ +DJANGO_DEBUG=true USE_DOCKER=yes HOSTNAME=localhost:8000 API_VERSION=v1 diff --git a/gateway/.envs/example/django.prod-example.env b/gateway/.envs/example/django.prod-example.env index 3f5a7c053..c4c55cd98 100644 --- a/gateway/.envs/example/django.prod-example.env +++ b/gateway/.envs/example/django.prod-example.env @@ -3,6 +3,7 @@ # GENERAL # ------------------------------------------------------------------------------ # DJANGO_READ_DOT_ENV_FILE=true +DJANGO_DEBUG=false DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SECRET_KEY= DJANGO_ADMIN_URL= diff --git a/gateway/config/settings/base.py b/gateway/config/settings/base.py index 5c49c220e..fe11d9606 100644 --- a/gateway/config/settings/base.py +++ b/gateway/config/settings/base.py @@ -10,6 +10,7 @@ from environs import env from config.settings.logs import ColoredFormatter +from config.settings.utils import guess_admin_console_env from config.settings.utils import guess_max_web_download_size __rng = random.SystemRandom() @@ -591,6 +592,24 @@ def __get_random_token(length: int) -> str: SDS_PROGRAMMATIC_SITE_NAME: str = env.str("SDS_PROGRAMMATIC_SITE_NAME", default="sds") SDS_SITE_FQDN: str = env.str("SDS_SITE_FQDN", default="localhost") +# ADMIN_CONSOLE_ENV is used to visually distinguish between different environments +# (production, staging, local) in the admin console and error emails. It does not affect +# any functionality and it is meant to prevent changes in production meant for testing +# or development environments. +ADMIN_CONSOLE_ENV: str = ( + env.str( + "SDS_ADMIN_CONSOLE_ENV", + default=guess_admin_console_env(is_debug=DEBUG), + ) + .strip() + .lower() +) +# cast to known values +if ADMIN_CONSOLE_ENV in {"dev", "development"}: + ADMIN_CONSOLE_ENV = "local" +elif ADMIN_CONSOLE_ENV not in {"production", "staging", "local"}: + ADMIN_CONSOLE_ENV = "local" if DEBUG else "production" + def _get_brand_image_url() -> str | None: """Resolve brand image path from static files only. diff --git a/gateway/config/settings/utils.py b/gateway/config/settings/utils.py index a19d340ab..b895a12b3 100644 --- a/gateway/config/settings/utils.py +++ b/gateway/config/settings/utils.py @@ -1,14 +1,33 @@ import os from socket import gethostname +from loguru import logger as log from sentry_sdk.types import Event from sentry_sdk.types import Hint +def _is_staging_guess() -> bool: + """Determines if the current environment is staging based on hostname.""" + # any of these substrings in hostname or SENTRY_ENVIRONMENT + # is enough to hint a staging environment + staging_hints_lower = { + "staging", + "-qa", + ".qa", + } + hostname: str = gethostname().lower() + is_staging_hostname = any(hint in hostname for hint in staging_hints_lower) + sentry_env = os.getenv("SENTRY_ENVIRONMENT", "").lower() + is_staging_sentry = any(hint in sentry_env for hint in staging_hints_lower) + + is_staging = is_staging_hostname or is_staging_sentry + log.debug(f"{is_staging=}") + + return is_staging + + def guess_best_sentry_env() -> str: - _hostname: str = gethostname() - _is_staging: bool = "-qa" in _hostname or "-dev" in _hostname - return "staging" if _is_staging else "production" + return "staging" if _is_staging_guess() else "production" def before_send(event: Event, hint: Hint) -> Event | None: @@ -39,7 +58,21 @@ def guess_max_web_download_size() -> int: - Production: 20GB - Dev/QA (staging): 5GB """ - _hostname: str = gethostname() - _is_staging: bool = "-qa" in _hostname or "-dev" in _hostname # Production: 20GB, Staging (dev/qa): 5GB - return 20 * 1024 * 1024 * 1024 if not _is_staging else 5 * 1024 * 1024 * 1024 + return ( + 20 * 1024 * 1024 * 1024 if not _is_staging_guess() else 5 * 1024 * 1024 * 1024 + ) + + +def guess_admin_console_env(*, is_debug: bool) -> str: + """Determine the admin console environment label. + + Returns: + str: One of "production", "staging", or "local". + """ + if _is_staging_guess(): + return "staging" + if is_debug: + return "local" + # safer default + return "production" diff --git a/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py b/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py index 0b9a475f0..00e0567b0 100644 --- a/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py +++ b/gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py @@ -275,21 +275,25 @@ def _stub(*_args, **_kwargs): return _stub + def _delete_owned_files(self) -> None: + """Remove files owned by the primary test user after clearing relations.""" + + owned_files = list(File.objects.filter(owner=self.user)) + for owned_file in owned_files: + owned_file.captures.clear() + owned_file.datasets.clear() + + File.objects.filter(owner=self.user).update( + capture=None, + dataset=None, + ) + File.objects.filter(owner=self.user).delete() + def tearDown(self) -> None: """Clean up test data.""" - # remove temporary files linked during tests - for temp_file in getattr(self, "_temp_files", []): - temp_file_obj = File.objects.get(pk=temp_file.pk) - # Clear M2M relationships - temp_file_obj.captures.clear() - temp_file_obj.datasets.clear() - # Clear FK relationships - File.objects.filter(pk=temp_file.pk).update( - capture=None, - dataset=None, - ) - File.objects.filter(pk=temp_file.pk).delete() + # remove files linked during tests, including ones created directly by tests + self._delete_owned_files() self._temp_files.clear() # Clean up OpenSearch documents @@ -2294,8 +2298,7 @@ def test_waterfall_slices_stream_success( assert data["end_index"] == 5 # noqa: PLR2004 mock_reconstruct_drf_files.assert_called_once() mock_compute_slices_on_demand.assert_called_once() - # Avoid unused variable linter warning - assert stream_file.capture_id == capture.id + assert stream_file.capture_id == capture.pk class OpenSearchErrorTestCases(APITestCase): diff --git a/gateway/sds_gateway/context_processors.py b/gateway/sds_gateway/context_processors.py index 648550d9c..c8cf2427f 100644 --- a/gateway/sds_gateway/context_processors.py +++ b/gateway/sds_gateway/context_processors.py @@ -9,10 +9,11 @@ from django.http import HttpRequest -def app_settings(_request: HttpRequest) -> dict[str, bool]: +def app_settings(_request: HttpRequest) -> dict[str, bool | str]: """Expose application-wide settings in templates.""" return { "VISUALIZATIONS_ENABLED": settings.VISUALIZATIONS_ENABLED, + "ADMIN_CONSOLE_ENV": settings.ADMIN_CONSOLE_ENV, } diff --git a/gateway/sds_gateway/static/css/admin_environment.css b/gateway/sds_gateway/static/css/admin_environment.css new file mode 100644 index 000000000..bc7a2e555 --- /dev/null +++ b/gateway/sds_gateway/static/css/admin_environment.css @@ -0,0 +1,27 @@ +body.admin-env-production #header { + background: #800020; +} + +body.admin-env-staging #header { + background: #ff8c00; +} + +body.admin-env-local #header { + background: #198754; +} + +body.admin-env-production #header, +body.admin-env-production #header a:link, +body.admin-env-production #header a:visited, +body.admin-env-staging #header, +body.admin-env-staging #header a:link, +body.admin-env-staging #header a:visited, +body.admin-env-local #header, +body.admin-env-local #header a:link, +body.admin-env-local #header a:visited { + color: #ffffff; +} + +.mono { + font-family: monospace; +} diff --git a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js index 1ab5b731f..4c43b143a 100644 --- a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js +++ b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramControls.js @@ -3,11 +3,11 @@ * Manages the control panel for spectrogram generation settings */ -import { DEFAULT_SPECTROGRAM_SETTINGS, INPUT_RANGES } from "./constants.js"; - export class SpectrogramControls { constructor() { - this.settings = { ...DEFAULT_SPECTROGRAM_SETTINGS }; + this.settings = null; + this.defaultSettings = null; + this.inputRanges = {}; this.onSettingsChange = null; this.onGenerateClick = null; this.initializeControls(); @@ -40,6 +40,9 @@ export class SpectrogramControls { return; } + this.settings = this.getSettingsFromControls(); + this.defaultSettings = { ...this.settings }; + this.inputRanges = this.getInputRanges(); this.setupEventListeners(); this.updateControlValues(); } @@ -57,7 +60,7 @@ export class SpectrogramControls { // Standard Deviation change this.stdDevInput.addEventListener("change", (e) => { const value = Number.parseInt(e.target.value); - if (this.validateInput(value, INPUT_RANGES.stdDev)) { + if (this.validateInput(value, this.inputRanges.stdDev)) { this.settings.stdDev = value; this.notifySettingsChange(); } else { @@ -68,7 +71,7 @@ export class SpectrogramControls { // Hop Size change this.hopSizeInput.addEventListener("change", (e) => { const value = Number.parseInt(e.target.value); - if (this.validateInput(value, INPUT_RANGES.hopSize)) { + if (this.validateInput(value, this.inputRanges.hopSize)) { this.settings.hopSize = value; this.notifySettingsChange(); } else { @@ -83,11 +86,39 @@ export class SpectrogramControls { }); } + /** + * Read the initial settings rendered by the server + */ + getSettingsFromControls() { + return { + fftSize: Number.parseInt(this.fftSizeSelect.value, 10), + stdDev: Number.parseInt(this.stdDevInput.value, 10), + hopSize: Number.parseInt(this.hopSizeInput.value, 10), + colorMap: this.colorMapSelect.value, + }; + } + + /** + * Read numeric validation ranges from the current form inputs + */ + getInputRanges() { + return { + stdDev: { + min: Number.parseInt(this.stdDevInput.min, 10), + max: Number.parseInt(this.stdDevInput.max, 10), + }, + hopSize: { + min: Number.parseInt(this.hopSizeInput.min, 10), + max: Number.parseInt(this.hopSizeInput.max, 10), + }, + }; + } + /** * Validate input value against specified range */ validateInput(value, range) { - return value >= range.min && value <= range.max; + return Number.isInteger(value) && value >= range.min && value <= range.max; } /** @@ -95,11 +126,10 @@ export class SpectrogramControls { */ resetInputValue(input, value) { input.value = value; + const range = this.inputRanges[input.id]; this.showValidationError( input, - `Value must be between ${INPUT_RANGES[input.id].min} and ${ - INPUT_RANGES[input.id].max - }`, + `Value must be between ${range.min} and ${range.max}`, ); } @@ -201,7 +231,7 @@ export class SpectrogramControls { * Reset controls to default values */ resetToDefaults() { - this.settings = { ...DEFAULT_SPECTROGRAM_SETTINGS }; + this.settings = { ...this.defaultSettings }; this.updateControlValues(); this.notifySettingsChange(); } diff --git a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js index a38a6f7a4..915c371ec 100644 --- a/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js +++ b/gateway/sds_gateway/static/js/visualizations/spectrogram/SpectrogramVisualization.js @@ -93,7 +93,10 @@ export class SpectrogramVisualization { } try { + this.clearStatusDisplay(); this.setGeneratingState(true); + this.showSaveButton(false); + this.showStatus(STATUS_MESSAGES.GENERATING); const settings = this.controls.getSettings(); @@ -101,7 +104,7 @@ export class SpectrogramVisualization { await this.createSpectrogramJob(settings); } catch (error) { console.error("Error generating spectrogram:", error); - this.showError(ERROR_MESSAGES.API_ERROR); + this.displayRequestError(error, ERROR_MESSAGES.API_ERROR); this.setGeneratingState(false); } } @@ -133,14 +136,18 @@ export class SpectrogramVisualization { }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + await this.throwOnFailedResponse( + response, + "Unable to create spectrogram job", + ); const data = await response.json(); if (!data.uuid) { - throw new Error("Spectrogram job ID not found"); + const missingUuidError = new Error("Spectrogram job ID not found"); + missingUuidError.userMessage = "Unable to create spectrogram job."; + missingUuidError.errorDetail = "Response did not include a job id."; + throw missingUuidError; } this.currentJobId = data.uuid; @@ -181,26 +188,34 @@ export class SpectrogramVisualization { }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + await this.throwOnFailedResponse( + response, + "Unable to check spectrogram status", + ); const data = await response.json(); + const processingStatus = data.processing_status; - if (data.processing_status === "completed") { + if (processingStatus === "completed") { // Job completed, stop polling and fetch result this.stopStatusPolling(); await this.fetchSpectrogramResult(); - } else if (data.processing_status === "failed") { + } else if (processingStatus === "failed") { // Job failed this.stopStatusPolling(); this.handleProcessingError(data); this.setGeneratingState(false); + } else if (processingStatus) { + this.showStatus( + `${STATUS_MESSAGES.GENERATING} (${this.formatProcessingStatus(processingStatus)})`, + ); } // If still processing, continue polling } catch (error) { console.error("Error checking job status:", error); - // Continue polling on error + this.stopStatusPolling(); + this.displayRequestError(error, "Failed to check spectrogram status"); + this.setGeneratingState(false); } } @@ -228,9 +243,10 @@ export class SpectrogramVisualization { }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + await this.throwOnFailedResponse( + response, + "Unable to fetch spectrogram result", + ); const blob = await response.blob(); @@ -245,21 +261,130 @@ export class SpectrogramVisualization { this.setGeneratingState(false); if (renderResult) { - this.updateStatus(STATUS_MESSAGES.SUCCESS); + this.clearStatusDisplay(); this.showSaveButton(true); } else { - this.showError("Failed to render spectrogram"); + this.showErrorWithDetails("Failed to render spectrogram"); } // Store the result for saving this.currentSpectrogramUrl = URL.createObjectURL(blob); } catch (error) { console.error("Error fetching spectrogram result:", error); - this.showError("Failed to fetch spectrogram result"); + this.displayRequestError(error, "Failed to fetch spectrogram result"); this.setGeneratingState(false); } } + /** + * Throw enriched error for failed HTTP responses + */ + async throwOnFailedResponse(response, userMessage) { + if (response.ok) { + return; + } + + const responseData = await this.safeParseJson(response); + const processingStatus = responseData?.processing_status; + const responseDetail = this.extractResponseDetail(responseData); + + const details = []; + if (processingStatus) { + details.push( + `Processing status: ${this.formatProcessingStatus(processingStatus)}`, + ); + } + if (responseDetail) { + details.push(responseDetail); + } else { + details.push(`HTTP ${response.status}: ${response.statusText}`); + } + + const requestError = new Error( + `HTTP ${response.status}: ${response.statusText}`, + ); + requestError.userMessage = `${userMessage}.`; + requestError.errorDetail = details.join(" • "); + throw requestError; + } + + /** + * Parse JSON payload safely from response + */ + async safeParseJson(response) { + try { + return await response.json(); + } catch { + return null; + } + } + + /** + * Extract a readable error detail from API response payload + */ + extractResponseDetail(responseData) { + if (!responseData) { + return null; + } + + if (typeof responseData === "string") { + return responseData; + } + + const detailFields = ["detail", "error", "message"]; + for (const fieldName of detailFields) { + if (typeof responseData[fieldName] === "string") { + return responseData[fieldName]; + } + } + + if (Array.isArray(responseData.errors)) { + return responseData.errors.join(", "); + } + + if ( + responseData.errors && + typeof responseData.errors === "object" && + !Array.isArray(responseData.errors) + ) { + const firstFieldErrors = Object.entries(responseData.errors)[0]; + if (!firstFieldErrors) { + return null; + } + + const [fieldName, fieldValue] = firstFieldErrors; + if (Array.isArray(fieldValue)) { + return `${fieldName}: ${fieldValue.join(", ")}`; + } + + if (typeof fieldValue === "string") { + return `${fieldName}: ${fieldValue}`; + } + } + + return null; + } + + /** + * Format backend processing status for user display + */ + formatProcessingStatus(processingStatus) { + if (!processingStatus || typeof processingStatus !== "string") { + return "unknown"; + } + + return processingStatus.replace(/_/g, " "); + } + + /** + * Display request error to user + */ + displayRequestError(error, fallbackMessage) { + const message = error?.userMessage || fallbackMessage; + const errorDetail = error?.errorDetail || error?.message || null; + this.showErrorWithDetails(message, errorDetail); + } + /** * Get CSRF token from form input */ @@ -286,18 +411,16 @@ export class SpectrogramVisualization { } } - // Hide error display during generation, show transparent overlay instead + // Keep status/error display visibility in sync with whether it has content if (this.errorDisplay) { - if (isGenerating) { - this.errorDisplay.classList.add("d-none"); + const hasContent = this.errorDisplay + .querySelector("p.error-message-text") + ?.textContent.trim(); + + if (hasContent) { + this.errorDisplay.classList.remove("d-none"); } else { - // Only show if it has content (error), otherwise keep hidden - const hasContent = this.errorDisplay - .querySelector("p.error-message-text") - ?.textContent.trim(); - if (hasContent) { - this.errorDisplay.classList.remove("d-none"); - } + this.errorDisplay.classList.add("d-none"); } } } @@ -350,22 +473,69 @@ export class SpectrogramVisualization { * Update status message */ updateStatus(message) { - if (this.errorDisplay) { - const statusText = this.errorDisplay.querySelector("p"); - if (statusText) { - statusText.textContent = message; - } + if (message) { + this.showStatus(message); + return; } + + this.clearStatusDisplay(); } /** * Show error message */ showError(message) { - this.updateStatus(message); - if (this.renderer) { - this.renderer.clearImage(); + this.showErrorWithDetails(message); + } + + /** + * Show non-error status message + */ + showStatus(message, detail = null) { + if (!this.errorDisplay) { + return; + } + + const messageElement = this.errorDisplay.querySelector( + "p.error-message-text", + ); + const errorDetailElement = this.errorDisplay.querySelector( + "p.error-detail-line", + ); + + setupErrorDisplay({ + messageElement, + errorDetailElement, + message, + errorDetail: detail, + }); + + this.errorDisplay.classList.remove("d-none"); + } + + /** + * Clear status/error display + */ + clearStatusDisplay() { + if (!this.errorDisplay) { + return; } + + const messageElement = this.errorDisplay.querySelector( + "p.error-message-text", + ); + const errorDetailElement = this.errorDisplay.querySelector( + "p.error-detail-line", + ); + + setupErrorDisplay({ + messageElement, + errorDetailElement, + message: "", + errorDetail: null, + }); + + this.errorDisplay.classList.add("d-none"); } /** @@ -374,11 +544,26 @@ export class SpectrogramVisualization { handleProcessingError(data) { const errorInfo = data.error_info || {}; const hasSourceDataError = data.has_source_data_error || false; + const processingStatus = data.processing_status; const { message, errorDetail } = generateErrorMessage( errorInfo, hasSourceDataError, ); - this.showErrorWithDetails(message, errorDetail); + + const detailParts = []; + if (processingStatus) { + detailParts.push( + `Processing status: ${this.formatProcessingStatus(processingStatus)}`, + ); + } + if (errorDetail) { + detailParts.push(errorDetail); + } + + this.showErrorWithDetails( + message, + detailParts.length > 0 ? detailParts.join(" • ") : null, + ); } /** diff --git a/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js b/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js index e5a9c0590..4bda26953 100644 --- a/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js +++ b/gateway/sds_gateway/static/js/visualizations/spectrogram/constants.js @@ -2,37 +2,6 @@ * Constants for Spectrogram Visualization */ -export const DEFAULT_SPECTROGRAM_SETTINGS = { - fftSize: 1024, - stdDev: 100, - hopSize: 500, - colorMap: "magma", -}; - -// FFT size options (powers of 2 from 64 to 65536) -export const FFT_SIZE_OPTIONS = [ - 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, -]; - -export const COLOR_MAP_OPTIONS = [ - "magma", - "viridis", - "plasma", - "inferno", - "cividis", - "turbo", - "jet", - "hot", - "cool", - "rainbow", -]; - -// Input validation ranges -export const INPUT_RANGES = { - stdDev: { min: 10, max: 500 }, - hopSize: { min: 100, max: 1000 }, -}; - export const DEFAULT_IMAGE_DIMENSIONS = { width: 800, height: 400, diff --git a/gateway/sds_gateway/templates/admin/base_site.html b/gateway/sds_gateway/templates/admin/base_site.html new file mode 100644 index 000000000..8256ac90c --- /dev/null +++ b/gateway/sds_gateway/templates/admin/base_site.html @@ -0,0 +1,16 @@ +{% extends "admin/base_site.html" %} + +{% load static %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock extrastyle %} +{% block bodyclass %} + {{ block.super }} admin-env-{{ ADMIN_CONSOLE_ENV }} +{% endblock bodyclass %} +{% block branding %} +

+ Django Administration · {{ ADMIN_CONSOLE_ENV }} · {{ SDS_SITE_FQDN }} +

+{% endblock branding %} diff --git a/gateway/sds_gateway/templates/visualizations/spectrogram.html b/gateway/sds_gateway/templates/visualizations/spectrogram.html index bd50fbbfd..1673e2b19 100644 --- a/gateway/sds_gateway/templates/visualizations/spectrogram.html +++ b/gateway/sds_gateway/templates/visualizations/spectrogram.html @@ -39,17 +39,12 @@
Controls
@@ -57,32 +52,28 @@
Controls
+ value="{{ spectrogram_form.default_std_dev }}" + min="{{ spectrogram_form.std_dev_min }}" + max="{{ spectrogram_form.std_dev_max }}" />
+ value="{{ spectrogram_form.default_hop_size }}" + min="{{ spectrogram_form.hop_size_min }}" + max="{{ spectrogram_form.hop_size_max }}" />
diff --git a/gateway/sds_gateway/users/tests/test_admin.py b/gateway/sds_gateway/users/tests/test_admin.py index 3c8771c80..a16f98c8d 100644 --- a/gateway/sds_gateway/users/tests/test_admin.py +++ b/gateway/sds_gateway/users/tests/test_admin.py @@ -52,7 +52,7 @@ def test_view_user(self, admin_client): def _force_allauth(self, settings) -> None: settings.DJANGO_ADMIN_FORCE_ALLAUTH = True # Reload the admin module to apply the setting change - with contextlib.suppress(admin.sites.AlreadyRegistered): # type: ignore[attr-defined] + with contextlib.suppress(admin.sites.AlreadyRegistered): reload(users_admin) @pytest.mark.django_db diff --git a/gateway/sds_gateway/users/tests/test_context_processors.py b/gateway/sds_gateway/users/tests/test_context_processors.py new file mode 100644 index 000000000..bcf58abb3 --- /dev/null +++ b/gateway/sds_gateway/users/tests/test_context_processors.py @@ -0,0 +1,62 @@ +from config.settings.utils import guess_admin_console_env +from django.template.loader import render_to_string +from django.test import RequestFactory + +from sds_gateway.context_processors import app_settings + + +def test_app_settings_exposes_admin_console_env(settings, rf: RequestFactory) -> None: + settings.ADMIN_CONSOLE_ENV = "staging" + request = rf.get("/") + + context = app_settings(request) + + assert context["ADMIN_CONSOLE_ENV"] == "staging" + assert "VISUALIZATIONS_ENABLED" in context + + +def test_guess_admin_console_env_for_staging(monkeypatch) -> None: + monkeypatch.setattr( + "config.settings.utils.gethostname", lambda: "sds-gateway-staging-app" + ) + + environment = guess_admin_console_env(is_debug=False) + + assert environment == "staging" + + +def test_guess_admin_console_env_for_local_debug(monkeypatch) -> None: + monkeypatch.setenv("SENTRY_ENVIRONMENT", "local") + monkeypatch.setattr( + "config.settings.utils.gethostname", lambda: "sds-gateway-prod-app" + ) + + environment = guess_admin_console_env(is_debug=True) + + assert environment == "local" + + +def test_guess_admin_console_env_for_production(monkeypatch) -> None: + monkeypatch.setenv("SENTRY_ENVIRONMENT", "production") + monkeypatch.setattr( + "config.settings.utils.gethostname", lambda: "sds-gateway-prod-app" + ) + + environment = guess_admin_console_env(is_debug=False) + + assert environment == "production" + + +def test_admin_template_shows_environment_and_fqdn( + settings, + rf: RequestFactory, +) -> None: + settings.ADMIN_CONSOLE_ENV = "staging" + settings.SDS_SITE_FQDN = "staging.sds.example" + request = rf.get("/admin/") + + rendered = render_to_string("admin/base_site.html", request=request) + + assert "Django Administration" in rendered + assert "staging" in rendered.lower() + assert "staging.sds.example" in rendered.lower() diff --git a/gateway/sds_gateway/visualizations/api_views.py b/gateway/sds_gateway/visualizations/api_views.py index b9f447d67..4f5c17aa7 100644 --- a/gateway/sds_gateway/visualizations/api_views.py +++ b/gateway/sds_gateway/visualizations/api_views.py @@ -1,5 +1,8 @@ """API views for the visualizations app.""" +from enum import StrEnum +from typing import ClassVar + from django.http import FileResponse from django.shortcuts import get_object_or_404 from drf_spectacular.utils import OpenApiExample @@ -7,6 +10,9 @@ from drf_spectacular.utils import OpenApiResponse from drf_spectacular.utils import extend_schema from loguru import logger as log +from pydantic import BaseModel +from pydantic import ValidationError as PydanticValidationError +from pydantic import field_validator from rest_framework import status from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action @@ -28,19 +34,139 @@ from .serializers import PostProcessedDataSerializer +class SpectrogramProcessingParams(BaseModel): + MIN_FFT_SIZE: ClassVar[int] = 64 + MAX_FFT_SIZE: ClassVar[int] = 2048 + MIN_STD_DEV: ClassVar[int] = 10 + MAX_STD_DEV: ClassVar[int] = 500 + MIN_HOP_SIZE: ClassVar[int] = 100 + MAX_HOP_SIZE: ClassVar[int] = 1000 + DEFAULT_FFT_SIZE: ClassVar[int] = 1024 + DEFAULT_STD_DEV: ClassVar[int] = 100 + DEFAULT_HOP_SIZE: ClassVar[int] = 500 + DEFAULT_COLORMAP: ClassVar[str] = "magma" + INTEGER_ERROR_MESSAGE: ClassVar[str] = "must be an integer" + FFT_RANGE_ERROR_MESSAGE: ClassVar[str] = "must be a power of 2 within allowed range" + RANGE_ERROR_MESSAGE: ClassVar[str] = "out of allowed range" + DIMENSIONS_TYPE_ERROR_MESSAGE: ClassVar[str] = "must be a dictionary" + DIMENSION_POSITIVE_TEMPLATE: ClassVar[str] = "{dimension} must be greater than 0" + DIMENSION_INTEGER_TEMPLATE: ClassVar[str] = "{dimension} must be an integer" + + fft_size: int + std_dev: int + hop_size: int + colormap: "Colormap" + dimensions: dict[str, int] | None = None + + @field_validator("fft_size", "std_dev", "hop_size", mode="before") + @classmethod + def validate_int_fields(cls, value): + if isinstance(value, bool): + msg = cls.INTEGER_ERROR_MESSAGE + raise TypeError(msg) + + try: + return int(value) + except (TypeError, ValueError) as exc: + msg = cls.INTEGER_ERROR_MESSAGE + raise ValueError(msg) from exc + + @field_validator("fft_size") + @classmethod + def validate_fft_size(cls, value: int) -> int: + if ( + value < cls.MIN_FFT_SIZE + or value > cls.MAX_FFT_SIZE + or (value & (value - 1)) != 0 + ): + msg = cls.FFT_RANGE_ERROR_MESSAGE + raise ValueError(msg) + return value + + @field_validator("std_dev") + @classmethod + def validate_std_dev(cls, value: int) -> int: + if value < cls.MIN_STD_DEV or value > cls.MAX_STD_DEV: + msg = cls.RANGE_ERROR_MESSAGE + raise ValueError(msg) + return value + + @field_validator("hop_size") + @classmethod + def validate_hop_size(cls, value: int) -> int: + if value < cls.MIN_HOP_SIZE or value > cls.MAX_HOP_SIZE: + msg = cls.RANGE_ERROR_MESSAGE + raise ValueError(msg) + return value + + @field_validator("dimensions", mode="before") + @classmethod + def validate_dimensions(cls, value): + if value is None: + return None + if not isinstance(value, dict): + msg = cls.DIMENSIONS_TYPE_ERROR_MESSAGE + raise TypeError(msg) + + validated_dimensions: dict[str, int] = {} + for key in ("width", "height"): + if key not in value: + continue + key_value = value[key] + if isinstance(key_value, bool): + msg = cls.DIMENSION_INTEGER_TEMPLATE.format(dimension=key) + raise TypeError(msg) + try: + dimension = int(key_value) + except (TypeError, ValueError) as exc: + msg = cls.DIMENSION_INTEGER_TEMPLATE.format(dimension=key) + raise ValueError(msg) from exc + if dimension <= 0: + msg = cls.DIMENSION_POSITIVE_TEMPLATE.format(dimension=key) + raise ValueError(msg) + validated_dimensions[key] = dimension + + return validated_dimensions + + def to_dict(self) -> dict[str, int | str | dict[str, int]]: + processing_params: dict[str, int | str | dict[str, int]] = { + "fft_size": self.fft_size, + "std_dev": self.std_dev, + "hop_size": self.hop_size, + "colormap": self.colormap.value, + } + if self.dimensions is not None: + processing_params["dimensions"] = self.dimensions + return processing_params + + @classmethod + def get_fft_size_options(cls) -> tuple[int, ...]: + fft_size_options: list[int] = [] + fft_size = cls.MIN_FFT_SIZE + while fft_size <= cls.MAX_FFT_SIZE: + fft_size_options.append(fft_size) + fft_size *= 2 + return tuple(fft_size_options) + + +class Colormap(StrEnum): + MAGMA = "magma" + VIRIDIS = "viridis" + PLASMA = "plasma" + INFERNO = "inferno" + CIVIDIS = "cividis" + TURBO = "turbo" + JET = "jet" + HOT = "hot" + COOL = "cool" + RAINBOW = "rainbow" + + class VisualizationViewSet(ViewSet): """ ViewSet for generating visualizations from captures. """ - # Constants for validation - MIN_FFT_SIZE = 64 - MAX_FFT_SIZE = 65536 - MIN_STD_DEV = 10 - MAX_STD_DEV = 500 - MIN_HOP_SIZE = 100 - MAX_HOP_SIZE = 1000 - authentication_classes = [SessionAuthentication, APIKeyAuthentication] permission_classes = [IsAuthenticated] @@ -136,30 +262,43 @@ def create_spectrogram(self, request: Request, pk: str | None = None) -> Respons ) # Extract and validate request parameters - fft_size = int(self.get_request_param(request, "fft_size", 1024)) - std_dev = int(self.get_request_param(request, "std_dev", 100)) - hop_size = int(self.get_request_param(request, "hop_size", 500)) - colormap = self.get_request_param(request, "colormap", "magma") + fft_size = self.get_request_param( + request, + "fft_size", + SpectrogramProcessingParams.DEFAULT_FFT_SIZE, + ) + std_dev = self.get_request_param( + request, + "std_dev", + SpectrogramProcessingParams.DEFAULT_STD_DEV, + ) + hop_size = self.get_request_param( + request, + "hop_size", + SpectrogramProcessingParams.DEFAULT_HOP_SIZE, + ) + colormap = self.get_request_param( + request, + "colormap", + SpectrogramProcessingParams.DEFAULT_COLORMAP, + ) dimensions = self.get_request_param(request, "dimensions", None) - # Validate parameters - if not self._validate_spectrogram_params( - fft_size, std_dev, hop_size, colormap, dimensions - ): + try: + spectrogram_params = self._validate_spectrogram_params( + fft_size=fft_size, + std_dev=std_dev, + hop_size=hop_size, + colormap=colormap, + dimensions=dimensions, + ) + except ValidationError as exc: return Response( - {"error": "Invalid spectrogram parameters"}, + {"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) - # Check if spectrogram already exists with same parameters - processing_params = { - "fft_size": fft_size, - "std_dev": std_dev, - "hop_size": hop_size, - "colormap": colormap, - } - if dimensions is not None: - processing_params["dimensions"] = dimensions + processing_params = spectrogram_params.to_dict() existing_spectrogram = PostProcessedData.objects.filter( capture=capture, @@ -447,52 +586,37 @@ def get_visualization_compatibility(self, request: Request) -> Response: def _validate_spectrogram_params( self, - fft_size: int, - std_dev: int, - hop_size: int, - colormap: str, - dimensions: dict[str, int] | None = None, - ) -> bool: + fft_size, + std_dev, + hop_size, + colormap, + dimensions=None, + ) -> SpectrogramProcessingParams: """ Validate spectrogram parameters. + + Raises: + ValidationError: If any parameter is invalid. """ - # Validate FFT size (must be power of 2) - if ( - fft_size < self.MIN_FFT_SIZE - or fft_size > self.MAX_FFT_SIZE - or (fft_size & (fft_size - 1)) != 0 - ): - return False - - # Validate standard deviation - if std_dev < self.MIN_STD_DEV or std_dev > self.MAX_STD_DEV: - return False - - # Validate hop size - if hop_size < self.MIN_HOP_SIZE or hop_size > self.MAX_HOP_SIZE: - return False - - # Validate dimensions - if dimensions and "width" in dimensions and "height" in dimensions: - if dimensions["width"] <= 0: - return False - if dimensions["height"] <= 0: - return False - - # Validate colormap - valid_colormaps = [ - "magma", - "viridis", - "plasma", - "inferno", - "cividis", - "turbo", - "jet", - "hot", - "cool", - "rainbow", - ] - return colormap in valid_colormaps + try: + return SpectrogramProcessingParams( + fft_size=fft_size, + std_dev=std_dev, + hop_size=hop_size, + colormap=colormap, + dimensions=dimensions, + ) + except PydanticValidationError as exc: + raise ValidationError(self._format_validation_error_message(exc)) from exc + + @staticmethod + def _format_validation_error_message(error: PydanticValidationError) -> str: + error_details = "; ".join( + f"{'.'.join(str(item) for item in validation_error['loc'])}: " + f"{validation_error['msg']}" + for validation_error in error.errors() + ) + return f"Invalid spectrogram parameters: {error_details}" @extend_schema( summary="Create waterfall visualization", diff --git a/gateway/sds_gateway/visualizations/tests/test_views.py b/gateway/sds_gateway/visualizations/tests/test_views.py index d7974875b..b3e7a47cf 100644 --- a/gateway/sds_gateway/visualizations/tests/test_views.py +++ b/gateway/sds_gateway/visualizations/tests/test_views.py @@ -13,6 +13,8 @@ from sds_gateway.api_methods.models import Capture from sds_gateway.api_methods.models import CaptureType +from sds_gateway.visualizations.api_views import Colormap +from sds_gateway.visualizations.api_views import SpectrogramProcessingParams from sds_gateway.visualizations.models import PostProcessedData from sds_gateway.visualizations.models import ProcessingStatus from sds_gateway.visualizations.models import ProcessingType @@ -450,6 +452,34 @@ def test_spectrogram_view_context_data(self) -> None: assert capture.owner == self.user assert capture.is_deleted is False + spectrogram_form = context["spectrogram_form"] + assert spectrogram_form.fft_size_options == ( + SpectrogramProcessingParams.get_fft_size_options() + ) + assert spectrogram_form.default_fft_size == ( + SpectrogramProcessingParams.DEFAULT_FFT_SIZE + ) + assert spectrogram_form.default_std_dev == ( + SpectrogramProcessingParams.DEFAULT_STD_DEV + ) + assert spectrogram_form.default_hop_size == ( + SpectrogramProcessingParams.DEFAULT_HOP_SIZE + ) + assert spectrogram_form.default_color_map == ( + SpectrogramProcessingParams.DEFAULT_COLORMAP + ) + assert spectrogram_form.color_map_options == tuple( + colormap.value for colormap in Colormap + ) + assert spectrogram_form.std_dev_min == SpectrogramProcessingParams.MIN_STD_DEV + assert spectrogram_form.std_dev_max == SpectrogramProcessingParams.MAX_STD_DEV + assert spectrogram_form.hop_size_min == ( + SpectrogramProcessingParams.MIN_HOP_SIZE + ) + assert spectrogram_form.hop_size_max == ( + SpectrogramProcessingParams.MAX_HOP_SIZE + ) + class SpectrogramAPIViewTestCases(TestCase): """Test cases for SpectrogramAPIView.""" diff --git a/gateway/sds_gateway/visualizations/views.py b/gateway/sds_gateway/visualizations/views.py index 7f3ca2698..7d872e381 100644 --- a/gateway/sds_gateway/visualizations/views.py +++ b/gateway/sds_gateway/visualizations/views.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import get_object_or_404 @@ -6,6 +8,43 @@ from sds_gateway.api_methods.models import Capture +from .api_views import Colormap +from .api_views import SpectrogramProcessingParams + + +@dataclass(frozen=True) +class SpectrogramFormContext: + """Server-provided spectrogram form values for the template.""" + + fft_size_options: tuple[int, ...] + color_map_options: tuple[str, ...] + default_fft_size: int + default_std_dev: int + default_hop_size: int + default_color_map: str + std_dev_min: int + std_dev_max: int + hop_size_min: int + hop_size_max: int + + @classmethod + def build(cls) -> "SpectrogramFormContext": + return cls( + fft_size_options=SpectrogramProcessingParams.get_fft_size_options(), + color_map_options=tuple(colormap.value for colormap in Colormap), + default_fft_size=SpectrogramProcessingParams.DEFAULT_FFT_SIZE, + default_std_dev=SpectrogramProcessingParams.DEFAULT_STD_DEV, + default_hop_size=SpectrogramProcessingParams.DEFAULT_HOP_SIZE, + default_color_map=SpectrogramProcessingParams.DEFAULT_COLORMAP, + std_dev_min=SpectrogramProcessingParams.MIN_STD_DEV, + std_dev_max=SpectrogramProcessingParams.MAX_STD_DEV, + hop_size_min=SpectrogramProcessingParams.MIN_HOP_SIZE, + hop_size_max=SpectrogramProcessingParams.MAX_HOP_SIZE, + ) + + +SPECTROGRAM_FORM_CONTEXT = SpectrogramFormContext.build() + @method_decorator(login_required, name="dispatch") class WaterfallVisualizationView(LoginRequiredMixin, TemplateView): @@ -46,4 +85,5 @@ def get_context_data(self, **kwargs): ) context["capture"] = capture + context["spectrogram_form"] = SPECTROGRAM_FORM_CONTEXT return context