barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python manage.py migrate:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
/media
|
||||||
|
/staticfiles
|
||||||
|
|
||||||
|
# React / Node
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ScalesApp - Multi-Component Application
|
||||||
|
|
||||||
|
A distributed application consisting of three main components:
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. **Frontend (React)**
|
||||||
|
- Location: `/frontend`
|
||||||
|
- Description: React web application that displays real-time data from the serial port
|
||||||
|
- Communicates with: Django backend via REST API
|
||||||
|
- Port: 3000 (default)
|
||||||
|
|
||||||
|
### 2. **Backend (Django)**
|
||||||
|
- Location: `/backend`
|
||||||
|
- Description: Django REST API server for storing and retrieving serial data
|
||||||
|
- Database: SQLite (configurable)
|
||||||
|
- Port: 8000 (default)
|
||||||
|
|
||||||
|
### 3. **Serial Bridge (Python)**
|
||||||
|
- Location: `/serial_bridge`
|
||||||
|
- Description: Python application that reads data from COM ports
|
||||||
|
- Features: System tray integration, runs as EXE (via PyInstaller)
|
||||||
|
- Communicates with: Django backend via REST API
|
||||||
|
- Data Flow: COM Port → Serial Bridge → Django Backend → React Frontend
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 16+ (for React)
|
||||||
|
- Python 3.8+ (for Django and Serial Bridge)
|
||||||
|
- pip or conda for Python packages
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. **Backend Setup**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Frontend Setup**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Serial Bridge Setup**
|
||||||
|
```bash
|
||||||
|
cd serial_bridge
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
COM Port
|
||||||
|
↓
|
||||||
|
Serial Bridge (Python)
|
||||||
|
↓
|
||||||
|
Django Backend REST API
|
||||||
|
↓
|
||||||
|
React Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Each component has its own configuration file:
|
||||||
|
- Frontend: `.env`
|
||||||
|
- Backend: `settings.py` and `.env`
|
||||||
|
- Serial Bridge: `config.py` and `.env`
|
||||||
|
|
||||||
|
## Development Tips
|
||||||
|
|
||||||
|
- Keep all three services running during development
|
||||||
|
- Use `CORS` headers properly in Django for frontend requests
|
||||||
|
- Serial Bridge should continuously read COM data and POST to backend
|
||||||
|
- Frontend should poll/subscribe to backend for updates
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# ScalesApp - Complete Setup & Installation Guide
|
||||||
|
|
||||||
|
A three-tier application for reading serial COM port data, storing it in a Django backend, and displaying it in a React frontend.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ScalesApp/
|
||||||
|
├── frontend/ # React web application
|
||||||
|
│ ├── src/
|
||||||
|
│ ├── public/
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── .env.example
|
||||||
|
├── backend/ # Django REST API
|
||||||
|
│ ├── api/ # Main API app
|
||||||
|
│ ├── manage.py
|
||||||
|
│ ├── settings.py
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── .env.example
|
||||||
|
├── serial_bridge/ # Python serial reader (system tray)
|
||||||
|
│ ├── app.py
|
||||||
|
│ ├── serial_reader.py
|
||||||
|
│ ├── backend_client.py
|
||||||
|
│ ├── tray_icon.py
|
||||||
|
│ ├── config.py
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── serial_bridge.spec
|
||||||
|
│ └── .env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.8+** (for Django and Serial Bridge)
|
||||||
|
- **Node.js 16+** (for React)
|
||||||
|
- **Windows OS** (Serial Bridge uses Windows-specific system tray)
|
||||||
|
- A COM port device (scales or serial reader)
|
||||||
|
|
||||||
|
## Step-by-Step Installation
|
||||||
|
|
||||||
|
### 1. Backend (Django) Setup
|
||||||
|
|
||||||
|
#### a. Create virtual environment
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
#### b. Install dependencies
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### c. Setup environment variables
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
# Edit .env file with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
#### d. Initialize database
|
||||||
|
```bash
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
#### e. Create admin user (optional)
|
||||||
|
```bash
|
||||||
|
python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
#### f. Run the backend server
|
||||||
|
```bash
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:8000`
|
||||||
|
|
||||||
|
**Available API Endpoints:**
|
||||||
|
- `GET /api/readings/` - List all readings
|
||||||
|
- `POST /api/readings/` - Create a new reading
|
||||||
|
- `GET /api/readings/latest/` - Get the latest reading
|
||||||
|
- `GET /api/readings/by_port/?port=COM1` - Get readings from specific port
|
||||||
|
|
||||||
|
### 2. Frontend (React) Setup
|
||||||
|
|
||||||
|
#### a. Install dependencies
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### b. Setup environment variables
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
# Ensure REACT_APP_API_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### c. Start development server
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will open at `http://localhost:3000`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Real-time data display from COM ports
|
||||||
|
- Port selector for filtering
|
||||||
|
- Recent readings table
|
||||||
|
- Auto-refresh every 2 seconds
|
||||||
|
|
||||||
|
### 3. Serial Bridge (Python) Setup
|
||||||
|
|
||||||
|
#### a. Create virtual environment
|
||||||
|
```bash
|
||||||
|
cd serial_bridge
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
#### b. Install dependencies
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### c. Setup environment variables
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
# Edit .env with your COM port and backend URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Key settings:
|
||||||
|
- `COM_PORT=COM1` (change to your port)
|
||||||
|
- `BAUD_RATE=9600` (adjust based on your device)
|
||||||
|
- `BACKEND_URL=http://localhost:8000`
|
||||||
|
|
||||||
|
#### d. Run the application
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will:
|
||||||
|
- Appear in system tray
|
||||||
|
- Start reading from COM port
|
||||||
|
- Automatically post data to Django backend
|
||||||
|
- Retry failed posts
|
||||||
|
- Log to `serial_bridge.log`
|
||||||
|
|
||||||
|
### 4. Running All Services (Development)
|
||||||
|
|
||||||
|
Open 3 terminal windows and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Backend
|
||||||
|
cd backend
|
||||||
|
venv\Scripts\activate
|
||||||
|
python manage.py runserver
|
||||||
|
|
||||||
|
# Terminal 2: Frontend
|
||||||
|
cd frontend
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Terminal 3: Serial Bridge
|
||||||
|
cd serial_bridge
|
||||||
|
venv\Scripts\activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building Serial Bridge as EXE
|
||||||
|
|
||||||
|
To create a standalone Windows executable that runs in the system tray:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd serial_bridge
|
||||||
|
|
||||||
|
# Install PyInstaller
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
# Build the executable
|
||||||
|
pyinstaller serial_bridge.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable will be created at `serial_bridge\dist\ScalesApp\ScalesApp.exe`
|
||||||
|
|
||||||
|
You can:
|
||||||
|
- Run it directly
|
||||||
|
- Add it to Startup folder (`C:\Users\<User>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup`)
|
||||||
|
- Create a Windows Task Scheduler entry to run at startup
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ Physical Device │
|
||||||
|
│ (Scales/COM) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
│ Serial data
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Serial Bridge (Python) │
|
||||||
|
│ - Reads COM port │
|
||||||
|
│ - System tray app │
|
||||||
|
│ - Retry logic │
|
||||||
|
└────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
│ HTTP POST /api/readings/
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Django Backend │
|
||||||
|
│ - REST API │
|
||||||
|
│ - Database storage │
|
||||||
|
│ - CORS enabled │
|
||||||
|
└────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
│ REST API GET /api/readings/
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ React Frontend │
|
||||||
|
│ - Real-time display │
|
||||||
|
│ - Port selector │
|
||||||
|
│ - Auto-refresh │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### Backend (backend/.env)
|
||||||
|
```env
|
||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
DEBUG=True
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
DATABASE_URL=sqlite:///db.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (frontend/.env)
|
||||||
|
```env
|
||||||
|
REACT_APP_API_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serial Bridge (serial_bridge/.env)
|
||||||
|
```env
|
||||||
|
COM_PORT=COM1
|
||||||
|
BAUD_RATE=9600
|
||||||
|
BACKEND_URL=http://localhost:8000
|
||||||
|
AUTO_CONNECT=True
|
||||||
|
DEBUG=False
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend Issues
|
||||||
|
- **Port already in use**: Change port: `python manage.py runserver 8001`
|
||||||
|
- **CORS errors**: Check `ALLOWED_HOSTS` and `CORS_ALLOWED_ORIGINS` in settings.py
|
||||||
|
- **Database errors**: Run migrations: `python manage.py migrate`
|
||||||
|
|
||||||
|
### Frontend Issues
|
||||||
|
- **Cannot connect to backend**: Ensure Django is running and `REACT_APP_API_URL` is correct
|
||||||
|
- **Port 3000 in use**: Kill process: `netstat -ano | findstr :3000`
|
||||||
|
|
||||||
|
### Serial Bridge Issues
|
||||||
|
- **COM port not found**: List available ports with: `python -m serial.tools.list_ports`
|
||||||
|
- **Backend not reachable**: Check `BACKEND_URL` and ensure Django server is running
|
||||||
|
- **No tray icon**: Run as administrator, check Windows system tray settings
|
||||||
|
- **Data not posting**: Check `serial_bridge.log` for errors
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
# Use production settings
|
||||||
|
export DJANGO_SETTINGS_MODULE=scalesapp.settings
|
||||||
|
python manage.py collectstatic
|
||||||
|
# Use Gunicorn
|
||||||
|
pip install gunicorn
|
||||||
|
gunicorn scalesapp.wsgi -b 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Serve dist folder with nginx or similar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serial Bridge
|
||||||
|
Create Windows Service or Scheduled Task to run the EXE at startup.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Test all three components locally
|
||||||
|
2. ✅ Verify data flow from COM → Backend → Frontend
|
||||||
|
3. ✅ Customize React components if needed
|
||||||
|
4. ✅ Build Serial Bridge as EXE
|
||||||
|
5. ✅ Deploy to production servers
|
||||||
|
6. ✅ Setup monitoring and logging
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Django REST Framework Docs](https://www.django-rest-framework.org/)
|
||||||
|
- [React Docs](https://react.dev/)
|
||||||
|
- [PySerial Docs](https://pyserial.readthedocs.io/)
|
||||||
|
- [Pystray Docs](https://github.com/moses-palmer/pystray)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues, check the logs:
|
||||||
|
- Backend: Django console output
|
||||||
|
- Frontend: Browser console (F12)
|
||||||
|
- Serial Bridge: `serial_bridge.log` file
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Backend environment variables
|
||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
DEBUG=True
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Database Settings
|
||||||
|
# Use 'django.db.backends.sqlite3' for SQLite (default)
|
||||||
|
# Use 'django.db.backends.postgresql' for PostgreSQL
|
||||||
|
DB_ENGINE=django.db.backends.sqlite3
|
||||||
|
DB_NAME=db.sqlite3
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Django API app
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from api.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Creates predefined admin user (username: admin, password: admin)'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
username = 'admin'
|
||||||
|
password = 'admin'
|
||||||
|
|
||||||
|
if User.objects.filter(username=username).exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'User "{username}" already exists')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user = User.objects.create(
|
||||||
|
username=username,
|
||||||
|
email='admin@scalesapp.com',
|
||||||
|
role='employee',
|
||||||
|
is_admin=True,
|
||||||
|
is_staff=True,
|
||||||
|
is_superuser=True,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Successfully created admin user: {username} / {password}'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Generated by Django 4.2 on 2026-01-12 18:39
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="User",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
|
(
|
||||||
|
"last_login",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="last login"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_superuser",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"username",
|
||||||
|
models.CharField(
|
||||||
|
error_messages={
|
||||||
|
"unique": "A user with that username already exists."
|
||||||
|
},
|
||||||
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
|
max_length=150,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||||
|
],
|
||||||
|
verbose_name="username",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True, max_length=254, verbose_name="email address"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_staff",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates whether the user can log into this admin site.",
|
||||||
|
verbose_name="staff status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
|
verbose_name="active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_joined",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="date joined"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"role",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("employee", "Employee"), ("viewer", "Viewer")],
|
||||||
|
default="viewer",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_admin", models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "api_user",
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ComPortReading",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("port", models.CharField(max_length=20)),
|
||||||
|
("data", models.TextField()),
|
||||||
|
("timestamp", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("source_ip", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-timestamp"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="comportreading",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["-timestamp"], name="api_comport_timesta_c2b399_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="comportreading",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["port", "-timestamp"], name="api_comport_port_123b9f_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="groups",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="user_permissions",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
"""Custom User model with role and admin flag"""
|
||||||
|
ROLE_CHOICES = [
|
||||||
|
('employee', 'Employee'),
|
||||||
|
('viewer', 'Viewer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer')
|
||||||
|
is_admin = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'api_user'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.username} ({self.get_role_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class ComPortReading(models.Model):
|
||||||
|
"""Model to store serial port readings"""
|
||||||
|
port = models.CharField(max_length=20)
|
||||||
|
data = models.TextField()
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
source_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['-timestamp']),
|
||||||
|
models.Index(fields=['port', '-timestamp']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.port} - {self.timestamp}"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from .models import ComPortReading, User
|
||||||
|
from vehicles.models import Vehicle, VehicleExtra
|
||||||
|
from nomenclatures.models import Nomenclature, NomenclatureField
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(write_only=True, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||||
|
'role', 'is_admin', 'is_active', 'date_joined', 'password']
|
||||||
|
read_only_fields = ['id', 'date_joined']
|
||||||
|
extra_kwargs = {
|
||||||
|
'password': {'write_only': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
user = User(**validated_data)
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
if password:
|
||||||
|
instance.set_password(password)
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class UserDetailSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for current user details (excludes password)"""
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||||
|
'role', 'is_admin', 'is_active', 'date_joined']
|
||||||
|
read_only_fields = ['id', 'date_joined']
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for password change endpoint"""
|
||||||
|
old_password = serializers.CharField(required=True, write_only=True)
|
||||||
|
new_password = serializers.CharField(required=True, write_only=True)
|
||||||
|
|
||||||
|
def validate_old_password(self, value):
|
||||||
|
user = self.context['request'].user
|
||||||
|
if not user.check_password(value):
|
||||||
|
raise serializers.ValidationError("Old password is incorrect")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_new_password(self, value):
|
||||||
|
# Use Django's password validators
|
||||||
|
validate_password(value, self.context['request'].user)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
user = self.context['request'].user
|
||||||
|
user.set_password(self.validated_data['new_password'])
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class ComPortReadingSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ComPortReading
|
||||||
|
fields = ['id', 'port', 'data', 'timestamp', 'source_ip']
|
||||||
|
read_only_fields = ['id', 'timestamp']
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleExtraSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = VehicleExtra
|
||||||
|
fields = ['id', 'data']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleSerializer(serializers.ModelSerializer):
|
||||||
|
extra = VehicleExtraSerializer(required=False, allow_null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Vehicle
|
||||||
|
fields = ['id', 'vehicle_number', 'extra']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
extra_data = validated_data.pop('extra', None)
|
||||||
|
vehicle = Vehicle.objects.create(**validated_data)
|
||||||
|
|
||||||
|
if extra_data:
|
||||||
|
VehicleExtra.objects.create(vehicle=vehicle, **extra_data)
|
||||||
|
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
extra_data = validated_data.pop('extra', None)
|
||||||
|
|
||||||
|
# Update Vehicle fields
|
||||||
|
instance.vehicle_number = validated_data.get('vehicle_number', instance.vehicle_number)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
# Handle VehicleExtra update/creation
|
||||||
|
if extra_data is not None:
|
||||||
|
if hasattr(instance, 'extra'):
|
||||||
|
# Update existing VehicleExtra
|
||||||
|
for attr, value in extra_data.items():
|
||||||
|
setattr(instance.extra, attr, value)
|
||||||
|
instance.extra.save()
|
||||||
|
else:
|
||||||
|
# Create new VehicleExtra
|
||||||
|
VehicleExtra.objects.create(vehicle=instance, **extra_data)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def validate_vehicle_number(self, value):
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise serializers.ValidationError("Vehicle number cannot be empty")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class NomenclatureFieldSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = NomenclatureField
|
||||||
|
fields = ['id', 'key', 'field_type']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
def validate_key(self, value):
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise serializers.ValidationError("Field key cannot be empty")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
def validate_field_type(self, value):
|
||||||
|
valid_types = [choice[0] for choice in NomenclatureField.FIELD_TYPES]
|
||||||
|
if value not in valid_types:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Invalid field type. Must be one of: {', '.join(valid_types)}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class NomenclatureSerializer(serializers.ModelSerializer):
|
||||||
|
fields = NomenclatureFieldSerializer(many=True, required=False, source='nomenclaturefield_set')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Nomenclature
|
||||||
|
fields = ['id', 'code', 'name', 'applies_to', 'fields']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
fields_data = validated_data.pop('nomenclaturefield_set', [])
|
||||||
|
nomenclature = Nomenclature.objects.create(**validated_data)
|
||||||
|
|
||||||
|
for field_data in fields_data:
|
||||||
|
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
|
||||||
|
|
||||||
|
return nomenclature
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
fields_data = validated_data.pop('nomenclaturefield_set', None)
|
||||||
|
|
||||||
|
# Update Nomenclature fields
|
||||||
|
instance.code = validated_data.get('code', instance.code)
|
||||||
|
instance.name = validated_data.get('name', instance.name)
|
||||||
|
instance.applies_to = validated_data.get('applies_to', instance.applies_to)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
# Handle fields update - replace all fields
|
||||||
|
if fields_data is not None:
|
||||||
|
# Delete existing fields
|
||||||
|
instance.nomenclaturefield_set.all().delete()
|
||||||
|
# Create new fields
|
||||||
|
for field_data in fields_data:
|
||||||
|
NomenclatureField.objects.create(nomenclature=instance, **field_data)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def validate_code(self, value):
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise serializers.ValidationError("Code cannot be empty")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
def validate_applies_to(self, value):
|
||||||
|
valid_choices = ['vehicle', 'container']
|
||||||
|
if value not in valid_choices:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Invalid applies_to value. Must be one of: {', '.join(valid_choices)}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from rest_framework_simplejwt.views import (
|
||||||
|
TokenObtainPairView,
|
||||||
|
TokenRefreshView,
|
||||||
|
)
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'users', views.UserViewSet, basename='user')
|
||||||
|
router.register(r'readings', views.ComPortReadingViewSet, basename='reading')
|
||||||
|
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
|
||||||
|
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# JWT token endpoints
|
||||||
|
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
|
||||||
|
# Router URLs
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
from .models import ComPortReading, User
|
||||||
|
from .serializers import ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer
|
||||||
|
from vehicles.models import Vehicle
|
||||||
|
from nomenclatures.models import Nomenclature
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for user management.
|
||||||
|
|
||||||
|
list: Get all users
|
||||||
|
create: Create a new user
|
||||||
|
retrieve: Get a specific user
|
||||||
|
update: Update a user
|
||||||
|
destroy: Delete a user
|
||||||
|
me: Get current authenticated user
|
||||||
|
change_password: Change password for current user
|
||||||
|
"""
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
filterset_fields = ['role', 'is_admin', 'is_active']
|
||||||
|
ordering = ['username']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||||
|
def me(self, request):
|
||||||
|
"""Get current authenticated user details"""
|
||||||
|
serializer = UserDetailSerializer(request.user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated],
|
||||||
|
url_path='change-password', url_name='change_password')
|
||||||
|
def change_password(self, request):
|
||||||
|
"""Change password for current user"""
|
||||||
|
serializer = ChangePasswordSerializer(
|
||||||
|
data=request.data,
|
||||||
|
context={'request': request}
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response({
|
||||||
|
'detail': 'Password updated successfully'
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for COM port readings.
|
||||||
|
|
||||||
|
list: Get all readings with optional filtering
|
||||||
|
create: Create a new reading (allows unauthenticated for serial bridge)
|
||||||
|
retrieve: Get a specific reading
|
||||||
|
destroy: Delete a reading
|
||||||
|
latest: Get the latest reading
|
||||||
|
by_port: Get readings for a specific port
|
||||||
|
"""
|
||||||
|
queryset = ComPortReading.objects.all()
|
||||||
|
serializer_class = ComPortReadingSerializer
|
||||||
|
filterset_fields = ['port']
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
"""Allow unauthenticated POST for serial bridge"""
|
||||||
|
if self.action == 'create':
|
||||||
|
return [AllowAny()]
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def latest(self, request):
|
||||||
|
"""Get the latest reading"""
|
||||||
|
reading = ComPortReading.objects.first()
|
||||||
|
if reading:
|
||||||
|
serializer = self.get_serializer(reading)
|
||||||
|
return Response(serializer.data)
|
||||||
|
return Response({'detail': 'No readings yet'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def by_port(self, request):
|
||||||
|
"""Get readings for a specific port"""
|
||||||
|
port = request.query_params.get('port', None)
|
||||||
|
if not port:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'port parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
readings = ComPortReading.objects.filter(port=port)
|
||||||
|
serializer = self.get_serializer(readings, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def get_client_ip(self, request):
|
||||||
|
"""Get client IP address"""
|
||||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
if x_forwarded_for:
|
||||||
|
ip = x_forwarded_for.split(',')[0]
|
||||||
|
else:
|
||||||
|
ip = request.META.get('REMOTE_ADDR')
|
||||||
|
return ip
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Save the reading with client IP"""
|
||||||
|
serializer.save(source_ip=self.get_client_ip(self.request))
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for vehicle management.
|
||||||
|
|
||||||
|
list: Get all vehicles with pagination
|
||||||
|
create: Create a new vehicle with optional extra data
|
||||||
|
retrieve: Get a specific vehicle by ID
|
||||||
|
update: Full update of vehicle and extra data
|
||||||
|
partial_update: Partial update of vehicle
|
||||||
|
destroy: Delete a vehicle (cascades to VehicleExtra)
|
||||||
|
by_number: Get vehicle by vehicle_number
|
||||||
|
"""
|
||||||
|
queryset = Vehicle.objects.select_related('extra').all()
|
||||||
|
serializer_class = VehicleSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filterset_fields = ['vehicle_number']
|
||||||
|
search_fields = ['vehicle_number']
|
||||||
|
ordering = ['vehicle_number']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='by-number')
|
||||||
|
def by_number(self, request):
|
||||||
|
"""Get vehicle by vehicle_number query parameter"""
|
||||||
|
vehicle_number = request.query_params.get('vehicle_number', None)
|
||||||
|
if not vehicle_number:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'vehicle_number parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
vehicle = Vehicle.objects.get(vehicle_number=vehicle_number)
|
||||||
|
serializer = self.get_serializer(vehicle)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except Vehicle.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Vehicle not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for nomenclature management.
|
||||||
|
|
||||||
|
list: Get all nomenclatures with pagination
|
||||||
|
create: Create a new nomenclature with fields
|
||||||
|
retrieve: Get a specific nomenclature by ID
|
||||||
|
update: Full update of nomenclature and fields
|
||||||
|
partial_update: Partial update of nomenclature
|
||||||
|
destroy: Delete a nomenclature (cascades to fields)
|
||||||
|
by_code: Get nomenclature by code
|
||||||
|
by_applies_to: Filter nomenclatures by applies_to type
|
||||||
|
"""
|
||||||
|
queryset = Nomenclature.objects.prefetch_related('nomenclaturefield_set').all()
|
||||||
|
serializer_class = NomenclatureSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filterset_fields = ['applies_to', 'code']
|
||||||
|
search_fields = ['code', 'name']
|
||||||
|
ordering = ['code']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='by-code')
|
||||||
|
def by_code(self, request):
|
||||||
|
"""Get nomenclature by code query parameter"""
|
||||||
|
code = request.query_params.get('code', None)
|
||||||
|
if not code:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'code parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
nomenclature = Nomenclature.objects.get(code=code)
|
||||||
|
serializer = self.get_serializer(nomenclature)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except Nomenclature.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'Nomenclature not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='by-applies-to')
|
||||||
|
def by_applies_to(self, request):
|
||||||
|
"""Get nomenclatures filtered by applies_to query parameter"""
|
||||||
|
applies_to = request.query_params.get('applies_to', None)
|
||||||
|
if not applies_to:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'applies_to parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if applies_to not in ['vehicle', 'container']:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'applies_to must be either vehicle or container'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
nomenclatures = Nomenclature.objects.filter(applies_to=applies_to)
|
||||||
|
page = self.paginate_queryset(nomenclatures)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(nomenclatures, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NomenclaturesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "nomenclatures"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Generated by Django 4.2.8 on 2026-01-13 16:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Nomenclature",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("code", models.CharField(max_length=50, unique=True)),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"applies_to",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("vehicle", "Vehicle"), ("container", "Container")],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="NomenclatureField",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("key", models.CharField(max_length=50)),
|
||||||
|
(
|
||||||
|
"field_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("text", "Text"),
|
||||||
|
("number", "Number"),
|
||||||
|
("bool", "Boolean"),
|
||||||
|
("choice", "Choice"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nomenclature",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="nomenclatures.nomenclature",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
class Nomenclature(models.Model):
|
||||||
|
code = models.CharField(max_length=50, unique=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
applies_to = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[("vehicle", "Vehicle"), ("container", "Container")]
|
||||||
|
)
|
||||||
|
|
||||||
|
class NomenclatureField(models.Model):
|
||||||
|
TEXT = "text"
|
||||||
|
NUMBER = "number"
|
||||||
|
BOOL = "bool"
|
||||||
|
CHOICE = "choice"
|
||||||
|
|
||||||
|
FIELD_TYPES = [
|
||||||
|
(TEXT, "Text"),
|
||||||
|
(NUMBER, "Number"),
|
||||||
|
(BOOL, "Boolean"),
|
||||||
|
(CHOICE, "Choice"),
|
||||||
|
]
|
||||||
|
|
||||||
|
nomenclature = models.ForeignKey(
|
||||||
|
Nomenclature, on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
key = models.CharField(max_length=50)
|
||||||
|
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
SSE - in scalesapp
|
||||||
|
async def authenticate_sse_request(request)
|
||||||
|
def broadcast_update(object_name, action, data)
|
||||||
|
async def updates_sse(request)
|
||||||
|
def send_message(request)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Django==6.0.1
|
||||||
|
djangorestframework==3.15.2
|
||||||
|
djangorestframework-simplejwt==5.5.1
|
||||||
|
django-cors-headers==4.6.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
requests==2.32.3
|
||||||
|
sqlparse==0.5.3
|
||||||
|
psycopg==3.3.2
|
||||||
|
psycopg-binary==3.3.2
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for scalesapp project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
Django settings for scalesapp project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-dev-key-change-in-production')
|
||||||
|
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
||||||
|
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||||
|
|
||||||
|
PROJECT_APPS = [
|
||||||
|
'api',
|
||||||
|
'vehicles',
|
||||||
|
'nomenclatures',
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework.authtoken',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'corsheaders',
|
||||||
|
] + PROJECT_APPS
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'scalesapp.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'scalesapp.wsgi.application'
|
||||||
|
ASGI_APPLICATION = 'scalesapp.asgi.application'
|
||||||
|
|
||||||
|
# Database configuration with support for SQLite and PostgreSQL
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': "django.db.backends.postgresql",
|
||||||
|
'NAME': os.getenv('DB_NAME', 'scalesapp'),
|
||||||
|
'USER': os.getenv('DB_USER', 'postgres'),
|
||||||
|
'PASSWORD': os.getenv('DB_PASSWORD', ''),
|
||||||
|
'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||||
|
'PORT': os.getenv('DB_PORT', '5432'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# Custom User Model
|
||||||
|
AUTH_USER_MODEL = 'api.User'
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'http://localhost:5174',
|
||||||
|
'http://127.0.0.1:5174',
|
||||||
|
]
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
CORS_ALLOW_HEADERS = [
|
||||||
|
'accept',
|
||||||
|
'accept-encoding',
|
||||||
|
'authorization',
|
||||||
|
'content-type',
|
||||||
|
'dnt',
|
||||||
|
'origin',
|
||||||
|
'user-agent',
|
||||||
|
'x-csrftoken',
|
||||||
|
'x-requested-with',
|
||||||
|
]
|
||||||
|
CORS_ALLOW_METHODS = [
|
||||||
|
'DELETE',
|
||||||
|
'GET',
|
||||||
|
'OPTIONS',
|
||||||
|
'PATCH',
|
||||||
|
'POST',
|
||||||
|
'PUT',
|
||||||
|
]
|
||||||
|
|
||||||
|
# DRF Configuration
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
],
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
],
|
||||||
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
|
'PAGE_SIZE': 100,
|
||||||
|
'DEFAULT_FILTER_BACKENDS': [
|
||||||
|
'rest_framework.filters.SearchFilter',
|
||||||
|
'rest_framework.filters.OrderingFilter',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||||
|
'ROTATE_REFRESH_TOKENS': False,
|
||||||
|
'BLACKLIST_AFTER_ROTATION': False,
|
||||||
|
'ALGORITHM': 'HS256',
|
||||||
|
'SIGNING_KEY': SECRET_KEY,
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
'USER_ID_FIELD': 'id',
|
||||||
|
'USER_ID_CLAIM': 'user_id',
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
SSE - Async Implementation for ASGI
|
||||||
|
Server Side Events, opens connection and keeps it alive, broadcasting new/changed objects to all connected clients
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from django.http import StreamingHttpResponse, JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.shortcuts import render
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# Global updates queue for all objects
|
||||||
|
updates_queue = []
|
||||||
|
updates_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Global event ID counter
|
||||||
|
event_id_counter = 0
|
||||||
|
event_id_lock = threading.Lock()
|
||||||
|
|
||||||
|
# called from views when an object is changed to broadcast the change to all connected clients
|
||||||
|
def sse_broadcast_update(object_name, action, data):
|
||||||
|
"""
|
||||||
|
Broadcast object update to all connected SSE clients
|
||||||
|
object_name: 'agent', 'vessel', 'cargo', and others..
|
||||||
|
action: 'created', 'updated', or 'deleted'
|
||||||
|
data: serialized object data
|
||||||
|
"""
|
||||||
|
global event_id_counter
|
||||||
|
|
||||||
|
# Map action to operation
|
||||||
|
operation_map = {
|
||||||
|
'created': 'insert',
|
||||||
|
'updated': 'update',
|
||||||
|
'deleted': 'delete'
|
||||||
|
}
|
||||||
|
|
||||||
|
with event_id_lock:
|
||||||
|
event_id_counter += 1
|
||||||
|
current_event_id = event_id_counter
|
||||||
|
|
||||||
|
with updates_lock:
|
||||||
|
updates_queue.append({
|
||||||
|
'event_id': current_event_id, # Add event ID
|
||||||
|
'type': 'data_update',
|
||||||
|
'object': object_name,
|
||||||
|
'operation': operation_map.get(action, action),
|
||||||
|
'data': data,
|
||||||
|
'timestamp': time.time()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Keep only last 100 updates
|
||||||
|
while len(updates_queue) > 100:
|
||||||
|
updates_queue.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def sse_authenticate_request(request):
|
||||||
|
"""
|
||||||
|
Called from sse_connect, Authenticate SSE request via JWT token from Authorization header or query parameter.
|
||||||
|
Returns (user, error_response) - if error_response is not None, return it immediately.
|
||||||
|
"""
|
||||||
|
# Try to get token from Authorization header
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
token_key = None
|
||||||
|
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
token_key = auth_header[7:]
|
||||||
|
|
||||||
|
# Fallback: try query parameter (for browsers' EventSource that can't set headers)
|
||||||
|
if not token_key:
|
||||||
|
token_key = request.GET.get('token')
|
||||||
|
|
||||||
|
if not token_key:
|
||||||
|
return None, JsonResponse({'error': 'Authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate JWT token - wrap in sync_to_async for DB queries
|
||||||
|
jwt_auth = JWTAuthentication()
|
||||||
|
validated_token = jwt_auth.get_validated_token(token_key)
|
||||||
|
user = await sync_to_async(jwt_auth.get_user)(validated_token)
|
||||||
|
return user, None
|
||||||
|
except (InvalidToken, TokenError) as e:
|
||||||
|
return None, JsonResponse({'error': 'Invalid token'}, status=401)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# User calls update_sse to establish a sse connection, then he receives updates until disconnect or error
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
async def sse_connect(request):
|
||||||
|
"""SSE endpoint for all object updates (requires authentication)"""
|
||||||
|
|
||||||
|
# Authenticate the request
|
||||||
|
user, error_response = await sse_authenticate_request(request)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Check for Last-Event-ID header (browser sends this on reconnect)
|
||||||
|
last_event_id_header = request.headers.get('Last-Event-ID', None)
|
||||||
|
|
||||||
|
# On fresh connection (no Last-Event-ID), use current event counter as baseline
|
||||||
|
# On reconnection (has Last-Event-ID), use that to replay missed events
|
||||||
|
if last_event_id_header:
|
||||||
|
last_sent_event_id = int(last_event_id_header)
|
||||||
|
print(f"SSE reconnection: Last-Event-ID={last_event_id_header}")
|
||||||
|
else:
|
||||||
|
# Fresh connection - use current event counter as baseline
|
||||||
|
with event_id_lock:
|
||||||
|
last_sent_event_id = event_id_counter
|
||||||
|
print(f"SSE new connection: Starting from event #{last_sent_event_id}")
|
||||||
|
|
||||||
|
# Async generator for ASGI compatibility
|
||||||
|
async def event_generator():
|
||||||
|
nonlocal last_sent_event_id
|
||||||
|
|
||||||
|
# Send connection established message with event ID
|
||||||
|
connected_msg = {
|
||||||
|
"type": "connected",
|
||||||
|
"message": "Connected to updates stream",
|
||||||
|
"current_event_id": last_sent_event_id
|
||||||
|
}
|
||||||
|
yield f'id: {last_sent_event_id}\n'.encode('utf-8')
|
||||||
|
yield f'data: {json.dumps(connected_msg)}\n\n'.encode('utf-8')
|
||||||
|
|
||||||
|
# Keep connection alive and send new updates
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with updates_lock:
|
||||||
|
# Get updates since last event ID
|
||||||
|
missed_updates = [u for u in updates_queue if u['event_id'] > last_sent_event_id]
|
||||||
|
|
||||||
|
if missed_updates:
|
||||||
|
print(f"Sending {len(missed_updates)} missed updates (after event #{last_sent_event_id})")
|
||||||
|
|
||||||
|
for update in missed_updates:
|
||||||
|
event_id = update['event_id']
|
||||||
|
data = json.dumps(update)
|
||||||
|
# Send event with ID in SSE format
|
||||||
|
yield f'id: {event_id}\n'.encode('utf-8')
|
||||||
|
yield f'data: {data}\n\n'.encode('utf-8')
|
||||||
|
last_sent_event_id = event_id
|
||||||
|
|
||||||
|
# Send keepalive comment to prevent timeout
|
||||||
|
yield b': keepalive\n\n'
|
||||||
|
|
||||||
|
# Use async sleep for proper ASGI compatibility
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Updates SSE error: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
event_generator(),
|
||||||
|
content_type='text/event-stream'
|
||||||
|
)
|
||||||
|
response['Cache-Control'] = 'no-cache'
|
||||||
|
response['X-Accel-Buffering'] = 'no'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# @csrf_exempt
|
||||||
|
# def send_message(request):
|
||||||
|
# """Endpoint to send a message"""
|
||||||
|
# if request.method != 'POST':
|
||||||
|
# return JsonResponse({'error': 'POST only'}, status=405)
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# data = json.loads(request.body)
|
||||||
|
# message_text = data.get('message', '').strip()
|
||||||
|
# sender = data.get('sender', 'Anonymous')
|
||||||
|
|
||||||
|
# if not message_text:
|
||||||
|
# return JsonResponse({'error': 'Empty message'}, status=400)
|
||||||
|
|
||||||
|
# # Add message to global queue
|
||||||
|
# with messages_lock:
|
||||||
|
# messages.append({
|
||||||
|
# 'type': 'message',
|
||||||
|
# 'sender': sender,
|
||||||
|
# 'message': message_text,
|
||||||
|
# 'timestamp': time.time()
|
||||||
|
# })
|
||||||
|
|
||||||
|
# # Keep only last 100 messages
|
||||||
|
# while len(messages) > 100:
|
||||||
|
# messages.pop(0)
|
||||||
|
|
||||||
|
# return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
# except Exception as e:
|
||||||
|
# return JsonResponse({'error': str(e)}, status=500)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Main URL configuration for scalesapp.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from .sse import sse_connect
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include('api.urls')),
|
||||||
|
path("sse-connect/", sse_connect, name='sse-connect'),
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for scalesapp project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'scalesapp.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class VehiclesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "vehicles"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 4.2.8 on 2026-01-13 16:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Vehicle",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("vehicle_number", models.CharField(max_length=15, unique=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="VehicleExtra",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("data", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"vehicle",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="extra",
|
||||||
|
to="vehicles.vehicle",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
class Vehicle(models.Model):
|
||||||
|
vehicle_number = models.CharField(max_length=15, unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.vehicle_number}"
|
||||||
|
|
||||||
|
class VehicleExtra(models.Model):
|
||||||
|
vehicle = models.OneToOneField(
|
||||||
|
Vehicle, on_delete=models.CASCADE, related_name="extra"
|
||||||
|
)
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Vehicle
|
||||||
|
|
||||||
|
class VehicleSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Vehicle
|
||||||
|
fields = ['id', 'vehicle_number']
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from vehicles.views import vehiclesView, VehicleListCreateAPIView, VehicleRetrieveUpdateDestroyAPIView, get_vehicles
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", vehiclesView.as_view(), name='list_vehicles'),
|
||||||
|
# API endpoints
|
||||||
|
path("api/", VehicleListCreateAPIView.as_view(), name='vehicles-list-create'),
|
||||||
|
path("api/<int:pk>/", VehicleRetrieveUpdateDestroyAPIView.as_view(), name='vehicles-detail'),
|
||||||
|
path("api/list/", get_vehicles, name='get-vehicles'),
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views.generic.list import ListView
|
||||||
|
from rest_framework import generics, status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .models import Vehicle
|
||||||
|
from .serializers import VehicleSerializer
|
||||||
|
from scalesapp.sse import broadcast_update
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
|
class CountriesView(ListView):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# API Views
|
||||||
|
class VehicleListCreateAPIView(generics.ListCreateAPIView):
|
||||||
|
queryset =Vehicle.objects.all()
|
||||||
|
serializer_class = VehicleSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Override create to broadcast SSE message"""
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_create(serializer)
|
||||||
|
|
||||||
|
# Broadcast SSE update
|
||||||
|
broadcast_update('vehicle', 'created', serializer.data)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
class VehicleRetrieveUpdateDestroyAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
queryset = Vehicle.objects.all()
|
||||||
|
serializer_class = VehicleSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""Override update to broadcast SSE message"""
|
||||||
|
partial = kwargs.pop('partial', False)
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_update(serializer)
|
||||||
|
|
||||||
|
# Broadcast SSE update
|
||||||
|
broadcast_update('vehicle', 'updated', serializer.data)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""Override destroy to broadcast SSE message"""
|
||||||
|
instance = self.get_object()
|
||||||
|
country_data = CountrySerializer(instance).data
|
||||||
|
self.perform_destroy(instance)
|
||||||
|
|
||||||
|
# Broadcast SSE update
|
||||||
|
broadcast_update('vehicle', 'deleted', country_data)
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_vehicles(request):
|
||||||
|
"""API endpoint for getting countries list"""
|
||||||
|
vehicles = Vehicle.objects.all().order_by('id')
|
||||||
|
serializer = VehicleSerializer(vehicles, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
REACT_APP_API_URL=http://localhost:8000
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "src",
|
||||||
|
"paths": {
|
||||||
|
"@components/*": ["components/*"],
|
||||||
|
"@contexts/*": ["contexts/*"],
|
||||||
|
"@hooks/*": ["hooks/*"],
|
||||||
|
"@utils/*": ["utils/*"],
|
||||||
|
"@services/*": ["services/*"],
|
||||||
|
"@assets/*": ["assets/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+17533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "scalesapp-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"recharts": "^2.10.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:8000"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#667eea" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="ScalesApp - Real-time COM port data monitoring"
|
||||||
|
/>
|
||||||
|
<title>ScalesApp - Data Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.App {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
background: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
color: #4CAF50;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: #f44336;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: #f44336;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.status-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
// import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
import Login from './components/Users/Login';
|
||||||
|
import Main from './components/Main';
|
||||||
|
import './App.css';
|
||||||
|
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||||
|
|
||||||
|
// function MainApp() {
|
||||||
|
// const [selectedPort, setSelectedPort] = useState(null);
|
||||||
|
// const { readings, isConnected, error } = useSerialData();
|
||||||
|
|
||||||
|
// const filteredReadings = selectedPort
|
||||||
|
// ? readings.filter(r => r.port === selectedPort)
|
||||||
|
// : readings;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="App">
|
||||||
|
// <Header />
|
||||||
|
|
||||||
|
// <div className="status-bar">
|
||||||
|
// {isConnected ? (
|
||||||
|
// <span className="status-connected">● Connected to Serial Bridge</span>
|
||||||
|
// ) : (
|
||||||
|
// <span className="status-disconnected">● Disconnected</span>
|
||||||
|
// )}
|
||||||
|
// {error && (
|
||||||
|
// <span className="status-error">{error}</span>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="container">
|
||||||
|
// <DataDisplay readings={filteredReadings} selectedPort={selectedPort} />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { user, login, logout, loading, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="loading">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Login onLogin={login} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NomenclatureProvider>
|
||||||
|
<Main />
|
||||||
|
</NomenclatureProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
.data-display {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-display.empty {
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-card.latest {
|
||||||
|
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readings-history {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readings-history h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table thead {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tbody tr:hover {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-cell {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.reading-info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th, table td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './DataDisplay.css';
|
||||||
|
|
||||||
|
function DataDisplay({ readings, selectedPort }) {
|
||||||
|
if (!readings || readings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="data-display empty">
|
||||||
|
<p>No data available. Waiting for COM port readings...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestReading = readings[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="data-display">
|
||||||
|
<div className="reading-card latest">
|
||||||
|
<h2>Latest Reading</h2>
|
||||||
|
<div className="reading-info">
|
||||||
|
<div className="info-item">
|
||||||
|
<label>Port:</label>
|
||||||
|
<span>{latestReading.port}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<label>Data:</label>
|
||||||
|
<span className="data-value">{latestReading.data}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<label>Time:</label>
|
||||||
|
<span>{new Date(latestReading.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="readings-history">
|
||||||
|
<h2>Recent Readings ({readings.length})</h2>
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{readings.map((reading, idx) => (
|
||||||
|
<tr key={reading.id || idx}>
|
||||||
|
<td>{reading.port}</td>
|
||||||
|
<td className="data-cell">{reading.data}</td>
|
||||||
|
<td>{new Date(reading.timestamp).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataDisplay;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
.app-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
||||||
|
import './Header.css';
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const { currentUser, logout } = useAuth();
|
||||||
|
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||||
|
|
||||||
|
const getInitials = (user) => {
|
||||||
|
if (user.first_name && user.last_name) {
|
||||||
|
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
setShowPasswordOverlay(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="header-content">
|
||||||
|
<div className="header-left">
|
||||||
|
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<div className="user-info">
|
||||||
|
<span className="user-name">{currentUser?.username}</span>
|
||||||
|
<span className="user-role">({currentUser?.role})</span>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-container">
|
||||||
|
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
|
||||||
|
{getInitials(currentUser || {})}
|
||||||
|
</div>
|
||||||
|
<button className="logout-button" onClick={logout} title="Logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{showPasswordOverlay && (
|
||||||
|
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Header from './Header';
|
||||||
|
import DataDisplay from './DataDisplay';
|
||||||
|
import useSerialData from '../hooks/useSerialData';
|
||||||
|
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
||||||
|
|
||||||
|
|
||||||
|
export default function Main() {
|
||||||
|
const { readings, isConnected, error } = useSerialData();
|
||||||
|
const { vehicles } = useNomenclatures();
|
||||||
|
console.log('Vehicles:', vehicles);
|
||||||
|
return (
|
||||||
|
<div className="main">
|
||||||
|
<div>
|
||||||
|
<div className="vehicles">
|
||||||
|
{ vehicles.map(vehicle => {
|
||||||
|
return (
|
||||||
|
<div key={vehicle.id} className="vehicle-card">
|
||||||
|
<h3>{vehicle.vehicle_number}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="vehicles-data">
|
||||||
|
<div className="data-header">
|
||||||
|
<h2>Vehicle Data</h2>
|
||||||
|
</div>
|
||||||
|
<div className="data-list">
|
||||||
|
<Header />
|
||||||
|
<DataDisplay readings={readings}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="loading">
|
||||||
|
<div className="loading-spinner"></div>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.2s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header {
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 32px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button,
|
||||||
|
.submit-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover:not(:disabled) {
|
||||||
|
border-color: #999;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:disabled,
|
||||||
|
.submit-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #fee;
|
||||||
|
color: #c33;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
animation: scaleIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message p {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.overlay-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import './ChangePasswordOverlay.css';
|
||||||
|
|
||||||
|
function ChangePasswordOverlay({ onClose }) {
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { changePassword } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldPassword === newPassword) {
|
||||||
|
setError('New password must be different from old password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const result = await changePassword(oldPassword, newPassword);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Extract error message
|
||||||
|
let errorMsg = 'Failed to change password';
|
||||||
|
if (result.error.old_password) {
|
||||||
|
errorMsg = result.error.old_password[0];
|
||||||
|
} else if (result.error.new_password) {
|
||||||
|
errorMsg = result.error.new_password[0];
|
||||||
|
} else if (result.error.detail) {
|
||||||
|
errorMsg = result.error.detail;
|
||||||
|
}
|
||||||
|
setError(errorMsg);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverlayClick = (e) => {
|
||||||
|
if (e.target.className === 'overlay') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={handleOverlayClick}>
|
||||||
|
<div className="overlay-content">
|
||||||
|
<div className="overlay-header">
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<button className="close-button" onClick={onClose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<div className="success-message">
|
||||||
|
<div className="success-icon">✓</div>
|
||||||
|
<p>Password changed successfully!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="password-form">
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="old-password">Current Password</label>
|
||||||
|
<input
|
||||||
|
id="old-password"
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="new-password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="confirm-password">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cancel-button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="submit-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangePasswordOverlay;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #fee;
|
||||||
|
color: #c33;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 4px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import './Login.css';
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const result = await login(username, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-container">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<h1>ScalesApp</h1>
|
||||||
|
<p>Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="login-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Initialize auth state from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const accessToken = localStorage.getItem('accessToken');
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
|
||||||
|
if (accessToken && refreshToken) {
|
||||||
|
try {
|
||||||
|
// Validate token by fetching user info
|
||||||
|
const userData = await fetchCurrentUser();
|
||||||
|
setCurrentUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token validation failed:', error);
|
||||||
|
clearTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-refresh token before expiry
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
// Refresh token every 14 minutes (tokens expire at 15 min)
|
||||||
|
const refreshInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}, 14 * 60 * 1000); // 14 minutes
|
||||||
|
|
||||||
|
return () => clearInterval(refreshInterval);
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const fetchCurrentUser = async () => {
|
||||||
|
const response = await api.get('/api/users/me/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
try {
|
||||||
|
// Get tokens
|
||||||
|
const response = await api.post('/api/token/', { username, password });
|
||||||
|
const { access, refresh } = response.data;
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
localStorage.setItem('accessToken', access);
|
||||||
|
localStorage.setItem('refreshToken', refresh);
|
||||||
|
|
||||||
|
// Fetch user data
|
||||||
|
const userData = await fetchCurrentUser();
|
||||||
|
setCurrentUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.detail || 'Login failed. Please check your credentials.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
clearTokens();
|
||||||
|
setCurrentUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.post('/api/token/refresh/', {
|
||||||
|
refresh: refreshToken
|
||||||
|
});
|
||||||
|
const { access } = response.data;
|
||||||
|
localStorage.setItem('accessToken', access);
|
||||||
|
return access;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTokens = () => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (oldPassword, newPassword) => {
|
||||||
|
try {
|
||||||
|
await api.post('/api/users/change-password/', {
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password change failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data || 'Password change failed.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
currentUser,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshAccessToken,
|
||||||
|
changePassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const NomenclatureContext = createContext();
|
||||||
|
|
||||||
|
// Centralized nomenclature configuration
|
||||||
|
const NOMENCLATURES_CONFIG = [
|
||||||
|
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'name', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
|
||||||
|
// { key: 'vesselOwners', jsonKey: 'vessel_owners', sortBy: 'name', endpoint: '/vessel_owners/api/list/', sseObjectType: 'vessel_owner' },
|
||||||
|
// { key: 'agents', jsonKey: 'agents', sortBy: 'name', endpoint: '/agents/api/list/', sseObjectType: 'agent' },
|
||||||
|
// { key: 'objects', jsonKey: 'objects', sortBy: 'id', endpoint: '/objects/api/list/', sseObjectType: 'object' },
|
||||||
|
// { key: 'cargos', jsonKey: 'cargos', sortBy: 'name', endpoint: '/cargos/api/list/', sseObjectType: 'cargo' },
|
||||||
|
// { key: 'groups', jsonKey: 'groups', sortBy: 'id', endpoint: '/groups/api/list/', sseObjectType: 'group' },
|
||||||
|
// { key: 'groupSmalls', jsonKey: 'groupsmalls', sortBy: 'id', endpoint: '/groups/api/getgroupsmalls/', sseObjectType: 'groupsmalls' },
|
||||||
|
// { key: 'tariffs', jsonKey: 'tariffs', sortBy: 'id', endpoint: '/tariff/api/list/', sseObjectType: 'tariff' },
|
||||||
|
// { key: 'tariffSmalls', jsonKey: 'tariffsmalls', sortBy: 'id', endpoint: '/tariff/api/tariffsmalls', sseObjectType: 'tariffsmalls' },
|
||||||
|
// { key: 'countries', jsonKey: 'countries', sortBy: 'name', endpoint: '/countries/api/list', sseObjectType: 'country' },
|
||||||
|
// { key: 'passThroughs', jsonKey: 'passthroughs', sortBy: 'id', endpoint: '/tariff/api/getpassthroughs/', sseObjectType: 'passthrough' },
|
||||||
|
// { key: 'vats', jsonKey: 'vats', sortBy: 'id', endpoint: '/tariff/api/getvats/', sseObjectType: 'vat' },
|
||||||
|
// { key: 'maneuvers', jsonKey: 'maneuvers', sortBy: 'id', endpoint: '/common/api/maneuvers/', sseObjectType: 'maneuver' },
|
||||||
|
// { key: 'prices', jsonKey: 'prices', sortBy: 'id', endpoint: '/tariff/api/prices/', sseObjectType: 'prices' },
|
||||||
|
// { key: 'bankAccounts', jsonKey: 'bank_accounts', sortBy: 'id', endpoint: '/common/api/bank-accounts/', sseObjectType: 'bank_account' },
|
||||||
|
// { key: 'noticeNotes', jsonKey: 'notice_notes', sortBy: 'name', endpoint: '/invoices/api/notice-notes/list/', sseObjectType: 'notice_note' },
|
||||||
|
// { key: 'translations', jsonKey: 'translations', sortBy: 'name', endpoint: '/common/api/translations/', sseObjectType: 'translation' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper function to get config for a specific key
|
||||||
|
const getConfigForKey = (key) => NOMENCLATURES_CONFIG.find(c => c.key === key);
|
||||||
|
|
||||||
|
// Helper function to get nomenclature key from SSE object type
|
||||||
|
const getNomenclatureKeyFromSSE = (sseObjectType) => {
|
||||||
|
const config = NOMENCLATURES_CONFIG.find(c => c.sseObjectType === sseObjectType);
|
||||||
|
return config?.key;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNomenclatures = () => {
|
||||||
|
const context = useContext(NomenclatureContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNomenclatures must be used within a NomenclatureProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to sort nomenclature array based on configuration
|
||||||
|
const sortNomenclature = (array, nomenclatureKey) => {
|
||||||
|
const config = getConfigForKey(nomenclatureKey);
|
||||||
|
const orderBy = config?.sortBy || 'name';
|
||||||
|
|
||||||
|
return [...array].sort((a, b) => {
|
||||||
|
if (orderBy === 'id') {
|
||||||
|
return (a.id || 0) - (b.id || 0);
|
||||||
|
} else {
|
||||||
|
// Order by name (case-insensitive)
|
||||||
|
const nameA = (a.name || '').toLowerCase();
|
||||||
|
const nameB = (b.name || '').toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to insert item in sorted position
|
||||||
|
const insertSorted = (array, newItem, nomenclatureKey) => {
|
||||||
|
const config = getConfigForKey(nomenclatureKey);
|
||||||
|
const orderBy = config?.sortBy || 'name';
|
||||||
|
const newArray = [...array];
|
||||||
|
|
||||||
|
// Find the correct insertion position
|
||||||
|
let insertIndex = newArray.length;
|
||||||
|
|
||||||
|
if (orderBy === 'id') {
|
||||||
|
insertIndex = newArray.findIndex(item => {
|
||||||
|
const comparison = item.id > newItem.id;
|
||||||
|
return comparison;
|
||||||
|
});
|
||||||
|
if (insertIndex === -1) insertIndex = newArray.length;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const newName = (newItem.name || '').toLowerCase();
|
||||||
|
insertIndex = newArray.findIndex(item =>
|
||||||
|
(item.name || '').toLowerCase() > newName
|
||||||
|
);
|
||||||
|
if (insertIndex === -1) insertIndex = newArray.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
newArray.splice(insertIndex, 0, newItem);
|
||||||
|
return newArray;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to update item and maintain sorted order
|
||||||
|
const updateSorted = (array, updatedItem, nomenclatureKey) => {
|
||||||
|
const config = getConfigForKey(nomenclatureKey);
|
||||||
|
const orderBy = config?.sortBy || 'name';
|
||||||
|
|
||||||
|
// Remove the old item
|
||||||
|
const filteredArray = array.filter(item => item.id !== updatedItem.id);
|
||||||
|
|
||||||
|
// Check if the ordering field has changed
|
||||||
|
const oldItem = array.find(item => item.id === updatedItem.id);
|
||||||
|
if (!oldItem) {
|
||||||
|
// Item not found, just insert it
|
||||||
|
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderFieldChanged = orderBy === 'id'
|
||||||
|
? oldItem.id !== updatedItem.id
|
||||||
|
: (oldItem.name || '').toLowerCase() !== (updatedItem.name || '').toLowerCase();
|
||||||
|
|
||||||
|
if (orderFieldChanged) {
|
||||||
|
// Re-insert in sorted position if the ordering field changed
|
||||||
|
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
|
||||||
|
} else {
|
||||||
|
// Just update in place if ordering hasn't changed
|
||||||
|
return array.map(item =>
|
||||||
|
item.id === updatedItem.id ? { ...item, ...updatedItem } : item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate initial state objects from config
|
||||||
|
const generateInitialState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||||
|
acc[config.key] = [];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const generateInitialLoadingState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||||
|
acc[config.key] = false;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const generateInitialErrorState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||||
|
acc[config.key] = null;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export const NomenclatureProvider = ({ children }) => {
|
||||||
|
// Ref to track invoice update subscribers
|
||||||
|
const subscribersRef = useRef(new Set());
|
||||||
|
|
||||||
|
const [nomenclatures, setNomenclatures] = useState(generateInitialState());
|
||||||
|
const [loading, setLoading] = useState(generateInitialLoadingState());
|
||||||
|
const [error, setError] = useState(generateInitialErrorState());
|
||||||
|
const [allLoaded, setAllLoaded] = useState(false);
|
||||||
|
const [hasStartedLoading, setHasStartedLoading] = useState(false);
|
||||||
|
|
||||||
|
// Generic fetch function
|
||||||
|
const fetchNomenclature = async (key, url) => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${key}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize data format - adjust based on your API response structure
|
||||||
|
let normalizedData;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
normalizedData = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
// Handle different response formats
|
||||||
|
const config = getConfigForKey(key);
|
||||||
|
const jsonKey = config?.jsonKey; // Get the correct JSON key from config
|
||||||
|
normalizedData = data.results || data.data || data[jsonKey] || data[key] || [];
|
||||||
|
} else {
|
||||||
|
normalizedData = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the data according to configured order
|
||||||
|
const sortedData = sortNomenclature(normalizedData, key);
|
||||||
|
|
||||||
|
setNomenclatures(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: sortedData
|
||||||
|
}));
|
||||||
|
|
||||||
|
setError(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const totalTime = performance.now() - startTime;
|
||||||
|
console.error(`❌ [${key}] Failed after ${totalTime.toFixed(2)}ms:`, err.message);
|
||||||
|
setError(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: err.message
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setLoading(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load all nomenclatures on mount, but only if we have auth
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllNomenclatures = async () => {
|
||||||
|
// Check if we have an auth token
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasStartedLoading(true);
|
||||||
|
|
||||||
|
// Set all loading states to true
|
||||||
|
const allLoadingState = NOMENCLATURES_CONFIG.reduce((acc, config) => {
|
||||||
|
acc[config.key] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
setLoading(allLoadingState);
|
||||||
|
|
||||||
|
// Load all nomenclatures in parallel
|
||||||
|
const promises = NOMENCLATURES_CONFIG.map(config =>
|
||||||
|
fetchNomenclature(config.key, API_BASE_URL + config.endpoint)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
/*
|
||||||
|
// Wait a tick for state to update, then apply translations
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch translations directly from API to ensure we have the latest data
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const translationsConfig = getConfigForKey('translations');
|
||||||
|
const response = await fetch(translationsConfig.endpoint, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Token ${token}`
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const translationsData = await response.json();
|
||||||
|
|
||||||
|
if (translationsData && translationsData.length > 0) {
|
||||||
|
// Dynamic import to avoid circular dependency
|
||||||
|
const module = await import('@utils/printTranslations');
|
||||||
|
const { translationService } = await import('@services/translationService');
|
||||||
|
const merged = translationService.applyOverrides(
|
||||||
|
module.printTranslations,
|
||||||
|
translationsData
|
||||||
|
);
|
||||||
|
Object.assign(module.printTranslations, merged);
|
||||||
|
console.log('✅ Translations initialized:', translationsData.length, 'translations loaded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply translation overrides:', error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
*/
|
||||||
|
|
||||||
|
setAllLoaded(true);
|
||||||
|
};
|
||||||
|
loadAllNomenclatures();
|
||||||
|
|
||||||
|
// Also listen for auth token changes
|
||||||
|
const handleStorageChange = (e) => {
|
||||||
|
if (e.key === 'accessToken' && e.newValue && !hasStartedLoading) {
|
||||||
|
loadAllNomenclatures();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, [hasStartedLoading]);
|
||||||
|
|
||||||
|
// SSE connection for real-time updates with auto-reconnect
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
return; // Don't connect if not authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSource;
|
||||||
|
let reconnectTimeout;
|
||||||
|
let isIntentionallyClosed = false;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (isIntentionallyClosed) return;
|
||||||
|
|
||||||
|
eventSource = new EventSource(`${API_BASE_URL}/sse-connect/?token=${token}`);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
// console.log('SSE connection opened');
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
handleSSEUpdate(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing SSE message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE connection error:', error);
|
||||||
|
eventSource.close();
|
||||||
|
|
||||||
|
// Reconnect after 5 seconds
|
||||||
|
if (!isIntentionallyClosed) {
|
||||||
|
console.log('SSE will reconnect in 5 seconds...');
|
||||||
|
reconnectTimeout = setTimeout(connect, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start initial connection
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
isIntentionallyClosed = true;
|
||||||
|
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to subscribe to invoice updates
|
||||||
|
const subscribeToUpdates = (callback) => {
|
||||||
|
subscribersRef.current.add(callback);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
subscribersRef.current.delete(callback);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle SSE updates
|
||||||
|
const handleSSEUpdate = (message) => {
|
||||||
|
if (message.type !== 'data_update') {
|
||||||
|
return; // Only handle data_update messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const { object, operation, data } = message;
|
||||||
|
|
||||||
|
// Handle invoice updates - notify all subscribers
|
||||||
|
if (object === 'invoice' || object === 'notice') {
|
||||||
|
subscribersRef.current.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in invoice update subscriber:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map object types to nomenclature keys using config
|
||||||
|
const nomenclatureKey = getNomenclatureKeyFromSSE(object);
|
||||||
|
if (!nomenclatureKey) {
|
||||||
|
console.warn('Unknown object type in SSE update:', object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for translations: update printTranslations in real-time
|
||||||
|
// if (nomenclatureKey === 'translations' && (operation === 'insert' || operation === 'update')) {
|
||||||
|
// import('@utils/printTranslations').then(async (module) => {
|
||||||
|
// const key = data.key;
|
||||||
|
// if (module.printTranslations[key]) {
|
||||||
|
// module.printTranslations[key] = {
|
||||||
|
// bg: data.text_bg,
|
||||||
|
// en: data.text_en
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// }).catch(error => {
|
||||||
|
// console.error('Failed to update printTranslations from SSE:', error);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
setNomenclatures(prev => {
|
||||||
|
const currentArray = prev[nomenclatureKey] || [];
|
||||||
|
|
||||||
|
if (operation === 'insert') {
|
||||||
|
// Add new item if it doesn't exist
|
||||||
|
const exists = currentArray.some(item => item.id === data.id);
|
||||||
|
if (!exists) {
|
||||||
|
// Insert in sorted position
|
||||||
|
const sortedArray = insertSorted(currentArray, data, nomenclatureKey);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[nomenclatureKey]: sortedArray
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
} else if (operation === 'update') {
|
||||||
|
// Update existing item and maintain sort order
|
||||||
|
const updatedArray = updateSorted(currentArray, data, nomenclatureKey);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[nomenclatureKey]: updatedArray
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
// Spread all nomenclature data
|
||||||
|
...nomenclatures,
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
loading,
|
||||||
|
allLoaded,
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
subscribeToUpdates,
|
||||||
|
|
||||||
|
// State helpers
|
||||||
|
hasStartedLoading,
|
||||||
|
|
||||||
|
// Helper to check if any is loading
|
||||||
|
isAnyLoading: Object.values(loading).some(Boolean),
|
||||||
|
|
||||||
|
// Helper to check if any has errors
|
||||||
|
hasErrors: Object.values(error).some(Boolean)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NomenclatureContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NomenclatureContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
function useSerialData() {
|
||||||
|
const [readings, setReadings] = useState([]);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource('http://localhost:5000/events');
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('Connected to serial bridge SSE');
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'connected') {
|
||||||
|
console.log('SSE:', data.message);
|
||||||
|
} else {
|
||||||
|
// Add new reading to the beginning
|
||||||
|
setReadings(prev => [data, ...prev].slice(0, 100)); // Keep last 100
|
||||||
|
console.log('Received:', data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing SSE data:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE connection error:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
setError('Failed to connect to serial bridge');
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { readings, isConnected, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSerialData;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor: Add JWT token to all requests
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor: Handle 401 errors with token refresh
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue = [];
|
||||||
|
|
||||||
|
const processQueue = (error, token = null) => {
|
||||||
|
failedQueue.forEach(prom => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// If error is 401 and we haven't tried refreshing yet
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Queue this request while refresh is in progress
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then(token => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
})
|
||||||
|
.catch(err => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (!refreshToken) {
|
||||||
|
// No refresh token, redirect to login
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to refresh the token
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/api/token/refresh/`, {
|
||||||
|
refresh: refreshToken
|
||||||
|
});
|
||||||
|
const { access } = response.data;
|
||||||
|
|
||||||
|
// Update stored token
|
||||||
|
localStorage.setItem('accessToken', access);
|
||||||
|
|
||||||
|
// Update authorization header
|
||||||
|
api.defaults.headers.common['Authorization'] = `Bearer ${access}`;
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access}`;
|
||||||
|
|
||||||
|
// Process queued requests
|
||||||
|
processQueue(null, access);
|
||||||
|
isRefreshing = false;
|
||||||
|
|
||||||
|
// Retry original request
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed, clear tokens and redirect to login
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
isRefreshing = false;
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/c/dev_projects/ScalesApp/frontend
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# COM Port Settings
|
||||||
|
COM_PORT=COM3
|
||||||
|
BAUD_RATE=9600
|
||||||
|
TIMEOUT=1
|
||||||
|
READ_INTERVAL=0.5
|
||||||
|
|
||||||
|
# Backend Server Settings
|
||||||
|
BACKEND_URL=http://localhost:8000
|
||||||
|
API_ENDPOINT=/api/readings/
|
||||||
|
REQUEST_TIMEOUT=5
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
DEBUG=False
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# System Tray Settings
|
||||||
|
SHOW_WINDOW_ON_START=False
|
||||||
|
AUTO_CONNECT=True
|
||||||
|
|
||||||
|
# Retry Settings
|
||||||
|
MAX_RETRIES=3
|
||||||
|
RETRY_DELAY=5
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Serial Bridge - COM Port Reader
|
||||||
|
|
||||||
|
This is the serial port reading application that runs as a system tray service.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Reads data from COM ports
|
||||||
|
- ✅ Runs in system tray (no console window)
|
||||||
|
- ✅ Automatically posts data to Django backend
|
||||||
|
- ✅ Retry logic for failed posts
|
||||||
|
- ✅ Backend health checks
|
||||||
|
- ✅ Configurable via environment variables
|
||||||
|
- ✅ Can be packaged as a Windows .exe
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install Python dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment:**
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the application:**
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building as EXE
|
||||||
|
|
||||||
|
To create a standalone Windows executable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyinstaller
|
||||||
|
pyinstaller serial_bridge.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable will be created in the `dist\ScalesApp\` folder.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `.env` to configure:
|
||||||
|
|
||||||
|
- `COM_PORT`: COM port to read from (default: COM1)
|
||||||
|
- `BAUD_RATE`: Serial port baud rate (default: 9600)
|
||||||
|
- `BACKEND_URL`: Django backend URL (default: http://localhost:8000)
|
||||||
|
- `AUTO_CONNECT`: Automatically connect on startup (default: True)
|
||||||
|
- `DEBUG`: Enable debug logging (default: False)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
COM Port → Serial Reader → Backend Client → Django API
|
||||||
|
↓
|
||||||
|
System Tray Icon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs are written to `serial_bridge.log` and console output.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **COM port not found**: Check `COM_PORT` setting and device connection
|
||||||
|
- **Cannot connect to backend**: Verify Django server is running on `BACKEND_URL`
|
||||||
|
- **Data not posting**: Check logs in `serial_bridge.log`
|
||||||
|
- **Tray icon not appearing**: Run as administrator, check Windows settings
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
Serial Bridge - SSE Server
|
||||||
|
Reads from COM port and streams data to React frontend via Server-Sent Events
|
||||||
|
No backend storage - direct streaming only
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from threading import Thread
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, Response
|
||||||
|
from flask_cors import CORS, cross_origin
|
||||||
|
from queue import Queue
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.getenv('LOG_LEVEL', 'INFO'),
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('serial_bridge.log'),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app) # Enable CORS for all routes
|
||||||
|
|
||||||
|
# Global state
|
||||||
|
data_queue = Queue()
|
||||||
|
connected_clients = []
|
||||||
|
is_running = False
|
||||||
|
reader_thread = None
|
||||||
|
|
||||||
|
|
||||||
|
class SerialReader:
|
||||||
|
def __init__(self, port, baudrate, timeout):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.timeout = timeout
|
||||||
|
self.ser = None
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
try:
|
||||||
|
self.ser = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
self.is_connected = True
|
||||||
|
logger.info(f"[OK] Connected to {self.port} at {self.baudrate} baud")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ERROR] Failed to connect to {self.port}: {e}")
|
||||||
|
self.is_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read_data(self):
|
||||||
|
if not self.is_connected:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.ser.in_waiting:
|
||||||
|
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
|
||||||
|
if line:
|
||||||
|
return {
|
||||||
|
'port': self.port,
|
||||||
|
'data': line,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'received_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading from {self.port}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if self.ser:
|
||||||
|
self.ser.close()
|
||||||
|
self.is_connected = False
|
||||||
|
logger.info(f"[OK] Disconnected from {self.port}")
|
||||||
|
|
||||||
|
|
||||||
|
def read_serial_data():
|
||||||
|
"""Continuously read from serial port and queue data"""
|
||||||
|
global is_running
|
||||||
|
|
||||||
|
reader = SerialReader(
|
||||||
|
port=os.getenv('COM_PORT', 'COM3'),
|
||||||
|
baudrate=int(os.getenv('BAUD_RATE', '9600')),
|
||||||
|
timeout=float(os.getenv('TIMEOUT', '1'))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not reader.connect():
|
||||||
|
logger.error("Failed to connect to COM port")
|
||||||
|
return
|
||||||
|
|
||||||
|
read_interval = float(os.getenv('READ_INTERVAL', '0.5'))
|
||||||
|
|
||||||
|
while is_running:
|
||||||
|
try:
|
||||||
|
data = reader.read_data()
|
||||||
|
if data:
|
||||||
|
data_queue.put(data)
|
||||||
|
logger.info(f"[RX] {data['port']}: {data['data']}")
|
||||||
|
|
||||||
|
time.sleep(read_interval)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Serial reader interrupted")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in serial reader: {e}")
|
||||||
|
|
||||||
|
reader.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/events')
|
||||||
|
@cross_origin()
|
||||||
|
def events():
|
||||||
|
"""SSE endpoint - streams serial data to connected clients"""
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
client_id = id(object())
|
||||||
|
connected_clients.append(client_id)
|
||||||
|
|
||||||
|
logger.info(f"[CONNECT] Client connected. Total: {len(connected_clients)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send connection message
|
||||||
|
yield f"data: {json.dumps({'status': 'connected', 'message': 'Connected to serial bridge'})}\n\n"
|
||||||
|
|
||||||
|
# Stream data from queue
|
||||||
|
while is_running:
|
||||||
|
try:
|
||||||
|
# Get data with timeout to allow graceful shutdown
|
||||||
|
data = data_queue.get(timeout=1)
|
||||||
|
yield f"data: {json.dumps(data)}\n\n"
|
||||||
|
logger.debug(f"[TX] Sent: {data['data']}")
|
||||||
|
|
||||||
|
except:
|
||||||
|
# Queue timeout - send heartbeat
|
||||||
|
yield f": heartbeat\n\n"
|
||||||
|
|
||||||
|
except GeneratorExit:
|
||||||
|
# Client disconnected
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if client_id in connected_clients:
|
||||||
|
connected_clients.remove(client_id)
|
||||||
|
logger.info(f"[DISCONNECT] Client disconnected. Total: {len(connected_clients)}")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/status')
|
||||||
|
def status():
|
||||||
|
"""Get current status"""
|
||||||
|
return {
|
||||||
|
'status': 'running' if is_running else 'stopped',
|
||||||
|
'connected_clients': len(connected_clients),
|
||||||
|
'com_port': os.getenv('COM_PORT', 'COM3'),
|
||||||
|
'baud_rate': int(os.getenv('BAUD_RATE', '9600'))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/ports')
|
||||||
|
def list_ports():
|
||||||
|
"""List available COM ports"""
|
||||||
|
ports = []
|
||||||
|
for port, desc, hwid in serial.tools.list_ports.comports():
|
||||||
|
ports.append({
|
||||||
|
'port': port,
|
||||||
|
'description': desc,
|
||||||
|
'hwid': hwid
|
||||||
|
})
|
||||||
|
return {'ports': ports}
|
||||||
|
|
||||||
|
|
||||||
|
def start_app():
|
||||||
|
"""Start the application"""
|
||||||
|
global is_running, reader_thread
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ScalesApp Serial Bridge - SSE Streaming")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"COM Port: {os.getenv('COM_PORT', 'COM3')}")
|
||||||
|
logger.info(f"Baud Rate: {os.getenv('BAUD_RATE', '9600')}")
|
||||||
|
logger.info(f"SSE Endpoint: http://127.0.0.1:5000/events")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
is_running = True
|
||||||
|
|
||||||
|
# Start serial reader thread
|
||||||
|
reader_thread = Thread(target=read_serial_data, daemon=True)
|
||||||
|
reader_thread.start()
|
||||||
|
logger.info("[OK] Serial reader thread started\n")
|
||||||
|
|
||||||
|
# Start Flask app
|
||||||
|
debug = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||||
|
app.run(host='127.0.0.1', port=5000, debug=debug, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_app():
|
||||||
|
"""Stop the application"""
|
||||||
|
global is_running
|
||||||
|
is_running = False
|
||||||
|
logger.info("\n[OK] Application stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
start_app()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
stop_app()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Backend API Client
|
||||||
|
Handles communication with Django backend
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BackendClient:
|
||||||
|
def __init__(self, base_url: str, timeout: int = 5, max_retries: int = 3):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.session = self._create_session()
|
||||||
|
|
||||||
|
def _create_session(self) -> requests.Session:
|
||||||
|
"""Create a requests session with retry logic"""
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=self.max_retries,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"]
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def post_reading(self, port: str, data: str) -> bool:
|
||||||
|
"""Post a serial port reading to the backend"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/api/readings/"
|
||||||
|
payload = {
|
||||||
|
'port': port,
|
||||||
|
'data': data
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
logger.info(f"Successfully posted reading to backend")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Backend returned status {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Error posting reading to backend: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_latest_reading(self, port: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the latest reading from backend"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/api/readings/latest/"
|
||||||
|
params = {'port': port} if port else {}
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
logger.warning(f"Backend returned status {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Error getting latest reading: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_readings(self, port: Optional[str] = None, limit: int = 10) -> Optional[list]:
|
||||||
|
"""Get readings from backend"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/api/readings/"
|
||||||
|
params = {'limit': limit}
|
||||||
|
if port:
|
||||||
|
params['port'] = port
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
logger.warning(f"Backend returned status {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Error getting readings: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if backend is available"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/api/health/"
|
||||||
|
response = self.session.get(url, timeout=self.timeout)
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Backend health check failed: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Configuration for Serial Bridge
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# COM Port Settings
|
||||||
|
COM_PORT = os.getenv('COM_PORT', 'COM1')
|
||||||
|
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
|
||||||
|
TIMEOUT = int(os.getenv('TIMEOUT', 1))
|
||||||
|
READ_INTERVAL = float(os.getenv('READ_INTERVAL', 0.5)) # seconds
|
||||||
|
|
||||||
|
# Backend Server Settings
|
||||||
|
BACKEND_URL = os.getenv('BACKEND_URL', 'http://localhost:8000')
|
||||||
|
API_ENDPOINT = os.getenv('API_ENDPOINT', '/api/readings/')
|
||||||
|
REQUEST_TIMEOUT = int(os.getenv('REQUEST_TIMEOUT', 5))
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
APP_NAME = 'ScalesApp Serial Bridge'
|
||||||
|
DEBUG = os.getenv('DEBUG', 'False') == 'True'
|
||||||
|
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||||
|
|
||||||
|
# System Tray Settings
|
||||||
|
SHOW_WINDOW_ON_START = os.getenv('SHOW_WINDOW_ON_START', 'False') == 'True'
|
||||||
|
AUTO_CONNECT = os.getenv('AUTO_CONNECT', 'True') == 'True'
|
||||||
|
|
||||||
|
# Retry Settings
|
||||||
|
MAX_RETRIES = int(os.getenv('MAX_RETRIES', 3))
|
||||||
|
RETRY_DELAY = int(os.getenv('RETRY_DELAY', 5)) # seconds
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Pillow==12.1.0
|
||||||
|
pyserial==3.5
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
flask==3.1.0
|
||||||
|
requests==2.32.5
|
||||||
|
urllib3==2.6.3
|
||||||
|
pystray==0.19.5
|
||||||
|
psutil==7.2.1
|
||||||
|
PyInstaller==6.17.0
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
PyInstaller spec file for creating a Windows executable
|
||||||
|
Run: pyinstaller serial_bridge.spec
|
||||||
|
"""
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['app.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=['serial', 'requests', 'pystray', 'PIL'],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludedimports=[],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='ScalesApp',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False, # No console window
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=None, # You can add an icon path here
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='ScalesApp',
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Serial Port Reader
|
||||||
|
Handles reading data from COM ports
|
||||||
|
"""
|
||||||
|
import serial
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SerialPortReader:
|
||||||
|
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.timeout = timeout
|
||||||
|
self.serial_conn: Optional[serial.Serial] = None
|
||||||
|
self.is_connected = False
|
||||||
|
self.reader_thread: Optional[threading.Thread] = None
|
||||||
|
self.is_running = False
|
||||||
|
self.data_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""Connect to the serial port"""
|
||||||
|
try:
|
||||||
|
self.serial_conn = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
self.is_connected = True
|
||||||
|
logger.info(f"Connected to {self.port} at {self.baudrate} baud")
|
||||||
|
return True
|
||||||
|
except serial.SerialException as e:
|
||||||
|
logger.error(f"Failed to connect to {self.port}: {e}")
|
||||||
|
self.is_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Disconnect from the serial port"""
|
||||||
|
self.is_running = False
|
||||||
|
if self.reader_thread:
|
||||||
|
self.reader_thread.join(timeout=2)
|
||||||
|
|
||||||
|
if self.serial_conn and self.serial_conn.is_open:
|
||||||
|
self.serial_conn.close()
|
||||||
|
self.is_connected = False
|
||||||
|
logger.info(f"Disconnected from {self.port}")
|
||||||
|
|
||||||
|
def set_data_callback(self, callback: Callable):
|
||||||
|
"""Set callback function for received data"""
|
||||||
|
self.data_callback = callback
|
||||||
|
|
||||||
|
def start_reading(self):
|
||||||
|
"""Start reading from serial port in a background thread"""
|
||||||
|
if not self.is_connected:
|
||||||
|
if not self.connect():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.is_running = True
|
||||||
|
self.reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
|
self.reader_thread.start()
|
||||||
|
logger.info("Serial reader started")
|
||||||
|
|
||||||
|
def stop_reading(self):
|
||||||
|
"""Stop reading from serial port"""
|
||||||
|
self.is_running = False
|
||||||
|
if self.reader_thread:
|
||||||
|
self.reader_thread.join(timeout=2)
|
||||||
|
logger.info("Serial reader stopped")
|
||||||
|
|
||||||
|
def _read_loop(self):
|
||||||
|
"""Main reading loop"""
|
||||||
|
while self.is_running and self.is_connected:
|
||||||
|
try:
|
||||||
|
if self.serial_conn and self.serial_conn.in_waiting > 0:
|
||||||
|
data = self.serial_conn.readline().decode('utf-8', errors='ignore').strip()
|
||||||
|
|
||||||
|
if data and self.data_callback:
|
||||||
|
self.data_callback({
|
||||||
|
'port': self.port,
|
||||||
|
'data': data,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading from {self.port}: {e}")
|
||||||
|
self.is_connected = False
|
||||||
|
break
|
||||||
|
|
||||||
|
def write_data(self, data: str) -> bool:
|
||||||
|
"""Write data to the serial port"""
|
||||||
|
try:
|
||||||
|
if self.is_connected and self.serial_conn:
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.encode('utf-8')
|
||||||
|
self.serial_conn.write(data)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing to {self.port}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get current status"""
|
||||||
|
return {
|
||||||
|
'port': self.port,
|
||||||
|
'connected': self.is_connected,
|
||||||
|
'running': self.is_running,
|
||||||
|
'baudrate': self.baudrate
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
System Tray Icon Handler
|
||||||
|
Manages the application in the system tray
|
||||||
|
"""
|
||||||
|
import pystray
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Optional
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SystemTrayIcon:
|
||||||
|
def __init__(self, app_name: str):
|
||||||
|
self.app_name = app_name
|
||||||
|
self.icon: Optional[pystray.Icon] = None
|
||||||
|
self.status_text = "Initializing..."
|
||||||
|
self.on_quit: Optional[Callable] = None
|
||||||
|
self.on_show: Optional[Callable] = None
|
||||||
|
|
||||||
|
def _create_image(self, status: str = "ready") -> Image.Image:
|
||||||
|
"""Create a simple tray icon image"""
|
||||||
|
size = 64
|
||||||
|
color_map = {
|
||||||
|
'ready': (0, 200, 0),
|
||||||
|
'running': (0, 150, 200),
|
||||||
|
'error': (200, 0, 0),
|
||||||
|
'waiting': (200, 150, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
color = color_map.get(status, (128, 128, 128))
|
||||||
|
image = Image.new('RGB', (size, size), color)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
draw.rectangle([0, 0, size-1, size-1], outline=(0, 0, 0), width=2)
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
def set_status(self, status: str, text: str):
|
||||||
|
"""Update the tray icon status"""
|
||||||
|
self.status_text = text
|
||||||
|
if self.icon:
|
||||||
|
self.icon.update_menu()
|
||||||
|
|
||||||
|
def create_menu(self) -> pystray.Menu:
|
||||||
|
"""Create the context menu for the tray icon"""
|
||||||
|
menu_items = [
|
||||||
|
pystray.MenuItem(f"📊 {self.app_name}", action=None),
|
||||||
|
pystray.MenuItem("─" * 30, action=None),
|
||||||
|
pystray.MenuItem("Status: " + self.status_text, action=None),
|
||||||
|
pystray.MenuItem("─" * 30, action=None),
|
||||||
|
pystray.MenuItem("Quit", self._on_quit_click),
|
||||||
|
]
|
||||||
|
return pystray.Menu(*menu_items)
|
||||||
|
|
||||||
|
def _on_quit_click(self, icon, item):
|
||||||
|
"""Handle quit click"""
|
||||||
|
if self.on_quit:
|
||||||
|
self.on_quit()
|
||||||
|
if self.icon:
|
||||||
|
self.icon.stop()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the tray icon"""
|
||||||
|
try:
|
||||||
|
self.icon = pystray.Icon(
|
||||||
|
name=self.app_name,
|
||||||
|
icon=self._create_image('running'),
|
||||||
|
menu=self.create_menu(),
|
||||||
|
title=self.app_name
|
||||||
|
)
|
||||||
|
self.icon.run()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error running tray icon: {e}")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the tray icon"""
|
||||||
|
if self.icon:
|
||||||
|
self.icon.stop()
|
||||||
|
|
||||||
|
def set_callbacks(self, on_quit: Optional[Callable] = None, on_show: Optional[Callable] = None):
|
||||||
|
"""Set callback functions"""
|
||||||
|
self.on_quit = on_quit
|
||||||
|
self.on_show = on_show
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# COM Port Test Writer configuration
|
||||||
|
COM_PORT=COM1
|
||||||
|
BAUD_RATE=9600
|
||||||
|
TIMEOUT=1
|
||||||
|
|
||||||
|
# Test Data Settings
|
||||||
|
WRITE_INTERVAL=1.0
|
||||||
|
TEST_DATA_TYPE=scales
|
||||||
|
|
||||||
|
# Scales data settings
|
||||||
|
SCALES_MIN=0
|
||||||
|
SCALES_MAX=100
|
||||||
|
SCALES_INCREMENT=0.5
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
DEBUG=True
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# COM Port Test Writer - Simulates device sending data
|
||||||
|
|
||||||
|
This utility simulates a physical device (like scales) by continuously writing test data to a COM port.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Writes test data continuously to COM port
|
||||||
|
- ✅ Multiple data types: scales, counter, random, mixed
|
||||||
|
- ✅ Configurable write interval
|
||||||
|
- ✅ Easy start/stop with Ctrl+C
|
||||||
|
- ✅ List available COM ports
|
||||||
|
- ✅ Command-line arguments
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment (optional):**
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### List Available COM Ports
|
||||||
|
```bash
|
||||||
|
python test_writer.py --list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Usage (using configured port)
|
||||||
|
```bash
|
||||||
|
python test_writer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specify COM Port
|
||||||
|
```bash
|
||||||
|
python test_writer.py --port COM3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Different Data Types
|
||||||
|
|
||||||
|
**Scales Data (default)** - Simulates scale readings
|
||||||
|
```bash
|
||||||
|
python test_writer.py --type scales
|
||||||
|
```
|
||||||
|
|
||||||
|
**Counter Data** - Incrementing numbers
|
||||||
|
```bash
|
||||||
|
python test_writer.py --type counter
|
||||||
|
```
|
||||||
|
|
||||||
|
**Random Data** - Random numeric values
|
||||||
|
```bash
|
||||||
|
python test_writer.py --type random
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mixed Sensor Data** - Temperature, humidity, pressure
|
||||||
|
```bash
|
||||||
|
python test_writer.py --type mixed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Write Interval
|
||||||
|
```bash
|
||||||
|
python test_writer.py --interval 2.0 # Send every 2 seconds
|
||||||
|
python test_writer.py --interval 0.5 # Send every 0.5 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Baud Rate
|
||||||
|
```bash
|
||||||
|
python test_writer.py --baud 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Workflow
|
||||||
|
|
||||||
|
1. **Start Serial Bridge** (reads COM port)
|
||||||
|
```bash
|
||||||
|
cd serial_bridge
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start Test Writer** (writes test data)
|
||||||
|
```bash
|
||||||
|
cd test_comport_writer
|
||||||
|
python test_writer.py --type scales --interval 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **View in React Frontend**
|
||||||
|
- Open http://localhost:3000
|
||||||
|
- Data should appear in real-time
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `.env` for default settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COM_PORT=COM1
|
||||||
|
BAUD_RATE=9600
|
||||||
|
WRITE_INTERVAL=1.0
|
||||||
|
TEST_DATA_TYPE=scales
|
||||||
|
SCALES_MIN=0
|
||||||
|
SCALES_MAX=100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
==================================================
|
||||||
|
COM Port Test Writer
|
||||||
|
==================================================
|
||||||
|
Port: COM1
|
||||||
|
Baud Rate: 9600
|
||||||
|
Data Type: scales
|
||||||
|
Write Interval: 1.0s
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
✓ Connected to COM1 at 9600 baud
|
||||||
|
Press Ctrl+C to stop writing data
|
||||||
|
|
||||||
|
→ Sent: 45.23 kg
|
||||||
|
→ Sent: 45.67 kg
|
||||||
|
→ Sent: 46.12 kg
|
||||||
|
→ Sent: 46.45 kg
|
||||||
|
...
|
||||||
|
(Press Ctrl+C to stop)
|
||||||
|
|
||||||
|
✓ Disconnected from COM1
|
||||||
|
✓ Test writer stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"COM port not found"**: Check `COM_PORT` setting and ensure port exists
|
||||||
|
- **"Port already in use"**: Another application is using the port (close Serial Bridge temporarily)
|
||||||
|
- **No data appearing**: Ensure Serial Bridge is running and pointing to same COM port
|
||||||
|
- **Baud rate mismatch**: Ensure test writer and serial bridge use same baud rate
|
||||||
|
|
||||||
|
## Complete System Test
|
||||||
|
|
||||||
|
To test the complete flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Django Backend
|
||||||
|
cd backend
|
||||||
|
venv\Scripts\activate
|
||||||
|
python manage.py runserver
|
||||||
|
|
||||||
|
# Terminal 2: React Frontend
|
||||||
|
cd frontend
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Terminal 3: Serial Bridge
|
||||||
|
cd serial_bridge
|
||||||
|
venv\Scripts\activate
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
# Terminal 4: Test Writer
|
||||||
|
cd test_comport_writer
|
||||||
|
python test_writer.py --type scales --interval 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit http://localhost:3000 to see real-time data flow.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Configuration for COM Port Test Writer
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# COM Port Settings
|
||||||
|
COM_PORT = os.getenv('COM_PORT', 'COM4')
|
||||||
|
BAUD_RATE = int(os.getenv('BAUD_RATE', 9600))
|
||||||
|
TIMEOUT = int(os.getenv('TIMEOUT', 1))
|
||||||
|
|
||||||
|
# Test Data Settings
|
||||||
|
WRITE_INTERVAL = float(os.getenv('WRITE_INTERVAL', 1.0)) # seconds between writes
|
||||||
|
TEST_DATA_TYPE = os.getenv('TEST_DATA_TYPE', 'scales') # 'scales', 'counter', 'random'
|
||||||
|
|
||||||
|
# Scales data
|
||||||
|
SCALES_MIN = int(os.getenv('SCALES_MIN', 5000))
|
||||||
|
SCALES_MAX = int(os.getenv('SCALES_MAX', 20000))
|
||||||
|
SCALES_INCREMENT = float(os.getenv('SCALES_INCREMENT', 5.0))
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pyserial==3.5
|
||||||
|
python-dotenv==1.0.0
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
COM Port Test Writer - Simulates data being sent to a COM port
|
||||||
|
"""
|
||||||
|
import serial
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
from config import (
|
||||||
|
COM_PORT, BAUD_RATE, TIMEOUT, WRITE_INTERVAL,
|
||||||
|
TEST_DATA_TYPE, SCALES_MIN, SCALES_MAX, SCALES_INCREMENT, DEBUG
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if DEBUG else logging.INFO,
|
||||||
|
# format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
format='%(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ComPortTestWriter:
|
||||||
|
def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.timeout = timeout
|
||||||
|
self.serial_conn: Optional[serial.Serial] = None
|
||||||
|
self.is_connected = False
|
||||||
|
self.is_running = False
|
||||||
|
self.data_counter = 0
|
||||||
|
self.current_scales_value = SCALES_MIN
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""Connect to the serial port"""
|
||||||
|
try:
|
||||||
|
self.serial_conn = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
self.is_connected = True
|
||||||
|
logger.info(f"✓ Connected to {self.port} at {self.baudrate} baud")
|
||||||
|
return True
|
||||||
|
except serial.SerialException as e:
|
||||||
|
logger.error(f"✗ Failed to connect to {self.port}: {e}")
|
||||||
|
logger.info("\nTip: Check that the COM port exists and is not in use.")
|
||||||
|
logger.info("You can list available ports with: python -m serial.tools.list_ports")
|
||||||
|
self.is_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Disconnect from the serial port"""
|
||||||
|
if self.serial_conn and self.serial_conn.is_open:
|
||||||
|
self.serial_conn.close()
|
||||||
|
self.is_connected = False
|
||||||
|
logger.info(f"✓ Disconnected from {self.port}")
|
||||||
|
|
||||||
|
def write_data(self, data: str) -> bool:
|
||||||
|
"""Write data to the serial port"""
|
||||||
|
try:
|
||||||
|
if self.is_connected and self.serial_conn:
|
||||||
|
# Add newline if not present
|
||||||
|
if not data.endswith('\n'):
|
||||||
|
data += '\n'
|
||||||
|
|
||||||
|
self.serial_conn.write(data.encode('utf-8'))
|
||||||
|
logger.info(f"→ Sent: {data.strip()}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing to {self.port}: {e}")
|
||||||
|
self.is_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_scales_data(self) -> str:
|
||||||
|
"""Generate simulated scales data"""
|
||||||
|
# Simulate scales value fluctuating slightly
|
||||||
|
variation = random.uniform(-0.1, 0.1)
|
||||||
|
self.current_scales_value += variation
|
||||||
|
|
||||||
|
# Keep within bounds
|
||||||
|
self.current_scales_value = max(SCALES_MIN, min(SCALES_MAX, self.current_scales_value))
|
||||||
|
|
||||||
|
return f"{self.current_scales_value:.2f} kg"
|
||||||
|
|
||||||
|
def generate_counter_data(self) -> str:
|
||||||
|
"""Generate incrementing counter data"""
|
||||||
|
self.data_counter += 1
|
||||||
|
return f"Count: {self.data_counter}"
|
||||||
|
|
||||||
|
def generate_random_data(self) -> str:
|
||||||
|
"""Generate random numeric data"""
|
||||||
|
value = random.uniform(10000, 50000)
|
||||||
|
return f"Value: {value:.2f}"
|
||||||
|
|
||||||
|
def generate_mixed_data(self) -> str:
|
||||||
|
"""Generate mixed sensor data"""
|
||||||
|
temperature = random.uniform(20, 30)
|
||||||
|
humidity = random.uniform(30, 70)
|
||||||
|
pressure = random.uniform(1010, 1020)
|
||||||
|
return f"T:{temperature:.1f}C H:{humidity:.1f}% P:{pressure:.1f}hPa"
|
||||||
|
|
||||||
|
def get_next_data(self) -> str:
|
||||||
|
"""Get the next data to send based on test type"""
|
||||||
|
if TEST_DATA_TYPE == 'scales':
|
||||||
|
return self.generate_scales_data()
|
||||||
|
elif TEST_DATA_TYPE == 'counter':
|
||||||
|
return self.generate_counter_data()
|
||||||
|
elif TEST_DATA_TYPE == 'random':
|
||||||
|
return self.generate_random_data()
|
||||||
|
elif TEST_DATA_TYPE == 'mixed':
|
||||||
|
return self.generate_mixed_data()
|
||||||
|
else:
|
||||||
|
return self.generate_scales_data()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the test writer"""
|
||||||
|
logger.info(f"\n{'='*50}")
|
||||||
|
logger.info(f"COM Port Test Writer")
|
||||||
|
logger.info(f"{'='*50}")
|
||||||
|
logger.info(f"Port: {self.port}")
|
||||||
|
logger.info(f"Baud Rate: {self.baudrate}")
|
||||||
|
logger.info(f"Data Type: {TEST_DATA_TYPE}")
|
||||||
|
logger.info(f"Write Interval: {WRITE_INTERVAL}s")
|
||||||
|
logger.info(f"{'='*50}\n")
|
||||||
|
|
||||||
|
if not self.connect():
|
||||||
|
logger.error("Failed to connect to COM port. Exiting.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Press Ctrl+C to stop writing data\n")
|
||||||
|
|
||||||
|
while self.is_running:
|
||||||
|
try:
|
||||||
|
data = self.get_next_data()
|
||||||
|
self.write_data(data)
|
||||||
|
time.sleep(WRITE_INTERVAL)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in write loop: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.is_running = False
|
||||||
|
self.disconnect()
|
||||||
|
logger.info("\n✓ Test writer stopped")
|
||||||
|
|
||||||
|
def list_available_ports():
|
||||||
|
"""List available COM ports"""
|
||||||
|
try:
|
||||||
|
from serial.tools import list_ports
|
||||||
|
ports = list_ports.comports()
|
||||||
|
if ports:
|
||||||
|
logger.info("\nAvailable COM ports:")
|
||||||
|
for port in ports:
|
||||||
|
logger.info(f" {port.device} - {port.description}")
|
||||||
|
else:
|
||||||
|
logger.info("No COM ports found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing ports: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Test COM port writer - continuously writes data to a COM port'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--port',
|
||||||
|
default=COM_PORT,
|
||||||
|
help=f'COM port to write to (default: {COM_PORT})'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--baud',
|
||||||
|
type=int,
|
||||||
|
default=BAUD_RATE,
|
||||||
|
help=f'Baud rate (default: {BAUD_RATE})'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--type',
|
||||||
|
choices=['scales', 'counter', 'random', 'mixed'],
|
||||||
|
default=TEST_DATA_TYPE,
|
||||||
|
help=f'Type of test data (default: {TEST_DATA_TYPE})'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--interval',
|
||||||
|
type=float,
|
||||||
|
default=WRITE_INTERVAL,
|
||||||
|
help=f'Write interval in seconds (default: {WRITE_INTERVAL})'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list',
|
||||||
|
action='store_true',
|
||||||
|
help='List available COM ports and exit'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
list_available_ports()
|
||||||
|
return
|
||||||
|
|
||||||
|
writer = ComPortTestWriter(args.port, args.baud)
|
||||||
|
writer.run()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user