From 7f2aec96dda115a538af117c0ad1198d54f2519a Mon Sep 17 00:00:00 2001 From: ferdzo Date: Fri, 14 Nov 2025 00:45:10 +0100 Subject: [PATCH] Introduced air quality and weather, onboarding for mobile devices with qr code and otp. Cascade on delete of device with telemtry. --- ...dd_device_onboarding_tokens_for_secure_.py | 45 ++++ ...36e13f5_add_cascade_delete_to_telemetry.py | 48 ++++ db_migrations/models.py | 25 +- frontend/.env.example | 8 + frontend/.gitignore | 1 + frontend/package-lock.json | 21 +- frontend/package.json | 25 +- frontend/public/vite.svg | 1 - frontend/src/api/index.ts | 53 +++- frontend/src/components/AddWidgetModal.tsx | 168 ++++++++++--- frontend/src/components/CredentialsViewer.tsx | 56 +++++ .../components/widgets/AirQualityWidget.tsx | 138 +++++++++++ .../src/components/widgets/WeatherWidget.tsx | 110 +++++++++ frontend/src/components/widgets/registry.ts | 4 + frontend/src/hooks/useDashboardConfig.ts | 3 +- frontend/src/types/api.ts | 1 + gpt.py | 44 ---- iotDashboard/.env.sample | 7 +- iotDashboard/api/serializers.py | 4 +- iotDashboard/api/urls.py | 4 +- iotDashboard/api/views.py | 127 ++++++++++ iotDashboard/device_manager_client.py | 22 ++ iotDashboard/gpt_service_client.py | 114 --------- iotDashboard/models.py | 30 +-- iotDashboard/settings.py | 71 +----- iotDashboard/urls.py | 1 - iotDashboard/views.py | 96 +------- iotDashboard/weather_client.py | 226 ++++++++++++++++++ pyproject.toml | 1 - services/device_manager/.env.sample | 24 ++ services/device_manager/app/app.py | 108 ++++++++- services/device_manager/app/db_models.py | 23 ++ services/device_manager/app/models.py | 1 + uv.lock | 2 - 34 files changed, 1186 insertions(+), 426 deletions(-) create mode 100644 db_migrations/alembic/versions/20251113_2208_0f2632e459d3_add_device_onboarding_tokens_for_secure_.py create mode 100644 db_migrations/alembic/versions/20251113_2318_4b84a36e13f5_add_cascade_delete_to_telemetry.py create mode 100644 frontend/.env.example delete mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/components/widgets/AirQualityWidget.tsx create mode 100644 frontend/src/components/widgets/WeatherWidget.tsx delete mode 100644 gpt.py create mode 100644 iotDashboard/weather_client.py diff --git a/db_migrations/alembic/versions/20251113_2208_0f2632e459d3_add_device_onboarding_tokens_for_secure_.py b/db_migrations/alembic/versions/20251113_2208_0f2632e459d3_add_device_onboarding_tokens_for_secure_.py new file mode 100644 index 0000000..a7a5c27 --- /dev/null +++ b/db_migrations/alembic/versions/20251113_2208_0f2632e459d3_add_device_onboarding_tokens_for_secure_.py @@ -0,0 +1,45 @@ +"""Add device onboarding tokens for secure QR code onboarding + +Revision ID: 0f2632e459d3 +Revises: 4e405f1129b1 +Create Date: 2025-11-13 22:08:31.765427+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0f2632e459d3' +down_revision: Union[str, Sequence[str], None] = '4e405f1129b1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('device_onboarding_tokens', + sa.Column('token', sa.Text(), nullable=False), + sa.Column('device_id', sa.Text(), nullable=False), + sa.Column('certificate_id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('token') + ) + op.create_index('idx_onboarding_tokens_device_id', 'device_onboarding_tokens', ['device_id'], unique=False) + op.create_index('idx_onboarding_tokens_expires', 'device_onboarding_tokens', ['expires_at'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('idx_onboarding_tokens_expires', table_name='device_onboarding_tokens') + op.drop_index('idx_onboarding_tokens_device_id', table_name='device_onboarding_tokens') + op.drop_table('device_onboarding_tokens') + # ### end Alembic commands ### diff --git a/db_migrations/alembic/versions/20251113_2318_4b84a36e13f5_add_cascade_delete_to_telemetry.py b/db_migrations/alembic/versions/20251113_2318_4b84a36e13f5_add_cascade_delete_to_telemetry.py new file mode 100644 index 0000000..b74b12d --- /dev/null +++ b/db_migrations/alembic/versions/20251113_2318_4b84a36e13f5_add_cascade_delete_to_telemetry.py @@ -0,0 +1,48 @@ +"""add_cascade_delete_to_telemetry + +Revision ID: 4b84a36e13f5 +Revises: 0f2632e459d3 +Create Date: 2025-11-13 23:18:36.029045+00:00 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '4b84a36e13f5' +down_revision: Union[str, Sequence[str], None] = '0f2632e459d3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema: Add ON DELETE CASCADE to telemetry foreign key.""" + # Drop existing foreign key constraint + op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey') + + # Re-create foreign key with ON DELETE CASCADE + op.create_foreign_key( + 'telemetry_device_id_fkey', + 'telemetry', + 'devices', + ['device_id'], + ['id'], + ondelete='CASCADE' + ) + + +def downgrade() -> None: + """Downgrade schema: Remove CASCADE from telemetry foreign key.""" + # Drop foreign key with CASCADE + op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey') + + # Re-create foreign key without CASCADE (original state) + op.create_foreign_key( + 'telemetry_device_id_fkey', + 'telemetry', + 'devices', + ['device_id'], + ['id'] + ) diff --git a/db_migrations/models.py b/db_migrations/models.py index edf8923..3ce8c56 100644 --- a/db_migrations/models.py +++ b/db_migrations/models.py @@ -78,7 +78,30 @@ class DeviceCredential(Base): return f"" -class Telemetry(Base): +class DeviceOnboardingToken(Base): + """One-time tokens for secure device onboarding via QR code.""" + + __tablename__ = "device_onboarding_tokens" + + token = Column(Text, primary_key=True) + device_id = Column( + Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False + ) + certificate_id = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + used_at = Column(DateTime(timezone=True)) + + __table_args__ = ( + Index("idx_onboarding_tokens_device_id", "device_id"), + Index("idx_onboarding_tokens_expires", "expires_at"), + ) + + def __repr__(self): + return f"" + + +class Telemetry(Base): """ Time-series telemetry data from devices. """ diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..38a9be0 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,8 @@ +# API Configuration +VITE_API_URL=/api +# Django proxy URL (used for QR code API endpoint) +VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api + +# MQTT Configuration +VITE_MQTT_BROKER=10.10.1.114 +VITE_MQTT_PORT=8883 diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..438657a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c27c801..bd2ad04 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,11 +13,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.62.8", - "@types/react-grid-layout": "^1.3.5", "axios": "^1.7.9", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.462.0", + "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-grid-layout": "^1.5.2", @@ -2520,6 +2520,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2535,15 +2536,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/react-grid-layout": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", - "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", @@ -4921,6 +4913,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index ad81d87..63e4d55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,42 +10,19 @@ "preview": "vite preview" }, "dependencies": { - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-toast": "^1.2.15", - "@tanstack/react-query": "^5.62.8", - "@types/react-grid-layout": "^1.3.5", - "axios": "^1.7.9", - "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "lucide-react": "^0.462.0", "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-grid-layout": "^1.5.2", - "react-hook-form": "^7.54.2", - "react-hot-toast": "^2.6.0", - "react-router": "^7.1.0", - "react-router-dom": "^7.9.5", - "recharts": "^2.15.0", - "tailwind-merge": "^2.5.5", - "zod": "^3.24.1", - "zustand": "^5.0.2" + "react-dom": "^19.1.1" }, "devDependencies": { "@eslint/js": "^9.36.0", - "@tailwindcss/vite": "^4.0.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", - "autoprefixer": "^10.4.20", - "daisyui": "^5.4.2", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", - "tailwindcss": "^4.0.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 96d9a56..49e3e2a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -17,7 +17,24 @@ interface PaginatedResponse { // Device API export const devicesApi = { - getAll: () => apiClient.get>('/devices/'), + getAll: async () => { + const response = await apiClient.get>('/devices/'); + // Handle both paginated and non-paginated responses + if (Array.isArray(response.data)) { + // Non-paginated response - wrap it + return { + ...response, + data: { + count: response.data.length, + next: null, + previous: null, + results: response.data, + }, + }; + } + // Already paginated + return response as typeof response & { data: PaginatedResponse }; + }, getOne: (id: string) => apiClient.get(`/devices/${id}/`), @@ -78,3 +95,37 @@ export const telemetryApi = { export const dashboardApi = { getOverview: () => apiClient.get('/dashboard/overview/'), }; + +// Weather API +export const weatherApi = { + getCurrent: (params: { city?: string; lat?: number; lon?: number }) => + apiClient.get<{ + location: string; + temperature: number; + apparent_temperature: number; + humidity: number; + weather_description: string; + weather_code: number; + precipitation: number; + rain: number; + cloud_cover: number; + wind_speed: number; + wind_direction: number; + time: string; + timezone: string; + }>('/weather/current/', { params }), + + getAirQuality: (city: string) => + apiClient.get<{ + city: string; + measurements: Record; + status: string; + timestamp: string; + sensor_count: number; + }>('/weather/air_quality/', { params: { city } }), +}; diff --git a/frontend/src/components/AddWidgetModal.tsx b/frontend/src/components/AddWidgetModal.tsx index fb18cc2..1b40784 100644 --- a/frontend/src/components/AddWidgetModal.tsx +++ b/frontend/src/components/AddWidgetModal.tsx @@ -17,8 +17,9 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa const [selectedDevices, setSelectedDevices] = useState([]) const [selectedMetrics, setSelectedMetrics] = useState([]) const [timeframeHours, setTimeframeHours] = useState(24) - const [widgetWidth, setWidgetWidth] = useState(1) // Default to 1 column (small) - const [widgetHeight, setWidgetHeight] = useState(2) // Default to 2 rows (medium) + const [widgetWidth, setWidgetWidth] = useState(1) + const [widgetHeight, setWidgetHeight] = useState(2) + const [city, setCity] = useState('Skopje') // Fetch devices const { data: devicesData } = useQuery({ @@ -63,20 +64,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa }, [selectedDevices]) const handleSubmit = () => { - if (selectedDevices.length === 0 || selectedMetrics.length === 0) { - alert('Please select at least one device and one metric') - return + // Weather and air-quality widgets don't need device/metric validation + if (widgetType !== 'weather' && widgetType !== 'air-quality') { + if (selectedDevices.length === 0 || selectedMetrics.length === 0) { + alert('Please select at least one device and one metric') + return + } } - const selectedDevice = devices.find(d => d.id === selectedDevices[0]) - const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics) + // Create title + let defaultTitle = '' + if (widgetType === 'weather') { + defaultTitle = `Weather - ${city}` + } else if (widgetType === 'air-quality') { + defaultTitle = `Air Quality - ${city}` + } else { + const selectedDevice = devices.find(d => d.id === selectedDevices[0]) + defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics) + } const newWidget: WidgetConfig = { id: `widget-${Date.now()}`, type: widgetType, title: title || defaultTitle, - deviceIds: selectedDevices, - metricIds: selectedMetrics, + deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices, + metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics, timeframe: { hours: timeframeHours, }, @@ -84,6 +96,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa showLegend: true, showGrid: true, height: widgetType === 'line-chart' ? 300 : undefined, + city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined, }, position: { x: 0, @@ -197,6 +210,32 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
GPT analysis
+ + + + @@ -204,7 +243,17 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa - @@ -212,7 +261,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa )} {/* Step 2: Data Source */} - {step === 2 && ( + {step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (