Skip to content

Commit d69dc6e

Browse files
committed
Add waterfall streaming functionality with tests and linting fixes
1 parent 1569200 commit d69dc6e

File tree

16 files changed

+3215
-175
lines changed

16 files changed

+3215
-175
lines changed

gateway/config/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ def __get_random_token(length: int) -> str:
281281
"django.template.context_processors.tz",
282282
"django.contrib.messages.context_processors.messages",
283283
"sds_gateway.users.context_processors.allauth_settings",
284+
"sds_gateway.context_processors.app_settings",
284285
"sds_gateway.context_processors.system_notifications",
285286
],
286287
},

gateway/sds_gateway/api_methods/tests/test_capture_endpoints.py

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
from sds_gateway.api_methods.utils.opensearch_client import get_opensearch_client
3434
from sds_gateway.api_methods.views.capture_endpoints import _normalize_top_level_dir
3535
from sds_gateway.users.models import UserAPIKey
36+
from sds_gateway.visualizations.models import PostProcessedData
37+
from sds_gateway.visualizations.models import ProcessingStatus
38+
from sds_gateway.visualizations.models import ProcessingType
3639

3740
# Test constants
3841
TEST_USER_PASSWORD = "testpass123" # noqa: S105
@@ -1752,6 +1755,366 @@ def test_disabled_share_permission_blocks_access(self) -> None:
17521755
f"got {data['count']}"
17531756
)
17541757

