diff --git a/CHANGELOG.md b/CHANGELOG.md index 14bab7ac..b249cfcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,10 @@ When adding a new entry, please use the following format: ## Log +- [2026-05-08] feature: Added /cell/id/sensors endpoint to query sensors associated with each cell [760](https://github.com/jlab-sensing/ENTS-backend/pull/760) - [2026-04-16] feature: added a clear all cells button to cell selection - [2026-04-16] fix: removed two scrollbars from dashboard, made power datapoints display as V and A, made data not truncate as often -- [2026-03-30] fix: added test decorators to resolve lack of "TTN_API_KEY" on fork PR's. removed k6 from github actions. changed github action to utilize env-import.py as opposeed to directly accessing s3 bucket for env variables. [#736] (https://github.com/jlab-sensing/ENTS-backend/pull/736) +- [2026-03-30] fix: added test decorators to resolve lack of "TTN_API_KEY" on fork PR's. removed k6 from github actions. changed github action to utilize env-import.py as opposeed to directly accessing s3 bucket for env variables. [#736](https://github.com/jlab-sensing/ENTS-backend/pull/736) - [2026-03-23] fix: increase fallback SECRET_KEY length to resolve PyJWT InsecureKeyLengthWarning [#682](https://github.com/jlab-sensing/ENTS-backend/pull/682) - [2026-03-12] fix: safely catch PyJWT DecodeErrors and remove log spam for invalid tokens [#683](https://github.com/jlab-sensing/ENTS-backend/pull/683) - [2026-03-12] hotfix: linted AddCellModal, ensured functionality of D10 charts, and added flags to github action pytest for logging [#703](https://github.com/jlab-sensing/ENTS-backend/pull/703) @@ -30,5 +31,4 @@ When adding a new entry, please use the following format: - [2026-03-10] hotfix: Fixed import errors [#698](https://github.com/jlab-sensing/ENTS-backend/pull/698) - [2026-03-05] chore: remove deprecated import_example_data script and update docs to use ents CLI [#665](https://github.com/jlab-sensing/ENTS-backend/pull/665) - [2026-03-05] fix: Temp removed "Export to CSV option" hotfix [#671](https://github.com/jlab-sensing/ENTS-backend/pull/671) - - [2026-03-21] fix: Fixed non-deterministic two-week date range fallback bug [#713](https://github.com/jlab-sensing/ENTS-backend/pull/713) diff --git a/backend/api/__init__.py b/backend/api/__init__.py index 1773b901..38c71dd6 100644 --- a/backend/api/__init__.py +++ b/backend/api/__init__.py @@ -171,6 +171,7 @@ def handle_unsubscribe_cells(data): from .resources.cell_tags import CellTags, CellTagDetail, CellsByTag from .resources.cell_users import CellUsers, CellUserDetail, CellByUser, CellShare from .resources.logger import Logger + from .resources.cell_sensors import Cell_Sensors from .auth.routes import auth @@ -187,6 +188,7 @@ def handle_unsubscribe_cells(data): api.add_resource(Session_r, "/session") api.add_resource(User_Data, "/user") api.add_resource(Status, "/status/") + api.add_resource(Cell_Sensors, "/cell//sensors") # Tag management endpoints api.add_resource(Tag, "/tag/") diff --git a/backend/api/resources/cell_sensors.py b/backend/api/resources/cell_sensors.py new file mode 100644 index 00000000..f4d295ca --- /dev/null +++ b/backend/api/resources/cell_sensors.py @@ -0,0 +1,11 @@ +from flask_restful import Resource +from ..models.sensor import Sensor +from ..schemas.sensor_schema import SensorSchema + +sensor_schema = SensorSchema(many=True) + + +class Cell_Sensors(Resource): + def get(self, cell_id): + sensors = Sensor.query.filter_by(cell_id=cell_id).all() + return sensor_schema.dump(sensors) diff --git a/backend/api/schemas/sensor_schema.py b/backend/api/schemas/sensor_schema.py new file mode 100644 index 00000000..edcf8182 --- /dev/null +++ b/backend/api/schemas/sensor_schema.py @@ -0,0 +1,14 @@ +from .. import ma +from ..models.sensor import Sensor + + +class SensorSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = Sensor + + id = ma.Integer() + cell_id = ma.Integer() + measurement = ma.String() + data_type = ma.String() + unit = ma.String() + name = ma.String() diff --git a/backend/tests/test_sensor.py b/backend/tests/test_sensor.py index 03420cf9..00b232d0 100644 --- a/backend/tests/test_sensor.py +++ b/backend/tests/test_sensor.py @@ -105,3 +105,33 @@ def test_get_sensor_obj(init_database): # data_type = db.Column(db.Text(), nullable=False) # unit = db.Column(db.Text()) # name = db.Column(db.Text(), nullable=False) + + +def test_sensor_endpoint_returns_only_sensors(init_database): + ts = 1705176162 + cell = Cell("test_cell_1", "", 1, 1, False, None) + cell.save() + + meas_dict = { + "type": "measurement_type", + "cellId": cell.id, + "data": { + "measurement_name": 1, + }, + # types: float, int, text + "data_type": { + "measurement_name": int, + }, + "ts": ts, + } + data = Sensor.add_data("measurement_name", "measurement_unit", meas_dict) + sensor = Sensor.query.get(data.sensor_id) + + response = init_database.get(f"/api/cell/{cell.id}/sensors") + + assert response.status_code == 200 + result = response.get_json() + + returned_ids = [sensor["id"] for sensor in result] + assert sensor.id in returned_ids + assert all(sensors["cell_id"] == cell.id for sensors in result) diff --git a/frontend/src/__tests__/Profile.test.jsx b/frontend/src/__tests__/Profile.test.jsx index f187fc6f..4fa2e7a4 100644 --- a/frontend/src/__tests__/Profile.test.jsx +++ b/frontend/src/__tests__/Profile.test.jsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // import AccountInfo from '../pages/profile/components/AccountInfo'; import DeleteCellModal from '../pages/profile/components/DeleteCellModal'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { updateCell, getCells, deleteCell, getUserCells } from '../services/cell'; +import { updateCell, getCells, deleteCell, getUserCells, getCellSensors } from '../services/cell'; import { useOutletContext } from 'react-router-dom'; import axios from 'axios'; vi.mock('axios'); @@ -333,6 +333,27 @@ describe('Cell Service API Functions', () => { expect(getUserCells).toHaveBeenCalledTimes(1); }); + it('get cell sensors', async () => { + const mockResponse = [{ id: 1, name: 'Mock Cell' }]; + axios.get.mockResolvedValue({ data: mockResponse}); + const data = await getCellSensors(1); + expect(data).toEqual(mockResponse); + expect(axios.get).toHaveBeenCalledWith(`${process.env.PUBLIC_URL}/api/cell/1/sensors`); + }); + + it ('returns error when get cell sensors fails', async () =>{ + const mockError = { + response: { + data: { error: 'Error getting cell sensors'}, + }, + }; + + axios.get.mockRejectedValueOnce(mockError); + const data = await getCellSensors(1); + expect(data).toBeUndefined(); + expect(axios.get).toHaveBeenCalledWith(`${process.env.PUBLIC_URL}/api/cell/1/sensors`); + }) + // it('fetches cell data', async () => { // const mockData = { result: 'Mock Cell Data' }; // axios.get.mockResolvedValue({ data: mockData }); diff --git a/frontend/src/pages/dashboard/Dashboard.jsx b/frontend/src/pages/dashboard/Dashboard.jsx index 60537625..abeccc65 100644 --- a/frontend/src/pages/dashboard/Dashboard.jsx +++ b/frontend/src/pages/dashboard/Dashboard.jsx @@ -6,7 +6,7 @@ import DateRangeNotification from '../../components/DateRangeNotification'; import { useSmartDateRange } from '../../hooks/useSmartDateRange'; import useAxiosPrivate from '../../auth/hooks/useAxiosPrivate'; import useAuth from '../../auth/hooks/useAuth'; -import { useCells } from '../../services/cell'; +import { useCells, getCellSensors } from '../../services/cell'; import ArchiveModal from './components/ArchiveModal'; import BackBtn from './components/BackBtn'; import CellSelect from './components/CellSelect'; @@ -35,6 +35,7 @@ function Dashboard() { const [powerHasData, setPowerHasData] = useState(false); const [terosHasData, setTerosHasData] = useState(false); const [liveData, setLiveData] = useState([]); + const [cellSensorsById, setCellSensorsById] = useState({}); // Background streaming data - always collecting in background const backgroundStreamDataRef = useRef([]); @@ -405,6 +406,26 @@ useEffect(() => { } }; + const selectedCellIds = useMemo(() => { + return selectedCells.map((cell) => cell.id.toString()).sort().join(','); + }, [selectedCells]) + + useEffect(() => { + const loadCellSensors = async () => { + const sensorsById = {}; + const cellIds = selectedCellIds.split(',').filter(Boolean); + for (const cellId of cellIds){ + sensorsById[cellId] = await getCellSensors(cellId); + } + setCellSensorsById(sensorsById); + }; + if (selectedCellIds){ + loadCellSensors(); + } else { + setCellSensorsById({}); + } + }, [selectedCellIds]); + useEffect(() => { if (selectedCells.length === 0) { @@ -667,6 +688,7 @@ useEffect(() => { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> { stream={stream} liveData={liveData} processedData={processedLiveData.sensors} + cellSensorsById={cellSensorsById} /> diff --git a/frontend/src/pages/dashboard/components/UnifiedChart.jsx b/frontend/src/pages/dashboard/components/UnifiedChart.jsx index 3f70c363..c19bafc7 100644 --- a/frontend/src/pages/dashboard/components/UnifiedChart.jsx +++ b/frontend/src/pages/dashboard/components/UnifiedChart.jsx @@ -6,6 +6,7 @@ import { getSensorData } from '../../../services/sensor'; import UniversalChart from '../../../charts/UniversalChart'; import { extractUnifiedStreamValue, matchesSensorStreamType, normalizeUnifiedStreamValue } from './unifiedChartUtils'; + const CHART_CONFIGS = { power_voltage: { sensor_name: 'POWER_VOLTAGE', @@ -135,7 +136,7 @@ const CHART_CONFIGS = { chartId: 'waterFlow', }, }; -function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, processedData, onDataStatusChange }) { +function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, processedData, onDataStatusChange, cellSensorsById ={}, }) { const [resample, setResample] = useState('hour'); const chartSettings = { label: [], @@ -169,7 +170,18 @@ function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, proce data[id] = { name: name, }; + // get list of sensors that are associated with selected cells + const cellSensors = cellSensorsById || []; for (const meas of measurements) { + // verify sensor mathches one of the sensors from CHART_CONFIGS + const hasSensor = cellSensors.some((sensor) => { + return sensor.name === sensor_name && sensor.measurement === meas; + }); + // if no matching sensor, skip this request + if (!hasSensor){ + continue; + } + data[id] = { ...data[id], [meas]: await getSensorData(sensor_name, id, meas, startDate.toHTTP(), endDate.toHTTP(), resample), @@ -411,7 +423,7 @@ function UnifiedChart({ type, cells, startDate, endDate, stream, liveData, proce }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cells, stream, resample, startDate, endDate]); + }, [cells, stream, resample, startDate, endDate, cellSensorsById]); const handleResampleChange = (newResample) => { setResample(newResample); @@ -461,6 +473,7 @@ UnifiedChart.propTypes = { liveData: PropTypes.array, processedData: PropTypes.object, onDataStatusChange: PropTypes.func, + cellSensorsById: PropTypes.object, }; export default UnifiedChart; diff --git a/frontend/src/services/cell.js b/frontend/src/services/cell.js index f840c2b5..a9e9fe1a 100644 --- a/frontend/src/services/cell.js +++ b/frontend/src/services/cell.js @@ -20,6 +20,15 @@ export const getCells = () => { }); }; +export const getCellSensors = (cellId) => { + return axios + .get(`${process.env.PUBLIC_URL}/api/cell/${cellId}/sensors`) + .then((res) => res.data) + .catch((error) => { + console.log('Error getting cell sensors:', error.response ? error.response.data : error.message); + }); +}; + // Get cells with their tags included export const getCellsWithTags = () => { return axios