Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POLIO-1716: add stock history #1801

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e1c9799
POLIO-1716: update VaccineCalculator to compute values with end date
quang-le Nov 8, 2024
883a54a
POLIO-1716: add VaccineStockHistory model
quang-le Nov 8, 2024
61db37a
POLIO-1716: add task to archive vaccine stock
quang-le Nov 8, 2024
7a5b98a
POLIO-1716: WIP archive task
quang-le Nov 8, 2024
bc08653
black
quang-le Nov 8, 2024
8420ddd
POLIO-1716 Fix bad query
mathvdh Nov 12, 2024
326dae6
POLIO-1716: change filtering logic for vrf
quang-le Nov 12, 2024
062ca27
POLIO-1716: add tests and fix bugs
quang-le Nov 12, 2024
f2f22ec
POLIO-1716: fix date bug + add test
quang-le Nov 12, 2024
7684b6d
black
quang-le Nov 12, 2024
93277fe
POLIO-1716: add add_stock_history_command
quang-le Nov 13, 2024
31a1af6
POLIO-1716: change fields of VaccineStockHistory to accept negative i…
quang-le Nov 13, 2024
a6131ad
POLIO-1716: add unique contraint on round+stock
quang-le Nov 14, 2024
c99626b
POLIO-1716: add methods to test mixins
quang-le Nov 14, 2024
71e7162
POLIO-1716: add dashboard endpoint for VaccineStockHistory
quang-le Nov 14, 2024
2f1513d
POLIO-1716: merge migrations
quang-le Nov 14, 2024
02a5c53
Merge branch 'main' into POLIO-1716_add_stock_history
quang-le Nov 14, 2024
c1db7d5
POLIO-1716: fix migration conflict
quang-le Nov 14, 2024
0a5ee72
POLIO-1716: fix tests
quang-le Nov 14, 2024
8f92b2d
POLIO-1716: remove print statement
quang-le Nov 14, 2024
6a3feef
POLIO-1716: fix bug with country param
quang-le Nov 14, 2024
6da0a6d
POLIO-1716: add doc comment
quang-le Nov 15, 2024
261c0e2
POLIO-1716: improve task logging
quang-le Nov 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions iaso/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from unittest import mock

from rest_framework.test import APITestCase as BaseAPITestCase, APIClient
from django.contrib.auth.models import AnonymousUser

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
Expand Down Expand Up @@ -107,6 +108,36 @@ def reload_urls(urlconfs: list) -> None:
importlib.reload(importlib.import_module(urlconf))
clear_url_caches()

@staticmethod
def create_base_users(account, permissions):
# anonymous user and user without needed permissions
anon = AnonymousUser()
user_no_perms = IasoTestCaseMixin.create_user_with_profile(
username="user_no_perm", account=account, permissions=[]
)

user = IasoTestCaseMixin.create_user_with_profile(username="user", account=account, permissions=permissions)
return [user, anon, user_no_perms]

@staticmethod
def create_account_datasource_version_project(source_name, account_name, project_name):
"""Create a project and all related data: account, data source, source version"""
data_source = m.DataSource.objects.create(name=source_name)
source_version = m.SourceVersion.objects.create(data_source=data_source, number=1)
account = m.Account.objects.create(name=account_name, default_version=source_version)
project = m.Project.objects.create(name=project_name, app_id=f"{project_name}.app", account=account)
data_source.projects.set([project])

return [account, data_source, source_version, project]

@staticmethod
def create_org_unit_type(name, projects, category=None):
type_category = category if category else name
org_unit_type = m.OrgUnitType.objects.create(name=name, category=type_category)
org_unit_type.projects.set(projects)
org_unit_type.save()
return org_unit_type


class TestCase(BaseTestCase, IasoTestCaseMixin):
pass
Expand Down
54 changes: 54 additions & 0 deletions plugins/polio/api/dashboards/vaccine_stock_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import django_filters
from rest_framework import serializers
from iaso.api.common import ModelViewSet
from plugins.polio.api.permission_classes import PolioReadPermission
from plugins.polio.models.base import VaccineStockHistory
from django.utils.translation import gettext_lazy as _
from django.db.models.query import QuerySet


