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
+14
View 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
+1
View File
@@ -0,0 +1 @@
# Django API app
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'
View File
@@ -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}'
)
)
+174
View File
@@ -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",
),
),
]
View File
+38
View File
@@ -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}"
+194
View File
@@ -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
+22
View File
@@ -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)),
]
+211
View File
@@ -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)
+20
View File
@@ -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()
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -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",
),
),
],
),
]
+29
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
+5
View File
@@ -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)
+10
View File
@@ -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
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()
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -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",
),
),
],
),
]
+14
View File
@@ -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)
+7
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -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'),
]
+70
View File
@@ -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)