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}