Skip to content

Commit

Permalink
Track Location Uptime (#1843)
Browse files Browse the repository at this point in the history
* Track location uptime

* Fix tests

* Use Paginator for migrations

* Refactor availability API endpoints

* Refactor availability viewsets and add permission checks

* Add tests for availability record access control

* update migrations

* Resolve merge conflict

---------

Co-authored-by: Aakash Singh <mail@singhaakash.dev>
  • Loading branch information
Ashesh3 and sainak authored Feb 12, 2024
1 parent fcea22e commit eeed4fd
Show file tree
Hide file tree
Showing 10 changed files with 397 additions and 74 deletions.
19 changes: 13 additions & 6 deletions care/facility/api/serializers/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
JSONField,
ModelSerializer,
Serializer,
SerializerMethodField,
UUIDField,
)
from rest_framework.validators import UniqueValidator
Expand All @@ -20,12 +21,12 @@
from care.facility.api.serializers.facility import FacilityBareMinimumSerializer
from care.facility.models.asset import (
Asset,
AssetAvailabilityRecord,
AssetLocation,
AssetService,
AssetServiceEdit,
AssetTransaction,
AssetTypeChoices,
AvailabilityRecord,
StatusChoices,
UserDefaultAssetLocation,
)
Expand Down Expand Up @@ -287,13 +288,19 @@ class Meta:
exclude = ("deleted", "external_id")


class AssetAvailabilitySerializer(ModelSerializer):
id = UUIDField(source="external_id", read_only=True)
asset = AssetBareMinimumSerializer(read_only=True)
class AvailabilityRecordSerializer(ModelSerializer):
linked_id = SerializerMethodField()
linked_model = SerializerMethodField()

class Meta:
model = AssetAvailabilityRecord
exclude = ("deleted", "external_id")
model = AvailabilityRecord
fields = ("status", "timestamp", "linked_id", "linked_model")

def get_linked_id(self, obj):
return obj.object_external_id

def get_linked_model(self, obj):
return obj.content_type.model


class UserDefaultAssetLocationSerializer(ModelSerializer):
Expand Down
58 changes: 46 additions & 12 deletions care/facility/api/viewsets/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,28 @@
from rest_framework.viewsets import GenericViewSet

from care.facility.api.serializers.asset import (
AssetAvailabilitySerializer,
AssetLocationSerializer,
AssetSerializer,
AssetServiceSerializer,
AssetTransactionSerializer,
AvailabilityRecordSerializer,
DummyAssetOperateResponseSerializer,
DummyAssetOperateSerializer,
UserDefaultAssetLocationSerializer,
)
from care.facility.models import (
Asset,
AssetAvailabilityRecord,
AssetLocation,
AssetService,
AssetTransaction,
ConsultationBedAsset,
UserDefaultAssetLocation,
)
from care.facility.models.asset import AssetTypeChoices, StatusChoices
from care.facility.models.asset import (
AssetTypeChoices,
AvailabilityRecord,
StatusChoices,
)
from care.users.models import User
from care.utils.assetintegration.asset_classes import AssetClasses
from care.utils.assetintegration.base import BaseAssetIntegration
Expand Down Expand Up @@ -215,15 +218,43 @@ def retrieve(self, request, *args, **kwargs):
return Response(hit)


class AssetAvailabilityFilter(filters.FilterSet):
external_id = filters.CharFilter(field_name="asset__external_id")

class AvailabilityViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
queryset = AvailabilityRecord.objects.all()
serializer_class = AvailabilityRecordSerializer
permission_classes = (IsAuthenticated,)

class AssetAvailabilityViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
queryset = AssetAvailabilityRecord.objects.all().select_related("asset")
serializer_class = AssetAvailabilitySerializer
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = AssetAvailabilityFilter
def get_queryset(self):
facility_queryset = get_facility_queryset(self.request.user)
if "asset_external_id" in self.kwargs:
asset = get_object_or_404(
Asset, external_id=self.kwargs["asset_external_id"]
)
if asset.current_location.facility in facility_queryset:
return self.queryset.filter(
content_type__model="asset",
object_external_id=self.kwargs["asset_external_id"],
)
else:
raise exceptions.PermissionDenied(
"You do not have access to this asset's availability records"
)
elif "asset_location_external_id" in self.kwargs:
asset_location = get_object_or_404(
AssetLocation, external_id=self.kwargs["asset_location_external_id"]
)
if asset_location.facility in facility_queryset:
return self.queryset.filter(
content_type__model="assetlocation",
object_external_id=self.kwargs["asset_location_external_id"],
)
else:
raise exceptions.PermissionDenied(
"You do not have access to this asset location's availability records"
)
else:
raise exceptions.ValidationError(
"Either asset_external_id or asset_location_external_id is required"
)


