diff --git a/.gitignore b/.gitignore
index f6f331f74..bfac6914e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.project
+.spyproject
.pydevproject
.idea
.settings
@@ -14,3 +15,5 @@ logistics_project/localsettings.py
logistics_project/logistics.log*
logistics_project/apps/malawi/tests/testscripts/*
logistics_project/apps/tanzania/tests/testscripts/*
+.spyproject/*
+.spyproject/config
\ No newline at end of file
diff --git a/data/cstock-logistics.json b/data/cstock-logistics.json
index 8e4a16d2c..06d095c90 100644
--- a/data/cstock-logistics.json
+++ b/data/cstock-logistics.json
@@ -10216,5 +10216,46 @@
"type": "hf",
"supplied_by": 4473
}
- }
-]
+ },
+ {
+ "pk": 1,
+ "model": "logistics.productreporttype",
+ "fields": {
+ "id" : 1,
+ "name" : "stock on hand",
+ "code" : "soh"
+ }
+ },
+ {
+ "pk": 2,
+ "model": "logistics.productreporttype",
+ "fields": {
+ "name" : "stock received",
+ "code" : "rec"
+ }
+ },
+ {
+ "pk": 3,
+ "model": "logistics.productreporttype",
+ "fields": {
+ "name" : "stock given",
+ "code" : "give"
+ }
+ },
+ {
+ "pk": 4,
+ "model": "logistics.productreporttype",
+ "fields": {
+ "name" : "emergency stock on hand",
+ "code" : "eo"
+ }
+ },
+ {
+ "pk": 5,
+ "model": "logistics.productreporttype",
+ "fields": {
+ "name" : "loss or adjustment",
+ "code" : "la"
+ }
+ }
+]
\ No newline at end of file
diff --git a/ex-submodules/rapidsms-logistics/logistics/models.py b/ex-submodules/rapidsms-logistics/logistics/models.py
index 2d8f99f84..27c4cbcb7 100644
--- a/ex-submodules/rapidsms-logistics/logistics/models.py
+++ b/ex-submodules/rapidsms-logistics/logistics/models.py
@@ -1482,7 +1482,7 @@ def get_product(self, product_code):
if the product can't be found.
"""
try:
- return Product.objects.get(sms_code__icontains=product_code)
+ return Product.objects.get(sms_code__iexact=product_code)
except (Product.DoesNotExist, Product.MultipleObjectsReturned):
raise UnknownCommodityCodeError(product_code)
diff --git a/logistics_project/apps/malawi/handlers/map_supply_point.py b/logistics_project/apps/malawi/handlers/map_supply_point.py
new file mode 100644
index 000000000..5c30e38c6
--- /dev/null
+++ b/logistics_project/apps/malawi/handlers/map_supply_point.py
@@ -0,0 +1,83 @@
+from __future__ import unicode_literals
+from builtins import str
+from django.utils.translation import gettext as _
+from rapidsms.contrib.handlers.handlers.keyword import KeywordHandler
+from rapidsms.models import Contact
+from logistics.util import config
+from logistics.models import SupplyPoint
+from rapidsms.contrib.locations.models import Location, Point
+import logging
+
+logger = logging.getLogger("django")
+
+
+class MapSupplyPointHandler(KeywordHandler):
+ """Set the location of a supply point using "map"."""
+
+ keyword = "map"
+
+ def help(self):
+ # only display help if contact is registered
+ if hasattr(self.msg,'logistics_contact'):
+ if self.msg.logistics_contact.supply_point.type == config.hsa_supply_point_type():
+ self.respond(_(config.Messages.MAPPING_HELP))
+ else:
+ self.respond(_(config.Messages.UNSUPPORTED_OPERATION))
+ else:
+ self.respond(_(config.Messages.NOT_REGISTERED))
+
+ def handle(self, text):
+ # only allow registered contact at hsa level
+ is_hsa = self.msg.logistics_contact.supply_point.type == config.hsa_supply_point_type()
+
+ if not hasattr(self.msg,'logistics_contact') or is_hsa == False:
+ self.respond(_(config.Messages.NOT_REGISTERED))
+ else:
+ words = text.split()
+ if len(words) < 2:
+ self.help()
+ else:
+ latitude = words[0]
+ longitude = words[1]
+
+ if(self._validate_latitude(latitude) and self._validate_longitude(longitude)):
+ # create location record and link to supply point
+
+ logger.info("Mapping location")
+ point = Point(latitude=float(latitude), longitude=float(longitude))
+ point.save()
+ self.msg.connection.contact.supply_point.location.point = point
+ self.msg.connection.contact.supply_point.location.save()
+
+ self.respond(_(config.Messages.MAPPING_SUCCESS), sp_name=self.msg.connection.contact.supply_point)
+ else:
+ logger.info(config.Messages.INVALID_COORDINATES)
+ self.respond(_(config.Messages.INVALID_COORDINATES))
+
+
+ def _validate_latitude(self,latitude):
+ if self._is_float(latitude):
+ lat_value = float(latitude)
+ if (lat_value > -90) and (lat_value < 90):
+ return True
+ else:
+ return False
+ else:
+ return False
+
+ def _validate_longitude(self,longitude):
+ if self._is_float(longitude):
+ long_value = float(longitude)
+ if (long_value > -180) and (long_value < 180):
+ return True
+ else:
+ return False
+ else:
+ return False
+
+ def _is_float(self,string):
+ try:
+ float(string)
+ return True
+ except ValueError:
+ return False
diff --git a/logistics_project/apps/malawi/static/malawi/css/malawi-new.css b/logistics_project/apps/malawi/static/malawi/css/malawi-new.css
index 18edf377c..1ce35eb84 100644
--- a/logistics_project/apps/malawi/static/malawi/css/malawi-new.css
+++ b/logistics_project/apps/malawi/static/malawi/css/malawi-new.css
@@ -178,3 +178,33 @@ form#login {
width: 300px;
border: none;
}
+
+dl dt.inline {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ vertical-align: middle;
+}
+
+dl dd.inline {
+ display: inline-block;
+ margin: 0px 10px;
+ padding: 2px;
+ vertical-align: middle;
+}
+
+dl dt.over-stock {
+ background: #800080;
+}
+
+dl dt.adequate-stock {
+ background: #008000;
+}
+
+dl dt.under-stock {
+ background: #FFA500;
+}
+
+div.legend-box {
+ margin: 5px
+}
\ No newline at end of file
diff --git a/logistics_project/apps/malawi/templates/malawi/new/reports/stock-status.html b/logistics_project/apps/malawi/templates/malawi/new/reports/stock-status.html
index da0ec152b..5e5ffa993 100644
--- a/logistics_project/apps/malawi/templates/malawi/new/reports/stock-status.html
+++ b/logistics_project/apps/malawi/templates/malawi/new/reports/stock-status.html
@@ -61,4 +61,32 @@
{{ base_level_description }} months of stock by product
{% endwith %}
{% endif %}
+
+{% if product_map %}
+
+
+
Current stock status of HSAs
+
+
+
+ - Under stock
+
+ - Adequate stock
+
+ - Overstocked
+
+
+ {{ product_map|safe }}
+
+{% endif %}
+
{% endblock %}
diff --git a/logistics_project/apps/malawi/warehouse/report_views/stock_status.py b/logistics_project/apps/malawi/warehouse/report_views/stock_status.py
index 7b27bae5c..56e4607de 100644
--- a/logistics_project/apps/malawi/warehouse/report_views/stock_status.py
+++ b/logistics_project/apps/malawi/warehouse/report_views/stock_status.py
@@ -1,9 +1,14 @@
from __future__ import unicode_literals
from builtins import range
import json
+import folium
+import logging
+import random
from collections import defaultdict
+from geopy.geocoders import Nominatim
-from logistics.models import Product, SupplyPoint, ProductType, ProductStock
+from logistics.models import (Product, SupplyPoint, SupplyPointType,
+ ProductType, ProductStock)
from logistics.util import config
from logistics_project.apps.malawi.util import (fmt_pct, pct,
is_country, is_district, is_facility, hsa_supply_points_below,
@@ -16,7 +21,6 @@
from django.db.models.aggregates import Sum
from django.shortcuts import get_object_or_404
-
class View(warehouse_view.DistrictOnlyView):
def _get_product_status_table(self, supply_points, product, date):
@@ -46,8 +50,8 @@ def get_selected_product_type(self, request):
return None
- def get_selected_product(self, request):
- pcode = request.GET.get("product")
+ def get_selected_product(self, request, element_id="product"):
+ pcode = request.GET.get(element_id)
if pcode:
return get_object_or_404(Product, sms_code=pcode, type__base_level=request.base_level)
@@ -204,11 +208,83 @@ def is_viewing_base_level_data(self, request, reporting_supply_point):
(is_district(reporting_supply_point) and request.base_level_is_facility)
)
+ def get_stock_status_map(self, request, reporting_supply_point, selected_product):
+ """
+ Status of product in map format.
+
+ """
+ product_map = folium.Map(location=(-13.9626, 33.7741), zoom_start=6)
+
+ try:
+ # Retrieve active product stock per supply point including location coordinates
+ # 1. Retrieve active supply points at hsa level
+ hsa_sup_type = SupplyPointType.objects.get(pk='hsa')
+ suppliers = SupplyPoint.objects.filter(active=True, type=hsa_sup_type)
+
+ # 2. Retrieve product details including stock levels
+ product = Product.by_code(selected_product.sms_code)
+ product_suppliers = [sup for sup in suppliers if sup.supplies(product)]
+
+ # 3. Obtain location point details for each supply point if available
+ for supplier in product_suppliers:
+ # retrieve location coordinates if set otherwise do not display
+ if supplier.location.point:
+ location_point = supplier.location.point
+ else:
+ continue
+
+ # 4. indicate stock levels using color codes
+ current_stock = ProductStock.objects.get(supply_point=supplier, product=product)
+ current_quantity = current_stock.quantity
+ product_amc = product.average_monthly_consumption
+ product_eo_level = product.emergency_order_level
+
+ # only mark location if quantity, and either amc or eo level are set
+ if current_quantity:
+ # if both amc and eo level set then any status can be set
+ if product_amc and product_eo_level:
+ if current_quantity >= product_amc:
+ stock_status_color = 'purple'
+ elif current_quantity <= product_eo_level:
+ stock_status_color = 'orange'
+ else:
+ stock_status_color = 'green'
+ elif product_amc and product_eo_level is None:
+ # if only product amc then either above amc or below
+ if current_quantity >= product_amc:
+ stock_status_color = 'purple'
+ else:
+ stock_status_color = 'orange'
+ else:
+ continue
+
+ # Define marker using supplier name, quantity, and stock status
+ label = f'{supplier.name} ({current_quantity})'
+
+ if location_point:
+ folium.Marker(
+ location=[location_point.latitude,
+ location_point.longitude],
+ tooltip=label,
+ popup=label,
+ icon=folium.Icon(color=stock_status_color)
+ ).add_to(product_map)
+ except Product.DoesNotExist:
+ pass
+ except ProductStock.DoesNotExist:
+ pass
+
+ return product_map._repr_html_()
+
def custom_context(self, request):
selected_type = self.get_selected_product_type(request)
selected_product = self.get_selected_product(request)
+ selected_map_product = self.get_selected_product(request, "map_product")
reporting_supply_point = self.get_reporting_supply_point(request)
+ product_map = self.get_stock_status_map(request, reporting_supply_point,
+ selected_map_product)
+
months_of_stock_table = None
stock_status_across_location_table = None
@@ -230,10 +306,12 @@ def custom_context(self, request):
'window_date': current_report_period(),
'selected_type': selected_type,
'selected_product': selected_product,
+ 'selected_map_product': selected_map_product,
'status_table': self.get_stock_status_by_product_table(request, reporting_supply_point),
'stock_status_across_location_table': stock_status_across_location_table,
# This apparently isn't used in the template but is needed in get_report() when export_csv=True
'stockout_table': stockout_table,
'months_of_stock_table': months_of_stock_table,
'stockout_graph': stockout_graph,
+ 'product_map': product_map
}
diff --git a/logistics_project/deployments/malawi/settings_base.py b/logistics_project/deployments/malawi/settings_base.py
index 2daf1fe99..eac3d94f5 100644
--- a/logistics_project/deployments/malawi/settings_base.py
+++ b/logistics_project/deployments/malawi/settings_base.py
@@ -217,4 +217,4 @@
# data warehouse config
WAREHOUSE_RUNNER = 'logistics_project.apps.malawi.warehouse.runner.MalawiWarehouseRunner'
ENABLE_FACILITY_WORKFLOWS = False
-LOGISTICS_USE_DEFAULT_HANDLERS = False
+LOGISTICS_USE_DEFAULT_HANDLERS = False
\ No newline at end of file
diff --git a/requirements.in b/requirements.in
index 68dd16460..7d16ccd77 100644
--- a/requirements.in
+++ b/requirements.in
@@ -15,3 +15,5 @@ django-picklefield==0.2.0
sentry-sdk==0.17.7 # pinned for celery issue https://github.com/getsentry/sentry-python/issues/844
future
gunicorn
+folium
+geopy
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index fd6fc9e79..6d80fa55d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
#
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
#
# pip-compile requirements.in
#
@@ -10,6 +10,8 @@ asgiref==3.5.0
# via django
billiard==3.6.4.0
# via celery
+branca==0.7.0
+ # via folium
celery==4.4.7
# via -r requirements.in
certifi==2021.10.8
@@ -29,16 +31,30 @@ django-taggit==1.5.1
# via -r requirements.in
djappsettings==0.4.0
# via -r requirements.in
+folium==0.15.1
+ # via -r requirements.in
future==0.18.2
# via -r requirements.in
+geographiclib==2.0
+ # via geopy
+geopy==2.4.1
+ # via -r requirements.in
gunicorn==19.10.0
# via -r requirements.in
gviz-api==1.10.0
# via -r requirements.in
+jinja2==3.1.2
+ # via
+ # branca
+ # folium
kombu==4.6.11
# via celery
-mysqlclient==1.4.6
+markupsafe==2.1.3
+ # via jinja2
+mysqlclient==2.2.0
# via -r requirements.in
+numpy==1.26.2
+ # via folium
python-dateutil==2.8.2
# via -r requirements.in
python-memcached==1.59
@@ -52,7 +68,9 @@ quickcache==0.5.4
redis==3.5.3
# via django-redis
requests==2.4.3
- # via -r requirements.in
+ # via
+ # -r requirements.in
+ # folium
sentry-sdk==0.17.7
# via -r requirements.in
six==1.16.0
@@ -71,6 +89,8 @@ vine==1.3.0
# via
# amqp
# celery
+xyzservices==2023.10.1
+ # via folium
# The following packages are considered to be unsafe in a requirements file:
# setuptools
diff --git a/static/malawi/config.py b/static/malawi/config.py
index 51705dd80..123317aad 100644
--- a/static/malawi/config.py
+++ b/static/malawi/config.py
@@ -344,6 +344,11 @@ class Messages(object):
APPROVAL_SUPERVISOR = "Successfully approved registration for %(hsa)s."
APPROVAL_HSA = "Congratulations, your registration has been approved. Welcome to the cStock system, %(hsa)s."
+ # map supply point
+ MAPPING_HELP = "To map supply point, type map {latitude} {longitude}"
+ MAPPING_SUCCESS = "Done. %(sp_name)s has been mapped on the cStock system."
+ INVALID_COORDINATES = "Sorry, GIS coordinates are not valid. Please verify that the values are correct."
+
# Other Messages (usually for error conditions)
NO_PRODUCTS_MANAGED = "Please add the products you manage before reporting. Text 'add ...' for all products you manage, then send your report again."
ALREADY_REGISTERED = "You are already registered. To change your information you must first text LEAVE"
@@ -357,7 +362,8 @@ class Messages(object):
GENERIC_ERROR = "Sorry, something was wrong with that message. If you keep having trouble, contact your supervisor for help."
NO_IN_CHARGE = "There is no supervisor registered for %(supply_point)s. Please contact your supervisor to resolve this."
TOO_MUCH_STOCK = 'Your %(keyword)s amount is too much and the message has been rejected. please resend your %(keyword)s message.'
-
+ NOT_REGISTERED = "We do not have a record of your registration. Nothing was done."
+
# messages originally in logistics.models.py
SUPERVISOR_TITLE = 'your supervisor'
GET_HELP_MESSAGE = "Please contact your %(supervisor)s for assistance." % {'supervisor' : SUPERVISOR_TITLE}