diff --git a/Pipfile b/Pipfile index 627e336..bf53dd8 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,7 @@ drf-spectacular = {version = "*", extras = ["sidecar"]} django-extra-fields = "*" pillow = "*" psycopg2 = "*" +django-simple-history = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index f1f704a..8120404 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7ca25164f3d5c5501004130b514ab73101cc9d6937aa0af8356bf65a3cb354fe" + "sha256": "ebe508cba3dbd2fa271c39d69ab71cb95f5630e21875aea04ad9f6893ced1877" }, "pipfile-spec": 6, "requires": { @@ -254,6 +254,14 @@ "index": "pypi", "version": "==3.0.2" }, + "django-simple-history": { + "hashes": [ + "sha256:19bd1a87e1e2eba34dfd43eab1fcf2da5752221f343232f2372b2121c7e3b97d", + "sha256:992dcca3cddc0b67b470fc91f77292e2d2a6010d37c9eac3536e9d80e8754032" + ], + "index": "pypi", + "version": "==3.4.0" + }, "django-templated-mail": { "hashes": [ "sha256:8db807effebb42a532622e2d142dfd453dafcd0d7794c4c3332acb90656315f9", diff --git a/equipment_tracker/api/urls.py b/equipment_tracker/api/urls.py index 14fb12e..03edd51 100644 --- a/equipment_tracker/api/urls.py +++ b/equipment_tracker/api/urls.py @@ -1,5 +1,6 @@ from django.urls import path, include urlpatterns = [ - path('accounts/', include('accounts.urls')) + path('accounts/', include('accounts.urls')), + path('equipments/', include('equipments.urls')) ] diff --git a/equipment_tracker/config/settings.py b/equipment_tracker/config/settings.py index 86a6e26..cc422ea 100644 --- a/equipment_tracker/config/settings.py +++ b/equipment_tracker/config/settings.py @@ -64,11 +64,13 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_simplejwt', + 'simple_history', 'djoser', 'corsheaders', 'drf_spectacular', 'drf_spectacular_sidecar', - 'accounts' + 'accounts', + 'equipments', ] MIDDLEWARE = [ diff --git a/equipment_tracker/db.sqlite3 b/equipment_tracker/db.sqlite3 index 151391c..87da6db 100644 Binary files a/equipment_tracker/db.sqlite3 and b/equipment_tracker/db.sqlite3 differ diff --git a/equipment_tracker/equipments/__init__.py b/equipment_tracker/equipments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/equipment_tracker/equipments/admin.py b/equipment_tracker/equipments/admin.py new file mode 100644 index 0000000..0e16a90 --- /dev/null +++ b/equipment_tracker/equipments/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import Equipment, EquipmentInstance + +admin.site.register(Equipment) +admin.site.register(EquipmentInstance) diff --git a/equipment_tracker/equipments/apps.py b/equipment_tracker/equipments/apps.py new file mode 100644 index 0000000..0b795db --- /dev/null +++ b/equipment_tracker/equipments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EquipmentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'equipments' diff --git a/equipment_tracker/equipments/migrations/0001_initial.py b/equipment_tracker/equipments/migrations/0001_initial.py new file mode 100644 index 0000000..b3e1f27 --- /dev/null +++ b/equipment_tracker/equipments/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.7 on 2023-11-12 12:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import simple_history.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Equipment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=40)), + ('description', models.TextField(max_length=512)), + ('date_added', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('last_changed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='HistoricalEquipmentInstance', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('status', models.CharField(choices=[('Working', 'Working'), ('Broken', 'Broken'), ('Under Maintenance', 'Under Maintenance'), ('Decomissioned', 'Decomissioned ')], default='PENDING', max_length=20)), + ('remarks', models.TextField(max_length=512)), + ('date_added', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_updated', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('equipment', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='equipments.equipment')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('last_changed_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical equipment instance', + 'verbose_name_plural': 'historical equipment instances', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='EquipmentInstance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('Working', 'Working'), ('Broken', 'Broken'), ('Under Maintenance', 'Under Maintenance'), ('Decomissioned', 'Decomissioned ')], default='PENDING', max_length=20)), + ('remarks', models.TextField(max_length=512)), + ('date_added', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='equipments.equipment')), + ('last_changed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/equipment_tracker/equipments/migrations/0002_remove_equipment_last_changed_by_and_more.py b/equipment_tracker/equipments/migrations/0002_remove_equipment_last_changed_by_and_more.py new file mode 100644 index 0000000..8c3f3ac --- /dev/null +++ b/equipment_tracker/equipments/migrations/0002_remove_equipment_last_changed_by_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2023-11-12 12:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('equipments', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='equipment', + name='last_changed_by', + ), + migrations.RemoveField( + model_name='equipmentinstance', + name='last_changed_by', + ), + migrations.RemoveField( + model_name='historicalequipmentinstance', + name='last_changed_by', + ), + ] diff --git a/equipment_tracker/equipments/migrations/0003_historicalequipment.py b/equipment_tracker/equipments/migrations/0003_historicalequipment.py new file mode 100644 index 0000000..81c6666 --- /dev/null +++ b/equipment_tracker/equipments/migrations/0003_historicalequipment.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.7 on 2023-11-12 12:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('equipments', '0002_remove_equipment_last_changed_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalEquipment', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('name', models.CharField(max_length=40)), + ('description', models.TextField(max_length=512)), + ('date_added', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('last_updated', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical equipment', + 'verbose_name_plural': 'historical equipments', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/equipment_tracker/equipments/migrations/__init__.py b/equipment_tracker/equipments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/equipment_tracker/equipments/models.py b/equipment_tracker/equipments/models.py new file mode 100644 index 0000000..937f3be --- /dev/null +++ b/equipment_tracker/equipments/models.py @@ -0,0 +1,50 @@ +from django.db import models +from django.utils.timezone import now +from simple_history.models import HistoricalRecords +from django.db.models.signals import post_migrate +from django.dispatch import receiver +# Create your models here. + + +class Equipment(models.Model): + name = models.CharField(max_length=40) + description = models.TextField(max_length=512) + date_added = models.DateTimeField(default=now, editable=False) + last_updated = models.DateTimeField(auto_now=True, editable=False) + history = HistoricalRecords() + + def __str__(self): + return f'{self.name} ID:{self.id}' + + +class EquipmentInstance(models.Model): + STATUS_CHOICES = ( + ('Working', 'Working'), + ('Broken', 'Broken'), + ('Under Maintenance', 'Under Maintenance'), + ('Decomissioned', 'Decomissioned '), + ) + + equipment = models.ForeignKey(Equipment, on_delete=models.CASCADE) + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default='PENDING') + remarks = models.TextField(max_length=512) + date_added = models.DateTimeField(default=now, editable=False) + last_updated = models.DateTimeField(auto_now=True, editable=False) + history = HistoricalRecords() + + def __str__(self): + return f'{self.equipment.name} ID:{self.id}' + + +@receiver(post_migrate) +def create_superuser(sender, **kwargs): + if sender.name == 'equipments': + EQUIPMENT, CREATED = Equipment.objects.get_or_create( + name="HP All-in-One PC", description="I5 6500 8GB RAM 1TB HDD") + if (CREATED): + print("Created sample equipment") + EQUIPMENT_INSTANCE, CREATED = EquipmentInstance.objects.get_or_create( + equipment=EQUIPMENT, status="Working", remarks="First PC of USTP!") + if (CREATED): + print("Created sample equipment instance") diff --git a/equipment_tracker/equipments/serializers.py b/equipment_tracker/equipments/serializers.py new file mode 100644 index 0000000..1efd779 --- /dev/null +++ b/equipment_tracker/equipments/serializers.py @@ -0,0 +1,157 @@ +from rest_framework import serializers +from django.contrib.auth.models import User +from .models import Equipment, EquipmentInstance + +# -- Equipment Serializers + + +class EquipmentHistoricalRecordField(serializers.ListField): + child = serializers.DictField() + + def to_representation(self, data): + return super().to_representation(data.values('name', 'description', 'history_date', 'history_user').order_by('-history_date')) + + +class EquipmentSerializer(serializers.HyperlinkedModelSerializer): + date_added = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated_by = serializers.SerializerMethodField() + name = serializers.CharField() + description = serializers.CharField() + + class Meta: + model = Equipment + fields = ('id', 'name', 'description', + 'last_updated', 'last_updated_by', 'date_added') + read_only_fields = ('id', 'last_updated', + 'last_updated_by', 'date_added') + + def get_history_user(self, obj): + return obj.history_user.username if obj.history_user else None + + def get_last_updated_by(self, obj): + return obj.history.first().history_user if obj.history.first().history_user else None + + +class EquipmentLogsSerializer(serializers.HyperlinkedModelSerializer): + history_date = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + history_user = serializers.SerializerMethodField() + + class Meta: + model = Equipment.history.model + fields = ('history_id', 'name', 'description', + 'history_date', 'history_user') + read_only_fields = ('history_id', 'id', 'name', 'description', + 'history_date', 'history_user') + + def get_history_user(self, obj): + return obj.history_user.username if obj.history_user else None + + +class EquipmentLogSerializer(serializers.HyperlinkedModelSerializer): + date_added = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated_by = serializers.SerializerMethodField() + name = serializers.CharField() + description = serializers.CharField() + history = EquipmentHistoricalRecordField() + + class Meta: + model = Equipment + fields = ('id', 'name', 'description', + 'last_updated', 'date_added', 'last_updated_by', 'history') + read_only_fields = ('id', 'last_updated', + 'date_added', 'last_updated_by', 'history') + + def get_last_updated_by(self, obj): + return obj.history.first().history_user if obj.history.first().history_user else None + +# -- Equipment Instance Serializers + + +class EquipmentInstanceHistoricalRecordField(serializers.ListField): + child = serializers.DictField() + + def to_representation(self, data): + return super().to_representation(data.values('equipment', 'status', 'remarks', 'history_date', 'history_user').order_by('-history_date')) + + +class EquipmentInstanceSerializer(serializers.HyperlinkedModelSerializer): + equipment = serializers.PrimaryKeyRelatedField( + source='equipment.name', queryset=Equipment.objects.all()) + status = serializers.CharField() + remarks = serializers.CharField() + date_added = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated_by = serializers.SerializerMethodField() + + # Forbid user from changing equipment field once the instance is already created + def update(self, instance, validated_data): + # Ignore any changes to 'equipment' + validated_data.pop('equipment', None) + return super().update(instance, validated_data) + + class Meta: + model = EquipmentInstance + fields = ('id', 'equipment', 'status', 'remarks', + 'last_updated', 'last_updated_by', 'date_added') + read_only_fields = ('id', 'last_updated', + 'last_updated_by', 'date_added') + + def get_history_user(self, obj): + return obj.history_user.username if obj.history_user else None + + def get_last_updated_by(self, obj): + return obj.history.first().history_user if obj.history.first().history_user else None + + +class EquipmentInstanceLogsSerializer(serializers.HyperlinkedModelSerializer): + history_date = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + history_user = serializers.SerializerMethodField() + + class Meta: + model = EquipmentInstance.history.model + fields = ('history_id', 'status', 'remarks', + 'history_date', 'history_user') + read_only_fields = ('history_id', 'id', 'status', 'remarks', + 'history_date', 'history_user') + + def get_history_user(self, obj): + return obj.history_user.username if obj.history_user else None + + +class EquipmentInstanceLogSerializer(serializers.HyperlinkedModelSerializer): + equipment = serializers.PrimaryKeyRelatedField( + source='equipment.name', queryset=Equipment.objects.all()) + status = serializers.CharField() + remarks = serializers.CharField() + date_added = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated = serializers.DateTimeField( + format="%m-%d-%Y %I:%M%p", read_only=True) + last_updated_by = serializers.SerializerMethodField() + history = EquipmentInstanceHistoricalRecordField() + + # Forbid user from changing equipment field once the instance is already created + def update(self, instance, validated_data): + # Ignore any changes to 'equipment' + validated_data.pop('equipment', None) + return super().update(instance, validated_data) + + class Meta: + model = EquipmentInstance + fields = ('id', 'equipment', 'status', 'remarks', + 'last_updated', 'date_added', 'last_updated_by', 'history') + read_only_fields = ('id', 'last_updated', + 'date_added', 'last_updated_by', 'history') + + def get_last_updated_by(self, obj): + return obj.history.first().history_user if obj.history.first().history_user else None diff --git a/equipment_tracker/equipments/tests.py b/equipment_tracker/equipments/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/equipment_tracker/equipments/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/equipment_tracker/equipments/urls.py b/equipment_tracker/equipments/urls.py new file mode 100644 index 0000000..9547051 --- /dev/null +++ b/equipment_tracker/equipments/urls.py @@ -0,0 +1,29 @@ +from django.urls import include, path +from rest_framework import routers +from . import views + +router = routers.DefaultRouter() +# For viewing all equipments +router.register(r'equipments', views.EquipmentViewSet) +router.register(r'equipment_instances', views.EquipmentInstanceViewSet) + +# Wire up our API using automatic URL routing. +# Additionally, we include login URLs for the browsable API. +urlpatterns = [ + path('', include(router.urls)), + # Logs for all equipments + path('equipments/logs', views.EquipmentsLogsViewSet.as_view()), + # Logs for each equipment + path('equipments//logs/', + views.EquipmentLogViewSet.as_view({'get': 'list'})), + # Last changed equipment + path('equipments/latest', views.LastUpdatedEquipmentViewSet.as_view()), + # Logs for all equipment instances + path('equipment_instances/logs', views.EquipmentInstancesLogsViewSet.as_view()), + # Logs for each equipment instance + path('equipment_instances//logs/', + views.EquipmentInstanceLogViewSet.as_view({'get': 'list'})), + # Last changed equipment instance + path('equipment_instances/latest', + views.LastUpdatedEquipmentInstanceViewSet.as_view()) +] diff --git a/equipment_tracker/equipments/views.py b/equipment_tracker/equipments/views.py new file mode 100644 index 0000000..b97306d --- /dev/null +++ b/equipment_tracker/equipments/views.py @@ -0,0 +1,89 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework import viewsets, generics +from .models import Equipment, EquipmentInstance +from . import serializers +from config.settings import DEBUG + +# -- Equipment Viewsets + + +class EquipmentViewSet(viewsets.ModelViewSet): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentSerializer + queryset = Equipment.objects.all().order_by('-date_added') + +# For viewing all logs for all equipments + + +class EquipmentsLogsViewSet(generics.ListAPIView): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentLogsSerializer + queryset = Equipment.history.all().order_by('-history_date') + +# For viewing logs per individual equipment + + +class EquipmentLogViewSet(viewsets.ReadOnlyModelViewSet): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentLogSerializer + + def get_queryset(self): + equipment_id = self.kwargs['equipment_id'] + return Equipment.objects.filter(id=equipment_id) + +# Last changed equipment + + +class LastUpdatedEquipmentViewSet(generics.ListAPIView): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentSerializer + queryset = Equipment.objects.all().order_by('-date_added') + + def get_queryset(self): + return super().get_queryset()[:1] + +# -- Equipment Instance Viewsets + + +class EquipmentInstanceViewSet(viewsets.ModelViewSet): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentInstanceSerializer + queryset = EquipmentInstance.objects.all().order_by('-date_added') + +# For viewing all equipment instance logs + + +class EquipmentInstancesLogsViewSet(generics.ListAPIView): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentInstanceLogsSerializer + queryset = EquipmentInstance.history.all().order_by('-history_date') + +# For viewing logs per individual equipment instance + + +class EquipmentInstanceLogViewSet(viewsets.ReadOnlyModelViewSet): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentInstanceLogSerializer + + def get_queryset(self): + equipment_id = self.kwargs['equipment_id'] + return EquipmentInstance.objects.filter(id=equipment_id) + +# Last changed equipment instance + + +class LastUpdatedEquipmentInstanceViewSet(generics.ListAPIView): + if (not DEBUG): + permission_classes = [IsAuthenticated] + serializer_class = serializers.EquipmentInstanceSerializer + queryset = EquipmentInstance.objects.all().order_by('-date_added') + + def get_queryset(self): + return super().get_queryset()[:1]