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
+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)