barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh

This commit is contained in:
2026-01-17 13:03:21 +02:00
commit 7f04566242
81 changed files with 22551 additions and 0 deletions
View File
+15
View File
@@ -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()
+156
View File
@@ -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',
}
+196
View File
@@ -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)
+12
View File
@@ -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'),
]
+10
View File
@@ -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()