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
commit
7f04566242
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python manage.py migrate:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
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()
|
||||||
Loading…
Reference in New Issue