1758+
def test_waterfall_slices_requires_authentication(self) -> None:
1759+
"""Test that waterfall_slices endpoint requires authentication."""
1760+
# Create a test capture
1761+
capture = Capture.objects.create(
1762+
capture_type=CaptureType.DigitalRF,
1763+
channel="test-channel",
1764+
index_name=f"{self.test_index_prefix}-test",
1765+
owner=self.user,
1766+
top_level_dir="test-dir",
1767+
)
1768+
1769+
url = reverse(
1770+
"api:captures-waterfall-slices",
1771+
kwargs={"pk": capture.uuid},
1772+
)
1773+
1774+
# Create unauthenticated client
1775+
unauthenticated_client = APIClient()
1776+
1777+
# Test without authentication
1778+
response = unauthenticated_client.get(url, {"start_index": 0, "end_index": 10})
1779+
assert response.status_code in [
1780+
status.HTTP_401_UNAUTHORIZED,
1781+
status.HTTP_403_FORBIDDEN,
1782+
]
1783+
1784+
def test_waterfall_slices_success(self) -> None:
1785+
"""Test successful retrieval of waterfall slices."""
1786+
# Create a test capture
1787+
capture = Capture.objects.create(
1788+
capture_type=CaptureType.DigitalRF,
1789+
channel="test-channel",
1790+
index_name=f"{self.test_index_prefix}-test",
1791+
owner=self.user,
1792+
top_level_dir="test-dir",
1793+
)
1794+
1795+
# Create sample waterfall data (10 slices)
1796+
waterfall_data = [
1797+
{
1798+
"data": "dGVzdGRhdGE=", # base64 encoded test data
1799+
"data_type": "float32",
1800+
"timestamp": f"2025-01-01T00:00:{i:02d}Z",
1801+
"min_frequency": 1_990_000_000.0,
1802+
"max_frequency": 2_010_000_000.0,
1803+
"num_samples": 1024,
1804+
"sample_rate": 2_000_000,
1805+
"center_frequency": 2_000_000_000.0,
1806+
"custom_fields": {
1807+
"channel_name": "test-channel",
1808+
"start_sample": i * 1024,
1809+
"num_samples": 1024,
1810+
"fft_size": 1024,
1811+
"scan_time": 0.000512,
1812+
"slice_index": i,
1813+
},
1814+
}
1815+
for i in range(10)
1816+
]
1817+
1818+
# Create JSON file content
1819+
json_content = json.dumps(waterfall_data).encode()
1820+
1821+
# Create PostProcessedData with the JSON file
1822+
processed_data = PostProcessedData.objects.create(
1823+
capture=capture,
1824+
processing_type=ProcessingType.Waterfall.value,
1825+
processing_status=ProcessingStatus.Completed.value,
1826+
metadata={
1827+
"center_frequency": 2_000_000_000.0,
1828+
"sample_rate": 2_000_000,
1829+
"total_slices": 10,
1830+
},
1831+
)
1832+
processed_data.data_file.save(
1833+
"waterfall_test.json",
1834+
SimpleUploadedFile(
1835+
"waterfall_test.json",
1836+
json_content,
1837+
content_type="application/json",
1838+
),
1839+
)
1840+
processed_data.save()
1841+
1842+
# Test getting slices 0-5
1843+
url = reverse(
1844+
"api:captures-waterfall-slices",
1845+
kwargs={"pk": capture.uuid},
1846+
)
1847+
response = self.client.get(
1848+
url,
1849+
{"start_index": 0, "end_index": 5, "processing_type": "waterfall"},
1850+
)
1851+
1852+
assert response.status_code == status.HTTP_200_OK
1853+
data = response.json()
1854+
assert "slices" in data
1855+
assert len(data["slices"]) == 5 # noqa: PLR2004
1856+
assert data["total_slices"] == 10 # noqa: PLR2004
1857+
assert data["start_index"] == 0
1858+
assert data["end_index"] == 5 # noqa: PLR2004
1859+
assert "metadata" in data
1860+
assert data["metadata"]["total_slices"] == 10 # noqa: PLR2004
1861+
1862+
# Verify slice indices
1863+
for i, slice_data in enumerate(data["slices"]):
1864+
assert slice_data["custom_fields"]["slice_index"] == i
1865+
1866+
def test_waterfall_slices_missing_parameters(self) -> None:
1867+
"""Test waterfall_slices endpoint with missing parameters."""
1868+
capture = Capture.objects.create(
1869+
capture_type=CaptureType.DigitalRF,
1870+
channel="test-channel",
1871+
index_name=f"{self.test_index_prefix}-test",
1872+
owner=self.user,
1873+
top_level_dir="test-dir",
1874+
)
1875+
1876+
url = reverse(
1877+
"api:captures-waterfall-slices",
1878+
kwargs={"pk": capture.uuid},
1879+
)
1880+
1881+
# Test missing start_index
1882+
response = self.client.get(url, {"end_index": 5})
1883+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1884+
assert "start_index" in response.json()["error"].lower()
1885+
1886+
# Test missing end_index
1887+
response = self.client.get(url, {"start_index": 0})
1888+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1889+
assert "end_index" in response.json()["error"].lower()
1890+
1891+
def test_waterfall_slices_invalid_indices(self) -> None:
1892+
"""Test waterfall_slices endpoint with invalid indices."""
1893+
capture = Capture.objects.create(
1894+
capture_type=CaptureType.DigitalRF,
1895+
channel="test-channel",
1896+
index_name=f"{self.test_index_prefix}-test",
1897+
owner=self.user,
1898+
top_level_dir="test-dir",
1899+
)
1900+
1901+
# Create waterfall data
1902+
waterfall_data = [
1903+
{"data": "dGVzdA==", "custom_fields": {"slice_index": i}} for i in range(5)
1904+
]
1905+
json_content = json.dumps(waterfall_data).encode()
1906+
1907+
processed_data = PostProcessedData.objects.create(
1908+
capture=capture,
1909+
processing_type=ProcessingType.Waterfall.value,
1910+
processing_status=ProcessingStatus.Completed.value,
1911+
)
1912+
processed_data.data_file.save(
1913+
"waterfall_test.json",
1914+
SimpleUploadedFile(
1915+
"waterfall_test.json", json_content, content_type="application/json"
1916+
),
1917+
)
1918+
processed_data.save()
1919+
1920+
url = reverse(
1921+
"api:captures-waterfall-slices",
1922+
kwargs={"pk": capture.uuid},
1923+
)
1924+
1925+
# Test negative start_index
1926+
response = self.client.get(url, {"start_index": -1, "end_index": 5})
1927+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1928+
1929+
# Test end_index <= start_index
1930+
response = self.client.get(url, {"start_index": 3, "end_index": 3})
1931+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1932+
1933+
response = self.client.get(url, {"start_index": 3, "end_index": 2})
1934+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1935+
1936+
# Test start_index exceeds total slices
1937+
response = self.client.get(url, {"start_index": 10, "end_index": 15})
1938+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1939+
1940+
# Test non-integer indices
1941+
response = self.client.get(url, {"start_index": "abc", "end_index": 5})
1942+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1943+
1944+
def test_waterfall_slices_no_processed_data(self) -> None:
1945+
"""Test waterfall_slices endpoint when no processed data exists."""
1946+
capture = Capture.objects.create(
1947+
capture_type=CaptureType.DigitalRF,
1948+
channel="test-channel",
1949+
index_name=f"{self.test_index_prefix}-test",
1950+
owner=self.user,
1951+
top_level_dir="test-dir",
1952+
)
1953+
1954+
url = reverse(
1955+
"api:captures-waterfall-slices",
1956+
kwargs={"pk": capture.uuid},
1957+
)
1958+
response = self.client.get(url, {"start_index": 0, "end_index": 5})
1959+
1960+
assert response.status_code == status.HTTP_404_NOT_FOUND
1961+
assert "no completed" in response.json()["error"].lower()
1962+
1963+
def test_waterfall_slices_no_data_file(self) -> None:
1964+
"""Test waterfall_slices endpoint when data_file is missing."""
1965+
capture = Capture.objects.create(
1966+
capture_type=CaptureType.DigitalRF,
1967+
channel="test-channel",
1968+
index_name=f"{self.test_index_prefix}-test",
1969+
owner=self.user,
1970+
top_level_dir="test-dir",
1971+
)
1972+
1973+
PostProcessedData.objects.create(
1974+
capture=capture,
1975+
processing_type=ProcessingType.Waterfall.value,
1976+
processing_status=ProcessingStatus.Completed.value,
1977+
# No data_file
1978+
)
1979+
1980+
url = reverse(
1981+
"api:captures-waterfall-slices",
1982+
kwargs={"pk": capture.uuid},
1983+
)
1984+
response = self.client.get(url, {"start_index": 0, "end_index": 5})
1985+
1986+
assert response.status_code == status.HTTP_404_NOT_FOUND
1987+
assert "data file not found" in response.json()["error"].lower()
1988+
1989+
def test_waterfall_slices_end_index_clamping(self) -> None:
1990+
"""Test that end_index is clamped to total_slices."""
1991+
capture = Capture.objects.create(
1992+
capture_type=CaptureType.DigitalRF,
1993+
channel="test-channel",
1994+
index_name=f"{self.test_index_prefix}-test",
1995+
owner=self.user,
1996+
top_level_dir="test-dir",
1997+
)
1998+
1999+
# Create 5 slices
2000+
waterfall_data = [
2001+
{"data": "dGVzdA==", "custom_fields": {"slice_index": i}} for i in range(5)
2002+
]
2003+
json_content = json.dumps(waterfall_data).encode()
2004+
2005+
processed_data = PostProcessedData.objects.create(
2006+
capture=capture,
2007+
processing_type=ProcessingType.Waterfall.value,
2008+
processing_status=ProcessingStatus.Completed.value,
2009+
)
2010+
processed_data.data_file.save(
2011+
"waterfall_test.json",
2012+
SimpleUploadedFile(
2013+
"waterfall_test.json", json_content, content_type="application/json"
2014+
),
2015+
)
2016+
processed_data.save()
2017+
2018+
url = reverse(
2019+
"api:captures-waterfall-slices",
2020+
kwargs={"pk": capture.uuid},
2021+
)
2022+
2023+
# Request slices 2-10 (end_index exceeds total)
2024+
response = self.client.get(url, {"start_index": 2, "end_index": 10})
2025+
2026+
assert response.status_code == status.HTTP_200_OK
2027+
data = response.json()
2028+
assert data["end_index"] == 5 # noqa: PLR2004 # Clamped to total_slices
2029+
assert len(data["slices"]) == 3 # noqa: PLR2004 # Only slices 2, 3, 4
2030+
2031+
def test_waterfall_slices_custom_processing_type(self) -> None:
2032+
"""Test waterfall_slices with custom processing_type."""
2033+
capture = Capture.objects.create(
2034+
capture_type=CaptureType.DigitalRF,
2035+
channel="test-channel",
2036+
index_name=f"{self.test_index_prefix}-test",
2037+
owner=self.user,
2038+
top_level_dir="test-dir",
2039+
)
2040+
2041+
waterfall_data = [
2042+
{"data": "dGVzdA==", "custom_fields": {"slice_index": i}} for i in range(3)
2043+
]
2044+
json_content = json.dumps(waterfall_data).encode()
2045+
2046+
processed_data = PostProcessedData.objects.create(
2047+
capture=capture,
2048+
processing_type="custom_type",
2049+
processing_status=ProcessingStatus.Completed.value,
2050+
)
2051+
processed_data.data_file.save(
2052+
"custom_test.json",
2053+
SimpleUploadedFile(
2054+
"custom_test.json", json_content, content_type="application/json"
2055+
),
2056+
)
2057+
processed_data.save()
2058+
2059+
url = reverse(
2060+
"api:captures-waterfall-slices",
2061+
kwargs={"pk": capture.uuid},
2062+
)
2063+
response = self.client.get(
2064+
url,
2065+
{"start_index": 0, "end_index": 2, "processing_type": "custom_type"},
2066+
)
2067+
2068+
assert response.status_code == status.HTTP_200_OK
2069+
data = response.json()
2070+
assert len(data["slices"]) == 2 # noqa: PLR2004
2071+
2072+
def test_waterfall_slices_other_user_capture(self) -> None:
2073+
"""Test that users cannot access waterfall slices for other users' captures."""
2074+
# Create another user
2075+
other_user = User.objects.create(
2076+
email="otheruser@example.com",
2077+
password="testpassword", # noqa: S106
2078+
is_approved=True,
2079+
)
2080+
2081+
# Create capture owned by other user
2082+
other_capture = Capture.objects.create(
2083+
capture_type=CaptureType.DigitalRF,
2084+
channel="test-channel",
2085+
index_name=f"{self.test_index_prefix}-other",
2086+
owner=other_user,
2087+
top_level_dir="other-dir",
2088+
)
2089+
2090+
# Create waterfall data for other user's capture
2091+
waterfall_data = [
2092+
{"data": "dGVzdA==", "custom_fields": {"slice_index": i}} for i in range(5)
2093+
]
2094+
json_content = json.dumps(waterfall_data).encode()
2095+
2096+
processed_data = PostProcessedData.objects.create(
2097+
capture=other_capture,
2098+
processing_type=ProcessingType.Waterfall.value,
2099+
processing_status=ProcessingStatus.Completed.value,
2100+
)
2101+
processed_data.data_file.save(
2102+
"waterfall_test.json",
2103+
SimpleUploadedFile(
2104+
"waterfall_test.json", json_content, content_type="application/json"
2105+
),
2106+
)
2107+
processed_data.save()
2108+
2109+
url = reverse(
2110+
"api:captures-waterfall-slices",
2111+
kwargs={"pk": other_capture.uuid},
2112+
)
2113+
2114+
# Try to access other user's capture - should fail
2115+
response = self.client.get(url, {"start_index": 0, "end_index": 5})
2116+
assert response.status_code == status.HTTP_404_NOT_FOUND
2117+
17552118

17562119
class OpenSearchErrorTestCases(APITestCase):
17572120
"""Test cases for OpenSearch error handling in capture endpoints."""

0 commit comments

Comments
 (0)