barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user