class AssetViewSet(
Expand Down Expand Up @@ -264,7 +295,10 @@ def get_queryset(self):
)
queryset = queryset.annotate(
latest_status=Subquery(
AssetAvailabilityRecord.objects.filter(asset=OuterRef("pk"))
AvailabilityRecord.objects.filter(
content_type__model="asset",
object_external_id=OuterRef("external_id"),
)
.order_by("-created_date")
.values("status")[:1]
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Generated by Django 4.2.8 on 2024-01-21 14:03

import uuid

import django.db.models.deletion
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import migrations, models

from care.facility.models.asset import Asset


def forwards_func(apps, schema_editor):
AssetAvailabilityRecord = apps.get_model("facility", "AssetAvailabilityRecord")
AvailabilityRecord = apps.get_model("facility", "AvailabilityRecord")
ContentType = apps.get_model("contenttypes", "ContentType")

asset_content_type = ContentType.objects.get_for_model(Asset)

aar_records = AssetAvailabilityRecord.objects.all()

paginator = Paginator(aar_records, 1000)
for page_number in paginator.page_range:
availability_records = []
for aar in paginator.page(page_number).object_list:
availability_record = AvailabilityRecord(
content_type=asset_content_type,
object_external_id=aar.asset.external_id,
status=aar.status,
timestamp=aar.timestamp,
)
availability_records.append(availability_record)

AvailabilityRecord.objects.bulk_create(availability_records)


def backwards_func(apps, schema_editor):
AssetAvailabilityRecord = apps.get_model("facility", "AssetAvailabilityRecord")
AvailabilityRecord = apps.get_model("facility", "AvailabilityRecord")
ContentType = apps.get_model("contenttypes", "ContentType")

asset_content_type = ContentType.objects.get_for_model(Asset)

ar_records = AvailabilityRecord.objects.filter(content_type=asset_content_type)

paginator = Paginator(ar_records, 1000)
for page_number in paginator.page_range:
asset_availability_records = []
for ar in paginator.page(page_number).object_list:
try:
AssetObject = Asset.objects.get(external_id=ar.object_external_id)
asset_availability_record = AssetAvailabilityRecord(
asset_id=AssetObject.id,
status=ar.status,
timestamp=ar.timestamp,
)
asset_availability_records.append(asset_availability_record)
except ObjectDoesNotExist:
continue # Skip if the asset was deleted

AssetAvailabilityRecord.objects.bulk_create(asset_availability_records)
AvailabilityRecord.objects.filter(
id__in=[ar.id for ar in paginator.page(page_number).object_list]
).delete()


class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("facility", "0409_merge_20240210_1510"),
]

operations = [
migrations.CreateModel(
name="AvailabilityRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"external_id",
models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
),
(
"created_date",
models.DateTimeField(auto_now_add=True, db_index=True, null=True),
),
(
"modified_date",
models.DateTimeField(auto_now=True, db_index=True, null=True),
),
("deleted", models.BooleanField(db_index=True, default=False)),
("object_external_id", models.UUIDField()),
(
"status",
models.CharField(
choices=[
("Not Monitored", "Not Monitored"),
("Operational", "Operational"),
("Down", "Down"),
("Under Maintenance", "Under Maintenance"),
],
default="Not Monitored",
max_length=20,
),
),
("timestamp", models.DateTimeField()),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"ordering": ["-timestamp"],
"unique_together": {("object_external_id", "timestamp")},
},
),
migrations.AddIndex(
model_name="availabilityrecord",
index=models.Index(
fields=["content_type", "object_external_id"],
name="facility_av_content_ad9eff_idx",
),
),
migrations.AlterUniqueTogether(
name="availabilityrecord",
unique_together={("object_external_id", "timestamp")},
),
migrations.RunPython(forwards_func, backwards_func),
migrations.DeleteModel(
name="AssetAvailabilityRecord",
),
]
26 changes: 19 additions & 7 deletions care/facility/models/asset.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import enum
import uuid

from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import JSONField, Q

Expand Down Expand Up @@ -181,19 +182,22 @@ def __str__(self):
return self.name


class AssetAvailabilityRecord(BaseModel):
class AvailabilityRecord(BaseModel):
"""
Model to store the availability status of an asset at a particular timestamp.
Model to store the availability status of an object (Asset/AssetLocation for now) at a particular timestamp.
Fields:
- asset: ForeignKey to Asset model
- content_type: ContentType of the related model
- object_external_id: UUIDField to store the external_id of the related model
- content_object: To get the linked object
- status: CharField with choices from AvailabilityStatus
- timestamp: DateTimeField to store the timestamp of the availability record
Note: A pair of asset and timestamp together should be unique, not just the timestamp alone.
Note: A pair of (object_external_id, timestamp) is unique
"""

asset = models.ForeignKey(Asset, on_delete=models.PROTECT, null=False, blank=False)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_external_id = models.UUIDField()
status = models.CharField(
choices=AvailabilityStatus.choices,
default=AvailabilityStatus.NOT_MONITORED,
Expand All @@ -202,11 +206,19 @@ class AssetAvailabilityRecord(BaseModel):
timestamp = models.DateTimeField(null=False, blank=False)

class Meta:
unique_together = (("asset", "timestamp"),)
indexes = [
models.Index(fields=["content_type", "object_external_id"]),
]
unique_together = (("object_external_id", "timestamp"),)
ordering = ["-timestamp"]

def __str__(self):
return f"{self.asset.name} - {self.status} - {self.timestamp}"
return f"{self.content_type} ({self.object_external_id}) - {self.status} - {self.timestamp}"

@property
def content_object(self):
model = self.content_type.model_class()
return model.objects.get(external_id=self.object_external_id)


class UserDefaultAssetLocation(BaseModel):
Expand Down
6 changes: 6 additions & 0 deletions care/facility/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from care.facility.tasks.asset_monitor import check_asset_status
from care.facility.tasks.cleanup import delete_old_notifications
from care.facility.tasks.location_monitor import check_location_status
from care.facility.tasks.plausible_stats import capture_goals
from care.facility.tasks.redis_index import load_redis_index
from care.facility.tasks.summarisation import (
Expand Down Expand Up @@ -68,3 +69,8 @@ def setup_periodic_tasks(sender, **kwargs):
load_redis_index.s(),
name="load_redis_index",
)
sender.add_periodic_task(
crontab(minute="*/30"),
check_location_status.s(),
name="check_location_status",
)
Loading

0 comments on commit eeed4fd

Please sign in to comment.