class VaccineStockHistoryDashboardSerializer(serializers.ModelSerializer):
class Meta:
model = VaccineStockHistory
fields = "__all__"


class VaccineStockHistoryFilter(django_filters.rest_framework.FilterSet):
vaccine = django_filters.CharFilter(method="filter_vaccine", label=_("Vaccine"))
campaign = django_filters.CharFilter(method="filter_campaign", label=_("Campaign OBR name"))
country = django_filters.NumberFilter(method="filter_country", label=_("Country ID"))
round = django_filters.NumberFilter(method="filter_round", label=_("Round ID"))

class Meta:
model = VaccineStockHistory
fields = "__all__"

def filter_country(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
return queryset.filter(round__campaign__country=value)

def filter_campaign(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
return queryset.filter(round__campaign__obr_name=value)

def filter_vaccine(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
return queryset.filter(vaccine_stock__vaccine=value)

def filter_round(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
return queryset.filter(round__id=value)


class VaccineStockHistoryDashboardViewSet(ModelViewSet):
"""
GET /api/polio/dashboards/vaccine_stock_history/
Returns all Preparedness sheet snapshots
Simple endpoint that returns all model fields to facilitate data manipulation by OpenHexa or PowerBI
"""

http_method_names = ["get"]
permission_classes = [PolioReadPermission]
model = VaccineStockHistory
serializer_class = VaccineStockHistoryDashboardSerializer
filterset_class = VaccineStockHistoryFilter
ordering_fields = ["created_at"]

def get_queryset(self):
return VaccineStockHistory.objects.filter_for_user(self.request.user)
8 changes: 8 additions & 0 deletions plugins/polio/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# TOFIX: Still haven't understood the exact problem but this should be
# the first import to avoid some 'BudgetProcess' errors in tests:
# `AttributeError: 'str' object has no attribute '_meta'`
from plugins.polio.api.dashboards.vaccine_stock_history import VaccineStockHistoryDashboardViewSet
from plugins.polio.budget.api import BudgetProcessViewSet, BudgetStepViewSet, WorkflowViewSet

from plugins.polio.api.campaigns.campaign_groups import CampaignGroupViewSet
Expand Down Expand Up @@ -46,6 +47,7 @@
IncidentReportViewSet,
)

from plugins.polio.tasks.api.launch_vaccine_stock_archive import ArchiveVaccineStockViewSet
from plugins.polio.tasks.api.refresh_im_data import (
RefreshIMAllDataViewset,
RefreshIMHouseholdDataViewset,
Expand Down Expand Up @@ -100,11 +102,17 @@
router.register(r"polio/notifications", NotificationViewSet, basename="notifications")

router.register(r"tasks/create/refreshpreparedness", RefreshPreparednessLaucherViewSet, basename="refresh_preparedness")
router.register(r"tasks/create/archivevaccinestock", ArchiveVaccineStockViewSet, basename="archive_vaccine_stock")
router.register(
r"polio/dashboards/vaccine_request_forms",
VaccineRequestFormDashboardViewSet,
basename="dashboard_vaccine_request_forms",
)
router.register(
r"polio/dashboards/vaccine_stock_history",
VaccineStockHistoryDashboardViewSet,
basename="dashboard_vaccine_stock_history",
)
router.register(
r"polio/dashboards/pre_alerts",
PreAlertDashboardViewSet,
Expand Down
84 changes: 64 additions & 20 deletions plugins/polio/api/vaccines/stock_management.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import datetime
import enum

from django.db.models import OuterRef, Subquery, Exists, Q
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import filters, serializers, status
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.response import Response

from rest_framework.exceptions import ValidationError
from django.utils.dateparse import parse_date
from hat.menupermissions import models as permission
from iaso.api.common import GenericReadWritePerm, ModelViewSet, Paginator
from iaso.models import OrgUnit
Expand All @@ -18,6 +19,7 @@
DestructionReport,
IncidentReport,
OutgoingStockMovement,
Round,
VaccineArrivalReport,
VaccineRequestForm,
VaccineStock,
Expand Down Expand Up @@ -59,21 +61,24 @@ def __init__(self, vaccine_stock: VaccineStock):
def get_doses_per_vial(self):
return DOSES_PER_VIAL[self.vaccine_stock.vaccine]

def get_vials_used(self):
results = self.get_list_of_used_vials()
def get_vials_used(self, end_date=None):
results = self.get_list_of_used_vials(end_date)
total = 0
for result in results:
total += result["vials_in"]

return total

def get_vials_destroyed(self):
def get_vials_destroyed(self, end_date=None):
if not self.destruction_reports.exists():
return 0
destruction_reports = self.destruction_reports
if end_date:
destruction_reports = destruction_reports.filter(destruction_report_date__lte=end_date)
return sum(report.unusable_vials_destroyed or 0 for report in self.destruction_reports)

def get_total_of_usable_vials(self):
results = self.get_list_of_usable_vials()
def get_total_of_usable_vials(self, end_date=None):
results = self.get_list_of_usable_vials(end_date)
total_vials_in = 0
total_doses_in = 0

Expand All @@ -86,11 +91,10 @@ def get_total_of_usable_vials(self):
total_vials_in -= result["vials_out"]
if result["doses_out"]:
total_doses_in -= result["doses_out"]

return total_vials_in, total_doses_in

def get_vials_received(self):
results = self.get_list_of_vaccines_received()
def get_vials_received(self, end_date=None):
results = self.get_list_of_vaccines_received(end_date)

total_vials_in = 0

Expand All @@ -100,8 +104,8 @@ def get_vials_received(self):

return total_vials_in

def get_total_of_unusable_vials(self):
results = self.get_list_of_unusable_vials()
def get_total_of_unusable_vials(self, end_date=None):
results = self.get_list_of_unusable_vials(end_date)

total_vials_in = 0
total_doses_in = 0
Expand All @@ -118,19 +122,36 @@ def get_total_of_unusable_vials(self):

return total_vials_in, total_doses_in

def get_list_of_vaccines_received(self):
def get_list_of_vaccines_received(self, end_date=None):
"""
Vaccines received are only those linked to an arrival report. We exclude those found e.g. during physical inventory
"""
# First find the corresponding VaccineRequestForms
vrfs = VaccineRequestForm.objects.filter(
campaign__country=self.vaccine_stock.country, vaccine_type=self.vaccine_stock.vaccine
)
if end_date:
eligible_rounds = (
Round.objects.filter(campaign=OuterRef("campaign"))
.filter(
(
Q(campaign__separate_scopes_per_round=False)
& Q(campaign__scopes__vaccine=self.vaccine_stock.vaccine)
)
| (Q(campaign__separate_scopes_per_round=True) & Q(scopes__vaccine=self.vaccine_stock.vaccine))
)
.filter(ended_at__lte=end_date)
.filter(id__in=OuterRef("rounds"))
)
vrfs = vrfs.filter(Exists(Subquery(eligible_rounds)))

if not vrfs.exists():
arrival_reports = []
else:
# Then find the corresponding VaccineArrivalReports
arrival_reports = VaccineArrivalReport.objects.filter(request_form__in=vrfs)
if end_date:
arrival_reports = arrival_reports.filter(arrival_report_date__lte=end_date)
if not arrival_reports.exists():
arrival_reports = []
results = []
Expand All @@ -149,12 +170,14 @@ def get_list_of_vaccines_received(self):
)
return results

def get_list_of_usable_vials(self):
def get_list_of_usable_vials(self, end_date=None):
# First get vaccines received from arrival reports
results = self.get_list_of_vaccines_received()
results = self.get_list_of_vaccines_received(end_date)

# Add stock movements (used and missing vials)
stock_movements = OutgoingStockMovement.objects.filter(vaccine_stock=self.vaccine_stock).order_by("report_date")
if end_date:
stock_movements = stock_movements.filter(report_date__lte=end_date)
for movement in stock_movements:
if movement.usable_vials_used > 0:
results.append(
Expand Down Expand Up @@ -186,6 +209,8 @@ def get_list_of_usable_vials(self):
incident_reports = IncidentReport.objects.filter(vaccine_stock=self.vaccine_stock).order_by(
"date_of_incident_report"
)
if end_date:
incident_reports = incident_reports.filter(date_of_incident_report__lte=end_date)
for report in incident_reports:
if (
report.usable_vials > 0
Expand Down Expand Up @@ -238,9 +263,11 @@ def get_list_of_usable_vials(self):

return results

def get_list_of_used_vials(self):
def get_list_of_used_vials(self, end_date=None):
# Used vials are those related to formA outgoing movements. Vials with e.g expired date become unusable, but have not been used
outgoing_movements = OutgoingStockMovement.objects.filter(vaccine_stock=self.vaccine_stock)
if end_date:
outgoing_movements = outgoing_movements.filter(report_date__lte=end_date)
results = []
for movement in outgoing_movements:
if movement.usable_vials_used > 0:
Expand All @@ -257,15 +284,20 @@ def get_list_of_used_vials(self):
)
return results

def get_list_of_unusable_vials(self):
def get_list_of_unusable_vials(self, end_date=None):
# First get the used vials
results = self.get_list_of_used_vials()
results = self.get_list_of_used_vials(end_date)

# Get all IncidentReports and Destruction reports for the VaccineStock
incident_reports = IncidentReport.objects.filter(vaccine_stock=self.vaccine_stock)
if end_date:
incident_reports = incident_reports.filter(date_of_incident_report__lte=end_date)

destruction_reports = DestructionReport.objects.filter(vaccine_stock=self.vaccine_stock).order_by(
"destruction_report_date"
)
if end_date:
destruction_reports = destruction_reports.filter(destruction_report_date__lte=end_date)

for report in destruction_reports:
results.append(
Expand Down Expand Up @@ -626,8 +658,14 @@ def usable_vials(self, request, pk=None):
except VaccineStock.DoesNotExist:
return Response({"error": "VaccineStock not found"}, status=status.HTTP_404_NOT_FOUND)

end_date = request.query_params.get("end_date", None)
if end_date:
parsed_end_date = parse_date(end_date)
if not parsed_end_date:
raise ValidationError("The 'end_date' query parameter is not a valid date.")

calc = VaccineStockCalculator(vaccine_stock)
results = calc.get_list_of_usable_vials()
results = calc.get_list_of_usable_vials(end_date)
results = self._sort_results(request, results)

paginator = Paginator()
Expand All @@ -653,8 +691,14 @@ def get_unusable_vials(self, request, pk=None):
except VaccineStock.DoesNotExist:
return Response({"error": "VaccineStock not found"}, status=status.HTTP_404_NOT_FOUND)

end_date = request.query_params.get("end_date", None)
if end_date:
parsed_end_date = parse_date(end_date)
if not parsed_end_date:
raise ValidationError("The 'end_date' query parameter is not a valid date.")

calc = VaccineStockCalculator(vaccine_stock)
results = calc.get_list_of_unusable_vials()
results = calc.get_list_of_unusable_vials(end_date)
results = self._sort_results(request, results)

paginator = Paginator()
Expand Down
34 changes: 34 additions & 0 deletions plugins/polio/management/commands/add_stock_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand
from datetime import timedelta
from iaso.management.commands.command_logger import CommandLogger
from plugins.polio.models import Round, VaccineStock
from logging import getLogger
from django.db.models import Q

from plugins.polio.models.base import VACCINES, VaccineStockHistory
from plugins.polio.tasks.archive_vaccine_stock_for_rounds import archive_stock_for_round

logger = getLogger(__name__)


class Command(BaseCommand):
help = """Compute stock history and add it when missing"""

def handle(self, *args, **options):
rounds_to_update = Round.objects.filter(
vaccinerequestform__isnull=False, vaccinerequestform__deleted_at__isnull=True
).order_by("ended_at")

vaccines = [v[0] for v in VACCINES]

for vaccine in vaccines:
rounds_for_vaccine = rounds_to_update.filter(
(Q(campaign__separate_scopes_per_round=False) & Q(campaign__scopes__vaccine=vaccine))
| (Q(campaign__separate_scopes_per_round=True) & Q(scopes__vaccine=vaccine))
).exclude(id__in=VaccineStockHistory.objects.filter(vaccine_stock__vaccine=vaccine).values("round_id"))
vaccine_stock = VaccineStock.objects.filter(vaccine=vaccine)
for round_to_update in rounds_for_vaccine:
reference_date = round_to_update.ended_at + timedelta(days=14)
archive_stock_for_round(
round=round_to_update, vaccine_stock=vaccine_stock, reference_date=reference_date
)
Loading
Loading