+ {% endif %}
- {% if performance_resource_links %}
-
- {% include 'scenes/department/ResourceLinks/resource-links.html' with resource_links=performance_resource_links section_title="Performance monitoring resources" %}
-
- {% endif %}
+ {% if in_year_monitoring_resource_links %}
+
+ {% include 'scenes/department/ResourceLinks/resource-links.html' with resource_links=in_year_monitoring_resource_links section_title="In-year monitoring resources" %}
+
+ {% endif %}
+
+ {% if performance_resource_links %}
+
+ {% include 'scenes/department/ResourceLinks/resource-links.html' with resource_links=performance_resource_links section_title="Performance monitoring resources" %}
+
+ {% endif %}
- {% if not infra_enabled and not in_year_monitoring_resource_links and not performance_resource_links %}
-
+ {% endif %}
- {% if expenditure_over_time %}
-
- {% include 'scenes/department/ExpenditureSection/index.html' with items=expenditure_over_time.expenditure cpi=global_values.cpi_dataset_url source_type=source_type year=selected_financial_year dataset=expenditure_over_time.dataset_detail_page pdf=pdf_link excel=excel csv=expenditure_over_time.department_data_csv guide=guide color="purple" title="Planned compared to historical expenditure" subtitle=subtitle description="Expenditure changes over time" %}
+
- {% endif %}
- {% with ""|add:department_location|add:" "|add:name|add:" Department" as text %}
-
- {% if budget_actual %}
+ {% if expenditure_over_time %}
- {% include 'scenes/department/ExpenditurePhaseSection/index.html' with items=budget_actual.expenditure cpi=global_values.cpi_dataset_url source_type="Expenditure Time Series" year=selected_financial_year dataset=budget_actual.dataset_detail_page csv=budget_actual.department_data_csv color="purple" description="Budgeted and Actual Expenditure comparison" subtitle=review_subtitle notices=budget_actual.notices website_url=website_url %}
+ {% include 'scenes/department/ExpenditureSection/index.html' with items=expenditure_over_time.expenditure cpi=global_values.cpi_dataset_url source_type=source_type year=selected_financial_year dataset=expenditure_over_time.dataset_detail_page pdf=pdf_link excel=excel csv=expenditure_over_time.department_data_csv guide=guide color="purple" title="Planned compared to historical expenditure" subtitle=subtitle description="Expenditure changes over time" %}
{% endif %}
- {% if budget_actual_programmes %}
-
- {% include 'scenes/department/ExpenditureMultiplesSection/index.html' with items=budget_actual_programmes.programmes cpi=global_values.cpi_dataset_url source_type="Expenditure Time Series" year=selected_financial_year dataset=budget_actual_programmes.dataset_detail_page csv=budget_actual_programmes.department_data_csv color="purple" subtitle=review_subtitle description="Budgeted and Actual Expenditure comparison by Programme" notices=budget_actual_programmes.notices %}
-
- {% endif %}
- {% endwith %}
-
-
-
{% endblock %}
diff --git a/budgetportal/templates/homepage.html b/budgetportal/templates/homepage.html
index 50d45978c..025cbed74 100644
--- a/budgetportal/templates/homepage.html
+++ b/budgetportal/templates/homepage.html
@@ -1,54 +1,43 @@
{% extends 'page-shell.html' %}
+{% load json_script_escape %}
{% load define_action %}
{% load staticfiles %}
{% block page_content %}
-
-
- {% include 'connections/homepage-hero/index.html' %}
-
-
-
+
+
+ {% include 'connections/homepage-hero/index.html' %}
+
-
-
+
- {% with events_list=events.upcoming %}
+ {% with events_list=events.upcoming %}
- {% include 'scenes/homepage/VideoSection/index.html' %}
+ {% include 'scenes/homepage/VideoSection/index.html' %}
- {% if events_list %}
- {% include 'scenes/homepage/EventSection/index.html' %}
- {% endif %}
+ {% if events_list %}
+ {% include 'scenes/homepage/EventSection/index.html' %}
+ {% endif %}
- {% include 'scenes/homepage/AboutSection/index.html' %}
+ {% include 'scenes/homepage/AboutSection/index.html' %}
- {% endwith %}
-
+ {% endwith %}
+
+
{% endblock %}
diff --git a/budgetportal/tests/helpers.py b/budgetportal/tests/helpers.py
index 17f9659fd..06954f9b0 100644
--- a/budgetportal/tests/helpers.py
+++ b/budgetportal/tests/helpers.py
@@ -4,7 +4,7 @@
import warnings
from datetime import datetime
-from django.contrib.staticfiles.testing import LiveServerTestCase
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.core.management import call_command
from django.db import connections
from django.test import TestCase
@@ -13,6 +13,7 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
+import socket
class WagtailHackMixin:
@@ -45,29 +46,40 @@ def _fixture_teardown(self):
)
-class BaseSeleniumTestCase(WagtailHackMixin, LiveServerTestCase):
+class BaseSeleniumTestCase(WagtailHackMixin, StaticLiveServerTestCase):
"""
Base class for Selenium tests.
This saves a screenshot to the current directory on test failure.
+
+ Much learned from https://github.com/marcgibbons/django-selenium-docker
"""
- def setUp(self):
- super(BaseSeleniumTestCase, self).setUp()
+ host = "0.0.0.0" # Bind to 0.0.0.0 to allow external access
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.host = socket.gethostbyname(socket.gethostname())
chrome_options = webdriver.ChromeOptions()
- chrome_options.add_argument("headless")
chrome_options.add_argument("--no-sandbox")
- d = DesiredCapabilities.CHROME
+ chrome_options.add_argument("disable-dev-shm-usage")
+ d = chrome_options.to_capabilities()
d["loggingPrefs"] = {"browser": "ALL"}
- self.selenium = webdriver.Chrome(
- chrome_options=chrome_options, desired_capabilities=d
+ cls.selenium = webdriver.Remote(
+ command_executor="http://selenium:4444/wd/hub", desired_capabilities=d
)
- self.selenium.implicitly_wait(10)
- self.wait = WebDriverWait(self.selenium, 5)
+ cls.selenium.implicitly_wait(10)
+ cls.wait = WebDriverWait(cls.selenium, 5)
+ @classmethod
+ def tearDownClass(cls):
+ cls.selenium.quit()
+ super().tearDownClass()
+
+ def setUp(self):
self.addCleanup(self.log_failure_details)
- self.addCleanup(self.selenium.quit)
def log_failure_details(self):
# https://stackoverflow.com/questions/14991244/how-do-i-capture-a-screenshot-if-my-nosetests-fail
@@ -87,7 +99,7 @@ def wait_until_text_in(self, selector, text):
)
-class WagtailHackLiveServerTestCase(WagtailHackMixin, LiveServerTestCase):
+class WagtailHackLiveServerTestCase(WagtailHackMixin, StaticLiveServerTestCase):
pass
diff --git a/budgetportal/tests/mock_data.py b/budgetportal/tests/mock_data.py
index b5987d12a..8ae43baea 100644
--- a/budgetportal/tests/mock_data.py
+++ b/budgetportal/tests/mock_data.py
@@ -1,160 +1,160 @@
from decimal import Decimal
CPI_2019_20 = {
- u"1996": {
- u"CPI": u"0.081341339073298",
- u"Year": u"1996/97",
- "financial_year_start": u"1996",
+ "1996": {
+ "CPI": "0.081341339073298",
+ "Year": "1996/97",
+ "financial_year_start": "1996",
"index": Decimal("29.15351953553183659377285178"),
},
- u"1997": {
- u"CPI": u"0.075536930330016",
- u"Year": u"1997/98",
- "financial_year_start": u"1997",
+ "1997": {
+ "CPI": "0.075536930330016",
+ "Year": "1997/98",
+ "financial_year_start": "1997",
"index": Decimal("31.35568690956206535036618471"),
},
- u"1998": {
- u"CPI": u"0.076173777518021",
- u"Year": u"1998/99",
- "financial_year_start": u"1998",
+ "1998": {
+ "CPI": "0.076173777518021",
+ "Year": "1998/99",
+ "financial_year_start": "1998",
"index": Decimal("33.74416802813576957260265599"),
},
- u"1999": {
- u"CPI": u"0.037925416364953",
- u"Year": u"1999/00",
- "financial_year_start": u"1999",
+ "1999": {
+ "CPI": "0.037925416364953",
+ "Year": "1999/00",
+ "financial_year_start": "1999",
"index": Decimal("35.02392965049175369249598743"),
},
- u"2000": {
- u"CPI": u"0.064969041597628",
- u"Year": u"2000/01",
- "financial_year_start": u"2000",
+ "2000": {
+ "CPI": "0.064969041597628",
+ "Year": "2000/01",
+ "financial_year_start": "2000",
"index": Decimal("37.29940079286694913746974641"),
},
- u"2001": {
- u"CPI": u"0.052898788077301",
- u"Year": u"2001/02",
- "financial_year_start": u"2001",
+ "2001": {
+ "CPI": "0.052898788077301",
+ "Year": "2001/02",
+ "financial_year_start": "2001",
"index": Decimal("39.27249389081913077278894942"),
},
- u"2002": {
- u"CPI": u"0.103981956758438",
- u"Year": u"2002/03",
- "financial_year_start": u"2002",
+ "2002": {
+ "CPI": "0.103981956758438",
+ "Year": "2002/03",
+ "financial_year_start": "2002",
"index": Decimal("43.35612465237030615432841586"),
},
- u"2003": {
- u"CPI": u"0.033392039450511",
- u"Year": u"2003/04",
- "financial_year_start": u"2003",
+ "2003": {
+ "CPI": "0.033392039450511",
+ "Year": "2003/04",
+ "financial_year_start": "2003",
"index": Decimal("44.80387407718352793313968934"),
},
- u"2004": {
- u"CPI": u"0.019905924057536",
- u"Year": u"2004/05",
- "financial_year_start": u"2004",
+ "2004": {
+ "CPI": "0.019905924057536",
+ "Year": "2004/05",
+ "financial_year_start": "2004",
"index": Decimal("45.69573659204734907313347654"),
},
- u"2005": {
- u"CPI": u"0.036227524898069",
- u"Year": u"2005/06",
- "financial_year_start": u"2005",
+ "2005": {
+ "CPI": "0.036227524898069",
+ "Year": "2005/06",
+ "financial_year_start": "2005",
"index": Decimal("47.35118002717134708630016805"),
},
- u"2006": {
- u"CPI": u"0.051860930142553",
- u"Year": u"2006/07",
- "financial_year_start": u"2006",
+ "2006": {
+ "CPI": "0.051860930142553",
+ "Year": "2006/07",
+ "financial_year_start": "2006",
"index": Decimal("49.80685626672793118196184207"),
},
- u"2007": {
- u"CPI": u"0.081345434475992",
- u"Year": u"2007/08",
- "financial_year_start": u"2007",
+ "2007": {
+ "CPI": "0.081345434475992",
+ "Year": "2007/08",
+ "financial_year_start": "2007",
"index": Decimal("53.85841662962819963199302250"),
},
- u"2008": {
- u"CPI": u"0.098760881277115",
- u"Year": u"2008/09",
- "financial_year_start": u"2008",
+ "2008": {
+ "CPI": "0.098760881277115",
+ "Year": "2008/09",
+ "financial_year_start": "2008",
"index": Decimal("59.17752132016030645441194773"),
},
- u"2009": {
- u"CPI": u"0.064516129032258",
- u"Year": u"2009/10",
- "financial_year_start": u"2009",
+ "2009": {
+ "CPI": "0.064516129032258",
+ "Year": "2009/10",
+ "financial_year_start": "2009",
"index": Decimal("62.99542592146096756905005273"),
},
- u"2010": {
- u"CPI": u"0.038181818181818",
- u"Year": u"2010/11",
- "financial_year_start": u"2010",
+ "2010": {
+ "CPI": "0.038181818181818",
+ "Year": "2010/11",
+ "financial_year_start": "2010",
"index": Decimal("65.40070582028037487706361448"),
},
- u"2011": {
- u"CPI": u"0.055458260361938",
- u"Year": u"2011/12",
- "financial_year_start": u"2011",
+ "2011": {
+ "CPI": "0.055458260361938",
+ "Year": "2011/12",
+ "financial_year_start": "2011",
"index": Decimal("69.02771519151599784307391477"),
},
- u"2012": {
- u"CPI": u"0.055420353982301",
- u"Year": u"2012/13",
- "financial_year_start": u"2012",
+ "2012": {
+ "CPI": "0.055420353982301",
+ "Year": "2012/13",
+ "financial_year_start": "2012",
"index": Decimal("72.85325560201927070902566593"),
},
- u"2013": {
- u"CPI": u"0.058170003144324",
- u"Year": u"2013/14",
- "financial_year_start": u"2013",
+ "2013": {
+ "CPI": "0.058170003144324",
+ "Year": "2013/14",
+ "financial_year_start": "2013",
"index": Decimal("77.09112970946297175373333027"),
},
- u"2014": {
- u"CPI": u"0.056259904912837",
- u"Year": u"2014/15",
- "financial_year_start": u"2014",
+ "2014": {
+ "CPI": "0.056259904912837",
+ "Year": "2014/15",
+ "financial_year_start": "2014",
"index": Decimal("81.42826933654054200675012982"),
},
- u"2015": {
- u"CPI": u"0.051669167291823",
- u"Year": u"2015/16",
- "financial_year_start": u"2015",
+ "2015": {
+ "CPI": "0.051669167291823",
+ "Year": "2015/16",
+ "financial_year_start": "2015",
"index": Decimal("85.63560020717387631656468800"),
},
- u"2016": {
- u"CPI": u"0.062951404369147",
- u"Year": u"2016/17",
- "financial_year_start": u"2016",
+ "2016": {
+ "CPI": "0.062951404369147",
+ "Year": "2016/17",
+ "financial_year_start": "2016",
"index": Decimal("91.02648150421028761249239849"),
},
- u"2017": {
- u"CPI": u"0.047143695998659",
- u"Year": u"2017/18",
- "financial_year_start": u"2017",
+ "2017": {
+ "CPI": "0.047143695998659",
+ "Year": "2017/18",
+ "financial_year_start": "2017",
"index": Decimal("95.31780627607233362007115993"),
},
- u"2018": {
- u"CPI": u"0.049121920728709",
- u"Year": u"2018/19",
- "financial_year_start": u"2018",
+ "2018": {
+ "CPI": "0.049121920728709",
+ "Year": "2018/19",
+ "financial_year_start": "2018",
"index": 100,
},
- u"2019": {
- u"CPI": u"0.051581145336389",
- u"Year": u"2019/20",
- "financial_year_start": u"2019",
+ "2019": {
+ "CPI": "0.051581145336389",
+ "Year": "2019/20",
+ "financial_year_start": "2019",
"index": Decimal("105.158114533638900"),
},
- u"2020": {
- u"CPI": u"0.054786768395168",
- u"Year": u"2020/21",
- "financial_year_start": u"2020",
+ "2020": {
+ "CPI": "0.054786768395168",
+ "Year": "2020/21",
+ "financial_year_start": "2020",
"index": Decimal("110.9193877994659244141042168"),
},
- u"2021": {
- u"CPI": u"0.054285094208856",
- u"Year": u"2021/22",
- "financial_year_start": u"2021",
+ "2021": {
+ "CPI": "0.054285094208856",
+ "Year": "2021/22",
+ "financial_year_start": "2021",
"index": Decimal("116.9406572157485649289660142"),
},
}
diff --git a/budgetportal/tests/test_bulk_upload.py b/budgetportal/tests/test_bulk_upload.py
index 9f06e3053..5083718d9 100644
--- a/budgetportal/tests/test_bulk_upload.py
+++ b/budgetportal/tests/test_bulk_upload.py
@@ -44,7 +44,7 @@ def setUp(self):
self.CKANMockClass.action.group_show.side_effect = NotFound()
self.addCleanup(self.ckan_patch.stop)
- self.ckan_patch2 = patch("budgetportal.models.ckan")
+ self.ckan_patch2 = patch("budgetportal.models.government.ckan")
self.CKANMockClass2 = self.ckan_patch2.start()
self.CKANMockClass2.action.package_search.return_value = {"results": []}
self.CKANMockClass2.action.package_show.side_effect = NotFound()
diff --git a/budgetportal/tests/test_datasets.py b/budgetportal/tests/test_datasets.py
index f97303714..0534e0ccb 100644
--- a/budgetportal/tests/test_datasets.py
+++ b/budgetportal/tests/test_datasets.py
@@ -29,16 +29,16 @@ def setUp(self):
def test_get_latest_cpi_resource(self):
results = [
{
- "financial_year": [u"2020-21"],
+ "financial_year": ["2020-21"],
"resources": [
- {"format": u"CSV", "id": u"0c173948-9674-4ca9-aec6-f144bde5cc1e"}
+ {"format": "CSV", "id": "0c173948-9674-4ca9-aec6-f144bde5cc1e"}
],
},
{
- "financial_year": [u"2018-19"],
+ "financial_year": ["2018-19"],
"resources": [
- {"format": u"XLSX", "id": u"d1f96183-83e5-4ff1-87f5-c58e279b6f63"},
- {"format": u"CSV", "id": u"5b315ff0-55e9-4ba8-b88c-2d70093bfe9d"},
+ {"format": "XLSX", "id": "d1f96183-83e5-4ff1-87f5-c58e279b6f63"},
+ {"format": "CSV", "id": "5b315ff0-55e9-4ba8-b88c-2d70093bfe9d"},
],
},
]
@@ -52,9 +52,9 @@ def test_get_latest_cpi_resource(self):
def test_get_latest_cpi_resource_multiple_financial_year_values(self):
results = [
{
- "financial_year": [u"2019-20", "2020-21"],
+ "financial_year": ["2019-20", "2020-21"],
"resources": [
- {"format": u"CSV", "id": u"0c173948-9674-4ca9-aec6-f144bde5cc1e"}
+ {"format": "CSV", "id": "0c173948-9674-4ca9-aec6-f144bde5cc1e"}
],
}
]
diff --git a/budgetportal/tests/test_department.py b/budgetportal/tests/test_department.py
index c2ef22f9c..4ee01b862 100644
--- a/budgetportal/tests/test_department.py
+++ b/budgetportal/tests/test_department.py
@@ -107,7 +107,7 @@ def setUp(self):
self.department._get_budget_virements = Mock(return_value=Mock())
self.department._get_budget_special_appropriations = Mock(return_value=Mock())
self.department._get_budget_direct_charges = Mock(return_value=Mock())
- models.csv_url = Mock(return_value=Mock())
+ models.government.csv_url = Mock(return_value=Mock())
def test_no_adjustment(self):
self.department._get_total_budget_adjustment = Mock(return_value=(123, 0))
@@ -168,7 +168,7 @@ def setUp(self):
return_value=self.mock_openspending_api
)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
def test_no_cells_null_response(self, mock_get_dataset):
self.mock_openspending_api.filter_dept = Mock(return_value={"cells": []})
mock_get_dataset.return_value = self.mock_dataset
@@ -176,16 +176,20 @@ def test_no_cells_null_response(self, mock_get_dataset):
result = self.department.get_expenditure_time_series_by_programme()
self.assertEqual(result, None)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
- @mock.patch("budgetportal.models.get_cpi", return_value=mock_data.CPI_2019_20)
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
+ @mock.patch(
+ "budgetportal.models.government.get_cpi", return_value=mock_data.CPI_2019_20
+ )
def test_complete_data_no_notices(self, mock_get_cpi, mock_get_dataset):
mock_get_dataset.return_value = self.mock_dataset
result = self.department.get_expenditure_time_series_by_programme()
self.assertEqual(result["notices"], [])
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
- @mock.patch("budgetportal.models.get_cpi", return_value=mock_data.CPI_2019_20)
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
+ @mock.patch(
+ "budgetportal.models.government.get_cpi", return_value=mock_data.CPI_2019_20
+ )
def test_missing_data_prog_did_not_exist(self, mock_get_cpi, mock_get_dataset):
"""
Here we feed an incomplete set of cells and expect it to tell us that
@@ -258,7 +262,7 @@ def setUp(self):
dataset_patch.start()
self.addCleanup(dataset_patch.stop)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
def test_no_cells_null_response(self, mock_get_dataset):
self.mock_openspending_api.aggregate_by_refs = Mock(return_value=[])
mock_get_dataset.return_value = self.mock_dataset
@@ -266,8 +270,10 @@ def test_no_cells_null_response(self, mock_get_dataset):
result = self.department.get_expenditure_time_series_summary()
self.assertEqual(result, None)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
- @mock.patch("budgetportal.models.get_cpi", return_value=mock_data.CPI_2019_20)
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
+ @mock.patch(
+ "budgetportal.models.government.get_cpi", return_value=mock_data.CPI_2019_20
+ )
def test_complete_data_no_notices(self, mock_get_cpi, mock_get_dataset):
mock_get_dataset.return_value = self.mock_dataset
self.mock_openspending_api.aggregate_by_refs = Mock(
@@ -277,8 +283,10 @@ def test_complete_data_no_notices(self, mock_get_cpi, mock_get_dataset):
result = self.department.get_expenditure_time_series_summary()
self.assertEqual(result["notices"], [])
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
- @mock.patch("budgetportal.models.get_cpi", return_value=mock_data.CPI_2019_20)
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
+ @mock.patch(
+ "budgetportal.models.government.get_cpi", return_value=mock_data.CPI_2019_20
+ )
def test_missing_data_not_published(self, mock_get_cpi, mock_get_dataset):
"""
Here we feed an incomplete set of cells and expect it to tell us that
@@ -301,8 +309,10 @@ def test_missing_data_not_published(self, mock_get_cpi, mock_get_dataset):
],
)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
- @mock.patch("budgetportal.models.get_cpi", return_value=mock_data.CPI_2019_20)
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
+ @mock.patch(
+ "budgetportal.models.government.get_cpi", return_value=mock_data.CPI_2019_20
+ )
def test_missing_data_dept_did_not_exist(self, mock_get_cpi, mock_get_dataset):
"""
Here we feed an incomplete set of cells and expect it to tell us
@@ -325,7 +335,7 @@ def test_missing_data_dept_did_not_exist(self, mock_get_cpi, mock_get_dataset):
class DepartmentWebsiteUrlTestCase(TestCase):
- """ Integration test to verify that website urls are retrieved and output correctly """
+ """Integration test to verify that website urls are retrieved and output correctly"""
def setUp(self):
year_old = FinancialYear.objects.create(slug="2017-18")
@@ -356,8 +366,8 @@ def setUp(self):
)
def test_website_url_always_returns_latest_department_year(self):
- """ Make sure that any given department for any given year always returns the website url of the
- latest department instance in that sphere, where it is not null """
+ """Make sure that any given department for any given year always returns the website url of the
+ latest department instance in that sphere, where it is not null"""
self.assertEqual(
self.department.get_latest_website_url(), "https://governmentwebsite.co.za"
)
@@ -368,7 +378,7 @@ def test_website_url_always_returns_latest_department_year(self):
class NationalTreemapExpenditureByDepartmentTestCase(TestCase):
- """ Unit tests for the treemap expenditure by department function. """
+ """Unit tests for the treemap expenditure by department function."""
def setUp(self):
self.mock_data = TREEMAP_MOCK_DATA
@@ -422,7 +432,7 @@ def setUp(self):
"budgetportal.models.Department.get_all_budget_totals_by_year_and_phase",
return_value=mock.MagicMock(),
)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
def test_no_cells_null_response(self, mock_get_dataset, total_budgets_mock):
self.mock_openspending_api.aggregate_by_refs = Mock(return_value=[])
mock_get_dataset.return_value = self.mock_dataset
@@ -436,7 +446,7 @@ def test_no_cells_null_response(self, mock_get_dataset, total_budgets_mock):
"budgetportal.models.Department.get_all_budget_totals_by_year_and_phase",
return_value=mock.MagicMock(),
)
- @mock.patch("budgetportal.models.get_expenditure_time_series_dataset")
+ @mock.patch("budgetportal.models.government.get_expenditure_time_series_dataset")
def test_complete_data(self, mock_get_dataset, total_budgets_mock):
self.mock_openspending_api.aggregate_by_refs = Mock(
return_value=self.mock_data["complete"]
diff --git a/budgetportal/tests/test_department_page.py b/budgetportal/tests/test_department_page.py
index 2cc373605..1a27a1d1c 100644
--- a/budgetportal/tests/test_department_page.py
+++ b/budgetportal/tests/test_department_page.py
@@ -14,6 +14,8 @@
class DepartmentPageTestCase(TestCase):
+ dataset_year_note = "Budget (Main appropriation) 2018-19"
+
def setUp(self):
self.mock_openspending_api = MagicMock()
self.mock_openspending_api.get_adjustment_kind_ref.return_value = (
@@ -49,7 +51,7 @@ def setUp(self):
government=south_africa, name="The Presidency", vote_number=1, intro=""
)
- models_ckan_patch = patch("budgetportal.models.ckan")
+ models_ckan_patch = patch("budgetportal.models.government.ckan")
ModelsCKANMockClass = models_ckan_patch.start()
ModelsCKANMockClass.action.package_search.return_value = {"results": []}
self.addCleanup(models_ckan_patch.stop)
@@ -72,12 +74,8 @@ def test_no_resource_links(self):
PerformanceResourceLink.objects.all().delete()
InYearMonitoringResourceLink.objects.all().delete()
- with patch(
- "budgetportal.views.DepartmentSubprogrammes.get_openspending_api",
- MagicMock(return_value=self.mock_openspending_api),
- ):
- c = Client()
- response = c.get("/2018-19/national/departments/the-presidency/")
+ c = Client()
+ response = c.get("/2018-19/national/departments/the-presidency/")
self.assertContains(
response, "The Presidency budget data for the 2018-19 financial year"
@@ -98,12 +96,8 @@ def test_basic_links(self):
title="an in-year link", url="a.com", description="abc"
)
- with patch(
- "budgetportal.views.DepartmentSubprogrammes.get_openspending_api",
- MagicMock(return_value=self.mock_openspending_api),
- ):
- c = Client()
- response = c.get("/2018-19/national/departments/the-presidency/")
+ c = Client()
+ response = c.get("/2018-19/national/departments/the-presidency/")
self.assertContains(
response, "The Presidency budget data for the 2018-19 financial year"
@@ -136,12 +130,8 @@ def test_sphere_specific_links(self):
sphere_slug="all",
)
- with patch(
- "budgetportal.views.DepartmentSubprogrammes.get_openspending_api",
- MagicMock(return_value=self.mock_openspending_api),
- ):
- c = Client()
- response = c.get("/2018-19/national/departments/the-presidency/")
+ c = Client()
+ response = c.get("/2018-19/national/departments/the-presidency/")
self.assertContains(
response, "The Presidency budget data for the 2018-19 financial year"
@@ -149,3 +139,27 @@ def test_sphere_specific_links(self):
self.assertContains(response, "a national link")
self.assertNotContains(response, "a provincial link")
self.assertContains(response, "an all-sphere link")
+
+ def test_missing_budget_dataset(self):
+ c = Client()
+ response = c.get("/2018-19/national/departments/the-presidency/")
+
+ self.assertContains(response, "Data not available")
+ self.assertNotContains(response, self.dataset_year_note)
+
+ def test_budget_dataset_available(self):
+ # mock get dataset to return mock dataset which includes opn_spending _api mocks
+ mock_dataset = MagicMock()
+ mock_dataset.get_openspending_api.return_value = self.mock_openspending_api
+ with patch(
+ "budgetportal.views.DepartmentSubprogrammes.get_dataset",
+ MagicMock(return_value=mock_dataset),
+ ):
+ c = Client()
+ response = c.get("/2018-19/national/departments/the-presidency/")
+
+ self.assertContains(response, self.dataset_year_note)
+ self.assertNotContains(response, "Data not available")
+ self.assertContains(
+ response, "/2018-19/national/departments/the-presidency/viz/subprog-treemap"
+ )
diff --git a/budgetportal/tests/test_featured_infra_projects.py b/budgetportal/tests/test_featured_infra_projects.py
index 3f4fbc72b..405191638 100644
--- a/budgetportal/tests/test_featured_infra_projects.py
+++ b/budgetportal/tests/test_featured_infra_projects.py
@@ -5,7 +5,7 @@
class ProjectedExpenditureTestCase(TestCase):
- """ Unit tests for get_projected_expenditure function """
+ """Unit tests for get_projected_expenditure function"""
fixtures = ["test-infrastructure-pages-detail"]
@@ -18,7 +18,7 @@ def test_success(self):
class CoordinatesTestCase(TestCase):
- """ Unit tests for parsing coordinates """
+ """Unit tests for parsing coordinates"""
def test_success_simple_format(self):
raw_coord_string = "-26.378582,27.654933"
@@ -54,7 +54,7 @@ def test_empty_response_for_invalid_value(self):
class ExpenditureTestCase(TestCase):
- """ Unit tests for expenditure functions """
+ """Unit tests for expenditure functions"""
fixtures = ["test-infrastructure-pages-detail"]
@@ -152,7 +152,7 @@ def setUp(self):
@mock.patch("requests.get", return_value=empty_ckan_response)
def test_success_empty_projects(self, mock_get):
- """ Test that it exists and that the correct years are linked. """
+ """Test that it exists and that the correct years are linked."""
InfrastructureProjectPart.objects.all().delete()
c = Client()
response = c.get("/json/infrastructure-projects.json")
@@ -170,7 +170,7 @@ def test_success_empty_projects(self, mock_get):
@mock.patch("requests.get", side_effect=mocked_requests_get)
def test_success_with_projects(self, mock_get):
- """ Test that it exists and that the correct years are linked. """
+ """Test that it exists and that the correct years are linked."""
c = Client()
response = c.get("/json/infrastructure-projects.json")
content = response.json()
@@ -242,7 +242,7 @@ def setUp(self):
@mock.patch("requests.get", side_effect=mocked_requests_get)
def test_success_with_projects(self, mock_get):
- """ Test that it exists and that the correct years are linked. """
+ """Test that it exists and that the correct years are linked."""
c = Client()
response = c.get(
"/json/infrastructure-projects/{}.json".format(self.project.project_slug)
diff --git a/budgetportal/tests/test_featured_projects.py b/budgetportal/tests/test_featured_projects.py
index 4b50071ba..564c8441c 100644
--- a/budgetportal/tests/test_featured_projects.py
+++ b/budgetportal/tests/test_featured_projects.py
@@ -48,7 +48,7 @@ def test_ppp_project_detail_page_fields(self):
project_title = selenium.find_element_by_css_selector("#project-title").text
budget = selenium.find_element_by_css_selector("#total-budget").text
line = selenium.find_element_by_css_selector(".recharts-line")
- self.assertEqual(partnership_type, u"Fake Partnership")
- self.assertEqual(project_title, u"School Infrastructure Backlogs Grant")
- self.assertEqual(budget, u"R4 billion")
+ self.assertEqual(partnership_type, "Fake Partnership")
+ self.assertEqual(project_title, "School Infrastructure Backlogs Grant")
+ self.assertEqual(budget, "R4 billion")
self.assertEqual(line.is_displayed(), True)
diff --git a/budgetportal/tests/test_infra_project_chart.py b/budgetportal/tests/test_infra_project_chart.py
index 47012902b..51b98382e 100644
--- a/budgetportal/tests/test_infra_project_chart.py
+++ b/budgetportal/tests/test_infra_project_chart.py
@@ -30,7 +30,7 @@ def setUp(self):
def test_dates_are_end_of_quarters(self):
"""Test that all dates are end day of a quarter"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 4)
# Q1->06-30, Q2->09-30, Q3->12-31, Q4->03-31
@@ -42,7 +42,7 @@ def test_dates_are_end_of_quarters(self):
def test_dates_match_with_quarters(self):
"""Test that dates and quarter_labels match"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 4)
# Q1->06-30, Q2->09-30, Q3->12-31, Q4->03-31
@@ -72,7 +72,7 @@ def setUp(self):
def test_estimated_total_project_cost_is_null(self):
"""Test that total project cost for Q1 (which created by Q2 snapshot) is Null"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q1 values
@@ -81,7 +81,7 @@ def test_estimated_total_project_cost_is_null(self):
def test_estimated_total_project_cost_assigned_correctly(self):
"""Test that total project cost for Q2 is 100"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q2 values
@@ -102,7 +102,7 @@ def setUp(self):
def test_status_is_null(self):
"""Test that status for Q1 (which created by Q2 snapshot) is Null"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q1 values
@@ -111,7 +111,7 @@ def test_status_is_null(self):
def test_status_assigned_correctly(self):
"""Test that status for Q2 is Tender"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q2 values
@@ -135,7 +135,7 @@ def setUp(self):
def test_q1_updated_after_q2_snapshot_inserted(self):
"""Test that Q1 values are updated correctly when Q2 snapshot is added"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 1)
# Check Q1 values
@@ -156,7 +156,7 @@ def test_q1_updated_after_q2_snapshot_inserted(self):
snapshots_data = time_series_data(
[self.project_snapshot, self.project_snapshot_2]
)
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q1 values
@@ -192,7 +192,7 @@ def test_q1_q2_updated_after_q3_snapshot_inserted(self):
snapshots_data = time_series_data(
[self.project_snapshot, self.project_snapshot_2]
)
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q1 values
@@ -218,7 +218,7 @@ def test_q1_q2_updated_after_q3_snapshot_inserted(self):
snapshots_data = time_series_data(
[self.project_snapshot, self.project_snapshot_2, self.project_snapshot_3]
)
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 3)
# Check Q1 values
@@ -249,7 +249,7 @@ def setUp(self):
def test_total_spends_are_correct(self):
"""Test that total spends are none because of actual_expenditure_q1"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 3)
# Check total_spent_to_date values for Q1, Q2 and Q3
@@ -277,7 +277,7 @@ def test_correct_value_used_for_previous_total(self):
Q2 snapshot's expenditure_from_previous_years_total updates total_spent of Q1 chart item.
"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 1)
self.assertEqual(snapshots_data[0]["total_spent_to_date"], 110)
@@ -295,7 +295,7 @@ def test_correct_value_used_for_previous_total(self):
snapshots_data = time_series_data(
[self.project_snapshot, self.project_snapshot_2]
)
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check total_spent_to_date values for Q1 and Q2
@@ -322,7 +322,7 @@ def test_total_spends_are_none(self):
"""Test that Q1 and Q2 total_spent values when expenditure_
from_previous_years_total is empty."""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check total_spent_to_date values for Q1 and Q2
@@ -344,7 +344,7 @@ def setUp(self):
def test_two_snapshots_emitted(self):
"""Test that if the first snapshot is Q2, items are created for Q1 and Q2 but nothing later than Q2."""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q1 values
@@ -380,7 +380,7 @@ def test_six_snapshots_emitted(self):
snapshots_data = time_series_data(
[self.project_snapshot, self.project_snapshot_2]
)
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 6)
# Check 2018's Q1 and Q2 in a row
@@ -435,7 +435,7 @@ def test_total_spent_to_dates_are_correct(self):
snapshots_data = time_series_data(
[self.project_snapshot, self.project_snapshot_2]
)
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 5)
# Check that 2019 Q1 Snapshot's total_spent_to_date is correct
@@ -457,7 +457,7 @@ def setUp(self):
def test_label_is_assigned_to_q1(self):
"""Test that financial year label is correctly assigned for Q1"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q1 values
@@ -467,7 +467,7 @@ def test_label_is_assigned_to_q1(self):
def test_label_is_empty_for_q2(self):
"""Test that financial year label is empty for quarters except Q1"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 2)
# Check Q2 values
@@ -489,7 +489,7 @@ def setUp(self):
def test_label_is_correct(self):
"""Test that quarter labels start with 'END Q' and ends with (1,2,3,4)"""
snapshots_data = time_series_data([self.project_snapshot])
- snapshots_data = snapshots_data[u"snapshots"]
+ snapshots_data = snapshots_data["snapshots"]
self.assertEqual(len(snapshots_data), 4)
# Check quarter label texts for all quarters
@@ -519,7 +519,7 @@ def setUp(self):
def test_events_assigned_correctly(self):
"""Test that all dates are assigned correctly"""
events_data = time_series_data([self.project_snapshot])
- events_data = events_data[u"events"]
+ events_data = events_data["events"]
self.assertEqual(len(events_data), 5)
# Project Start Date
@@ -541,7 +541,7 @@ def test_events_when_latest_snapshot_has_empty_dates(self):
irm_snapshot=irm_snapshot_2, project=self.project, start_date="2029-09-30"
)
events_data = time_series_data([self.project_snapshot, self.project_snapshot_2])
- events_data = events_data[u"events"]
+ events_data = events_data["events"]
self.assertEqual(len(events_data), 1)
# Project Start Date
diff --git a/budgetportal/tests/test_infra_projects.py b/budgetportal/tests/test_infra_projects.py
index 3e1bc6623..9e71e9021 100644
--- a/budgetportal/tests/test_infra_projects.py
+++ b/budgetportal/tests/test_infra_projects.py
@@ -237,11 +237,11 @@ def test_project_detail_page_fields(self):
selenium = self.selenium
self.wait.until(
EC.text_to_be_present_in_element(
- (By.CSS_SELECTOR, ".page-heading"), u"BLUE JUNIOR SECONDARY SCHOOL"
+ (By.CSS_SELECTOR, ".page-heading"), "BLUE JUNIOR SECONDARY SCHOOL"
)
)
title = selenium.find_element_by_css_selector(".page-heading").text
- self.assertEqual(title, u"BLUE JUNIOR SECONDARY SCHOOL")
+ self.assertEqual(title, "BLUE JUNIOR SECONDARY SCHOOL")
source = selenium.find_element_by_css_selector(
".primary-funding-source-field"
@@ -256,9 +256,9 @@ def test_project_detail_page_fields(self):
".header__download"
).get_attribute("href")
- self.assertEqual(source, u"Education Infrastructure Grant")
- self.assertEqual(investment, u"Upgrading and Additions")
- self.assertEqual(funding_status, u"Tabled")
+ self.assertEqual(source, "Education Infrastructure Grant")
+ self.assertEqual(investment, "Upgrading and Additions")
+ self.assertEqual(funding_status, "Tabled")
self.assertIn(self.project.csv_download_url, csv_download_url)
department = selenium.find_element_by_css_selector(".department-field").text
@@ -270,12 +270,12 @@ def test_project_detail_page_fields(self):
".project-number-field"
).text
- self.assertEqual(department, u"Education")
+ self.assertEqual(department, "Education")
self.assertEqual(
- budget_programme, u"Programme 2 - Public Ordinary School Education"
+ budget_programme, "Programme 2 - Public Ordinary School Education"
)
- self.assertEqual(project_status, u"Construction")
- self.assertEqual(project_number, u"W/50042423/WS")
+ self.assertEqual(project_status, "Construction")
+ self.assertEqual(project_number, "W/50042423/WS")
province = selenium.find_element_by_css_selector(".province-field").text
local_muni = selenium.find_element_by_css_selector(
@@ -286,10 +286,10 @@ def test_project_detail_page_fields(self):
).text
gps_location = selenium.find_element_by_css_selector(".coordinates-field").text
- self.assertEqual(province, u"KwaZulu-Natal")
- self.assertEqual(local_muni, u"Dr Nkosazana Dlamini Zuma")
- self.assertEqual(district_muni, u"Harry Gwala")
- self.assertEqual(gps_location, u"Not available")
+ self.assertEqual(province, "KwaZulu-Natal")
+ self.assertEqual(local_muni, "Dr Nkosazana Dlamini Zuma")
+ self.assertEqual(district_muni, "Harry Gwala")
+ self.assertEqual(gps_location, "Not available")
implementing_agent = selenium.find_element_by_css_selector(
".program-implementing-agent-field"
@@ -304,17 +304,17 @@ def test_project_detail_page_fields(self):
".other-service-providers-field"
).text
- self.assertEqual(implementing_agent, u"DOPW")
- self.assertEqual(principle_agent, u"PRINCIPLE AGENT")
- self.assertEqual(main_contractor, u"MAIN CONTRACTOR")
- self.assertEqual(others, u"OTHERS")
+ self.assertEqual(implementing_agent, "DOPW")
+ self.assertEqual(principle_agent, "PRINCIPLE AGENT")
+ self.assertEqual(main_contractor, "MAIN CONTRACTOR")
+ self.assertEqual(others, "OTHERS")
professional_fees = selenium.find_element_by_css_selector(
"#total-professional-fees-field"
).text
- self.wait_until_text_in("#total-construction-costs-field", u"R 562,000")
- self.assertEqual(professional_fees, u"R 118,000")
+ self.wait_until_text_in("#total-construction-costs-field", "R 562,000")
+ self.assertEqual(professional_fees, "R 118,000")
expenditure_from_prev = selenium.find_element_by_css_selector(
".expenditure-from-previous-years-total-field"
@@ -329,10 +329,10 @@ def test_project_detail_page_fields(self):
".variation-orders-field"
).text
- self.assertEqual(expenditure_from_prev, u"R 556,479")
- self.assertEqual(const_cost_from_prev, u"R 0")
- self.assertEqual(prof_cost_from_prev, u"R 118,000")
- self.assertEqual(variation_order, u"R 0")
+ self.assertEqual(expenditure_from_prev, "R 556,479")
+ self.assertEqual(const_cost_from_prev, "R 0")
+ self.assertEqual(prof_cost_from_prev, "R 118,000")
+ self.assertEqual(variation_order, "R 0")
total_main_approp = selenium.find_element_by_css_selector(
".main-appropriation-total-field"
@@ -344,17 +344,17 @@ def test_project_detail_page_fields(self):
".main-appropriation-professional-fees-field"
).text
- self.assertEqual(total_main_approp, u"R 337,000")
- self.assertEqual(const_cost_main_approp, u"R 276,000")
- self.assertEqual(prof_fees_main_approp, u"R 61,000")
+ self.assertEqual(total_main_approp, "R 337,000")
+ self.assertEqual(const_cost_main_approp, "R 276,000")
+ self.assertEqual(prof_fees_main_approp, "R 61,000")
start_date = selenium.find_element_by_css_selector(".start-date-field").text
estimated_completion = selenium.find_element_by_css_selector(
".estimated-completion-date-field"
).text
- self.assertEqual(start_date, u"2016-06-13")
- self.assertEqual(estimated_completion, u"2021-06-30")
+ self.assertEqual(start_date, "2016-06-13")
+ self.assertEqual(estimated_completion, "2021-06-30")
est_const_start_date = selenium.find_element_by_css_selector(
".estimated-construction-start-date-field"
@@ -366,9 +366,9 @@ def test_project_detail_page_fields(self):
".estimated-construction-end-date-field"
).text
- self.assertEqual(est_const_start_date, u"2017-02-01")
- self.assertEqual(contracted_const_end_date, u"2021-01-01")
- self.assertEqual(est__const_end_date, u"2020-12-31")
+ self.assertEqual(est_const_start_date, "2017-02-01")
+ self.assertEqual(contracted_const_end_date, "2021-01-01")
+ self.assertEqual(est__const_end_date, "2020-12-31")
class InfraProjectSearchPageTestCase(BaseSeleniumTestCase):
@@ -425,7 +425,7 @@ def test_search_homepage_correct_numbers(self):
selenium.get("%s%s" % (self.live_server_url, self.url))
self.wait.until(
EC.text_to_be_present_in_element(
- (By.CSS_SELECTOR, "#num-matching-projects-field"), u"11"
+ (By.CSS_SELECTOR, "#num-matching-projects-field"), "11"
)
)
num_of_projects = selenium.find_element_by_css_selector(
@@ -447,7 +447,7 @@ def test_number_updated_after_search(self):
selenium.get("%s%s" % (self.live_server_url, self.url))
self.wait.until(
EC.text_to_be_present_in_element(
- (By.CSS_SELECTOR, "#num-matching-projects-field"), u"11"
+ (By.CSS_SELECTOR, "#num-matching-projects-field"), "11"
)
)
num_of_projects = selenium.find_element_by_css_selector(
@@ -464,7 +464,7 @@ def test_number_updated_after_search(self):
search_button.click()
self.wait.until(
EC.text_to_be_present_in_element(
- (By.CSS_SELECTOR, "#num-matching-projects-field"), u"5"
+ (By.CSS_SELECTOR, "#num-matching-projects-field"), "5"
)
)
filtered_num_of_projects = selenium.find_element_by_css_selector(
@@ -478,7 +478,7 @@ def test_csv_download_button_populating(self):
selenium.get("%s%s" % (self.live_server_url, self.url))
self.wait.until(
EC.text_to_be_present_in_element(
- (By.CSS_SELECTOR, "#num-matching-projects-field"), u"11"
+ (By.CSS_SELECTOR, "#num-matching-projects-field"), "11"
)
)
csv_download_url = selenium.find_element_by_css_selector(
@@ -1488,7 +1488,8 @@ def test_csv_download_empty_file(self):
csv_download_url = response.data["csv_download_url"]
response = self.client.get(csv_download_url)
self._test_response_correctness(
- response, "infrastructure-projects-q-data-that-won-t-be-found.csv",
+ response,
+ "infrastructure-projects-q-data-that-won-t-be-found.csv",
)
content = b"".join(response.streaming_content)
diff --git a/budgetportal/tests/test_pages.py b/budgetportal/tests/test_pages.py
index 85263bfa9..f752e49f3 100644
--- a/budgetportal/tests/test_pages.py
+++ b/budgetportal/tests/test_pages.py
@@ -52,7 +52,7 @@ def setUp(self):
Department.objects.create(
government=fake_cape, name="Fake Health", vote_number=1, intro=""
)
- models_ckan_patch = patch("budgetportal.models.ckan")
+ models_ckan_patch = patch("budgetportal.models.government.ckan")
ModelsCKANMockClass = models_ckan_patch.start()
ModelsCKANMockClass.action.package_search.return_value = {"results": []}
self.addCleanup(models_ckan_patch.stop)
@@ -141,7 +141,6 @@ def test_department_detail_page(self):
self.assertContains(
response, "The Presidency budget data for the 2019-20 financial year"
)
- self.assertContains(response, "Budget (Main appropriation) 2019-20")
def test_department_preview_page(self):
"""Test that it loads and that some text is present"""
diff --git a/budgetportal/tests/test_summaries.py b/budgetportal/tests/test_summaries.py
index 9cfb810d4..5933562b8 100644
--- a/budgetportal/tests/test_summaries.py
+++ b/budgetportal/tests/test_summaries.py
@@ -41,7 +41,7 @@
class ConsolidatedTreemapTestCase(TestCase):
- """ Unit tests for the consolidated treemap function(s) """
+ """Unit tests for the consolidated treemap function(s)"""
def setUp(self):
self.year = FinancialYear.objects.create(slug="2019-20")
@@ -86,7 +86,7 @@ def test_complete_data(self, mock_get_dataset):
class FocusAreaPagesTestCase(TestCase):
- """ Integration test focus area page data generation """
+ """Integration test focus area page data generation"""
def setUp(self):
self.year = FinancialYear.objects.create(slug="2019-20")
@@ -184,7 +184,7 @@ def test_get_focus_area_preview(
class NationalDepartmentPreviewTestCase(TestCase):
- """ Unit tests for the national department preview department function. """
+ """Unit tests for the national department preview department function."""
def setUp(self):
self.mock_data = NATIONAL_DEPARTMENT_PREVIEW_MOCK_DATA
diff --git a/budgetportal/urls.py b/budgetportal/urls.py
index 193f5b2e9..9b3a36f8e 100644
--- a/budgetportal/urls.py
+++ b/budgetportal/urls.py
@@ -20,7 +20,6 @@
admin.autodiscover()
CACHE_MINUTES_SECS = 60 * 5 # minutes
-CACHE_DAYS_SECS = 60 * 60 * 24 * 5 # days
def permission_denied(request):
@@ -37,17 +36,17 @@ def trigger_error(request):
),
url(
r"^viz/subprog-treemap$",
- cache_page(CACHE_DAYS_SECS)(views.department_viz_subprog_treemap),
+ cache_page(CACHE_MINUTES_SECS)(views.department_viz_subprog_treemap),
name="department-viz-subprog-treemap",
),
url(
r"^viz/subprog-econ4-circles$",
- cache_page(CACHE_DAYS_SECS)(views.department_viz_subprog_econ4_circles),
+ cache_page(CACHE_MINUTES_SECS)(views.department_viz_subprog_econ4_circles),
name="department-viz-subprog-econ4-circles",
),
url(
r"^viz/subprog-econ4-bars$",
- cache_page(CACHE_DAYS_SECS)(views.department_viz_subprog_econ4_bars),
+ cache_page(CACHE_MINUTES_SECS)(views.department_viz_subprog_econ4_bars),
name="department-viz-subprog-econ4-bars",
),
]
@@ -61,7 +60,7 @@ def trigger_error(request):
),
url(
r"^json/(?P
\d{4}-\d{2})" "/focus.json",
- cache_page(CACHE_DAYS_SECS)(views.focus_preview_json),
+ cache_page(CACHE_MINUTES_SECS)(views.focus_preview_json),
name="focus-json",
),
# National and provincial treemap data
@@ -69,7 +68,7 @@ def trigger_error(request):
r"^json/(?P\d{4}-\d{2})"
"/(?P[\w-]+)"
"/(?P[\w-]+).json",
- cache_page(CACHE_DAYS_SECS)(views.treemaps_json),
+ cache_page(CACHE_MINUTES_SECS)(views.treemaps_json),
),
# Preview pages
url(
@@ -87,13 +86,13 @@ def trigger_error(request):
"/(?P[\w-]+)"
"/(?P[\w-]+)"
"/(?P[\w-]+).json",
- cache_page(CACHE_DAYS_SECS)(views.department_preview_json),
+ cache_page(CACHE_MINUTES_SECS)(views.department_preview_json),
name="department-preview-json",
),
# Consolidated
url(
r"^json/(?P\d{4}-\d{2})" "/consolidated.json",
- cache_page(CACHE_DAYS_SECS)(views.consolidated_treemap_json),
+ cache_page(CACHE_MINUTES_SECS)(views.consolidated_treemap_json),
name="consolidated-json",
),
# Homepage
@@ -101,7 +100,7 @@ def trigger_error(request):
# Search results
url(
r"^json/static-search.json",
- cache_page(CACHE_DAYS_SECS)(views.static_search_data),
+ cache_page(CACHE_MINUTES_SECS)(views.static_search_data),
),
# Department list as CSV
url(
@@ -130,14 +129,14 @@ def trigger_error(request):
# CSV
url(
r"^csv/$",
- cache_page(CACHE_DAYS_SECS)(views.openspending_csv),
+ cache_page(CACHE_MINUTES_SECS)(views.openspending_csv),
name="openspending_csv",
),
# Admin
url(r"^admin/", admin.site.urls),
url(r"^admin/bulk_upload/template", bulk_upload.template_view),
# Budget Portal
- url(r"^about/?$", cache_page(CACHE_DAYS_SECS)(views.about), name="about"),
+ url(r"^about/?$", cache_page(CACHE_MINUTES_SECS)(views.about), name="about"),
url(r"^events/?$", cache_page(CACHE_MINUTES_SECS)(views.events), name="events"),
url(
r"^learning-resources/?$",
@@ -151,12 +150,12 @@ def trigger_error(request):
),
url(
r"^terms-and-conditions/?$",
- cache_page(CACHE_DAYS_SECS)(views.terms_and_conditions),
+ cache_page(CACHE_MINUTES_SECS)(views.terms_and_conditions),
name="terms-and-conditions",
),
url(
r"^learning-resources/resources/?$",
- cache_page(CACHE_DAYS_SECS)(views.resources),
+ cache_page(CACHE_MINUTES_SECS)(views.resources),
name="resources",
),
url(
@@ -245,16 +244,29 @@ def trigger_error(request):
"/(?P[\w-]+)/",
include((department_urlpatterns, "provincial"), namespace="provincial"),
),
- url(r"^robots\.txt$", views.robots,),
+ url(
+ r"^robots\.txt$",
+ views.robots,
+ ),
+ # Performance app
+ path("performance/", include("performance.urls")),
+ # IYM app
+ path("iym/", include("iym.urls")),
+ # Budget summary
+ url(
+ r"^budget-summary/?$",
+ cache_page(CACHE_MINUTES_SECS)(views.budget_summary_view),
+ name="budget-summary",
+ ),
# Sitemap
url(
r"^sitemap\.xml$",
- cache_page(CACHE_DAYS_SECS)(sitemap_views.index),
+ cache_page(CACHE_MINUTES_SECS)(sitemap_views.index),
{"sitemaps": sitemaps},
),
url(
r"^sitemap-(?P.+)\.xml$",
- cache_page(CACHE_DAYS_SECS)(sitemap_views.sitemap),
+ cache_page(CACHE_MINUTES_SECS)(sitemap_views.sitemap),
{"sitemaps": sitemaps},
name="django.contrib.sitemaps.views.sitemap",
),
@@ -264,6 +276,7 @@ def trigger_error(request):
re_path(r"^", include(wagtail_urls)),
] + static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+
if settings.DEBUG_TOOLBAR:
import debug_toolbar
diff --git a/budgetportal/views.py b/budgetportal/views.py
index db1e94f95..e95fc31fc 100644
--- a/budgetportal/views.py
+++ b/budgetportal/views.py
@@ -11,6 +11,7 @@
from budgetportal.csv_gen import generate_csv_response
from budgetportal.openspending import PAGE_SIZE
from django.conf import settings
+from django.core.serializers import serialize
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Count
from django.forms.models import model_to_dict
@@ -18,6 +19,7 @@
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from haystack.query import SearchQuerySet
+from constance import config
from .datasets import Category, Dataset
from .models import (
@@ -35,6 +37,7 @@
ProcurementResourceLink,
Sphere,
Video,
+ ShowcaseItem,
)
from .summaries import (
DepartmentProgrammesEcon4,
@@ -44,6 +47,7 @@
get_focus_area_preview,
get_preview_page,
)
+from .json_encoder import JSONEncoder
logger = logging.getLogger(__name__)
@@ -51,6 +55,25 @@
COMMON_DESCRIPTION_ENDING = "from National Treasury in partnership with IMALI YETHU."
+def serialize_showcase(showcase_items):
+ showcase_items_dicts = [
+ {
+ "name": i.name,
+ "description": i.description,
+ "cta_text_1": i.cta_text_1,
+ "cta_link_1": i.cta_link_1,
+ "cta_text_2": i.cta_text_2,
+ "cta_link_2": i.cta_link_2,
+ "second_cta_type": i.second_cta_type,
+ "thumbnail_url": i.file.url,
+ }
+ for i in showcase_items
+ ]
+ return json.dumps(
+ showcase_items_dicts, cls=DjangoJSONEncoder, sort_keys=True, indent=4
+ )
+
+
def homepage(request):
year = FinancialYear.get_latest_year()
titles = {
@@ -68,6 +91,8 @@ def homepage(request):
.first()
)
+ showcase_items = ShowcaseItem.objects.all()
+
context = {
"selected_financial_year": None,
"financial_years": [],
@@ -91,6 +116,7 @@ def homepage(request):
"call_to_action_heading": page_data.call_to_action_heading,
"call_to_action_link_label": page_data.call_to_action_link_label,
"call_to_action_link_url": page_data.call_to_action_link_url,
+ "showcase_items_json": serialize_showcase(showcase_items),
}
return render(request, "homepage.html", context)
@@ -375,17 +401,31 @@ def department_page(
"selected_tab": "departments",
"title": "%s budget %s - vulekamali" % (department.name, selected_year.slug),
"description": "%s department: %s budget data for the %s financial year %s"
- % (govt_label, department.name, selected_year.slug, COMMON_DESCRIPTION_ENDING,),
+ % (
+ govt_label,
+ department.name,
+ selected_year.slug,
+ COMMON_DESCRIPTION_ENDING,
+ ),
"department_budget": department_budget,
"department_adjusted_budget": department_adjusted_budget,
"procurement_resource_links": ProcurementResourceLink.objects.filter(
- sphere_slug__in=("all", department.government.sphere.slug,)
+ sphere_slug__in=(
+ "all",
+ department.government.sphere.slug,
+ )
),
"performance_resource_links": PerformanceResourceLink.objects.filter(
- sphere_slug__in=("all", department.government.sphere.slug,)
+ sphere_slug__in=(
+ "all",
+ department.government.sphere.slug,
+ )
),
"in_year_monitoring_resource_links": InYearMonitoringResourceLink.objects.filter(
- sphere_slug__in=("all", department.government.sphere.slug,)
+ sphere_slug__in=(
+ "all",
+ department.government.sphere.slug,
+ )
),
"vote_number": department.vote_number,
"vote_primary": {
@@ -403,6 +443,8 @@ def department_page(
context["admin_url"] = reverse(
"admin:budgetportal_department_change", args=(department.pk,)
)
+ context["EQPRS_DATA_ENABLED"] = config.EQPRS_DATA_ENABLED
+
return render(request, "department.html", context)
@@ -475,7 +517,7 @@ def department_viz_subprog_econ4_bars(
def infrastructure_projects_overview(request):
- """ Overview page to showcase all featured infrastructure projects """
+ """Overview page to showcase all featured infrastructure projects"""
infrastructure_projects = InfrastructureProjectPart.objects.filter(
featured=True
).distinct("project_slug")
@@ -966,7 +1008,7 @@ def department_list_json(request, financial_year_id):
def treemaps_data(financial_year_id, phase_slug, sphere_slug):
- """ The data for the vulekamali home page treemaps """
+ """The data for the vulekamali home page treemaps"""
dept = Department.objects.filter(government__sphere__slug=sphere_slug)[0]
if sphere_slug == "national":
page_data = dept.get_national_expenditure_treemap(financial_year_id, phase_slug)
@@ -990,7 +1032,7 @@ def treemaps_json(request, financial_year_id, phase_slug, sphere_slug):
def consolidated_treemap(financial_year_id):
- """ The data for the vulekamali home page treemaps """
+ """The data for the vulekamali home page treemaps"""
financial_year = FinancialYear.objects.get(slug=financial_year_id)
page_data = get_consolidated_expenditure_treemap(financial_year)
if page_data is None:
@@ -1009,7 +1051,7 @@ def consolidated_treemap_json(request, financial_year_id):
def focus_preview_data(financial_year_id):
- """ The data for the focus area preview pages for a specific year """
+ """The data for the focus area preview pages for a specific year"""
financial_year = FinancialYear.objects.get(slug=financial_year_id)
page_data = get_focus_area_preview(financial_year)
return page_data
@@ -1079,3 +1121,19 @@ def robots(request):
def read_object_from_yaml(path_file):
with open(path_file, "r") as f:
return yaml.load(f, Loader=yaml.FullLoader)
+
+
+def budget_summary_view(request):
+ latest_provincial_year = (
+ FinancialYear.objects.filter(spheres__slug="provincial")
+ .annotate(num_depts=Count("spheres__governments__departments"))
+ .filter(num_depts__gt=0)
+ .first()
+ )
+ context = {
+ "navbar": MainMenuItem.objects.prefetch_related("children").all(),
+ "latest_year": FinancialYear.get_latest_year().slug,
+ "latest_provincial_year": latest_provincial_year
+ and latest_provincial_year.slug,
+ }
+ return render(request, "budget-summary.html", context)
diff --git a/budgetportal/webflow/views.py b/budgetportal/webflow/views.py
index 523b2e686..fd9c69fee 100644
--- a/budgetportal/webflow/views.py
+++ b/budgetportal/webflow/views.py
@@ -55,9 +55,11 @@ def infrastructure_project_detail(request, id, slug):
)
page_data["department_url"] = department.get_url_path() if department else None
- page_data["province_depts_url"] = (
- "/%s/departments?province=%s&sphere=provincial"
- % (models.FinancialYear.get_latest_year().slug, slugify(snapshot.province),)
+ page_data[
+ "province_depts_url"
+ ] = "/%s/departments?province=%s&sphere=provincial" % (
+ models.FinancialYear.get_latest_year().slug,
+ slugify(snapshot.province),
)
page_data[
"latest_snapshot_financial_year"
diff --git a/docker-compose.yml b/docker-compose.yml
index 5b09906fd..9925c74dd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,6 +18,7 @@ services:
GROUP_ID: ${GROUP_ID:-1001}
command: python manage.py runserver_plus --nopin 0.0.0.0:8000
environment:
+ - TAG_MANAGER_ID
- DATABASE_URL=postgresql://budgetportal:devpassword@db/budgetportal
- DJANGO_DEBUG_TOOLBAR
- AWS_ACCESS_KEY_ID=minio-access-key
@@ -30,6 +31,13 @@ services:
- DEBUG_CACHE
- DJANGO_WHITENOISE_AUTOREFRESH=TRUE
- PORT=8000
+ - CKAN_URL
+ - DJANGO_Q_SYNC=${DJANGO_Q_SYNC}
+ - CKAN_API_KEY
+ - OPENSPENDING_USER_ID
+ - OPENSPENDING_API_KEY
+ - OPENSPENDING_HOST
+ - OPENSPENDING_DATASET_CREATE_SUFFIX=-dev
volumes:
- .:/app
ports:
@@ -39,6 +47,8 @@ services:
- solr
- minio-client
- minio
+ links:
+ - selenium
# Should be same as app except for command and ports
worker:
@@ -56,30 +66,12 @@ services:
- AWS_STORAGE_BUCKET_NAME=budgetportal-storage
- AWS_S3_ENDPOINT_URL=http://minio:9000
- SOLR_URL=http://solr:8983/solr/budgetportal
- volumes:
- - .:/app
- depends_on:
- - db
- - solr
- - minio
- restart: on-failure
-
- test:
- build:
- context: .
- args:
- USER_ID: ${USER_ID:-1001}
- GROUP_ID: ${GROUP_ID:-1001}
- command: bin/wait-for-db.sh coverage run --source='budgetportal' manage.py test
- environment:
- - DATABASE_URL=postgresql://budgetportal:devpassword@db/budgetportal
- - AWS_ACCESS_KEY_ID=minio-access-key
- - AWS_SECRET_ACCESS_KEY=minio-secret-key
- - AWS_STORAGE_BUCKET_NAME=budgetportal-storage
- - AWS_S3_ENDPOINT_URL=http://minio:9000
- - AWS_S3_SECURE_URLS=True
- - SOLR_URL=http://solr:8983/solr/budgetportal-test
- - DJANGO_Q_SYNC=TRUE
+ - CKAN_URL
+ - CKAN_API_KEY
+ - OPENSPENDING_USER_ID
+ - OPENSPENDING_API_KEY
+ - OPENSPENDING_HOST
+ - OPENSPENDING_DATASET_CREATE_SUFFIX=-dev
volumes:
- .:/app
depends_on:
@@ -120,8 +112,18 @@ services:
- "8983:8983"
volumes:
- solr-data:/opt/solr/server/solr/budgetportal/data
+ ulimits:
+ nofile:
+ soft: 65536
+ hard: 65536
-
+ selenium:
+ image: selenium/standalone-chrome:3.141
+ ports:
+ - 4444:4444
+ - 5900:5900
+ - 7900:7900
+ shm_size: '2gb'
volumes:
db-data:
diff --git a/iym/__init__.py b/iym/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/iym/admin.py b/iym/admin.py
new file mode 100644
index 000000000..f981d3f6b
--- /dev/null
+++ b/iym/admin.py
@@ -0,0 +1,76 @@
+from django.contrib import admin
+from iym import models
+from django_q.tasks import async_task, fetch
+
+import iym
+from iym.tasks import process_uploaded_file
+
+
+class IYMFileUploadAdmin(admin.ModelAdmin):
+ readonly_fields = (
+ "import_report",
+ "user",
+ "processing_completed",
+ "status",
+ "task_id",
+ )
+ fieldsets = (
+ (
+ "",
+ {
+ "fields": (
+ "user",
+ "financial_year",
+ "latest_quarter",
+ "file",
+ "task_id",
+ "import_report",
+ "status",
+ "processing_completed",
+ )
+ },
+ ),
+ )
+ list_display = (
+ "created_at",
+ "user",
+ "financial_year",
+ "latest_quarter",
+ "status",
+ "processing_completed",
+ "updated_at",
+ )
+
+ def save_model(self, request, obj, form, change):
+ if not obj.pk:
+ obj.user = request.user
+ super().save_model(request, obj, form, change)
+
+ obj.task_id = async_task(func=process_uploaded_file, obj_id=obj.id)
+ obj.save()
+
+ def get_readonly_fields(self, request, obj=None):
+ if obj: # editing an existing object
+ return (
+ "financial_year",
+ "latest_quarter",
+ "file",
+ ) + self.readonly_fields
+ return self.readonly_fields
+
+ def has_change_permission(self, request, obj=None):
+ super(IYMFileUploadAdmin, self).has_change_permission(request, obj)
+
+ def has_delete_permission(self, request, obj=None):
+ super(IYMFileUploadAdmin, self).has_delete_permission(request, obj)
+
+ def processing_completed(self, obj):
+ task = fetch(obj.task_id)
+ if task:
+ return task.success
+
+ processing_completed.boolean = True
+ processing_completed.short_description = "Processing completed"
+
+
+admin.site.register(models.IYMFileUpload, IYMFileUploadAdmin)
diff --git a/iym/apps.py b/iym/apps.py
new file mode 100644
index 000000000..b9e0df6d6
--- /dev/null
+++ b/iym/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class IymConfig(AppConfig):
+ name = "In-year monitoring"
diff --git a/iym/data_package/data_package_template.json b/iym/data_package/data_package_template.json
new file mode 100644
index 000000000..3e55d4219
--- /dev/null
+++ b/iym/data_package/data_package_template.json
@@ -0,0 +1,998 @@
+{
+ "promise": {},
+ "resources": [
+ {
+ "format": "csv",
+ "mediatype": "text/csv",
+ "dialect": {
+ "csvddfVersion": 1,
+ "delimiter": ",",
+ "lineTerminator": "\n"
+ },
+ "encoding": "utf-8",
+ "schema": {
+ "fields": [
+ {
+ "title": "VoteNumber",
+ "name": "Vote No#",
+ "slug": "VoteNumber",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level1:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "Department",
+ "name": "Department",
+ "slug": "Department",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level1:label",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel1",
+ "name": "Responsibility_Level_1",
+ "slug": "ResponsibilityLevel1",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level2:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel2",
+ "name": "Responsibility_Level_2",
+ "slug": "ResponsibilityLevel2",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level3:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel3",
+ "name": "Responsibility_Level_3",
+ "slug": "ResponsibilityLevel3",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level4:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel4",
+ "name": "Responsibility_Level_4",
+ "slug": "ResponsibilityLevel4",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level5:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel5",
+ "name": "Responsibility_Level_5",
+ "slug": "ResponsibilityLevel5",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level6:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel6",
+ "name": "Responsibility_Level_6",
+ "slug": "ResponsibilityLevel6",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level7:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel7",
+ "name": "Responsibility_Level_7",
+ "slug": "ResponsibilityLevel7",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level8:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel8",
+ "name": "Responsibility_Level_8",
+ "slug": "ResponsibilityLevel8",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level9:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel9",
+ "name": "Responsibility_Level_9",
+ "slug": "ResponsibilityLevel9",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level10:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel10",
+ "name": "Responsibility_Level_10",
+ "slug": "ResponsibilityLevel10",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level11:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel11",
+ "name": "Responsibility_Level_11",
+ "slug": "ResponsibilityLevel11",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level12:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel12",
+ "name": "Responsibility_Level_12",
+ "slug": "ResponsibilityLevel12",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level13:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel13",
+ "name": "Responsibility_Level_13",
+ "slug": "ResponsibilityLevel13",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level14:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel14",
+ "name": "Responsibility_Level_14",
+ "slug": "ResponsibilityLevel14",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level15:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ResponsibilityLevel15",
+ "name": "Responsibility_Lowest_Level",
+ "slug": "ResponsibilityLevel15",
+ "type": "string",
+ "format": "default",
+ "columnType": "administrative-classification:generic:level16:code",
+ "conceptType": "administrative-classification"
+ },
+ {
+ "title": "ProgNumber",
+ "name": "Programme No#",
+ "slug": "ProgNumber",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:program:code",
+ "conceptType": "activity"
+ },
+ {
+ "title": "Programme",
+ "name": "Programme",
+ "slug": "Programme",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:program:label",
+ "conceptType": "activity"
+ },
+ {
+ "title": "SubprogNumber",
+ "name": "Subprogramme No#",
+ "slug": "SubprogNumber",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:subprogram:code",
+ "conceptType": "activity"
+ },
+ {
+ "title": "Subprogramme",
+ "name": "Subprogramme",
+ "slug": "Subprogramme",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:subprogram:label",
+ "conceptType": "activity"
+ },
+
+ {
+ "title": "EconomicClassification1",
+ "name": "econClass_L1",
+ "slug": "EconomicClassification1",
+ "type": "string",
+ "format": "default",
+ "columnType": "economic-classification:generic:level1:code",
+ "conceptType": "economic-classification"
+ },
+ {
+ "title": "EconomicClassification2",
+ "name": "econClass_L2",
+ "slug": "EconomicClassification2",
+ "type": "string",
+ "format": "default",
+ "columnType": "economic-classification:generic:level2:code",
+ "conceptType": "economic-classification"
+ },
+ {
+ "title": "EconomicClassification3",
+ "name": "econClass_L3",
+ "slug": "EconomicClassification3",
+ "type": "string",
+ "format": "default",
+ "columnType": "economic-classification:generic:level3:code",
+ "conceptType": "economic-classification"
+ },
+ {
+ "title": "EconomicClassification4",
+ "name": "econClass_L4",
+ "slug": "EconomicClassification4",
+ "type": "string",
+ "format": "default",
+ "columnType": "economic-classification:generic:level4:code",
+ "conceptType": "economic-classification"
+ },
+ {
+ "title": "EconomicClassification5",
+ "name": "econClass_L5",
+ "slug": "EconomicClassification5",
+ "type": "string",
+ "format": "default",
+ "columnType": "economic-classification:generic:level5:code",
+ "conceptType": "economic-classification"
+ },
+ {
+ "title": "EconomicClassificationLowestLevel",
+ "name": "IYM_econLowestLevel",
+ "slug": "EconomicClassificationLowestLEvel",
+ "type": "string",
+ "format": "default",
+ "columnType": "economic-classification:generic:level6:code",
+ "conceptType": "economic-classification"
+ },
+
+
+ {
+ "title": "AssetClassification1",
+ "name": "Assets_Level_1",
+ "slug": "AssetClassification1",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level1:code",
+ "conceptType": "asset"
+ },
+ {
+ "title": "AssetClassification2",
+ "name": "Assets_Level_2",
+ "slug": "AssetClassification2",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level2:code",
+ "conceptType": "asset"
+ },
+ {
+ "title": "AssetClassification3",
+ "name": "Assets_Level_3",
+ "slug": "AssetClassification3",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level3:code",
+ "conceptType": "asset"
+ },
+ {
+ "title": "AssetClassification4",
+ "name": "Assets_Level_4",
+ "slug": "AssetClassification4",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level4:code",
+ "conceptType": "asset"
+ },
+ {
+ "title": "AssetClassification5",
+ "name": "Assets_Level_5",
+ "slug": "AssetClassification5",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level5:code",
+ "conceptType": "asset"
+ },
+ {
+ "title": "AssetClassification6",
+ "name": "Assets_Level_6",
+ "slug": "AssetClassification6",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level6:code",
+ "conceptType": "asset"
+ },
+ {
+ "title": "AssetClassificationLowestLevel",
+ "name": "Assets_Lowest_Level",
+ "slug": "AssetClassificationLowestLevel",
+ "type": "string",
+ "format": "default",
+ "columnType": "asset:generic:level7:code",
+ "conceptType": "asset"
+ },
+
+ {
+ "title": "ProjectLevel1",
+ "name": "Project_Level_1",
+ "slug": "ProjectLevel1",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level1:code"
+ },
+ {
+ "title": "ProjectLevel2",
+ "name": "Project_Level_2",
+ "slug": "ProjectLevel2",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level2:code"
+ },
+ {
+ "title": "ProjectLevel3",
+ "name": "Project_Level_3",
+ "slug": "ProjectLevel3",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level3:code"
+ },
+ {
+ "title": "ProjectLevel4",
+ "name": "Project_Level_4",
+ "slug": "ProjectLevel4",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level4:code"
+ },
+ {
+ "title": "ProjectLevel5",
+ "name": "Project_Level_5",
+ "slug": "ProjectLevel5",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level5:code"
+ },
+ {
+ "title": "ProjectLevel6",
+ "name": "Project_Level_6",
+ "slug": "ProjectLevel6",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level6:code"
+ },
+ {
+ "title": "ProjectLevel7",
+ "name": "Project_Level_7",
+ "slug": "ProjectLevel7",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level7:code"
+ },
+ {
+ "title": "ProjectLevel8",
+ "name": "Project_Level_8",
+ "slug": "ProjectLevel8",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level8:code"
+ },
+ {
+ "title": "ProjectLevel9",
+ "name": "Project_Level_9",
+ "slug": "ProjectLevel9",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level9:code"
+ },
+ {
+ "title": "ProjectLevel10",
+ "name": "Project_Level_10",
+ "slug": "ProjectLevel10",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level10:code"
+ },
+ {
+ "title": "ProjectLevel11",
+ "name": "Project_Level_11",
+ "slug": "ProjectLevel11",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level11:code"
+ },
+ {
+ "title": "ProjectLowestLevel",
+ "name": "Project_Lowest_Level",
+ "slug": "ProjectLowest",
+ "type": "string",
+ "format": "default",
+ "columnType": "activity:generic:project:level12:code"
+ },
+
+
+ {
+ "title": "FundLevel1",
+ "name": "Fund_Level_1",
+ "slug": "FundLevel1",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level1:code"
+ },
+ {
+ "title": "FundLevel2",
+ "name": "Fund_Level_2",
+ "slug": "FundLevel2",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level2:code"
+ },
+ {
+ "title": "FundLevel3",
+ "name": "Fund_Level_3",
+ "slug": "FundLevel3",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level3:code"
+ },
+ {
+ "title": "FundLevel4",
+ "name": "Fund_Level_4",
+ "slug": "FundLevel4",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level4:code"
+ },
+ {
+ "title": "FundLevel5",
+ "name": "Fund_Level_5",
+ "slug": "FundLevel5",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level5:code"
+ },
+ {
+ "title": "FundLevel6",
+ "name": "Fund_Level_6",
+ "slug": "FundLevel6",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level6:code"
+ },
+ {
+ "title": "FundLevel7",
+ "name": "Fund_Level_7",
+ "slug": "FundLevel7",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level7:code"
+ },
+ {
+ "title": "FundLevel8",
+ "name": "Fund_Level_8",
+ "slug": "FundLevel8",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level8:code"
+ },
+ {
+ "title": "FundLowestLevel",
+ "name": "Fund_Lowest_Level",
+ "slug": "FundLowestLevel",
+ "type": "string",
+ "format": "default",
+ "columnType": "fin-source:generic:level9:code"
+ },
+
+ {
+ "title": "InfrastructureLevel1",
+ "name": "Infrastructure_Level_1",
+ "slug": "InfrastructureLevel1",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level1:code"
+ },
+ {
+ "title": "InfrastructureLevel2",
+ "name": "Infrastructure_Level_2",
+ "slug": "InfrastructureLevel2",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level2:code"
+ },
+ {
+ "title": "InfrastructureLevel3",
+ "name": "Infrastructure_Level_3",
+ "slug": "InfrastructureLevel3",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level3:code"
+ },
+ {
+ "title": "InfrastructureLevel4",
+ "name": "Infrastructure_Level_4",
+ "slug": "InfrastructureLevel4",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level4:code"
+ },
+ {
+ "title": "InfrastructureLevel5",
+ "name": "Infrastructure_Level_5",
+ "slug": "InfrastructureLevel5",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level5:code"
+ },
+ {
+ "title": "InfrastructureLevel6",
+ "name": "Infrastructure_Level_6",
+ "slug": "InfrastructureLevel6",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level6:code"
+ },
+ {
+ "title": "InfrastructureLowestLevel",
+ "name": "Infrastructure_Lowest_Level",
+ "slug": "InfrastructureLowestLevel",
+ "type": "string",
+ "format": "default",
+ "columnType": "infrastructure:generic:level7:code"
+ },
+
+ {
+ "title": "ItemLevel1",
+ "name": "Item_Level_1",
+ "slug": "ItemLevel1",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level1:code"
+ },
+ {
+ "title": "ItemLevel2",
+ "name": "Item_Level_2",
+ "slug": "ItemLevel2",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level2:code"
+ },
+ {
+ "title": "ItemLevel3",
+ "name": "Item_Level_3",
+ "slug": "ItemLevel3",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level3:code"
+ },
+ {
+ "title": "ItemLevel4",
+ "name": "Item_Level_4",
+ "slug": "ItemLevel4",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level4:code"
+ },
+ {
+ "title": "ItemLevel5",
+ "name": "Item_Level_5",
+ "slug": "ItemLevel5",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level5:code"
+ },
+ {
+ "title": "ItemLevel6",
+ "name": "Item_Level_6",
+ "slug": "ItemLevel6",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level6:code"
+ },
+ {
+ "title": "ItemLevel7",
+ "name": "Item_Level_7",
+ "slug": "ItemLevel7",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level7:code"
+ },
+ {
+ "title": "ItemLevel8",
+ "name": "Item_Level_8",
+ "slug": "ItemLevel8",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level8:code"
+ },
+
+ {
+ "title": "ItemLowestLevel",
+ "name": "Item_Lowest_Level",
+ "slug": "ItemLowestLevel",
+ "type": "string",
+ "format": "default",
+ "columnType": "item:generic:level9:code"
+ },
+
+ {
+ "title": "RegionalIDLevel1",
+ "name": "Regional_ID_Level_1",
+ "slug": "RegionalIDLevel1",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level1:code"
+ },
+ {
+ "title": "RegionalIDLevel2",
+ "name": "Regional_ID_Level_2",
+ "slug": "RegionalIDLevel2",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level2:code"
+ },
+ {
+ "title": "RegionalIDLevel3",
+ "name": "Regional_ID_Level_3",
+ "slug": "RegionalIDLevel3",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level3:code"
+ },
+ {
+ "title": "RegionalIDLevel4",
+ "name": "Regional_ID_Level_4",
+ "slug": "RegionalIDLevel4",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level4:code"
+ },
+ {
+ "title": "RegionalIDLevel5",
+ "name": "Regional_ID_Level_5",
+ "slug": "RegionalIDLevel5",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level5:code"
+ },
+ {
+ "title": "RegionalIDLevel6",
+ "name": "Regional_ID_Level_6",
+ "slug": "RegionalIDLevel6",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level6:code"
+ },
+ {
+ "title": "RegionalIDLevel7",
+ "name": "Regional_ID_Level_7",
+ "slug": "RegionalIDLevel7",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level7:code"
+ },
+ {
+ "title": "RegionalIDLevel8",
+ "name": "Regional_ID_Level_8",
+ "slug": "RegionalIDLevel8",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level8:code"
+ },
+ {
+ "title": "RegionalIDLowestLevel",
+ "name": "Regional_ID_Lowest_Level",
+ "slug": "RegionalIDLowestLevel",
+ "type": "string",
+ "format": "default",
+ "columnType": "geo-source:target:level9:code"
+ },
+
+ {
+ "title": "year",
+ "name": "Financial_Year",
+ "slug": "year",
+ "type": "integer",
+ "format": "default",
+ "columnType": "date:fiscal-year",
+ "conceptType": "date"
+ },
+ {
+ "title": "Budget",
+ "name": "Budget",
+ "slug": "Budget",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "AdjustmentBudget",
+ "name": "AdjustmentBudget",
+ "slug": "AdjustmentBudget",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "April",
+ "name": "April",
+ "slug": "April",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "May",
+ "name": "May",
+ "slug": "May",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "June",
+ "name": "June",
+ "slug": "June",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "July",
+ "name": "July",
+ "slug": "July",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "August",
+ "name": "August",
+ "slug": "August",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "September",
+ "name": "September",
+ "slug": "September",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "October",
+ "name": "October",
+ "slug": "October",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "November",
+ "name": "November",
+ "slug": "November",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "December",
+ "name": "December",
+ "slug": "December",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "January",
+ "name": "January",
+ "slug": "January",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "February",
+ "name": "February",
+ "slug": "February",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "March",
+ "name": "March",
+ "slug": "March",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "Q1",
+ "name": "Q1",
+ "slug": "Q1",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "Q2",
+ "name": "Q2",
+ "slug": "Q2",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "Q3",
+ "name": "Q3",
+ "slug": "Q3",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ },
+ {
+ "title": "Q4",
+ "name": "Q4",
+ "slug": "Q4",
+ "type": "number",
+ "format": "default",
+ "columnType": "value",
+ "conceptType": "value",
+ "decimalChar": ".",
+ "groupChar": ","
+ }
+ ],
+ "primaryKey": [
+ "VoteNumber",
+ "Department",
+ "ResponsibilityLevel1",
+ "ResponsibilityLevel2",
+ "ResponsibilityLevel3",
+ "ResponsibilityLevel4",
+ "ResponsibilityLevel5",
+ "ResponsibilityLevel6",
+ "ResponsibilityLevel7",
+ "ResponsibilityLevel8",
+ "ResponsibilityLevel9",
+ "ResponsibilityLevel10",
+ "ResponsibilityLevel11",
+ "ResponsibilityLevel12",
+ "ResponsibilityLevel13",
+ "ResponsibilityLevel14",
+ "ResponsibilityLevel15",
+ "ProgNumber",
+ "Programme",
+ "SubprogNumber",
+ "Subprogramme",
+ "EconomicClassification1",
+ "EconomicClassification2",
+ "EconomicClassification3",
+ "EconomicClassification4",
+ "EconomicClassification5",
+ "EconomicClassificationLowestLEvel",
+ "AssetClassification1",
+ "AssetClassification2",
+ "AssetClassification3",
+ "AssetClassification4",
+ "AssetClassification5",
+ "AssetClassification6",
+ "AssetClassificationLowestLEvel",
+ "ProjectLevel1",
+ "ProjectLevel2",
+ "ProjectLevel3",
+ "ProjectLevel4",
+ "ProjectLevel5",
+ "ProjectLevel6",
+ "ProjectLevel7",
+ "ProjectLevel8",
+ "ProjectLevel9",
+ "ProjectLevel10",
+ "ProjectLevel11",
+ "ProjectLowest",
+ "FundLevel1",
+ "FundLevel2",
+ "FundLevel3",
+ "FundLevel4",
+ "FundLevel5",
+ "FundLevel6",
+ "FundLevel7",
+ "FundLevel8",
+ "FundLowestLevel",
+ "InfrastructureLevel1",
+ "InfrastructureLevel2",
+ "InfrastructureLevel3",
+ "InfrastructureLevel4",
+ "InfrastructureLevel5",
+ "InfrastructureLevel6",
+ "InfrastructureLowestLevel",
+ "ItemLevel1",
+ "ItemLevel2",
+ "ItemLevel3",
+ "ItemLevel4",
+ "ItemLevel5",
+ "ItemLevel6",
+ "ItemLevel7",
+ "ItemLevel8",
+ "ItemLowestLevel",
+ "RegionalIDLevel1",
+ "RegionalIDLevel2",
+ "RegionalIDLevel3",
+ "RegionalIDLevel4",
+ "RegionalIDLevel5",
+ "RegionalIDLevel6",
+ "RegionalIDLevel7",
+ "RegionalIDLevel8",
+ "RegionalIDLowestLevel",
+ "year"
+ ]
+ }
+ }
+ ],
+ "@context": "http://schemas.frictionlessdata.io/fiscal-data-package.jsonld",
+ "owner": "b9d2af843f3a7ca223eea07fb608e62a",
+ "author": "vulekamali South African Budget Portal",
+ "count_of_rows": 8
+}
\ No newline at end of file
diff --git a/iym/migrations/0001_initial.py b/iym/migrations/0001_initial.py
new file mode 100644
index 000000000..0373eff8f
--- /dev/null
+++ b/iym/migrations/0001_initial.py
@@ -0,0 +1,65 @@
+# Generated by Django 2.2.28 on 2023-06-07 08:23
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import iym.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("budgetportal", "0071_auto_20230605_1521"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IYMFileUpload",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "latest_quarter",
+ models.CharField(
+ choices=[
+ ("Q1", "Q1"),
+ ("Q2", "Q2"),
+ ("Q3", "Q3"),
+ ("Q4", "Q4"),
+ ],
+ max_length=2,
+ ),
+ ),
+ ("process_completed", models.BooleanField(default=False)),
+ ("import_report", models.TextField()),
+ ("file", models.FileField(upload_to=iym.models.iym_file_path)),
+ ("created_at", models.DateTimeField(auto_now_add=True, null=True)),
+ ("updated_at", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "financial_year",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="budgetportal.FinancialYear",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ blank=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/iym/migrations/0002_iymfileupload_status.py b/iym/migrations/0002_iymfileupload_status.py
new file mode 100644
index 000000000..20373793d
--- /dev/null
+++ b/iym/migrations/0002_iymfileupload_status.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.28 on 2023-06-14 17:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("iym", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="iymfileupload",
+ name="status",
+ field=models.TextField(default=""),
+ preserve_default=False,
+ ),
+ ]
diff --git a/iym/migrations/0003_iymfileupload_task_id.py b/iym/migrations/0003_iymfileupload_task_id.py
new file mode 100644
index 000000000..4ef91fede
--- /dev/null
+++ b/iym/migrations/0003_iymfileupload_task_id.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.28 on 2023-06-26 04:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("iym", "0002_iymfileupload_status"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="iymfileupload",
+ name="task_id",
+ field=models.TextField(default=""),
+ preserve_default=False,
+ ),
+ ]
diff --git a/iym/migrations/0004_auto_20230703_1449.py b/iym/migrations/0004_auto_20230703_1449.py
new file mode 100644
index 000000000..d5a8253c0
--- /dev/null
+++ b/iym/migrations/0004_auto_20230703_1449.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2023-07-03 14:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("iym", "0003_iymfileupload_task_id"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="iymfileupload",
+ name="process_completed",
+ field=models.BooleanField(),
+ ),
+ ]
diff --git a/iym/migrations/0005_auto_20230703_1456.py b/iym/migrations/0005_auto_20230703_1456.py
new file mode 100644
index 000000000..724410190
--- /dev/null
+++ b/iym/migrations/0005_auto_20230703_1456.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2023-07-03 14:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("iym", "0004_auto_20230703_1449"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="iymfileupload",
+ name="process_completed",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/iym/migrations/__init__.py b/iym/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/iym/models.py b/iym/models.py
new file mode 100644
index 000000000..759a09839
--- /dev/null
+++ b/iym/models.py
@@ -0,0 +1,28 @@
+from django.contrib.auth.models import User
+from django.db import models
+from budgetportal.models import FinancialYear
+import uuid
+
+QUARTERS = (
+ ("Q1", "Q1"),
+ ("Q2", "Q2"),
+ ("Q3", "Q3"),
+ ("Q4", "Q4"),
+)
+
+
+def iym_file_path(instance, filename):
+ return f"iym_uploads/{uuid.uuid4()}/{filename}"
+
+
+class IYMFileUpload(models.Model):
+ user = models.ForeignKey(User, models.DO_NOTHING, blank=True)
+ financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE)
+ latest_quarter = models.CharField(max_length=2, choices=QUARTERS)
+ process_completed = models.BooleanField(default=False)
+ import_report = models.TextField()
+ status = models.TextField()
+ file = models.FileField(upload_to=iym_file_path)
+ task_id = models.TextField()
+ created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True)
+ updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
diff --git a/iym/tasks.py b/iym/tasks.py
new file mode 100644
index 000000000..dab354355
--- /dev/null
+++ b/iym/tasks.py
@@ -0,0 +1,454 @@
+from django.contrib import admin
+from iym import models
+from io import StringIO
+import petl as etl
+from decimal import Decimal
+from urllib.parse import urlencode
+from slugify import slugify
+from zipfile import ZipFile
+from django.conf import settings
+from django_q.tasks import async_task
+import logging
+
+import os
+import csv
+import tempfile
+import requests
+import hashlib
+import base64
+import json
+import time
+import re
+import iym
+import datetime
+
+logger = logging.getLogger(__name__)
+ckan = settings.CKAN
+
+RE_END_YEAR = re.compile(r"/\d+")
+
+MEASURES = [
+ "Budget",
+ "AdjustmentBudget",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+ "January",
+ "February",
+ "March",
+ "Q1",
+ "Q2",
+ "Q3",
+ "Q4",
+]
+
+
+def authorise_upload(path, filename, userid, data_package_name, datastore_token):
+ md5 = hashlib.md5()
+ with open(path, "rb") as fh:
+ while True:
+ bytes = fh.read(1000)
+ if not bytes:
+ break
+ else:
+ md5.update(bytes)
+
+ md5_b64 = base64.b64encode(md5.digest())
+
+ authorize_upload_url = f"{settings.OPENSPENDING_HOST}/datastore/"
+ authorize_upload_payload = {
+ "metadata": {
+ "owner": userid,
+ "name": data_package_name,
+ "author": "Vulekamali",
+ },
+ "filedata": {
+ filename: {
+ "md5": md5_b64,
+ "name": filename,
+ "length": os.stat(path).st_size,
+ "type": "application/octet-stream",
+ }
+ },
+ }
+ authorize_upload_headers = {"auth-token": datastore_token}
+ r = requests.post(
+ authorize_upload_url,
+ json=authorize_upload_payload,
+ headers=authorize_upload_headers,
+ )
+
+ r.raise_for_status()
+ return r.json()
+
+
+def upload(path, authorisation):
+ # Unpack values out of lists
+ upload_query = {k: v[0] for k, v in authorisation["upload_query"].items()}
+ upload_url = f"{authorisation['upload_url']}?{urlencode(upload_query)}"
+ upload_headers = {
+ "content-type": "application/octet-stream",
+ "Content-MD5": authorisation["md5"],
+ }
+ with open(path, "rb") as file:
+ r = requests.put(upload_url, data=file, headers=upload_headers)
+ r.raise_for_status()
+
+
+def unzip_uploaded_file(obj_to_update):
+ relative_path = "iym/temp_files/"
+ zip_file = obj_to_update.file
+
+ with ZipFile(zip_file, "r") as zip:
+ file_name = zip.namelist()[0]
+ zip.extractall(path=relative_path)
+
+ original_csv_path = os.path.join(settings.BASE_DIR, relative_path, file_name)
+
+ return original_csv_path
+
+
+def create_composite_key_using_csv_headers(original_csv_path):
+ # Get all the headers to come up with the composite key
+ with open(original_csv_path) as original_csv_file:
+ reader = csv.DictReader(original_csv_file)
+ first_row = next(reader)
+ fields = first_row.keys()
+ composite_key = list(set(fields) - set(MEASURES))
+
+ return composite_key
+
+
+def tidy_csv_table(original_csv_path, composite_key):
+ table1 = etl.fromcsv(original_csv_path)
+ table2 = etl.convert(table1, MEASURES, lambda v: v.replace(",", "."))
+ table3 = etl.convert(table2, "Financial_Year", lambda v: RE_END_YEAR.sub("", v))
+ table4 = etl.convert(table3, MEASURES, Decimal)
+
+ # Roll up rows with the same composite key into one, summing values together
+ # Prefixing each new measure header with "sum" because petl seems to need
+ # different headers for aggregation output
+ aggregation = {f"sum{measure}": (measure, sum) for measure in MEASURES}
+ table5 = etl.aggregate(table4, composite_key, aggregation)
+
+ # Strip sum prefix from aggregation results
+ measure_rename = {key: key[3:] for key in aggregation}
+ table6 = etl.rename(table5, measure_rename)
+
+ return table6
+
+
+def authenticate_openspending():
+ headers = {"x-api-key": settings.OPENSPENDING_API_KEY}
+ url = f"{settings.OPENSPENDING_HOST}/user/authenticate_api_key"
+ r = requests.post(url, headers=headers)
+ r.raise_for_status()
+ return r.json()["token"]
+
+
+def create_data_package(
+ csv_filename,
+ csv_table,
+ userid,
+ data_package_name,
+ data_package_title,
+ obj_to_update,
+):
+ data_package_template_path = "iym/data_package/data_package_template.json"
+ base_token = authenticate_openspending()
+
+ with tempfile.NamedTemporaryFile(mode="w", delete=True) as csv_file:
+ csv_path = csv_file.name
+ etl.tocsv(csv_table, csv_path)
+ update_import_report(obj_to_update, "Getting authorisation for datastore")
+
+ authorize_query = {
+ "jwt": base_token,
+ "service": "os.datastore",
+ "userid": userid,
+ }
+ authorize_url = (
+ f"{settings.OPENSPENDING_HOST}/user/authorize?{urlencode(authorize_query)}"
+ )
+ r = requests.get(authorize_url)
+
+ r.raise_for_status()
+
+ authorize_result = r.json()
+ if "token" not in authorize_result:
+ raise Exception("Authorization with OpenSpending failed.")
+
+ datastore_token = authorize_result["token"]
+
+ update_import_report(obj_to_update, f"Uploading CSV {csv_path}")
+
+ authorise_csv_upload_result = authorise_upload(
+ csv_path, csv_filename, userid, data_package_name, datastore_token
+ )
+
+ upload(csv_path, authorise_csv_upload_result["filedata"][csv_filename])
+
+ ##===============================================
+ update_import_report(obj_to_update, "Creating and uploading datapackage.json")
+ with open(data_package_template_path) as data_package_file:
+ data_package = json.load(data_package_file)
+
+ data_package["title"] = data_package_title
+ data_package["name"] = data_package_name
+ data_package["resources"][0]["name"] = slugify(
+ os.path.splitext(csv_filename)[0]
+ )
+ data_package["resources"][0]["path"] = csv_filename
+ data_package["resources"][0]["bytes"] = os.path.getsize(csv_path)
+
+ return {"data_package": data_package, "datastore_token": datastore_token}
+
+
+def upload_data_package(
+ data_package, userid, data_package_name, datastore_token, obj_to_update
+):
+ with tempfile.NamedTemporaryFile(mode="w", delete=True) as data_package_file:
+ json.dump(data_package, data_package_file)
+ data_package_file.flush()
+ data_package_path = data_package_file.name
+ authorise_data_package_upload_result = authorise_upload(
+ data_package_path,
+ "data_package.json",
+ userid,
+ data_package_name,
+ datastore_token,
+ )
+
+ data_package_upload_authorisation = authorise_data_package_upload_result[
+ "filedata"
+ ]["data_package.json"]
+ upload(data_package_path, data_package_upload_authorisation)
+ update_import_report(
+ obj_to_update,
+ f'Datapackage url: {data_package_upload_authorisation["upload_url"]}',
+ )
+
+ return data_package_upload_authorisation
+
+
+def import_uploaded_package(data_package_url, datastore_token, obj_to_update):
+ import_query = {"datapackage": data_package_url, "jwt": datastore_token}
+ import_url = (
+ f"{settings.OPENSPENDING_HOST}/package/upload?{urlencode(import_query)}"
+ )
+ r = requests.post(import_url)
+ update_import_report(obj_to_update, f"Initial status: {r.text}")
+
+ r.raise_for_status()
+ status = r.json()["status"]
+
+ return status
+
+
+def update_import_report(obj_to_update, message):
+ now = datetime.datetime.now().strftime("%H:%M:%S")
+ obj_to_update.import_report += f"{now} - {message}" + os.linesep
+ obj_to_update.save()
+
+
+def check_and_update_status(status, data_package_url, obj_to_update):
+ last_logged_progress = -1
+ status_query = {
+ "datapackage": data_package_url,
+ }
+ status_url = (
+ f"{settings.OPENSPENDING_HOST}/package/status?{urlencode(status_query)}"
+ )
+ update_import_report(
+ obj_to_update, f"Monitoring status until completion ({status_url}):"
+ )
+ while status not in ["done", "fail"]:
+ time.sleep(5)
+ r = requests.get(status_url)
+ r.raise_for_status()
+ status_result = r.json()
+ new_progress = int(float(status_result["progress"]) * 100)
+ if new_progress != last_logged_progress:
+ update_status(
+ obj_to_update,
+ f"loading data ({new_progress}%)",
+ )
+ update_import_report(
+ obj_to_update,
+ f"loading data ({new_progress}%)",
+ )
+ last_logged_progress = new_progress
+ status = status_result["status"]
+
+ if status == "fail":
+ print(status_result["error"])
+
+ update_status(obj_to_update, status)
+
+
+def update_status(obj_to_update, status):
+ obj_to_update.status = status
+ obj_to_update.save()
+
+
+def process_uploaded_file(obj_id):
+ # read file
+ obj_to_update = models.IYMFileUpload.objects.get(id=obj_id)
+ if obj_to_update.process_completed:
+ return
+ try:
+ update_status(obj_to_update, "process started")
+ update_import_report(obj_to_update, "Cleaning CSV")
+
+ financial_year = obj_to_update.financial_year.slug
+ userid = settings.OPENSPENDING_USER_ID
+ data_package_name = f"national-in-year-spending-{financial_year}{settings.OPENSPENDING_DATASET_CREATE_SUFFIX}"
+ data_package_title = f"National in-year spending {financial_year}{settings.OPENSPENDING_DATASET_CREATE_SUFFIX}"
+
+ original_csv_path = unzip_uploaded_file(obj_to_update)
+
+ update_status(obj_to_update, "cleaning data")
+
+ csv_filename = os.path.basename(original_csv_path)
+
+ composite_key = create_composite_key_using_csv_headers(original_csv_path)
+
+ csv_table = tidy_csv_table(original_csv_path, composite_key)
+
+ update_status(obj_to_update, "uploading data")
+
+ func_result = create_data_package(
+ csv_filename,
+ csv_table,
+ userid,
+ data_package_name,
+ data_package_title,
+ obj_to_update,
+ )
+
+ data_package = func_result["data_package"]
+ datastore_token = func_result["datastore_token"]
+
+ data_package_upload_authorisation = upload_data_package(
+ data_package, userid, data_package_name, datastore_token, obj_to_update
+ )
+
+ ##===============================================
+ # Starting import of uploaded data_package
+ update_status(obj_to_update, "import queued")
+ update_import_report(obj_to_update, "Starting import of uploaded datapackage.")
+ data_package_url = data_package_upload_authorisation["upload_url"]
+ status = import_uploaded_package(
+ data_package_url, datastore_token, obj_to_update
+ )
+
+ ##===============================================
+
+ check_and_update_status(status, data_package_url, obj_to_update)
+
+ os.remove(original_csv_path)
+
+ create_or_update_dataset(
+ obj_to_update,
+ financial_year,
+ userid,
+ data_package_name,
+ obj_to_update.latest_quarter,
+ )
+
+ obj_to_update.process_completed = True
+ obj_to_update.save()
+ except Exception as e:
+ logger.exception("Error processing file")
+ update_import_report(obj_to_update, str(e))
+ update_status(obj_to_update, "fail")
+
+
+def create_or_update_dataset(
+ obj_to_update, financial_year, userid, data_package_name, latest_quarter
+):
+ update_import_report(obj_to_update, "CKAN process started")
+ vocab_map = get_vocab_map()
+ tags = [
+ {"vocabulary_id": vocab_map["financial_years"], "name": financial_year},
+ {"vocabulary_id": vocab_map["spheres"], "name": "national"},
+ ]
+
+ dataset_fields = {
+ "title": f"National in-year spending {financial_year}",
+ "name": f"national_in_year_spending_{financial_year}",
+ "owner_org": "national-treasury",
+ "groups": [{"name": "in-year-spending"}],
+ "tags": tags,
+ "extras": [{"key": "latest_quarter", "value": latest_quarter}],
+ }
+
+ query = {"fq": (f"+name:{dataset_fields['name']}")}
+ search_response = ckan.action.package_search(**query)
+
+ if search_response["count"] == 0:
+ # create dataset and add resource
+ update_import_report(obj_to_update, "Creating a new dataset in CKAN")
+ response = create_dataset(dataset_fields)
+ add_resource(response, dataset_fields, userid, data_package_name)
+ else:
+ # update dataset
+ dataset_fields["id"] = search_response["results"][0]["id"]
+ update_import_report(obj_to_update, "Updating the dataset in CKAN")
+ response = update_dataset(dataset_fields)
+
+
+def add_resource(response, dataset_fields, userid, data_package_name):
+ query = {"id": response["id"]}
+
+ dataset_data = ckan.action.package_show(**query)
+
+ if len(dataset_data["resources"]) == 0:
+ # add resource
+ should_add_resource = True
+ else:
+ should_add_resource = True
+ for resource in dataset_data["resources"]:
+ if resource["format"] == "OpenSpending API":
+ # resource is added - update it
+ should_add_resource = False
+
+ if should_add_resource:
+ add_resource_to_dataset(dataset_fields, userid, data_package_name)
+
+
+def create_dataset(dataset_fields):
+ response = ckan.action.package_create(**dataset_fields)
+ return response
+
+
+def update_dataset(dataset_fields):
+ response = ckan.action.package_patch(**dataset_fields)
+ return response
+
+
+def add_resource_to_dataset(dataset_fields, userid, data_package_name):
+ url = (
+ f"{settings.OPENSPENDING_HOST}/api/3/cubes/{userid}:{data_package_name}/model/"
+ )
+ resource_fields = {
+ "package_id": dataset_fields["name"],
+ "name": data_package_name,
+ "url": url,
+ "format": "OpenSpending API",
+ }
+ result = ckan.action.resource_create(**resource_fields)
+
+
+def get_vocab_map():
+ vocab_map = {}
+ for vocab in ckan.action.vocabulary_list():
+ vocab_map[vocab["name"]] = vocab["id"]
+
+ return vocab_map
diff --git a/iym/temp_files/test_data.csv b/iym/temp_files/test_data.csv
new file mode 100644
index 000000000..3599d627e
--- /dev/null
+++ b/iym/temp_files/test_data.csv
@@ -0,0 +1,4 @@
+Vote No#,Department,Programme No#,Programme,Subprogramme No#,Subprogramme,econClass_L1,econClass_L2,econClass_L3,econClass_L4,econClass_L5,IYM_econLowestLevel,Item_Lowest_Level,Assets_Level_1,Assets_Level_2,Assets_Level_3,Assets_Level_4,Assets_Level_5,Assets_Level_6,Assets_Lowest_Level,Project_Level_1,Project_Level_2,Project_Level_3,Project_Level_4,Project_Level_5,Project_Level_6,Project_Level_7,Project_Level_8,Project_Level_9,Project_Level_10,Project_Level_11,Project_Lowest_Level,Responsibility_Level_2,Responsibility_Level_3,Responsibility_Level_4,Responsibility_Level_5,Responsibility_Level_6,Responsibility_Level_7,Responsibility_Level_8,Responsibility_Level_9,Responsibility_Level_10,Responsibility_Level_11,Responsibility_Level_12,Responsibility_Level_13,Responsibility_Level_14,Responsibility_Level_15,Responsibility_Lowest_Level,Fund_Level_1,Fund_Level_2,Fund_Level_3,Fund_Level_4,Fund_Level_5,Fund_Level_6,Fund_Level_7,Fund_Level_8,Fund_Lowest_Level,Infrastructure_Level_1,Infrastructure_Level_2,Infrastructure_Level_3,Infrastructure_Level_4,Infrastructure_Level_5,Infrastructure_Level_6,Infrastructure_Lowest_Level,Item_Level_1,Item_Level_2,Item_Level_3,Item_Level_4,Item_Level_5,Item_Level_6,Item_Level_7,Item_Level_8,Regional_ID_Level_1,Regional_ID_Level_2,Regional_ID_Level_3,Regional_ID_Level_4,Regional_ID_Level_5,Regional_ID_Level_6,Regional_ID_Level_7,Regional_ID_Level_8,Regional_ID_Lowest_Level,Budget,AdjustmentBudget,April,May,June,July,August,September,October,November,December,January,February,March,Q1,Q2,Q3,Q4,Financial_Year
+5,Home Affairs,2,Citizen Affairs,5,Service Delivery to Provinces,Current,Current payments,Goods and services,Travel and subsistence,Travel and subsistence,Travel and subsistence,T&S DOM:FOOD&BEVER,NON-ASSETS RELATED,NON-ASSETS RELATED,,,,,NON-ASSETS RELATED,NO PROJECTS,NO PROJECTS,,,,,,,,,,NO PROJECTS,DEPARTMENT HOME AFFAIRS 17,DIRECTOR-GENERAL 17,CITIZEN AFFAIRS 17,EASTERN CAPE 17,EASTERN CAPE 17,DISTR MUN: JOE GQABI 17,MO MT FLETCHER 17,,,,,,,,MO MT FLETCHER 17,EXPENDITURE:VOTED,DEPARTMENTAL APPROPRIATION,VOTED FUNDS DISCRETIONARY,DEPARTMENTAL VOTE,VOTED FUNDS,,,,VOTED FUNDS,EXPENDITURE,NON INFRASTRUCTURE,NON INFRASTRUCTURE: CURRENT,NON INFRASTRUCTURE: CURRENT,NON INFRA/ST ALONE:CURRENT,,NON INFRA/ST ALONE:CURRENT,PAYMENTS,PAYMENTS,GOODS AND SERVICES,TRAVEL AND SUBSISTENCE,T&S DOMESTIC,T&S DOM:FOOD&BEVER,,,REGIONAL IDENTIFIER,NAT FUNCTION:WHOLE COUNTRY DOM,EASTERN CAPE 17,,,,,,EASTERN CAPE 17,0,0,0.8,0,1.28,1.08,0,2.76,1.46,0.92,0,3.16,0,3.08,2.08,3.84,2.38,6.24,2021
+5,Home Affairs,2,Citizen Affairs,5,Service Delivery to Provinces,Current,Current payments,Goods and services,Travel and subsistence,Travel and subsistence,Travel and subsistence,T&S DOM:ACCOMMODATION,NON-ASSETS RELATED,NON-ASSETS RELATED,,,,,NON-ASSETS RELATED,NO PROJECTS,NO PROJECTS,,,,,,,,,,NO PROJECTS,DEPARTMENT HOME AFFAIRS 17,DIRECTOR-GENERAL 17,CITIZEN AFFAIRS 17,GAUTENG 17,GAUTENG 17,DISTR MUN: SEBIDENG 17,LO VEREENIGING 17,,,,,,,,LO VEREENIGING 17,EXPENDITURE:VOTED,DEPARTMENTAL APPROPRIATION,VOTED FUNDS DISCRETIONARY,DEPARTMENTAL VOTE,VOTED FUNDS,,,,VOTED FUNDS,EXPENDITURE,NON INFRASTRUCTURE,NON INFRASTRUCTURE: CURRENT,NON INFRASTRUCTURE: CURRENT,NON INFRA/ST ALONE:CURRENT,,NON INFRA/ST ALONE:CURRENT,PAYMENTS,PAYMENTS,GOODS AND SERVICES,TRAVEL AND SUBSISTENCE,T&S DOMESTIC,T&S DOM:ACCOMMODATION,,,REGIONAL IDENTIFIER,NAT FUNCTION:WHOLE COUNTRY DOM,GAUTENG 17,,,,,,GAUTENG 17,0,0,0,0,2.46,0,0,0,3.2825,0,0,0,0,0,2.46,0,3.2825,0,2021
+5,Home Affairs,2,Citizen Affairs,5,Service Delivery to Provinces,Current,Current payments,Goods and services,Travel and subsistence,Travel and subsistence,Travel and subsistence,T&S DOM:FOOD&BEVER,NON-ASSETS RELATED,NON-ASSETS RELATED,,,,,NON-ASSETS RELATED,NO PROJECTS,NO PROJECTS,,,,,,,,,,NO PROJECTS,DEPARTMENT HOME AFFAIRS 17,DIRECTOR-GENERAL 17,CITIZEN AFFAIRS 17,FREE STATE 17,FREE STATE 17,DISTR MUN: LEJWELEPUTSWA 17,MO BULTFONTEIN 17,,,,,,,,MO BULTFONTEIN 17,EXPENDITURE:VOTED,DEPARTMENTAL APPROPRIATION,VOTED FUNDS DISCRETIONARY,DEPARTMENTAL VOTE,VOTED FUNDS,,,,VOTED FUNDS,EXPENDITURE,NON INFRASTRUCTURE,NON INFRASTRUCTURE: CURRENT,NON INFRASTRUCTURE: CURRENT,NON INFRA/ST ALONE:CURRENT,,NON INFRA/ST ALONE:CURRENT,PAYMENTS,PAYMENTS,GOODS AND SERVICES,TRAVEL AND SUBSISTENCE,T&S DOMESTIC,T&S DOM:FOOD&BEVER,,,REGIONAL IDENTIFIER,NAT FUNCTION:WHOLE COUNTRY DOM,FREE STATE 17,,,,,,FREE STATE 17,0,0,1.84,4.54,5.2,1.07,0,0,0.32,2.44,1.12,0.36,0,0.36,11.58,1.07,3.88,0.72,2021
diff --git a/iym/tests/static/test_data.zip b/iym/tests/static/test_data.zip
new file mode 100644
index 000000000..cff197a1a
Binary files /dev/null and b/iym/tests/static/test_data.zip differ
diff --git a/iym/tests/test_iym_uploads.py b/iym/tests/test_iym_uploads.py
new file mode 100644
index 000000000..fda54d8a7
--- /dev/null
+++ b/iym/tests/test_iym_uploads.py
@@ -0,0 +1,181 @@
+from django.test import TestCase
+from django.contrib.auth.models import User
+from django.core.files import File
+from iym.models import IYMFileUpload
+from budgetportal.models.government import FinancialYear
+from django.conf import settings
+
+import os
+import iym.tasks
+import mock
+
+USERNAME = "testuser"
+EMAIL = "testuser@domain.com"
+PASSWORD = "12345"
+
+
+class MockResponse:
+ def __init__(self, json_data, status_code, text):
+ self.json_data = json_data
+ self.status_code = status_code
+ self.text = text
+
+ def json(self):
+ return self.json_data
+
+ def raise_for_status(self):
+ return None
+
+
+def mocked_requests_get(*args, **kwargs):
+ if f"{settings.OPENSPENDING_HOST}/user/authorize" in args[0]:
+ return MockResponse({"token": "test token"}, 200, "")
+ elif f"{settings.OPENSPENDING_HOST}/package/status?" in args[0]:
+ return MockResponse({"progress": 1, "status": "done"}, 200, "")
+ elif args[0] == "https://test-upload-url.com/data_package.json?":
+ return MockResponse({"name": "mock datapackage"}, 200, "")
+
+ raise Exception(f"Unmocked GET request {args}")
+
+
+def mocked_requests_put(*args, **kwargs):
+ if (
+ f"{settings.OPENSPENDING_HOST}/package/upload?datapackage=https%3A%2F%2Ftest-upload-url.com%2Fdata_package.json&jwt=test+token"
+ in args[0]
+ ):
+ return MockResponse({"status": "0.0"}, 200, "initial status")
+ elif "https://test-upload-url.com/test_data.csv" in args[0]:
+ return MockResponse({"test": "file"}, 200, "")
+ elif args[0] == "https://test-upload-url.com/data_package.json?":
+ return MockResponse({"name": "mock datapackage"}, 200, "")
+
+ raise Exception(f"Unmocked PUT request {args}")
+
+
+def mocked_requests_post(*args, **kwargs):
+ if f"{settings.OPENSPENDING_HOST}/user/authenticate_api_key" in args[0]:
+ assert "x-api-key" in kwargs["headers"]
+ return MockResponse({"token": "fake.jwt.blah"}, 200, "")
+ elif f"{settings.OPENSPENDING_HOST}/datastore/" in args[0]:
+ return MockResponse(
+ {
+ "filedata": {
+ "test_data.csv": {
+ "md5": "LTP9Xtp/f8n2DrFWG2/h1g==",
+ "name": "test_data.csv",
+ "length": 2354098,
+ "upload_url": "https://test-upload-url.com/test_data.csv",
+ "upload_query": {},
+ "type": "application/octet-stream",
+ },
+ "data_package.json": {
+ "md5": "T57ewjs7A5m0f0fJfCW2Iw==",
+ "name": "data_package.json",
+ "length": 21510,
+ "upload_url": "https://test-upload-url.com/data_package.json",
+ "upload_query": {},
+ "type": "application/octet-stream",
+ },
+ }
+ },
+ 200,
+ "",
+ )
+ elif (
+ f"{settings.OPENSPENDING_HOST}/package/upload?datapackage=https%3A%2F%2Ftest-upload-url.com%2Fdata_package.json&jwt=test+token"
+ in args[0]
+ ):
+ return MockResponse({"status": "0.0"}, 200, "initial status")
+
+ raise Exception(f"Unmocked POST request {args}")
+
+
+def mocked_wrong_requests_get(*args, **kwargs):
+ if f"{settings.OPENSPENDING_HOST}/user/authorize" in args[0]:
+ return MockResponse({"permissions": {}}, 200, "")
+
+ raise Exception(f"Unmocked GET request {args}")
+
+
+class IYMFileUploadTestCase(TestCase):
+ def setUp(self):
+ self.superuser = User.objects.create_user(
+ username=USERNAME,
+ password=PASSWORD,
+ is_staff=True,
+ is_superuser=True,
+ is_active=True,
+ )
+ test_file_path = os.path.abspath(("iym/tests/static/test_data.zip"))
+ self.zip_file = File(open(test_file_path, "rb"))
+
+ self.ckan_patch = mock.patch("iym.tasks.ckan")
+ self.CKANMockClass = self.ckan_patch.start()
+ self.CKANMockClass.action.package_search.return_value = {"count": 0}
+ self.CKANMockClass.action.package_create.return_value = {"id": "whatever"}
+ self.CKANMockClass.action.resource_create.return_value = {}
+ self.CKANMockClass.action.vocabulary_list.return_value = [
+ {"name": "financial_years", "id": "a"},
+ {"name": "spheres", "id": "b"},
+ ]
+ self.addCleanup(self.ckan_patch.stop)
+
+ def tearDown(self):
+ self.zip_file.close()
+
+ @mock.patch("requests.get", side_effect=mocked_requests_get)
+ @mock.patch("requests.post", side_effect=mocked_requests_post)
+ @mock.patch("requests.put", side_effect=mocked_requests_put)
+ def test_uploading(self, mock_get, mock_post, mock_put):
+ financial_year = FinancialYear.objects.create(slug="2021-22")
+ test_element = IYMFileUpload.objects.create(
+ user=self.superuser,
+ file=self.zip_file,
+ financial_year=financial_year,
+ latest_quarter="Q1",
+ )
+
+ iym.tasks.process_uploaded_file(test_element.id)
+ test_element.refresh_from_db()
+
+ import_report_lines = test_element.import_report.split("\n")
+ assert " - Cleaning CSV" in import_report_lines[0]
+ assert " - Getting authorisation for datastore" in import_report_lines[1]
+ assert " - Uploading CSV /tmp/" in import_report_lines[2]
+ assert " - Creating and uploading datapackage.json" in import_report_lines[3]
+ assert (
+ " - Datapackage url: https://test-upload-url.com/data_package.json"
+ in import_report_lines[4]
+ )
+ assert " - Starting import of uploaded datapackage." in import_report_lines[5]
+ assert " - Initial status: initial status" in import_report_lines[6]
+ assert (
+ f" - Monitoring status until completion ({settings.OPENSPENDING_HOST}/package/status?datapackage=https%3A%2F%2Ftest-upload-url.com%2Fdata_package.json):"
+ in import_report_lines[7]
+ )
+ assert test_element.status == "done"
+
+ @mock.patch("requests.get", side_effect=mocked_wrong_requests_get)
+ @mock.patch("requests.post", side_effect=mocked_requests_post)
+ @mock.patch("requests.put", side_effect=mocked_requests_put)
+ def test_uploading_with_wrong_token(self, mock_get, mock_post, mock_put):
+ financial_year = FinancialYear.objects.create(slug="2021-22")
+ test_element = IYMFileUpload.objects.create(
+ user=self.superuser,
+ file=self.zip_file,
+ financial_year=financial_year,
+ latest_quarter="Q1",
+ )
+
+ iym.tasks.process_uploaded_file(test_element.id)
+ test_element.refresh_from_db()
+
+ import_report_lines = test_element.import_report.split("\n")
+ assert " - Cleaning CSV" in import_report_lines[0]
+ assert (
+ " - Getting authorisation for datastore" in import_report_lines[1]
+ ), import_report_lines
+ assert (
+ " - Authorization with OpenSpending failed." in import_report_lines[2]
+ ), import_report_lines
+ assert test_element.status == "fail"
diff --git a/iym/urls.py b/iym/urls.py
new file mode 100644
index 000000000..5342a8afd
--- /dev/null
+++ b/iym/urls.py
@@ -0,0 +1,10 @@
+from django.contrib import admin
+from django.conf.urls import url
+from rest_framework.routers import DefaultRouter
+from django.urls import path, include
+from performance import views
+
+urlpatterns = [
+ # IYM
+ path("", views.performance_tabular_view, name="iym"),
+]
diff --git a/iym/views.py b/iym/views.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/package.json b/package.json
index 92b002f50..d64d87053 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
"build:webapp": "cd ./packages/webapp && node ./scripts/build",
"build:root": "webpack --display errors-only --progress -p",
"build:dev": "webpack --progress --watch --mode=development",
- "build": "yarn build:webapp && yarn build:root"
+ "build": "yarn build:webapp && yarn build:root",
+ "start-test": "react-app-rewired --progress --watch --mode=development"
},
"devDependencies": {
"app-root-dir": "^1.0.2",
@@ -66,7 +67,6 @@
"opn": "^5.4.0",
"postcss-loader": "^2.1.6",
"postcss-normalize": "^4.0.0",
- "preact-render-to-string": "^3.8.2",
"prettier": "^1.17.1",
"prop-types": "^15.6.1",
"sass-loader": "^6.0.6",
@@ -97,17 +97,11 @@
"presets": [
"env",
"stage-0"
- ],
- "plugins": [
- [
- "transform-react-jsx",
- {
- "pragma": "h"
- }
- ]
]
},
"dependencies": {
+ "@material-ui/core": "^4.12.4",
+ "@material-ui/icons": "^4.11.3",
"array.from": "^1.0.3",
"array.prototype.every": "^1.0.0",
"array.prototype.findindex": "^2.0.2",
@@ -116,20 +110,23 @@
"canvg-browser": "^1.0.0",
"chart.js": "^2.7.3",
"color-string": "^1.5.3",
+ "d3-scale": "^2.2.2",
+ "d3-selection": "^1.3.0",
"jquery": "^3.3.1",
"js-yaml": "^3.11.0",
"lodash": "^4.17.11",
+ "lodash.debounce": "^4.0.8",
"lunr": "^2.3.5",
"mini-css-extract-plugin": "^0.9.0",
"object.assign": "^4.1.0",
- "preact": "^8.3.1",
- "preact-css-transition-group": "^1.3.0",
- "preact-transition-group": "^1.1.1",
"promise-polyfill": "^6.0.2",
"pym.js": "^1.3.2",
"query-string": "^5.1.1",
+ "react": "^16.8.1",
+ "react-dom": "^16.8.1",
"react-ga": "^2.5.3",
"react-html-connector": "^0.2.6",
+ "react-lines-ellipsis": "^0.15.3",
"redux": "^4.0.1",
"save-svg-as-png": "^1.4.6",
"url-search-params-polyfill": "^8.1.0",
diff --git a/performance/__init__.py b/performance/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/performance/admin.py b/performance/admin.py
new file mode 100644
index 000000000..d7f0fc01a
--- /dev/null
+++ b/performance/admin.py
@@ -0,0 +1,531 @@
+from django.contrib import admin
+from io import StringIO
+from performance import models
+from django_q.tasks import async_task, fetch
+
+from frictionless import validate
+
+import os
+import csv
+import budgetportal
+
+VALID_REPORT_TYPES = [
+ "Provincial Institutions Oversight Performance Report",
+ "National Institutions Oversight Performance Report",
+]
+
+
+def generate_import_report(
+ report_type_validated, frictionless_report, not_matching_departments
+):
+ report = ""
+ if not report_type_validated:
+ report += "Report type must be for one of " + os.linesep
+ for report_type in VALID_REPORT_TYPES:
+ report += f"* {report_type} {os.linesep}"
+
+ if frictionless_report:
+ if not frictionless_report.valid:
+ for error in frictionless_report.tasks[0].errors:
+ report += f"* {error.message} {os.linesep}"
+
+ if len(not_matching_departments) > 0:
+ report += "Department names that could not be matched on import : " + os.linesep
+ for department in not_matching_departments:
+ report += f"* {department} {os.linesep}"
+
+ return report
+
+
+def save_imported_indicators(obj_id):
+ # read file
+ obj_to_update = models.EQPRSFileUpload.objects.get(id=obj_id)
+ full_text = obj_to_update.file.read().decode("utf-8")
+
+ # validate report type
+ report_type_validated = validate_report_type(full_text, obj_id)
+ if not report_type_validated:
+ return
+
+ # clean the csv & extract data
+ financial_year = get_financial_year(full_text)
+ sphere = get_sphere(full_text)
+ clean_text = full_text.split("\n", 3)[3]
+ f = StringIO(clean_text)
+ reader = csv.DictReader(f)
+ parsed_data = list(reader)
+
+ # find the objects
+ department_government_pairs = set(
+ [(x["Institution"], x["Programme"]) for x in parsed_data]
+ ) # Programme column in CSV is mislabeled
+ num_imported = 0
+ total_record_count = len(parsed_data)
+ not_matching_departments = set()
+
+ for department, government_name in department_government_pairs:
+ if government_name == "National":
+ government_name = "South Africa"
+
+ if department.startswith(f"{government_name}: "):
+ department = department.replace(f"{government_name}: ", "")
+
+ # clear by department
+ models.Indicator.objects.filter(
+ department__name=department,
+ department__government__name=government_name,
+ department__government__sphere__name=sphere,
+ department__government__sphere__financial_year__slug=financial_year,
+ ).delete()
+
+ # clear by alias
+ alias_obj = models.EQPRSDepartmentAlias.objects.filter(
+ alias=department,
+ department__government__name=government_name,
+ department__government__sphere__name=sphere,
+ department__government__sphere__financial_year__slug=financial_year,
+ ).first()
+ if alias_obj:
+ models.Indicator.objects.filter(department=alias_obj.department).delete()
+
+ # create new indicators
+ for indicator_data in parsed_data:
+ frequency = indicator_data["Frequency"]
+ government_name = indicator_data["Programme"]
+ if government_name == "National":
+ government_name = "South Africa"
+ department_name = indicator_data["Institution"]
+ if department_name.startswith(f"{government_name}: "):
+ department_name = department_name.replace(f"{government_name}: ", "")
+
+ department_matches = models.Department.objects.filter(
+ name=department_name,
+ government__name=government_name,
+ government__sphere__name=sphere,
+ government__sphere__financial_year__slug=financial_year,
+ )
+
+ assert department_matches.count() <= 1
+ department_obj = department_matches.first()
+
+ if not department_obj:
+ alias_matches = models.EQPRSDepartmentAlias.objects.filter(
+ alias=department_name,
+ department__government__name=government_name,
+ department__government__sphere__name=sphere,
+ department__government__sphere__financial_year__slug=financial_year,
+ )
+ assert alias_matches.count() <= 1
+ if len(alias_matches) > 0:
+ department_obj = alias_matches.first().department
+
+ if department_obj:
+ models.Indicator.objects.create(
+ indicator_name=indicator_data["Indicator"],
+ department=department_obj,
+ source_id=obj_id,
+ q1_target=indicator_data["Target_Q1"],
+ q1_actual_output=indicator_data["ActualOutput_Q1"],
+ q1_deviation_reason=indicator_data["ReasonforDeviation_Q1"],
+ q1_corrective_action=indicator_data["CorrectiveAction_Q1"],
+ q1_national_comments=indicator_data.get("National_Q1", ""),
+ q1_otp_comments=indicator_data.get("OTP_Q1", ""),
+ q1_dpme_coordinator_comments=indicator_data.get("OTP_Q1", ""),
+ q1_treasury_comments=indicator_data.get("National_Q1", ""),
+ q2_target=indicator_data["Target_Q2"],
+ q2_actual_output=indicator_data["ActualOutput_Q2"],
+ q2_deviation_reason=indicator_data["ReasonforDeviation_Q2"],
+ q2_corrective_action=indicator_data["CorrectiveAction_Q2"],
+ q2_national_comments=indicator_data.get("National_Q2", ""),
+ q2_otp_comments=indicator_data.get("OTP_Q2", ""),
+ q2_dpme_coordinator_comments=indicator_data.get("OTP_Q2", ""),
+ q2_treasury_comments=indicator_data.get("National_Q2", ""),
+ q3_target=indicator_data["Target_Q3"],
+ q3_actual_output=indicator_data["ActualOutput_Q3"],
+ q3_deviation_reason=indicator_data["ReasonforDeviation_Q3"],
+ q3_corrective_action=indicator_data["CorrectiveAction_Q3"],
+ q3_national_comments=indicator_data.get("National_Q3", ""),
+ q3_otp_comments=indicator_data.get("OTP_Q3", ""),
+ q3_dpme_coordinator_comments=indicator_data.get("OTP_Q3", ""),
+ q3_treasury_comments=indicator_data.get("National_Q3", ""),
+ q4_target=indicator_data["Target_Q4"],
+ q4_actual_output=indicator_data["ActualOutput_Q4"],
+ q4_deviation_reason=indicator_data["ReasonforDeviation_Q4"],
+ q4_corrective_action=indicator_data["CorrectiveAction_Q4"],
+ q4_national_comments=indicator_data.get("National_Q4", ""),
+ q4_otp_comments=indicator_data.get("OTP_Q4", ""),
+ q4_dpme_coordinator_comments=indicator_data.get("OTP_Q4", ""),
+ q4_treasury_comments=indicator_data.get("National_Q4", ""),
+ annual_target=indicator_data["AnnualTarget_Summary2"],
+ annual_aggregate_output="",
+ annual_pre_audit_output=indicator_data["PrelimaryAudited_Summary2"],
+ annual_deviation_reason=indicator_data["ReasonforDeviation_Summary"],
+ annual_corrective_action=indicator_data["CorrectiveAction_Summary"],
+ annual_otp_comments=indicator_data.get("OTP_Summary", ""),
+ annual_national_comments=indicator_data.get("National_Summary", ""),
+ annual_dpme_coordinator_comments=indicator_data.get("OTP_Summary", ""),
+ annual_treasury_comments=indicator_data.get("National_Summary", ""),
+ annual_audited_output=indicator_data["ValidatedAudited_Summary2"],
+ sector=indicator_data["Sector"],
+ programme_name=indicator_data[
+ "SubProgramme"
+ ], # SubProgramme column in CSV is mislabeled
+ subprogramme_name=indicator_data[
+ "Location"
+ ], # Location column in CSV is mislabeled
+ frequency=[i[0] for i in models.FREQUENCIES if i[1] == frequency][0],
+ type=indicator_data["Type"],
+ subtype=indicator_data["SubType"],
+ mtsf_outcome=indicator_data["Outcome"],
+ cluster=indicator_data["Cluster"],
+ uid=indicator_data["UID"],
+ )
+ num_imported = num_imported + 1
+ else:
+ not_matching_departments.add(department_name)
+
+ # update object
+ obj_to_update.num_imported = num_imported
+ obj_to_update.num_not_imported = total_record_count - num_imported
+ obj_to_update.import_report = generate_import_report(
+ True, None, not_matching_departments
+ )
+ obj_to_update.save()
+
+
+def validate_report_type(full_text, obj_id):
+ validated = False
+ for report_type in VALID_REPORT_TYPES:
+ validated = validated or (report_type in full_text)
+
+ if not validated:
+ obj_to_update = models.EQPRSFileUpload.objects.get(id=obj_id)
+ obj_to_update.num_imported = None
+ obj_to_update.num_not_imported = None
+ obj_to_update.import_report = generate_import_report(False, None, [])
+ obj_to_update.save()
+
+ return validated
+
+
+def validate_frictionless(data, obj_id):
+ report = validate(data)
+ validated = report.valid
+ if not validated:
+ obj_to_update = models.EQPRSFileUpload.objects.get(id=obj_id)
+ obj_to_update.num_imported = None
+ obj_to_update.num_not_imported = None
+ obj_to_update.import_report = generate_import_report(True, report, [])
+ obj_to_update.save()
+
+ return validated
+
+
+def get_financial_year(full_text):
+ financial_year = full_text.split("\n", 1)[1]
+ financial_year = financial_year.replace("QPR for FY ", "")
+ financial_year = financial_year[: financial_year.index(" ")]
+ financial_year = financial_year.strip()
+
+ return financial_year
+
+
+def get_sphere(full_text):
+ line = full_text.split("\n", 2)[1]
+ if "Provincial" in line:
+ sphere = "Provincial"
+ else:
+ sphere = "National"
+
+ return sphere
+
+
+class EQPRSFileUploadAdmin(admin.ModelAdmin):
+ exclude = ("num_imported", "import_report", "num_not_imported")
+ readonly_fields = (
+ "num_imported",
+ "import_report",
+ "num_not_imported",
+ "user",
+ )
+ list_display = (
+ "created_at",
+ "user",
+ "num_imported",
+ "num_not_imported",
+ "processing_completed",
+ "updated_at",
+ )
+ fieldsets = (
+ (
+ "",
+ {
+ "fields": (
+ "user",
+ "file",
+ "import_report",
+ "num_imported",
+ "num_not_imported",
+ )
+ },
+ ),
+ )
+
+ def get_readonly_fields(self, request, obj=None):
+ if obj: # editing an existing object
+ return (
+ "user",
+ "file",
+ ) + self.readonly_fields
+ return self.readonly_fields
+
+ def render_change_form(
+ self, request, context, add=False, change=False, form_url="", obj=None
+ ):
+ response = super(EQPRSFileUploadAdmin, self).render_change_form(
+ request, context, add, change, form_url, obj
+ )
+ response.context_data["title"] = (
+ "EQPRS file upload"
+ if response.context_data["object_id"]
+ else "Upload EQPRS file"
+ )
+ return response
+
+ def has_change_permission(self, request, obj=None):
+ super(EQPRSFileUploadAdmin, self).has_change_permission(request, obj)
+
+ def has_delete_permission(self, request, obj=None):
+ super(EQPRSFileUploadAdmin, self).has_delete_permission(request, obj)
+
+ def save_model(self, request, obj, form, change):
+ if not obj.pk:
+ obj.user = request.user
+ super().save_model(request, obj, form, change)
+ # It looks like the task isn't saved synchronously, so we can't set the
+ # task as a related object synchronously. We have to fetch it by its ID
+ # when we want to see if it's available yet.
+
+ obj.task_id = async_task(func=save_imported_indicators, obj_id=obj.id)
+ obj.save()
+
+ def processing_completed(self, obj):
+ task = fetch(obj.task_id)
+ if task:
+ return task.success
+
+ processing_completed.boolean = True
+ processing_completed.short_description = "Processing completed"
+
+
+class IndicatorAdmin(admin.ModelAdmin):
+ list_display = (
+ "indicator_name",
+ "financial_year",
+ "sphere",
+ "government",
+ "get_department",
+ "created_at",
+ )
+ list_filter = (
+ "department__government__sphere__financial_year__slug",
+ "department__government__sphere__name",
+ "department__government__name",
+ "department__name",
+ )
+ search_fields = (
+ "department__government__sphere__financial_year__slug",
+ "department__government__sphere__name",
+ "department__government__name",
+ "department__name",
+ "indicator_name",
+ )
+
+ readonly_fields = (
+ "source",
+ "indicator_name",
+ "department",
+ "q1_target",
+ "q1_actual_output",
+ "q1_deviation_reason",
+ "q1_corrective_action",
+ "q1_national_comments",
+ "q1_otp_comments",
+ "q1_dpme_coordinator_comments",
+ "q1_treasury_comments",
+ "q2_target",
+ "q2_actual_output",
+ "q2_deviation_reason",
+ "q2_corrective_action",
+ "q2_national_comments",
+ "q2_otp_comments",
+ "q2_dpme_coordinator_comments",
+ "q2_treasury_comments",
+ "q3_target",
+ "q3_actual_output",
+ "q3_deviation_reason",
+ "q3_corrective_action",
+ "q3_national_comments",
+ "q3_otp_comments",
+ "q3_dpme_coordinator_comments",
+ "q3_treasury_comments",
+ "q4_target",
+ "q4_actual_output",
+ "q4_deviation_reason",
+ "q4_corrective_action",
+ "q4_national_comments",
+ "q4_otp_comments",
+ "q4_dpme_coordinator_comments",
+ "q4_treasury_comments",
+ "annual_target",
+ "annual_aggregate_output",
+ "annual_pre_audit_output",
+ "annual_deviation_reason",
+ "annual_corrective_action",
+ "annual_otp_comments",
+ "annual_national_comments",
+ "annual_dpme_coordinator_comments",
+ "annual_treasury_comments",
+ "annual_audited_output",
+ "sector",
+ "programme_name",
+ "subprogramme_name",
+ "frequency",
+ "type",
+ "subtype",
+ "mtsf_outcome",
+ "cluster",
+ "uid",
+ )
+
+ fieldsets = (
+ (
+ None,
+ {
+ "fields": (
+ "source",
+ "indicator_name",
+ "department",
+ "sector",
+ "programme_name",
+ "subprogramme_name",
+ "frequency",
+ "type",
+ "subtype",
+ "mtsf_outcome",
+ "cluster",
+ ),
+ },
+ ),
+ (
+ "Quarter 1",
+ {
+ "fields": (
+ "q1_target",
+ "q1_actual_output",
+ "q1_deviation_reason",
+ "q1_corrective_action",
+ "q1_national_comments",
+ "q1_otp_comments",
+ "q1_dpme_coordinator_comments",
+ "q1_treasury_comments",
+ ),
+ },
+ ),
+ (
+ "Quarter 2",
+ {
+ "fields": (
+ "q2_target",
+ "q2_actual_output",
+ "q2_deviation_reason",
+ "q2_corrective_action",
+ "q2_national_comments",
+ "q2_otp_comments",
+ "q2_dpme_coordinator_comments",
+ "q2_treasury_comments",
+ ),
+ },
+ ),
+ (
+ "Quarter 3",
+ {
+ "fields": (
+ "q3_target",
+ "q3_actual_output",
+ "q3_deviation_reason",
+ "q3_corrective_action",
+ "q3_national_comments",
+ "q3_otp_comments",
+ "q3_dpme_coordinator_comments",
+ "q3_treasury_comments",
+ ),
+ },
+ ),
+ (
+ "Quarter 4",
+ {
+ "fields": (
+ "q4_target",
+ "q4_actual_output",
+ "q4_deviation_reason",
+ "q4_corrective_action",
+ "q4_national_comments",
+ "q4_otp_comments",
+ "q4_dpme_coordinator_comments",
+ "q4_treasury_comments",
+ ),
+ },
+ ),
+ (
+ "Full year",
+ {
+ "fields": (
+ "annual_target",
+ "annual_aggregate_output",
+ "annual_pre_audit_output",
+ "annual_deviation_reason",
+ "annual_corrective_action",
+ "annual_otp_comments",
+ "annual_national_comments",
+ "annual_dpme_coordinator_comments",
+ "annual_treasury_comments",
+ "annual_audited_output",
+ ),
+ },
+ ),
+ )
+
+ def get_department(self, obj):
+ return obj.department.name
+
+ get_department.short_description = "Department"
+
+ def government(self, obj):
+ return obj.department.government.name
+
+ def sphere(self, obj):
+ return obj.department.government.sphere.name
+
+ def financial_year(self, obj):
+ return obj.department.government.sphere.financial_year.slug
+
+
+class EQPRSDepartmentAliasAdmin(admin.ModelAdmin):
+ list_display = ("alias", "department")
+ list_filter = (
+ "department__government__sphere__financial_year__slug",
+ "department__government__sphere__name",
+ "department__government__name",
+ )
+ search_fields = (
+ "department__name",
+ "alias",
+ )
+
+ autocomplete_fields = ("department",)
+
+
+admin.site.register(models.EQPRSFileUpload, EQPRSFileUploadAdmin)
+admin.site.register(models.Indicator, IndicatorAdmin)
+admin.site.register(models.EQPRSDepartmentAlias, EQPRSDepartmentAliasAdmin)
diff --git a/performance/apps.py b/performance/apps.py
new file mode 100644
index 000000000..35b57e429
--- /dev/null
+++ b/performance/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class PerformanceConfig(AppConfig):
+ name = "performance"
diff --git a/performance/fixtures/test_api.json b/performance/fixtures/test_api.json
new file mode 100644
index 000000000..3deefa8d5
--- /dev/null
+++ b/performance/fixtures/test_api.json
@@ -0,0 +1,180 @@
+[{
+ "model": "auth.user",
+ "pk": 1,
+ "fields": {
+ "password": "pbkdf2_sha256$36000$0lM3TbZBY94V$xirXvIgKYBggcH0tMDjE6GTjoS6KYuim4sl7UbpYsrA=",
+ "last_login": "2019-04-16T05:45:02Z",
+ "is_superuser": true,
+ "username": "admin@localhost",
+ "first_name": "",
+ "last_name": "",
+ "email": "admin@localhost",
+ "is_staff": true,
+ "is_active": true,
+ "date_joined": "2019-04-15T20:06:18Z",
+ "groups": [],
+ "user_permissions": []
+ }
+},
+{
+ "model": "budgetportal.financialyear",
+ "pk": 1,
+ "fields": {
+ "slug": "2015-16",
+ "published": false
+ }
+},
+{
+ "model": "budgetportal.sphere",
+ "pk": 1,
+ "fields": {
+ "name": "National",
+ "slug": "national",
+ "financial_year": 1
+ }
+},
+{
+ "model": "budgetportal.government",
+ "pk": 1,
+ "fields": {
+ "sphere": 1,
+ "name": "South Africa",
+ "slug": "south-africa"
+ }
+},
+{
+ "model": "budgetportal.department",
+ "pk": 1,
+ "fields": {
+ "government": 1,
+ "name": "Agriculture Forestry and Fisheries",
+ "vote_number": 1,
+ "intro": " ",
+ "slug": "agriculture-forestry-and-fisheries"
+ }
+},
+ {
+ "pk": 1,
+ "model": "performance.eqprsfileupload",
+ "fields": {
+ "user": 1,
+ "task_id": "43553e179fab466aaeb967dda549bf7c",
+ "import_report": "Everything was good.\n\nJust look at the data!",
+ "num_imported": 123,
+ "num_not_imported": 0
+ }
+ },
+
+ {
+ "pk": 1,
+ "model": "django_q.task",
+ "fields": {
+ "id": "43553e179fab466aaeb967dda549bf7c",
+ "func": "somefunc",
+ "started": "2022-11-29T14:16:55Z",
+ "stopped": "2022-11-29T14:16:56Z"
+ }
+ },
+
+ {
+ "pk": 1,
+ "model": "performance.indicator",
+ "fields": {
+ "sector": "",
+ "department": 1,
+ "programme_name": "Programme 1: Administration",
+ "subprogramme_name": "Financial Management Services",
+ "frequency": "Annually",
+ "indicator_name": "1.1.1 Unqualified audit opinion",
+ "type": "Non-Standardized",
+ "subtype": "Not Applicable",
+ "mtsf_outcome": "Priority 1: A Capable, Ethical and Developmental State",
+ "cluster": "Governance and Administration cluster",
+ "q1_target": "",
+ "q1_actual_output": "",
+ "q1_deviation_reason": "",
+ "q1_corrective_action": "",
+ "q1_otp_comments": "",
+ "q1_national_comments": "",
+ "q2_target": "",
+ "q2_actual_output": "",
+ "q2_deviation_reason": "",
+ "q2_corrective_action": "",
+ "q2_otp_comments": "",
+ "q2_national_comments": "",
+ "q3_target": "",
+ "q3_actual_output": "",
+ "q3_deviation_reason": "",
+ "q3_corrective_action": "",
+ "q3_otp_comments": "",
+ "q3_national_comments": "",
+ "q4_target": "",
+ "q4_actual_output": "",
+ "q4_deviation_reason": "",
+ "q4_corrective_action": "",
+ "q4_otp_comments": "",
+ "q4_national_comments": "",
+ "annual_target": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "annual_aggregate_output": "-",
+ "annual_pre_audit_output": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "annual_deviation_reason": "",
+ "annual_corrective_action": "",
+ "annual_otp_comments": "",
+ "annual_national_comments": "",
+ "annual_audited_output": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "uid": 50,
+ "source_id": 1
+ }
+ },
+ {
+ "pk": 2,
+ "model": "performance.indicator",
+ "fields": {
+ "sector": "",
+ "department": 1,
+ "programme_name": "Programme 1: Administration",
+ "subprogramme_name": "Financial Management Services",
+ "frequency": "Quarterly",
+ "indicator_name": "1.2.1 Percentage of valid invoices paid within 30 days upon receipt by the department",
+ "type": "Non-Standardized",
+ "subtype": "Not Applicable",
+ "mtsf_outcome": "Priority 1: A Capable, Ethical and Developmental State",
+ "cluster": "Governance and Administration cluster",
+ "q1_target": "100 %",
+ "q1_actual_output": "97 %",
+ "q1_deviation_reason": "The variance was caused by the re-allocation of resources to the finalisation of the year-end closure process and audit\n",
+ "q1_corrective_action": "The closure of the year (2020/21) has now been finalised; thus it should not be a factor in hampering the processing of invoices going forth\n",
+ "q1_otp_comments": "",
+ "q1_national_comments": "",
+ "q2_target": "100 %",
+ "q2_actual_output": "97% %",
+ "q2_deviation_reason": "Constant closure of offices due to exposure to COVID- 19 cases. Outstanding Official, Flight, Transport and Accommodation Request (OFTARs). Entities not registered on Basic Accounting System (BAS). Service Providers changing banking details after services are rendered. Officials using photocopier machines after the contract has expired resulting in exposit-facto\n",
+ "q2_corrective_action": "Constant awareness to both officials and service providers the importance of adhering to Treasury Regulations and ensuring documents are complaint at all times\n",
+ "q2_otp_comments": "",
+ "q2_national_comments": "",
+ "q3_target": "100 %",
+ "q3_actual_output": "96% %",
+ "q3_deviation_reason": "Service providers not registered on Basic Accounting System (BAS). Payments rejected on safety web. Awaiting verification/approval\n",
+ "q3_corrective_action": "Continuous consultation with relevant directorates\n",
+ "q3_otp_comments": "",
+ "q3_national_comments": "",
+ "q4_target": "100 %",
+ "q4_actual_output": "95% %",
+ "q4_deviation_reason": "Payment submitted to finance after 30 days. Supplier banking detail on invoice not the same as on the system",
+ "q4_corrective_action": "Weekly monitoring of payments at hand\n",
+ "q4_otp_comments": "",
+ "q4_national_comments": "",
+ "annual_target": "100 %",
+ "annual_aggregate_output": "0 %",
+ "annual_pre_audit_output": "96",
+ "annual_deviation_reason": "Supplier banking detail on invoice not the same as on the system. This caused delays in payment as the process need to be followed in verifying the correct details. ",
+ "annual_corrective_action": "Weekly monitoring of payments at hand\n",
+ "annual_otp_comments": "",
+ "annual_national_comments": "",
+ "annual_audited_output": "",
+ "uid": 50,
+ "source_id": 1
+ }
+ }
+
+ ]
\ No newline at end of file
diff --git a/performance/fixtures/test_repetitive_data.json b/performance/fixtures/test_repetitive_data.json
new file mode 100644
index 000000000..3e6187730
--- /dev/null
+++ b/performance/fixtures/test_repetitive_data.json
@@ -0,0 +1,180 @@
+[{
+ "model": "auth.user",
+ "pk": 1,
+ "fields": {
+ "password": "pbkdf2_sha256$36000$0lM3TbZBY94V$xirXvIgKYBggcH0tMDjE6GTjoS6KYuim4sl7UbpYsrA=",
+ "last_login": "2019-04-16T05:45:02Z",
+ "is_superuser": true,
+ "username": "admin@localhost",
+ "first_name": "",
+ "last_name": "",
+ "email": "admin@localhost",
+ "is_staff": true,
+ "is_active": true,
+ "date_joined": "2019-04-15T20:06:18Z",
+ "groups": [],
+ "user_permissions": []
+ }
+},
+{
+ "model": "budgetportal.financialyear",
+ "pk": 1,
+ "fields": {
+ "slug": "2015-16",
+ "published": false
+ }
+},
+{
+ "model": "budgetportal.sphere",
+ "pk": 1,
+ "fields": {
+ "name": "National",
+ "slug": "national",
+ "financial_year": 1
+ }
+},
+{
+ "model": "budgetportal.government",
+ "pk": 1,
+ "fields": {
+ "sphere": 1,
+ "name": "South Africa",
+ "slug": "south-africa"
+ }
+},
+{
+ "model": "budgetportal.department",
+ "pk": 1,
+ "fields": {
+ "government": 1,
+ "name": "Agriculture Forestry and Fisheries",
+ "vote_number": 1,
+ "intro": " ",
+ "slug": "agriculture-forestry-and-fisheries"
+ }
+},
+ {
+ "pk": 1,
+ "model": "performance.eqprsfileupload",
+ "fields": {
+ "user": 1,
+ "task_id": "43553e179fab466aaeb967dda549bf7c",
+ "import_report": "Everything was good.\n\nJust look at the data!",
+ "num_imported": 123,
+ "num_not_imported": 0
+ }
+ },
+
+ {
+ "pk": 1,
+ "model": "django_q.task",
+ "fields": {
+ "id": "43553e179fab466aaeb967dda549bf7c",
+ "func": "somefunc",
+ "started": "2022-11-29T14:16:55Z",
+ "stopped": "2022-11-29T14:16:56Z"
+ }
+ },
+
+ {
+ "pk": 1,
+ "model": "performance.indicator",
+ "fields": {
+ "sector": "",
+ "department": 1,
+ "programme_name": "Programme 1: Administration",
+ "subprogramme_name": "Financial Management Services",
+ "frequency": "Annually",
+ "indicator_name": "1.1.1 Unqualified audit opinion",
+ "type": "Non-Standardized",
+ "subtype": "Not Applicable",
+ "mtsf_outcome": "Priority 1: A Capable, Ethical and Developmental State",
+ "cluster": "Governance and Administration cluster",
+ "q1_target": "",
+ "q1_actual_output": "",
+ "q1_deviation_reason": "",
+ "q1_corrective_action": "",
+ "q1_otp_comments": "",
+ "q1_national_comments": "",
+ "q2_target": "",
+ "q2_actual_output": "",
+ "q2_deviation_reason": "",
+ "q2_corrective_action": "",
+ "q2_otp_comments": "",
+ "q2_national_comments": "",
+ "q3_target": "",
+ "q3_actual_output": "",
+ "q3_deviation_reason": "",
+ "q3_corrective_action": "",
+ "q3_otp_comments": "",
+ "q3_national_comments": "",
+ "q4_target": "",
+ "q4_actual_output": "",
+ "q4_deviation_reason": "",
+ "q4_corrective_action": "",
+ "q4_otp_comments": "",
+ "q4_national_comments": "",
+ "annual_target": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "annual_aggregate_output": "-",
+ "annual_pre_audit_output": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "annual_deviation_reason": "",
+ "annual_corrective_action": "",
+ "annual_otp_comments": "",
+ "annual_national_comments": "",
+ "annual_audited_output": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "uid": 50,
+ "source_id": 1
+ }
+ },
+ {
+ "pk": 2,
+ "model": "performance.indicator",
+ "fields": {
+ "sector": "",
+ "department": 1,
+ "programme_name": "Programme 1: Administration",
+ "subprogramme_name": "Financial Management Services",
+ "frequency": "Annually",
+ "indicator_name": "1.1.1 Unqualified audit opinion",
+ "type": "Non-Standardized",
+ "subtype": "Not Applicable",
+ "mtsf_outcome": "Priority 1: A Capable, Ethical and Developmental State",
+ "cluster": "Governance and Administration cluster",
+ "q1_target": "",
+ "q1_actual_output": "",
+ "q1_deviation_reason": "",
+ "q1_corrective_action": "",
+ "q1_otp_comments": "",
+ "q1_national_comments": "",
+ "q2_target": "",
+ "q2_actual_output": "",
+ "q2_deviation_reason": "",
+ "q2_corrective_action": "",
+ "q2_otp_comments": "",
+ "q2_national_comments": "",
+ "q3_target": "",
+ "q3_actual_output": "",
+ "q3_deviation_reason": "",
+ "q3_corrective_action": "",
+ "q3_otp_comments": "",
+ "q3_national_comments": "",
+ "q4_target": "",
+ "q4_actual_output": "",
+ "q4_deviation_reason": "",
+ "q4_corrective_action": "",
+ "q4_otp_comments": "",
+ "q4_national_comments": "",
+ "annual_target": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "annual_aggregate_output": "-",
+ "annual_pre_audit_output": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "annual_deviation_reason": "",
+ "annual_corrective_action": "",
+ "annual_otp_comments": "",
+ "annual_national_comments": "",
+ "annual_audited_output": "Unqualified audit opinion on 2020/21 annual financial statements",
+ "uid": 51,
+ "source_id": 1
+ }
+ }
+
+ ]
\ No newline at end of file
diff --git a/performance/migrations/0001_initial.py b/performance/migrations/0001_initial.py
new file mode 100644
index 000000000..15eaf5182
--- /dev/null
+++ b/performance/migrations/0001_initial.py
@@ -0,0 +1,139 @@
+# Generated by Django 2.2.20 on 2022-11-29 07:50
+
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import performance.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("budgetportal", "0064_auto_20200728_1054"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("django_q", "0009_auto_20171009_0915"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="EQPRSFileUpload",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "file",
+ models.FileField(upload_to=performance.models.eqprs_file_path),
+ ),
+ (
+ "file_validation_report",
+ django.contrib.postgres.fields.jsonb.JSONField(),
+ ),
+ ("import_report", models.TextField()),
+ ("num_imported", models.IntegerField(null=True)),
+ ("num_not_imported", models.IntegerField(null=True)),
+ (
+ "task",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="django_q.Task"
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="Indicator",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("indicator_name", models.TextField()),
+ ("q1_target", models.TextField(blank=True)),
+ ("q1_actual_outcome", models.TextField(blank=True)),
+ ("q1_deviation_reason", models.TextField(blank=True)),
+ ("q1_corrective_action", models.TextField(blank=True)),
+ ("q1_national_comments", models.TextField(blank=True)),
+ ("q1_otp_comments", models.TextField(blank=True)),
+ ("q1_dpme_coordinator_comments", models.TextField(blank=True)),
+ ("q1_treasury_comments", models.TextField(blank=True)),
+ ("q2_target", models.TextField(blank=True)),
+ ("q2_actual_outcome", models.TextField(blank=True)),
+ ("q2_deviation_reason", models.TextField(blank=True)),
+ ("q2_corrective_action", models.TextField(blank=True)),
+ ("q2_national_comments", models.TextField(blank=True)),
+ ("q2_otp_comments", models.TextField(blank=True)),
+ ("q2_dpme_coordinator_comments", models.TextField(blank=True)),
+ ("q2_treasury_comments", models.TextField(blank=True)),
+ ("q3_target", models.TextField(blank=True)),
+ ("q3_actual_outcome", models.TextField(blank=True)),
+ ("q3_deviation_reason", models.TextField(blank=True)),
+ ("q3_corrective_action", models.TextField(blank=True)),
+ ("q3_national_comments", models.TextField(blank=True)),
+ ("q3_otp_comments", models.TextField(blank=True)),
+ ("q3_dpme_coordinator_comments", models.TextField(blank=True)),
+ ("q3_treasury_comments", models.TextField(blank=True)),
+ ("q4_target", models.TextField(blank=True)),
+ ("q4_actual_outcome", models.TextField(blank=True)),
+ ("q4_deviation_reason", models.TextField(blank=True)),
+ ("q4_corrective_action", models.TextField(blank=True)),
+ ("q4_national_comments", models.TextField(blank=True)),
+ ("q4_otp_comments", models.TextField(blank=True)),
+ ("q4_dpme_coordinator_comments", models.TextField(blank=True)),
+ ("q4_treasury_comments", models.TextField(blank=True)),
+ ("sector", models.TextField(blank=True)),
+ ("programme_name", models.TextField(blank=True)),
+ ("subprogramme_name", models.TextField(blank=True)),
+ (
+ "frequency",
+ models.CharField(
+ choices=[("annually", "Annually"), ("quarterly", "Quarterly")],
+ max_length=9,
+ ),
+ ),
+ ("type", models.TextField(blank=True)),
+ ("subtype", models.TextField(blank=True)),
+ ("mtsf_outcome", models.TextField(blank=True)),
+ ("uid", models.TextField(blank=True)),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="indicator_values",
+ to="budgetportal.Department",
+ ),
+ ),
+ (
+ "source",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="indicator_values",
+ to="performance.EQPRSFileUpload",
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {("department", "indicator_name")},
+ },
+ ),
+ ]
diff --git a/performance/migrations/0002_auto_20221129_0819.py b/performance/migrations/0002_auto_20221129_0819.py
new file mode 100644
index 000000000..2d9877531
--- /dev/null
+++ b/performance/migrations/0002_auto_20221129_0819.py
@@ -0,0 +1,88 @@
+# Generated by Django 2.2.20 on 2022-11-29 08:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="indicator",
+ old_name="q1_actual_outcome",
+ new_name="q1_actual_output",
+ ),
+ migrations.RenameField(
+ model_name="indicator",
+ old_name="q2_actual_outcome",
+ new_name="q2_actual_output",
+ ),
+ migrations.RenameField(
+ model_name="indicator",
+ old_name="q3_actual_outcome",
+ new_name="q3_actual_output",
+ ),
+ migrations.RenameField(
+ model_name="indicator",
+ old_name="q4_actual_outcome",
+ new_name="q4_actual_output",
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_aggregate_output",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_audited_output",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_corrective_action",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_deviation_reason",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_dpme_coordincator_comments",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_national_comments",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_otp_comments",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_pre_audit_output",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_target",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="annual_treasury_comments",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name="indicator",
+ name="cluster",
+ field=models.TextField(blank=True),
+ ),
+ ]
diff --git a/performance/migrations/0003_remove_eqprsfileupload_file_validation_report.py b/performance/migrations/0003_remove_eqprsfileupload_file_validation_report.py
new file mode 100644
index 000000000..33f6f1681
--- /dev/null
+++ b/performance/migrations/0003_remove_eqprsfileupload_file_validation_report.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.2.20 on 2022-11-29 12:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0002_auto_20221129_0819"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="eqprsfileupload",
+ name="file_validation_report",
+ ),
+ ]
diff --git a/performance/migrations/0004_auto_20221130_2138.py b/performance/migrations/0004_auto_20221130_2138.py
new file mode 100644
index 000000000..2b092eb9a
--- /dev/null
+++ b/performance/migrations/0004_auto_20221130_2138.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2.20 on 2022-11-30 21:38
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0003_remove_eqprsfileupload_file_validation_report"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="eqprsfileupload",
+ name="task",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="django_q.Task",
+ ),
+ ),
+ ]
diff --git a/performance/migrations/0004_auto_20221205_1147.py b/performance/migrations/0004_auto_20221205_1147.py
new file mode 100644
index 000000000..e01cb690d
--- /dev/null
+++ b/performance/migrations/0004_auto_20221205_1147.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.20 on 2022-12-05 11:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0003_remove_eqprsfileupload_file_validation_report"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="eqprsfileupload",
+ name="created_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="eqprsfileupload",
+ name="updated_at",
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ ]
diff --git a/performance/migrations/0005_indicator_created_at.py b/performance/migrations/0005_indicator_created_at.py
new file mode 100644
index 000000000..78df9b46c
--- /dev/null
+++ b/performance/migrations/0005_indicator_created_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.20 on 2022-12-05 11:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0004_auto_20221205_1147"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="indicator",
+ name="created_at",
+ field=models.DateTimeField(auto_now_add=True, null=True),
+ ),
+ ]
diff --git a/performance/migrations/0006_merge_20221206_1915.py b/performance/migrations/0006_merge_20221206_1915.py
new file mode 100644
index 000000000..0468bd532
--- /dev/null
+++ b/performance/migrations/0006_merge_20221206_1915.py
@@ -0,0 +1,13 @@
+# Generated by Django 2.2.20 on 2022-12-06 19:15
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0005_indicator_created_at"),
+ ("performance", "0004_auto_20221130_2138"),
+ ]
+
+ operations = []
diff --git a/performance/migrations/0007_auto_20221213_2013.py b/performance/migrations/0007_auto_20221213_2013.py
new file mode 100644
index 000000000..d7417342b
--- /dev/null
+++ b/performance/migrations/0007_auto_20221213_2013.py
@@ -0,0 +1,42 @@
+# Generated by Django 2.2.20 on 2022-12-13 20:13
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0006_merge_20221206_1915"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="eqprsfileupload",
+ name="num_imported",
+ field=models.IntegerField(
+ default=0, null=True, verbose_name="Number of rows we could import"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="eqprsfileupload",
+ name="num_not_imported",
+ field=models.IntegerField(
+ default=0, null=True, verbose_name="Number of rows we could not import"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="eqprsfileupload",
+ name="user",
+ field=models.ForeignKey(
+ default=0,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="indicator",
+ unique_together=set(),
+ ),
+ ]
diff --git a/performance/migrations/0008_auto_20230104_2150.py b/performance/migrations/0008_auto_20230104_2150.py
new file mode 100644
index 000000000..8c81b3b5e
--- /dev/null
+++ b/performance/migrations/0008_auto_20230104_2150.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2.20 on 2023-01-04 21:50
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0007_auto_20221213_2013"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="eqprsfileupload",
+ name="user",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/performance/migrations/0009_auto_20230113_1028.py b/performance/migrations/0009_auto_20230113_1028.py
new file mode 100644
index 000000000..14154155f
--- /dev/null
+++ b/performance/migrations/0009_auto_20230113_1028.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.2.28 on 2023-01-13 10:28
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0008_auto_20230104_2150"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="eqprsfileupload",
+ name="task",
+ ),
+ migrations.AlterField(
+ model_name="eqprsfileupload",
+ name="user",
+ field=models.ForeignKey(
+ blank=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/performance/migrations/0010_eqprsfileupload_task_id.py b/performance/migrations/0010_eqprsfileupload_task_id.py
new file mode 100644
index 000000000..1ba48388b
--- /dev/null
+++ b/performance/migrations/0010_eqprsfileupload_task_id.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.28 on 2023-01-13 10:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0009_auto_20230113_1028"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="eqprsfileupload",
+ name="task_id",
+ field=models.TextField(default="WASN'T SET WHEN FIELD WAS ADDED"),
+ preserve_default=False,
+ ),
+ ]
diff --git a/performance/migrations/0011_auto_20230202_1043.py b/performance/migrations/0011_auto_20230202_1043.py
new file mode 100644
index 000000000..74c376989
--- /dev/null
+++ b/performance/migrations/0011_auto_20230202_1043.py
@@ -0,0 +1,26 @@
+# Generated by Django 2.2.28 on 2023-02-02 10:43
+
+import django.contrib.postgres.indexes
+import django.contrib.postgres.search
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0010_eqprsfileupload_task_id"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="indicator",
+ name="content_search",
+ field=django.contrib.postgres.search.SearchVectorField(null=True),
+ ),
+ migrations.AddIndex(
+ model_name="indicator",
+ index=django.contrib.postgres.indexes.GinIndex(
+ fields=["content_search"], name="performance_content_b5accd_gin"
+ ),
+ ),
+ ]
diff --git a/performance/migrations/0012_auto_20230202_1121.py b/performance/migrations/0012_auto_20230202_1121.py
new file mode 100644
index 000000000..ae98bdfc4
--- /dev/null
+++ b/performance/migrations/0012_auto_20230202_1121.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2023-02-02 11:21
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0011_auto_20230202_1043"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="indicator",
+ old_name="annual_dpme_coordincator_comments",
+ new_name="annual_dpme_coordinator_comments",
+ ),
+ ]
diff --git a/performance/migrations/0013__add_full_text_index_triggers.py b/performance/migrations/0013__add_full_text_index_triggers.py
new file mode 100644
index 000000000..613f4751a
--- /dev/null
+++ b/performance/migrations/0013__add_full_text_index_triggers.py
@@ -0,0 +1,49 @@
+# Generated by Django 2.2.28 on 2023-02-02 10:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0012_auto_20230202_1121"),
+ ]
+
+ migration = """
+ CREATE TRIGGER content_search_update BEFORE INSERT OR UPDATE
+ ON performance_indicator FOR EACH ROW EXECUTE PROCEDURE
+ tsvector_update_trigger(
+ content_search, 'pg_catalog.english',
+ q1_target, q1_actual_output, q1_deviation_reason, q1_corrective_action,
+ q1_national_comments, q1_otp_comments, q1_dpme_coordinator_comments,
+ q1_treasury_comments,
+
+ q2_target, q2_actual_output, q2_deviation_reason, q2_corrective_action,
+ q2_national_comments, q2_otp_comments, q2_dpme_coordinator_comments,
+ q2_treasury_comments,
+
+ q3_target, q3_actual_output, q3_deviation_reason, q3_corrective_action,
+ q3_national_comments, q3_otp_comments, q3_dpme_coordinator_comments,
+ q3_treasury_comments,
+
+ q4_target, q4_actual_output, q4_deviation_reason, q4_corrective_action,
+ q4_national_comments, q4_otp_comments, q4_dpme_coordinator_comments,
+ q4_treasury_comments,
+
+ annual_target, annual_aggregate_output, annual_pre_audit_output,
+ annual_deviation_reason, annual_corrective_action,
+ annual_national_comments, annual_otp_comments, annual_dpme_coordinator_comments,
+ annual_treasury_comments, annual_audited_output,
+
+ sector, programme_name, subprogramme_name, frequency, type, subtype,
+ mtsf_outcome, cluster
+ );
+ -- Force triggers to run and populate the content_search column.
+ UPDATE performance_indicator set ID = ID;
+ """
+
+ reverse_migration = """
+ DROP TRIGGER content_search_update ON performance_indicator;
+ """
+
+ operations = [migrations.RunSQL(migration, reverse_migration)]
diff --git a/performance/migrations/0014_eqprsdepartmentalias.py b/performance/migrations/0014_eqprsdepartmentalias.py
new file mode 100644
index 000000000..ae42d3be9
--- /dev/null
+++ b/performance/migrations/0014_eqprsdepartmentalias.py
@@ -0,0 +1,37 @@
+# Generated by Django 2.2.28 on 2023-02-23 07:53
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("budgetportal", "0068_auto_20230220_0910"),
+ ("performance", "0013__add_full_text_index_triggers"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="EQPRSDepartmentAlias",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("alias", models.TextField()),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="budgetportal.Department",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/performance/migrations/0015_auto_20230223_0755.py b/performance/migrations/0015_auto_20230223_0755.py
new file mode 100644
index 000000000..558f15971
--- /dev/null
+++ b/performance/migrations/0015_auto_20230223_0755.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.2.28 on 2023-02-23 07:55
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0014_eqprsdepartmentalias"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="eqprsdepartmentalias",
+ options={"verbose_name_plural": "Eqprs department aliases"},
+ ),
+ ]
diff --git a/performance/migrations/0016_auto_20230223_0804.py b/performance/migrations/0016_auto_20230223_0804.py
new file mode 100644
index 000000000..8320f712b
--- /dev/null
+++ b/performance/migrations/0016_auto_20230223_0804.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2023-02-23 08:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("performance", "0015_auto_20230223_0755"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="eqprsdepartmentalias",
+ name="alias",
+ field=models.CharField(max_length=200),
+ ),
+ ]
diff --git a/performance/migrations/__init__.py b/performance/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/performance/models.py b/performance/models.py
new file mode 100644
index 000000000..5338c25a1
--- /dev/null
+++ b/performance/models.py
@@ -0,0 +1,130 @@
+from django.contrib.auth.models import User
+from django.contrib.postgres.fields import JSONField
+from django.contrib.postgres.search import SearchVectorField
+from django.contrib.postgres.indexes import GinIndex
+from django.db import models
+from django_q.tasks import Task
+
+from budgetportal.models.government import Department
+
+import uuid
+
+
+def eqprs_file_path(instance, filename):
+ return f"eqprs_uploads/{uuid.uuid4()}/{filename}"
+
+
+class EQPRSFileUpload(models.Model):
+ user = models.ForeignKey(User, models.DO_NOTHING, blank=True)
+ task_id = models.TextField()
+ file = models.FileField(upload_to=eqprs_file_path)
+ # Plain text listing which departments could not be matched and were not imported
+ import_report = models.TextField()
+ num_imported = models.IntegerField(
+ null=True, default=0, verbose_name="Number of rows we could import"
+ ) # number of rows we could import
+ num_not_imported = models.IntegerField(
+ null=True, default=0, verbose_name="Number of rows we could not import"
+ ) # number of rows we could not import
+ created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True)
+ updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
+
+
+class Frequency:
+ ANNUALLY = "annually"
+ QUARTERLY = "quarterly"
+
+
+FREQUENCIES = (
+ (Frequency.ANNUALLY, "Annually"),
+ (Frequency.QUARTERLY, "Quarterly"),
+)
+
+
+class Indicator(models.Model):
+ """The indicator values available for a indicator in a department in a financial year"""
+
+ department = models.ForeignKey(
+ Department, on_delete=models.CASCADE, related_name="indicator_values"
+ )
+ indicator_name = models.TextField()
+
+ # OTP stands for Office of the Premier
+ # national_comments is provincial only, headed "National Oversight Comments"
+ # dpme_comments is national only, headed "DPME Coordinator Comments"
+ # treasury_comments is national only, headed "National Treasury Coordinator comments
+
+ q1_target = models.TextField(blank=True)
+ q1_actual_output = models.TextField(blank=True)
+ q1_deviation_reason = models.TextField(blank=True)
+ q1_corrective_action = models.TextField(blank=True)
+ q1_national_comments = models.TextField(blank=True)
+ q1_otp_comments = models.TextField(blank=True)
+ q1_dpme_coordinator_comments = models.TextField(blank=True)
+ q1_treasury_comments = models.TextField(blank=True)
+
+ q2_target = models.TextField(blank=True)
+ q2_actual_output = models.TextField(blank=True)
+ q2_deviation_reason = models.TextField(blank=True)
+ q2_corrective_action = models.TextField(blank=True)
+ q2_national_comments = models.TextField(blank=True)
+ q2_otp_comments = models.TextField(blank=True)
+ q2_dpme_coordinator_comments = models.TextField(blank=True)
+ q2_treasury_comments = models.TextField(blank=True)
+
+ q3_target = models.TextField(blank=True)
+ q3_actual_output = models.TextField(blank=True)
+ q3_deviation_reason = models.TextField(blank=True)
+ q3_corrective_action = models.TextField(blank=True)
+ q3_national_comments = models.TextField(blank=True)
+ q3_otp_comments = models.TextField(blank=True)
+ q3_dpme_coordinator_comments = models.TextField(blank=True)
+ q3_treasury_comments = models.TextField(blank=True)
+
+ q4_target = models.TextField(blank=True)
+ q4_actual_output = models.TextField(blank=True)
+ q4_deviation_reason = models.TextField(blank=True)
+ q4_corrective_action = models.TextField(blank=True)
+ q4_national_comments = models.TextField(blank=True)
+ q4_otp_comments = models.TextField(blank=True)
+ q4_dpme_coordinator_comments = models.TextField(blank=True)
+ q4_treasury_comments = models.TextField(blank=True)
+
+ annual_target = models.TextField(blank=True) # AnnualTarget_Summary2
+ annual_aggregate_output = models.TextField(blank=True) # Preliminary_Summary2
+ annual_pre_audit_output = models.TextField(blank=True) # PrelimaryAudited_Summary2
+ annual_deviation_reason = models.TextField(blank=True)
+ annual_corrective_action = models.TextField(blank=True)
+ annual_otp_comments = models.TextField(blank=True)
+ annual_national_comments = models.TextField(blank=True)
+ annual_dpme_coordinator_comments = models.TextField(blank=True)
+ annual_treasury_comments = models.TextField(blank=True)
+ annual_audited_output = models.TextField(blank=True) # ValidatedAuditedSummary2
+
+ sector = models.TextField(blank=True)
+ programme_name = models.TextField(blank=True)
+ subprogramme_name = models.TextField(blank=True)
+ frequency = models.CharField(max_length=9, choices=FREQUENCIES)
+ type = models.TextField(blank=True)
+ subtype = models.TextField(blank=True)
+ mtsf_outcome = models.TextField(blank=True)
+ cluster = models.TextField(blank=True)
+ uid = models.TextField(blank=True)
+
+ source = models.ForeignKey(
+ EQPRSFileUpload, on_delete=models.CASCADE, related_name="indicator_values"
+ )
+ created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True)
+
+ content_search = SearchVectorField(null=True)
+
+ class Meta:
+ indexes = [GinIndex(fields=["content_search"])]
+
+
+class EQPRSDepartmentAlias(models.Model):
+ department = models.ForeignKey(Department, on_delete=models.CASCADE)
+ alias = models.CharField(max_length=200)
+
+ class Meta:
+ verbose_name_plural = "Eqprs department aliases"
diff --git a/performance/serializer.py b/performance/serializer.py
new file mode 100644
index 000000000..9e0bfc681
--- /dev/null
+++ b/performance/serializer.py
@@ -0,0 +1,75 @@
+from rest_framework.serializers import ModelSerializer
+from .models import Indicator
+from budgetportal.models.government import Department, Government, Sphere, FinancialYear
+
+
+class FinancialYearSerializer(ModelSerializer):
+ class Meta:
+ model = FinancialYear
+ fields = ("slug",)
+
+
+class SphereSerializer(ModelSerializer):
+ financial_year = FinancialYearSerializer()
+
+ class Meta:
+ model = Sphere
+ fields = (
+ "financial_year",
+ "name",
+ )
+
+
+class GovernmentSerializer(ModelSerializer):
+ sphere = SphereSerializer()
+
+ class Meta:
+ model = Government
+ fields = (
+ "sphere",
+ "name",
+ )
+
+
+class DepartmentSerializer(ModelSerializer):
+ government = GovernmentSerializer()
+
+ class Meta:
+ model = Department
+ fields = (
+ "government",
+ "name",
+ )
+
+
+class IndicatorSerializer(ModelSerializer):
+ department = DepartmentSerializer()
+
+ class Meta:
+ model = Indicator
+ exclude = (
+ "source",
+ "content_search",
+ "uid",
+ "created_at",
+ "annual_otp_comments",
+ "annual_national_comments",
+ "annual_dpme_coordinator_comments",
+ "annual_treasury_comments",
+ "q1_national_comments",
+ "q1_otp_comments",
+ "q1_dpme_coordinator_comments",
+ "q1_treasury_comments",
+ "q2_national_comments",
+ "q2_otp_comments",
+ "q2_dpme_coordinator_comments",
+ "q2_treasury_comments",
+ "q3_national_comments",
+ "q3_otp_comments",
+ "q3_dpme_coordinator_comments",
+ "q3_treasury_comments",
+ "q4_national_comments",
+ "q4_otp_comments",
+ "q4_dpme_coordinator_comments",
+ "q4_treasury_comments",
+ )
diff --git a/performance/settings.py b/performance/settings.py
new file mode 100644
index 000000000..d8f943175
--- /dev/null
+++ b/performance/settings.py
@@ -0,0 +1,7 @@
+REST_FRAMEWORK = {
+ "DEFAULT_RENDERER_CLASSES": (
+ "rest_framework.renderers.JSONRenderer",
+ "rest_framework.renderers.BrowsableAPIRenderer",
+ "drf_excel.renderers.XLSXRenderer",
+ )
+}
diff --git a/performance/template.xlsx b/performance/template.xlsx
new file mode 100644
index 000000000..ade2a2aaf
Binary files /dev/null and b/performance/template.xlsx differ
diff --git a/performance/templates/performance/performance.html b/performance/templates/performance/performance.html
new file mode 100644
index 000000000..c06861492
--- /dev/null
+++ b/performance/templates/performance/performance.html
@@ -0,0 +1,15 @@
+{% extends 'page-shell.html' %}
+{% load define_action %}
+{% block page_content %}
+
+
+
+ Quarterly performance reporting (QPR) indicators
+
+ Find the latest quarterly performance monitoring indicators, results, and explanations from national and provincial departments.
+
+ How is performance measured in government? Quarterly and audited annual indicators is one of the tools to monitor implementation of department mandates.
+ {% include 'components/performance/Table/index.html' %}
+
+
+{% endblock %}
diff --git a/performance/tests/static/correct_data.csv b/performance/tests/static/correct_data.csv
new file mode 100644
index 000000000..4a325e11c
--- /dev/null
+++ b/performance/tests/static/correct_data.csv
@@ -0,0 +1,9 @@
+ReportTitle,Textbox95,Textbox80,Textbox81,Textbox82,Textbox83
+QPR for FY 2021-22 Provincial Institutions Oversight Performance Report as of ( FY 2021-22),"Location/s: Eastern Cape, Free State",InstitutionType: Provincial,Institution: Health,FinancialYear: FY 2021-22,"Report Printed On: Wednesday, September 28, 2022 11:11:10 AM"
+
+Sector,Institution,Programme,SubProgramme,Location,Frequency,Indicator,Type,SubType,Outcome,Cluster,Target_Q1,ActualOutput_Q1,ReasonforDeviation_Q1,CorrectiveAction_Q1,OTP_Q1,National_Q1,Target_Q2,ActualOutput_Q2,ReasonforDeviation_Q2,CorrectiveAction_Q2,OTP_Q2,National_Q2,Target_Q3,ActualOutput_Q3,ReasonforDeviation_Q3,CorrectiveAction_Q3,OTP_Q3,National_Q3,Target_Q4,ActualOutput_Q4,ReasonforDeviation_Q4,CorrectiveAction_Q4,OTP_Q4,National_Q4,AnnualTarget_Summary2,Preliminary_Summary2,PrelimaryAudited_Summary2,ReasonforDeviation_Summary,CorrectiveAction_Summary,OTP_Summary,National_Summary,ValidatedAudited_Summary2,UID
+Health,Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.1: Office of the MEC,Quarterly,9.1.2 Number of statutory documents tabled at Legislature,Non-Standardized,Max,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",0,0,There is no target for quarter one,,,,1,2,Target achieved,,,,2,2,Target achieved,,,,5,8,All statutory documents submitted.,,,,8,8,8,Target achieved ,,,,,291
+Health,Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.2: Management,Annually,6.4.1 Holistic Human Resources for Health (HRH) strategy approved ,Non-Standardized,Not Applicable,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",,,,,,,,,,,,,,,,,,,,,,,,,Approved HRH Strategy,A draft plan for Human Resources for Health (HRH has been produced,Draft HRH Strategy,"The HRH 2030 Strategy Plan is in progress.
+The Task Team has produced a draft HRH 2030 Plan by the end of the 4th quarter.
+ The Task Team requires the additional capacity of a qualified Technical Advisor to conclude this plan.
+",,,,,291
diff --git a/performance/tests/static/data_for_deleting_indicators.csv b/performance/tests/static/data_for_deleting_indicators.csv
new file mode 100644
index 000000000..9d02be107
--- /dev/null
+++ b/performance/tests/static/data_for_deleting_indicators.csv
@@ -0,0 +1,9 @@
+ReportTitle,Textbox95,Textbox80,Textbox81,Textbox82,Textbox83
+QPR for FY 2017-18 Provincial Institutions Oversight Performance Report as of ( FY 2017-18),"Location/s: Eastern Cape, Free State",InstitutionType: Provincial,Institution: Health,FinancialYear: FY 2017-18,"Report Printed On: Wednesday, September 28, 2022 11:11:10 AM"
+
+Sector,Institution,Programme,SubProgramme,Location,Frequency,Indicator,Type,SubType,Outcome,Cluster,Target_Q1,ActualOutput_Q1,ReasonforDeviation_Q1,CorrectiveAction_Q1,OTP_Q1,National_Q1,Target_Q2,ActualOutput_Q2,ReasonforDeviation_Q2,CorrectiveAction_Q2,OTP_Q2,National_Q2,Target_Q3,ActualOutput_Q3,ReasonforDeviation_Q3,CorrectiveAction_Q3,OTP_Q3,National_Q3,Target_Q4,ActualOutput_Q4,ReasonforDeviation_Q4,CorrectiveAction_Q4,OTP_Q4,National_Q4,AnnualTarget_Summary2,Preliminary_Summary2,PrelimaryAudited_Summary2,ReasonforDeviation_Summary,CorrectiveAction_Summary,OTP_Summary,National_Summary,ValidatedAudited_Summary2,UID
+Health,Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.1: Office of the MEC,Quarterly,9.1.2 Number of statutory documents tabled at Legislature,Non-Standardized,Max,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",0,0,There is no target for quarter one,,,,1,2,Target achieved,,,,2,2,Target achieved,,,,5,8,All statutory documents submitted.,,,,8,8,8,Target achieved ,,,,,291
+Health,Education,Western Cape,Programme 1: Administration,Sub-Programme 1.2: Management,Annually,6.4.1 Holistic Human Resources for Health (HRH) strategy approved ,Non-Standardized,Not Applicable,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",,,,,,,,,,,,,,,,,,,,,,,,,Approved HRH Strategy,A draft plan for Human Resources for Health (HRH has been produced,Draft HRH Strategy,"The HRH 2030 Strategy Plan is in progress.
+The Task Team has produced a draft HRH 2030 Plan by the end of the 4th quarter.
+ The Task Team requires the additional capacity of a qualified Technical Advisor to conclude this plan.
+",,,,,291
diff --git a/performance/tests/static/department_name_containing_government.csv b/performance/tests/static/department_name_containing_government.csv
new file mode 100644
index 000000000..2b53d67ba
--- /dev/null
+++ b/performance/tests/static/department_name_containing_government.csv
@@ -0,0 +1,9 @@
+ReportTitle,Textbox95,Textbox80,Textbox81,Textbox82,Textbox83
+QPR for FY 2021-22 Provincial Institutions Oversight Performance Report as of ( FY 2021-22),"Location/s: Eastern Cape, Free State",InstitutionType: Provincial,Institution: Health,FinancialYear: FY 2021-22,"Report Printed On: Wednesday, September 28, 2022 11:11:10 AM"
+
+Sector,Institution,Programme,SubProgramme,Location,Frequency,Indicator,Type,SubType,Outcome,Cluster,Target_Q1,ActualOutput_Q1,ReasonforDeviation_Q1,CorrectiveAction_Q1,OTP_Q1,National_Q1,Target_Q2,ActualOutput_Q2,ReasonforDeviation_Q2,CorrectiveAction_Q2,OTP_Q2,National_Q2,Target_Q3,ActualOutput_Q3,ReasonforDeviation_Q3,CorrectiveAction_Q3,OTP_Q3,National_Q3,Target_Q4,ActualOutput_Q4,ReasonforDeviation_Q4,CorrectiveAction_Q4,OTP_Q4,National_Q4,AnnualTarget_Summary2,Preliminary_Summary2,PrelimaryAudited_Summary2,ReasonforDeviation_Summary,CorrectiveAction_Summary,OTP_Summary,National_Summary,ValidatedAudited_Summary2,UID
+Health,Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.1: Office of the MEC,Quarterly,9.1.2 Number of statutory documents tabled at Legislature,Non-Standardized,Max,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",0,0,There is no target for quarter one,,,,1,2,Target achieved,,,,2,2,Target achieved,,,,5,8,All statutory documents submitted.,,,,8,8,8,Target achieved ,,,,,291
+Health,Eastern Cape: Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.2: Management,Annually,6.4.1 Holistic Human Resources for Health (HRH) strategy approved ,Non-Standardized,Not Applicable,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",,,,,,,,,,,,,,,,,,,,,,,,,Approved HRH Strategy,A draft plan for Human Resources for Health (HRH has been produced,Draft HRH Strategy,"The HRH 2030 Strategy Plan is in progress.
+The Task Team has produced a draft HRH 2030 Plan by the end of the 4th quarter.
+ The Task Team requires the additional capacity of a qualified Technical Advisor to conclude this plan.
+",,,,,291
diff --git a/performance/tests/static/national_data.csv b/performance/tests/static/national_data.csv
new file mode 100644
index 000000000..2b5524672
--- /dev/null
+++ b/performance/tests/static/national_data.csv
@@ -0,0 +1,5 @@
+ReportTitle,Textbox95,Textbox80,Textbox81,Textbox82,Textbox83
+QPR for FY 2021-22 Provincial Institutions Oversight Performance Report as of ( FY 2021-22),"Location/s: Eastern Cape, Free State",InstitutionType: Provincial,Institution: Health,FinancialYear: FY 2021-22,"Report Printed On: Wednesday, September 28, 2022 11:11:10 AM"
+
+Sector,Institution,Programme,SubProgramme,Location,Frequency,Indicator,Type,SubType,Outcome,Cluster,Target_Q1,ActualOutput_Q1,ReasonforDeviation_Q1,CorrectiveAction_Q1,OTP_Q1,National_Q1,Target_Q2,ActualOutput_Q2,ReasonforDeviation_Q2,CorrectiveAction_Q2,OTP_Q2,National_Q2,Target_Q3,ActualOutput_Q3,ReasonforDeviation_Q3,CorrectiveAction_Q3,OTP_Q3,National_Q3,Target_Q4,ActualOutput_Q4,ReasonforDeviation_Q4,CorrectiveAction_Q4,OTP_Q4,National_Q4,AnnualTarget_Summary2,Preliminary_Summary2,PrelimaryAudited_Summary2,ReasonforDeviation_Summary,CorrectiveAction_Summary,OTP_Summary,National_Summary,ValidatedAudited_Summary2,UID
+Health,Health,National,Programme 1: Administration,Sub-Programme 1.1: Office of the MEC,Quarterly,9.1.2 Number of statutory documents tabled at Legislature,Non-Standardized,Max,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",0,0,There is no target for quarter one,,,,1,2,Target achieved,,,,2,2,Target achieved,,,,5,8,All statutory documents submitted.,,,,8,8,8,Target achieved ,,,,,291
\ No newline at end of file
diff --git a/performance/tests/static/wrong_report_type.csv b/performance/tests/static/wrong_report_type.csv
new file mode 100644
index 000000000..f3589f94e
--- /dev/null
+++ b/performance/tests/static/wrong_report_type.csv
@@ -0,0 +1,9 @@
+ReportTitle,Textbox95,Textbox80,Textbox81,Textbox82,Textbox83
+QPR for FY 2021-22 Provincial This is not correct Report as of ( FY 2021-22),"Location/s: Eastern Cape, Free State",InstitutionType: Provincial,Institution: Health,FinancialYear: FY 2021-22,"Report Printed On: Wednesday, September 28, 2022 11:11:10 AM"
+
+Sector,Institution,Programme,SubProgramme,Location,Frequency,Indicator,Type,SubType,Outcome,Cluster,Target_Q1,ActualOutput_Q1,ReasonforDeviation_Q1,CorrectiveAction_Q1,OTP_Q1,National_Q1,Target_Q2,ActualOutput_Q2,ReasonforDeviation_Q2,CorrectiveAction_Q2,OTP_Q2,National_Q2,Target_Q3,ActualOutput_Q3,ReasonforDeviation_Q3,CorrectiveAction_Q3,OTP_Q3,National_Q3,Target_Q4,ActualOutput_Q4,ReasonforDeviation_Q4,CorrectiveAction_Q4,OTP_Q4,National_Q4,AnnualTarget_Summary2,Preliminary_Summary2,PrelimaryAudited_Summary2,ReasonforDeviation_Summary,CorrectiveAction_Summary,OTP_Summary,National_Summary,ValidatedAudited_Summary2,UID
+Health,Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.1: Office of the MEC,Quarterly,9.1.2 Number of statutory documents tabled at Legislature,Non-Standardized,Max,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",0,0,There is no target for quarter one,,,,1,2,Target achieved,,,,2,2,Target achieved,,,,5,8,All statutory documents submitted.,,,,8,8,8,Target achieved ,,,,,291
+Health,Health,Eastern Cape,Programme 1: Administration,Sub-Programme 1.2: Management,Annually,6.4.1 Holistic Human Resources for Health (HRH) strategy approved ,Non-Standardized,Not Applicable,"Priority 3: Education, Skills And Health","The Social Protection, Community and Human Development cluster",,,,,,,,,,,,,,,,,,,,,,,,,Approved HRH Strategy,A draft plan for Human Resources for Health (HRH has been produced,Draft HRH Strategy,"The HRH 2030 Strategy Plan is in progress.
+The Task Team has produced a draft HRH 2030 Plan by the end of the 4th quarter.
+ The Task Team requires the additional capacity of a qualified Technical Advisor to conclude this plan.
+",,,,,291
\ No newline at end of file
diff --git a/performance/tests/test_eqprs_api.py b/performance/tests/test_eqprs_api.py
new file mode 100644
index 000000000..c6b417992
--- /dev/null
+++ b/performance/tests/test_eqprs_api.py
@@ -0,0 +1,94 @@
+from django.urls import reverse
+from rest_framework.test import APITestCase, APIRequestFactory
+from rest_framework import status
+from django.test import Client
+from openpyxl import load_workbook
+
+import io
+
+
+class indicator_API_Test(APITestCase):
+ list_url = "/performance/api/v1/eqprs/"
+ fixtures = ["test_api.json"]
+
+ def test_api(self):
+ response = self.client.get(self.list_url)
+ self.assertEqual(response.status_code, 200)
+ response_payload = self.client.get(self.list_url).json()
+
+ found = False
+ message1 = "1.2.1 Percentage of valid invoices paid within 30 days upon receipt by the department"
+ message2 = "1.1.1 Unqualified audit opinion"
+ if (
+ response_payload["results"]["items"][0]["indicator_name"] == message2
+ or response_payload["results"]["items"][1]["indicator_name"] == message1
+ ):
+ found = True
+
+ self.assertEqual(found, True)
+ self.assertTrue(found, True)
+
+ def test_create(self):
+ response = self.client.post(self.list_url)
+ self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+ def test_update(self):
+ response = self.client.patch(self.list_url)
+ self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+ def test_text_search(self):
+ filter_url = self.list_url + "?page=1&q=Unqualified%20audit%20opinion"
+ response_payload = self.client.get(filter_url).json()
+ self.assertEqual(len(response_payload["results"]["items"]), 1)
+
+ def test_frequency_search(self):
+ filter_url = self.list_url + "?page=1&frequency=Annually"
+ response_payload = self.client.get(filter_url).json()
+ self.assertEqual(len(response_payload["results"]["items"]), 1)
+
+ def test_downloaded_file(self):
+ file_url = "/performance/performance-indicators.xlsx/"
+ response = self.client.get(file_url, stream=True)
+ file_content = response.getvalue()
+ file_obj = io.BytesIO(file_content)
+ wb = load_workbook(file_obj)
+ sh = wb["Sheet1"]
+
+ self.assertEqual(sh["A1"].value, "Financial year")
+ self.assertEqual(sh["A2"].value, "2015-16")
+ self.assertEqual(sh["H3"].value, "Annually")
+ self.assertEqual(sh["A4"].value, None)
+
+ def test_downloaded_file_with_filter(self):
+ file_url = "/performance/performance-indicators.xlsx/?frequency=Annually"
+ response = self.client.get(file_url, stream=True)
+ file_content = response.getvalue()
+ file_obj = io.BytesIO(file_content)
+ wb = load_workbook(file_obj)
+ sh = wb["Sheet1"]
+
+ self.assertEqual(sh["A1"].value, "Financial year")
+ self.assertEqual(sh["H2"].value, "Annually")
+ self.assertEqual(sh["A3"].value, None)
+
+
+class repetitive_API_Test(APITestCase):
+ list_url = "/performance/api/v1/eqprs/"
+ file_url = "/performance/performance-indicators.xlsx/"
+ fixtures = ["test_repetitive_data.json"]
+
+ def test_response_with_repetitive_data(self):
+ api_response_payload = self.client.get(self.list_url).json()
+
+ file_response = self.client.get(self.file_url, stream=True)
+ file_content = file_response.getvalue()
+ file_obj = io.BytesIO(file_content)
+ wb = load_workbook(file_obj)
+
+ sh = wb["Sheet1"]
+
+ api_result_length = len(api_response_payload["results"]["items"])
+ file_result_length = sh.max_row
+ header_row_count = 1
+
+ self.assertEqual(file_result_length - header_row_count, api_result_length)
diff --git a/performance/tests/test_eqprs_uploads.py b/performance/tests/test_eqprs_uploads.py
new file mode 100644
index 000000000..09012f68a
--- /dev/null
+++ b/performance/tests/test_eqprs_uploads.py
@@ -0,0 +1,367 @@
+from django.test import TestCase
+from performance.admin import EQPRSFileUploadAdmin
+from django.contrib.auth.models import User
+from django.contrib.admin.sites import AdminSite
+from django.contrib import admin
+from performance.models import EQPRSFileUpload, Indicator, EQPRSDepartmentAlias
+from budgetportal.models.government import Department, Government, Sphere, FinancialYear
+from django.test import RequestFactory
+from performance import models
+from django.core.files import File
+from unittest.mock import Mock
+
+import performance.admin
+import os
+import time
+
+USERNAME = "testuser"
+EMAIL = "testuser@domain.com"
+PASSWORD = "12345"
+
+
+def get_mocked_request(superuser):
+ request = RequestFactory().get("/get/request")
+ request.method = "GET"
+ request.user = superuser
+ return request
+
+
+class EQPRSFileUploadTestCase(TestCase):
+ def setUp(self):
+ self.superuser = User.objects.create_user(
+ username=USERNAME,
+ password=PASSWORD,
+ is_staff=True,
+ is_superuser=True,
+ is_active=True,
+ )
+ file_path = os.path.abspath(("performance/tests/static/correct_data.csv"))
+ national_file_path = os.path.abspath(
+ ("performance/tests/static/national_data.csv")
+ )
+ wrong_report_type_file_path = os.path.abspath(
+ ("performance/tests/static/wrong_report_type.csv")
+ )
+ data_for_deleting_indicators_path = os.path.abspath(
+ ("performance/tests/static/data_for_deleting_indicators.csv")
+ )
+ department_name_containing_government_path = os.path.abspath(
+ ("performance/tests/static/department_name_containing_government.csv")
+ )
+ self.csv_file = File(open(file_path, "rb"))
+ self.national_file = File(open(national_file_path, "rb"))
+ self.wrong_report_type_file = File(open(wrong_report_type_file_path, "rb"))
+ self.data_for_deleting_indicators_file = File(
+ open(data_for_deleting_indicators_path, "rb")
+ )
+ self.department_name_containing_government_file = File(
+ open(department_name_containing_government_path, "rb")
+ )
+ self.mocked_request = get_mocked_request(self.superuser)
+
+ def tearDown(self):
+ self.csv_file.close()
+ self.national_file.close()
+ self.wrong_report_type_file.close()
+ self.data_for_deleting_indicators_file.close()
+
+ def test_report_name_validation(self):
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.wrong_report_type_file
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+ assert "Report type must be for one of" in test_element.import_report
+ assert (
+ "* Provincial Institutions Oversight Performance Report"
+ in test_element.import_report
+ )
+ assert (
+ "* National Institutions Oversight Performance Report"
+ in test_element.import_report
+ )
+
+ def test_with_missing_department(self):
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.csv_file
+ )
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="Eastern Cape", sphere=sphere)
+ department = Department.objects.create(
+ name="HealthTest", government=government, vote_number=1
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+ assert test_element.num_not_imported == 2
+ assert (
+ "Department names that could not be matched on import :"
+ in test_element.import_report
+ )
+ assert "* Health" in test_element.import_report
+
+ def test_with_correct_csv(self):
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="Eastern Cape", sphere=sphere)
+ department = Department.objects.create(
+ name="Health", government=government, vote_number=1
+ )
+
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.csv_file
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+ assert Indicator.objects.all().count() == 2
+
+ indicator = models.Indicator.objects.all().first()
+ assert test_element.import_report == ""
+ assert test_element.num_imported == 2
+ assert (
+ indicator.indicator_name
+ == "9.1.2 Number of statutory documents tabled at Legislature"
+ )
+ assert indicator.sector == "Health"
+ assert indicator.programme_name == "Programme 1: Administration"
+ assert indicator.subprogramme_name == "Sub-Programme 1.1: Office of the MEC"
+ assert indicator.frequency == "quarterly"
+ assert indicator.type == "Non-Standardized"
+ assert indicator.subtype == "Max"
+ assert indicator.mtsf_outcome == "Priority 3: Education, Skills And Health"
+ assert (
+ indicator.cluster
+ == "The Social Protection, Community and Human Development cluster"
+ )
+
+ assert indicator.q1_target == "0"
+ assert indicator.q1_actual_output == "0"
+ assert indicator.q1_deviation_reason == "There is no target for quarter one"
+ assert indicator.q1_corrective_action == ""
+ assert indicator.q1_national_comments == ""
+ assert indicator.q1_otp_comments == ""
+ assert indicator.q1_dpme_coordinator_comments == ""
+ assert indicator.q1_treasury_comments == ""
+
+ assert indicator.q2_target == "1"
+ assert indicator.q2_actual_output == "2"
+ assert indicator.q2_deviation_reason == "Target achieved"
+ assert indicator.q2_corrective_action == ""
+ assert indicator.q2_national_comments == ""
+ assert indicator.q2_otp_comments == ""
+ assert indicator.q2_dpme_coordinator_comments == ""
+ assert indicator.q2_treasury_comments == ""
+
+ assert indicator.q3_target == "2"
+ assert indicator.q3_actual_output == "2"
+ assert indicator.q3_deviation_reason == "Target achieved"
+ assert indicator.q3_corrective_action == ""
+ assert indicator.q3_national_comments == ""
+ assert indicator.q3_otp_comments == ""
+ assert indicator.q3_dpme_coordinator_comments == ""
+ assert indicator.q3_treasury_comments == ""
+
+ assert indicator.q4_target == "5"
+ assert indicator.q4_actual_output == "8"
+ assert indicator.q4_deviation_reason == "All statutory documents submitted."
+ assert indicator.q4_corrective_action == ""
+ assert indicator.q4_national_comments == ""
+ assert indicator.q4_otp_comments == ""
+ assert indicator.q4_dpme_coordinator_comments == ""
+ assert indicator.q4_treasury_comments == ""
+
+ assert indicator.annual_target == "8"
+ assert indicator.annual_aggregate_output == ""
+ assert indicator.annual_pre_audit_output == "8"
+ assert indicator.annual_deviation_reason == "Target achieved "
+ assert indicator.annual_corrective_action == ""
+ assert indicator.annual_otp_comments == ""
+ assert indicator.annual_national_comments == ""
+ assert indicator.annual_dpme_coordinator_comments == ""
+ assert indicator.annual_treasury_comments == ""
+ assert indicator.annual_audited_output == ""
+
+ def test_task_creation(self):
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="Eastern Cape", sphere=sphere)
+ department = Department.objects.create(
+ name="Health", government=government, vote_number=1
+ )
+
+ model_admin = EQPRSFileUploadAdmin(
+ model=EQPRSFileUpload, admin_site=AdminSite()
+ )
+ model_admin.save_model(
+ obj=EQPRSFileUpload(file=self.csv_file),
+ request=Mock(user=self.superuser),
+ form=None,
+ change=None,
+ )
+
+ last_element = EQPRSFileUpload.objects.all().last()
+ assert last_element.task_id is not None
+
+ def test_status_in_list_view(self):
+ assert "processing_completed" in EQPRSFileUploadAdmin.list_display
+
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="Eastern Cape", sphere=sphere)
+ department = Department.objects.create(
+ name="Health", government=government, vote_number=1
+ )
+
+ model_admin = EQPRSFileUploadAdmin(
+ model=EQPRSFileUpload, admin_site=AdminSite()
+ )
+ model_admin.save_model(
+ obj=EQPRSFileUpload(file=self.csv_file),
+ request=Mock(user=self.superuser),
+ form=None,
+ change=None,
+ )
+
+ last_element = EQPRSFileUpload.objects.all().last()
+ assert model_admin.processing_completed(last_element) == True
+
+ def test_with_national_government(self):
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="South Africa", sphere=sphere)
+ department = Department.objects.create(
+ name="Health", government=government, vote_number=1
+ )
+
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.national_file
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+
+ assert test_element.import_report == ""
+ assert test_element.num_imported == 1
+ indicator = models.Indicator.objects.all().first()
+ assert indicator.programme_name == "Programme 1: Administration"
+
+ def test_with_alias(self):
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="South Africa", sphere=sphere)
+ department = Department.objects.create(
+ name="Department to be found by its alias",
+ government=government,
+ vote_number=1,
+ )
+ EQPRSDepartmentAlias.objects.create(department=department, alias="Health")
+
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.national_file
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+
+ assert test_element.import_report == ""
+ assert test_element.num_imported == 1
+ indicator = models.Indicator.objects.all().first()
+ assert indicator.programme_name == "Programme 1: Administration"
+ assert indicator.department.name == "Department to be found by its alias"
+
+ def test_deleting_old_indicators(self):
+ """
+ test that
+ - if gov A and dept A is in the data, its old indicators for that
+ financial year are deleted. Any other financial years remain
+ - if Gov A, B and dept A, B is in the data, old indicators for Gov A
+ DeptA are deleted but Gov A dept B are not deleted
+ """
+
+ fy = FinancialYear.objects.create(slug="2017-18")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+
+ government_1 = Government.objects.create(name="Eastern Cape", sphere=sphere)
+ department_1 = Department.objects.create(
+ name="Health",
+ government=government_1,
+ vote_number=1,
+ )
+ Indicator.objects.create(
+ indicator_name="Test Indicator 1",
+ department=department_1,
+ source=EQPRSFileUpload.objects.create(user=self.superuser, file=None),
+ )
+
+ government_2 = Government.objects.create(name="Western Cape", sphere=sphere)
+ department_2 = Department.objects.create(
+ name="Education",
+ government=government_2,
+ vote_number=1,
+ )
+ Indicator.objects.create(
+ indicator_name="Test Indicator 2",
+ department=department_2,
+ source=EQPRSFileUpload.objects.create(user=self.superuser, file=None),
+ )
+
+ department_3 = Department.objects.create(
+ name="Health",
+ government=government_2,
+ vote_number=2,
+ )
+ Indicator.objects.create(
+ indicator_name="Test Indicator 3",
+ department=department_3,
+ source=EQPRSFileUpload.objects.create(user=self.superuser, file=None),
+ )
+
+ assert Indicator.objects.all().count() == 3
+
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.data_for_deleting_indicators_file
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+
+ # ids 1 & 2 are deleted
+ assert Indicator.objects.all().count() == 3
+
+ # id 3 is remaining
+ indicator_3 = Indicator.objects.get(id=3)
+ assert indicator_3.indicator_name == "Test Indicator 3"
+ assert indicator_3.department.name == "Health"
+ assert indicator_3.department.government.name == "Western Cape"
+ assert indicator_3.department.government.sphere.financial_year.slug == "2017-18"
+
+ # new objects(with id 4 & 5) are created
+ indicator_4 = Indicator.objects.get(id=4)
+ assert (
+ indicator_4.indicator_name
+ == "9.1.2 Number of statutory documents tabled at Legislature"
+ )
+ assert indicator_4.department.name == "Health"
+ assert indicator_4.department.government.name == "Eastern Cape"
+ assert indicator_4.department.government.sphere.financial_year.slug == "2017-18"
+
+ indicator_5 = Indicator.objects.get(id=5)
+ assert (
+ indicator_5.indicator_name
+ == "6.4.1 Holistic Human Resources for Health (HRH) strategy approved "
+ )
+ assert indicator_5.department.name == "Education"
+ assert indicator_5.department.government.name == "Western Cape"
+ assert indicator_5.department.government.sphere.financial_year.slug == "2017-18"
+
+ def test_department_name_containing_government(self):
+ fy = FinancialYear.objects.create(slug="2021-22")
+ sphere = Sphere.objects.create(name="Provincial", financial_year=fy)
+ government = Government.objects.create(name="Eastern Cape", sphere=sphere)
+ department = Department.objects.create(
+ name="Health", government=government, vote_number=1
+ )
+
+ test_element = EQPRSFileUpload.objects.create(
+ user=self.superuser, file=self.department_name_containing_government_file
+ )
+ performance.admin.save_imported_indicators(test_element.id)
+ test_element.refresh_from_db()
+ assert Indicator.objects.all().count() == 2
diff --git a/performance/urls.py b/performance/urls.py
new file mode 100644
index 000000000..9dff46f91
--- /dev/null
+++ b/performance/urls.py
@@ -0,0 +1,17 @@
+from django.contrib import admin
+from django.conf.urls import url
+from rest_framework.routers import DefaultRouter
+from django.urls import path, include
+from performance import views
+
+
+urlpatterns = [
+ # Performance
+ path("", views.performance_tabular_view, name="performance"),
+ path(r"api/v1/eqprs/", views.IndicatorListView.as_view()),
+ path(
+ r"performance-indicators.xlsx/",
+ views.IndicatorXLSXListView.as_view(),
+ name="performance-indicators-xlsx",
+ ),
+]
diff --git a/performance/views.py b/performance/views.py
new file mode 100644
index 000000000..91fdae55c
--- /dev/null
+++ b/performance/views.py
@@ -0,0 +1,163 @@
+from django.shortcuts import render
+from .models import Indicator
+from .serializer import IndicatorSerializer
+from rest_framework import generics
+from django.db.models import Count, Q
+from rest_framework.pagination import PageNumberPagination
+from budgetportal.models import MainMenuItem
+from django.contrib.postgres.search import SearchQuery
+from drf_excel.mixins import XLSXFileMixin
+from django.http import StreamingHttpResponse
+
+import xlsx_streaming
+
+FIELD_MAP = {
+ "department_name": "department__name",
+ "financial_year_slug": "department__government__sphere__financial_year__slug",
+ "government_name": "department__government__name",
+ "sphere_name": "department__government__sphere__name",
+ "frequency": "frequency",
+ "mtsf_outcome": "mtsf_outcome",
+ "sector": "sector",
+}
+
+XLSX_COLUMNS = [
+ "department__government__sphere__financial_year__slug",
+ "department__government__sphere__name",
+ "department__government__name",
+ "department__name",
+ "programme_name",
+ "subprogramme_name",
+ "indicator_name",
+ "frequency",
+ "q1_target",
+ "q1_actual_output",
+ "q1_deviation_reason",
+ "q1_corrective_action",
+ "q2_target",
+ "q2_actual_output",
+ "q2_deviation_reason",
+ "q2_corrective_action",
+ "q3_target",
+ "q3_actual_output",
+ "q3_deviation_reason",
+ "q3_corrective_action",
+ "q4_target",
+ "q4_actual_output",
+ "q4_deviation_reason",
+ "q4_corrective_action",
+ "annual_target",
+ "annual_aggregate_output",
+ "annual_pre_audit_output",
+ "annual_deviation_reason",
+ "annual_corrective_action",
+ "annual_audited_output",
+ "sector",
+ "type",
+ "subtype",
+ "mtsf_outcome",
+ "cluster",
+]
+
+
+def performance_tabular_view(request):
+ context = {
+ "navbar": MainMenuItem.objects.prefetch_related("children").all(),
+ "title": "Quarterly performance reporting (QPR) indicators",
+ "description": "Find the latest quarterly performance monitoring indicators, results, and explanations from national and provincial departments. How is performance measured in government? Quarterly and audited annual indicators is one of the tools to monitor implementation of department mandates.",
+ }
+ return render(request, "performance/performance.html", context)
+
+
+def text_search(qs, text):
+ if len(text) == 0:
+ return qs
+
+ return qs.filter(
+ Q(content_search=SearchQuery(text))
+ | Q(content_search__icontains=text)
+ | Q(indicator_name__icontains=text)
+ )
+
+
+def add_filters(qs, params):
+ query_dict = {}
+ for k, v in FIELD_MAP.items():
+ if v in params:
+ query_dict[v] = params[v]
+
+ return qs.filter(**query_dict)
+
+
+def get_filtered_queryset(queryset, request):
+ search_query = request.GET.get("q", "")
+
+ filtered_queryset = queryset.select_related(
+ "department",
+ "department__government",
+ "department__government__sphere",
+ "department__government__sphere__financial_year",
+ )
+ filtered_queryset = text_search(filtered_queryset, search_query)
+ filtered_queryset = add_filters(filtered_queryset, request.GET)
+
+ return filtered_queryset
+
+
+class IndicatorListView(generics.ListAPIView):
+ serializer_class = IndicatorSerializer
+ queryset = Indicator.objects.all()
+ pagination_class = PageNumberPagination
+
+ def list(self, request):
+ queryset = get_filtered_queryset(self.get_queryset(), request)
+
+ facets = self.get_facets(queryset)
+
+ queryset = self.paginate_queryset(queryset)
+ serializer_class = self.get_serializer_class()
+ serializer = serializer_class(queryset, many=True)
+ data = {
+ "items": serializer.data,
+ "facets": facets,
+ }
+ return self.get_paginated_response(data)
+
+ def get_facets(self, qs):
+ def facet_query(field):
+ return qs.values(field).annotate(count=Count(field))
+
+ return {
+ "department_name": facet_query("department__name"),
+ "financial_year_slug": facet_query(
+ "department__government__sphere__financial_year__slug"
+ ),
+ "government_name": facet_query("department__government__name"),
+ "sphere_name": facet_query("department__government__sphere__name"),
+ "frequency": facet_query("frequency"),
+ "mtsf_outcome": facet_query("mtsf_outcome"),
+ "sector": facet_query("sector"),
+ }
+
+
+class IndicatorXLSXListView(XLSXFileMixin, generics.ListAPIView):
+ pagination_class = None
+ template_filename = "performance/template.xlsx"
+ filename = "eqprs-indicators.xlsx"
+ queryset = Indicator.objects.all()
+
+ def list(self, request, *args, **kwargs):
+ excel_data = get_filtered_queryset(self.get_queryset(), request)
+
+ with open(self.template_filename, "rb") as template:
+ stream = xlsx_streaming.stream_queryset_as_xlsx(
+ self.filter_queryset(excel_data).values_list(*XLSX_COLUMNS),
+ xlsx_template=template,
+ batch_size=50,
+ )
+ response = StreamingHttpResponse(
+ stream,
+ content_type="application/vnd.xlsxformats-officedocument.spreadsheetml.sheet",
+ )
+ response["Content-Disposition"] = f"attachment; filename={self.filename}"
+ return response
diff --git a/requirements-test.txt b/requirements-test.txt
index 244241f28..e69de29bb 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,3 +0,0 @@
-codecov==2.0.15
-mock==4.0.1
-selenium==3.141.0
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index c10a8bf0c..367853d85 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,29 +1,31 @@
arrow==0.15.5
backports.csv==1.0.7
backports.functools-lru-cache==1.6.1
+black==22.3.0
blessed==1.17.2
boto3==1.12.21
cachetools==4.0.0
-certifi==2019.11.28
+certifi==2022.12.7
chardet==3.0.4
ckanapi==4.3
defusedxml==0.6.0
diff-match-patch==20181111
dj-database-url==0.5.0
-Django==2.2.20
+Django==2.2.28
django-admin-sortable==2.2.3
django-adminplus==0.5
django-allauth==0.41.0
django-autoslug==1.9.6
django-ckeditor==5.9.0
+django-constance==2.9.1
django-debug-toolbar==2.2
-django-extensions==2.2.8
+django-extensions==3.1.5
django-environ==0.4.5
django-filter==2.2.0
django-haystack==2.8.1
django-import-export==2.0.2
django-js-asset==1.2.2
-django-markdownify==0.8.0
+django-markdownify==0.9.2
django-partial-index==0.6.0
django-picklefield==2.1.1
django-pipeline==1.7.0
@@ -34,7 +36,9 @@ djangorestframework-csv==2.1.0
django-storages==1.9.1
docopt==0.6.2
drf-haystack==1.8.9
+drf-excel==1.0.0
et-xmlfile==1.0.1
+frictionless[json]==4.40.8
futures==3.1.1
gevent==1.4.0
greenlet==0.4.15
@@ -48,7 +52,7 @@ openpyxl==3.0.3
pathlib==1.0.1
psycopg2==2.8.4
pyaml==19.12.0
-pyScss==1.3.5
+pyScss==1.3.7
pysolr==3.8.1
python-dateutil==2.8.1
python-slugify==4.0.0
@@ -66,7 +70,10 @@ Unidecode==1.1.1
urllib3==1.25.8
wagtail==2.7.4
wcwidth==0.1.8
-Werkzeug==1.0.0
+Werkzeug==2.0.3
whitenoise==5.0.1
xlrd==1.2.0
+xlsx-streaming==1.1.0
xlwt==1.3.0
+mock==4.0.1
+selenium==3.141.0
diff --git a/runtime.txt b/runtime.txt
index 6919bf9ed..91b17a136 100644
--- a/runtime.txt
+++ b/runtime.txt
@@ -1 +1 @@
-python-3.7.6
+python-3.7.16
diff --git a/webpack.config.js b/webpack.config.js
index 224fb879c..f4f801e62 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -27,7 +27,9 @@ module.exports = {
{
test: /\.jsx?$/,
loader: 'babel-loader',
- query: { compact: false },
+ options: {
+ presets: ['react']
+ }
},
{
@@ -52,5 +54,5 @@ module.exports = {
plugins: [
new MiniCssExtractPlugin({filename: 'styles.bundle.css'}),
- ],
+ ]
};
diff --git a/yarn.lock b/yarn.lock
index 80e11e259..9359af09d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -914,6 +914,13 @@
dependencies:
regenerator-runtime "^0.13.2"
+"@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7":
+ version "7.20.13"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b"
+ integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==
+ dependencies:
+ regenerator-runtime "^0.13.11"
+
"@babel/template@^7.1.0", "@babel/template@^7.2.2", "@babel/template@^7.4.0", "@babel/template@^7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.4.tgz#f4b88d1225689a08f5bc3a17483545be9e4ed237"
@@ -1012,6 +1019,11 @@
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44"
integrity sha512-ojhgxzUHZ7am3D2jHkMzPpsBAiB005GF5YU4ea+8DNPybMk01JJUM9V9YRlF/GE95tcOm8DxQvWA2jq19bGalQ==
+"@emotion/hash@^0.8.0":
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
+ integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
+
"@emotion/is-prop-valid@0.7.3", "@emotion/is-prop-valid@^0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.7.3.tgz#a6bf4fa5387cbba59d44e698a4680f481a8da6cc"
@@ -1292,6 +1304,24 @@
recompose "0.28.0 - 0.30.0"
warning "^4.0.1"
+"@material-ui/core@^4.12.4":
+ version "4.12.4"
+ resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.4.tgz#4ac17488e8fcaf55eb6a7f5efb2a131e10138a73"
+ integrity sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+ "@material-ui/styles" "^4.11.5"
+ "@material-ui/system" "^4.12.2"
+ "@material-ui/types" "5.1.0"
+ "@material-ui/utils" "^4.11.3"
+ "@types/react-transition-group" "^4.2.0"
+ clsx "^1.0.4"
+ hoist-non-react-statics "^3.3.2"
+ popper.js "1.16.1-lts"
+ prop-types "^15.7.2"
+ react-is "^16.8.0 || ^17.0.0"
+ react-transition-group "^4.4.0"
+
"@material-ui/icons@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-3.0.2.tgz#d67a6dd1ec8312d3a88ec97944a63daeef24fe10"
@@ -1300,6 +1330,13 @@
"@babel/runtime" "^7.2.0"
recompose "0.28.0 - 0.30.0"
+"@material-ui/icons@^4.11.3":
+ version "4.11.3"
+ resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.11.3.tgz#b0693709f9b161ce9ccde276a770d968484ecff1"
+ integrity sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+
"@material-ui/lab@^3.0.0-alpha.30":
version "3.0.0-alpha.30"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-3.0.0-alpha.30.tgz#c6c64d0ff2b28410a09e4009f3677499461f3df8"
@@ -1311,6 +1348,28 @@
keycode "^2.1.9"
prop-types "^15.6.0"
+"@material-ui/styles@^4.11.5":
+ version "4.11.5"
+ resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.5.tgz#19f84457df3aafd956ac863dbe156b1d88e2bbfb"
+ integrity sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+ "@emotion/hash" "^0.8.0"
+ "@material-ui/types" "5.1.0"
+ "@material-ui/utils" "^4.11.3"
+ clsx "^1.0.4"
+ csstype "^2.5.2"
+ hoist-non-react-statics "^3.3.2"
+ jss "^10.5.1"
+ jss-plugin-camel-case "^10.5.1"
+ jss-plugin-default-unit "^10.5.1"
+ jss-plugin-global "^10.5.1"
+ jss-plugin-nested "^10.5.1"
+ jss-plugin-props-sort "^10.5.1"
+ jss-plugin-rule-value-function "^10.5.1"
+ jss-plugin-vendor-prefixer "^10.5.1"
+ prop-types "^15.7.2"
+
"@material-ui/system@^3.0.0-alpha.0":
version "3.0.0-alpha.2"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-3.0.0-alpha.2.tgz#096e80c8bb0f70aea435b9e38ea7749ee77b4e46"
@@ -1321,6 +1380,21 @@
prop-types "^15.6.0"
warning "^4.0.1"
+"@material-ui/system@^4.12.2":
+ version "4.12.2"
+ resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.12.2.tgz#f5c389adf3fce4146edd489bf4082d461d86aa8b"
+ integrity sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+ "@material-ui/utils" "^4.11.3"
+ csstype "^2.5.2"
+ prop-types "^15.7.2"
+
+"@material-ui/types@5.1.0":
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2"
+ integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==
+
"@material-ui/utils@^3.0.0-alpha.2":
version "3.0.0-alpha.3"
resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-3.0.0-alpha.3.tgz#836c62ea46f5ffc6f0b5ea05ab814704a86908b1"
@@ -1330,6 +1404,15 @@
prop-types "^15.6.0"
react-is "^16.6.3"
+"@material-ui/utils@^4.11.3":
+ version "4.11.3"
+ resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.11.3.tgz#232bd86c4ea81dab714f21edad70b7fdf0253942"
+ integrity sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+ prop-types "^15.7.2"
+ react-is "^16.8.0 || ^17.0.0"
+
"@mrmlnc/readdir-enhanced@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@@ -1946,6 +2029,13 @@
dependencies:
"@types/react" "*"
+"@types/react-transition-group@^4.2.0":
+ version "4.4.5"
+ resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416"
+ integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==
+ dependencies:
+ "@types/react" "*"
+
"@types/react@*":
version "16.8.17"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.17.tgz#f287b76a5badb93bc9aa3f54521a3eb53d6c2374"
@@ -4928,6 +5018,11 @@ clone@^1.0.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+clsx@^1.0.4:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
+ integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
+
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -5632,6 +5727,14 @@ css-vendor@^0.3.8:
dependencies:
is-in-browser "^1.0.2"
+css-vendor@^2.0.8:
+ version "2.0.8"
+ resolved "https://registry.yarnpkg.com/css-vendor/-/css-vendor-2.0.8.tgz#e47f91d3bd3117d49180a3c935e62e3d9f7f449d"
+ integrity sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==
+ dependencies:
+ "@babel/runtime" "^7.8.3"
+ is-in-browser "^1.0.2"
+
css-what@2.1, css-what@^2.1.2:
version "2.1.3"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
@@ -5812,6 +5915,11 @@ csstype@^2.0.0, csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.7:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.4.tgz#d585a6062096e324e7187f80e04f92bd0f00e37f"
integrity sha512-lAJUJP3M6HxFXbqtGRc0iZrdyeN+WzOWeY0q/VnFzI+kqVrYIzC7bWlKqCW7oCIdzoPkvfp82EVvrTlQ8zsWQg==
+csstype@^3.0.2:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
+ integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
+
currently-unhandled@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
@@ -6045,9 +6153,9 @@ decimal.js@^10.2.0:
integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==
decode-uri-component@^0.2.0:
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
- integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
+ integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
deep-equal@^1.0.1:
version "1.0.1"
@@ -6303,6 +6411,14 @@ dom-helpers@^3.2.1, dom-helpers@^3.4.0:
dependencies:
"@babel/runtime" "^7.1.2"
+dom-helpers@^5.0.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
+ integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
+ dependencies:
+ "@babel/runtime" "^7.8.7"
+ csstype "^3.0.2"
+
dom-serializer@0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
@@ -8286,6 +8402,13 @@ hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0:
dependencies:
react-is "^16.7.0"
+hoist-non-react-statics@^3.3.2:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
+ integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
+ dependencies:
+ react-is "^16.7.0"
+
home-or-tmp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -8508,6 +8631,11 @@ hyphenate-style-name@^1.0.0, hyphenate-style-name@^1.0.2:
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48"
integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ==
+hyphenate-style-name@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
+ integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
+
iconv-lite@0.4.23:
version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
@@ -9933,6 +10061,66 @@ jss-nested@^6.0.1:
dependencies:
warning "^3.0.0"
+jss-plugin-camel-case@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.9.2.tgz#76dddfa32f9e62d17daa4e3504991fd0933b89e1"
+ integrity sha512-wgBPlL3WS0WDJ1lPJcgjux/SHnDuu7opmgQKSraKs4z8dCCyYMx9IDPFKBXQ8Q5dVYij1FFV0WdxyhuOOAXuTg==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ hyphenate-style-name "^1.0.3"
+ jss "10.9.2"
+
+jss-plugin-default-unit@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-default-unit/-/jss-plugin-default-unit-10.9.2.tgz#3e7f4a1506b18d8fe231554fd982439feb2a9c53"
+ integrity sha512-pYg0QX3bBEFtTnmeSI3l7ad1vtHU42YEEpgW7pmIh+9pkWNWb5dwS/4onSfAaI0kq+dOZHzz4dWe+8vWnanoSg==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ jss "10.9.2"
+
+jss-plugin-global@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-global/-/jss-plugin-global-10.9.2.tgz#e7f2ad4a5e8e674fb703b04b57a570b8c3e5c2c2"
+ integrity sha512-GcX0aE8Ef6AtlasVrafg1DItlL/tWHoC4cGir4r3gegbWwF5ZOBYhx04gurPvWHC8F873aEGqge7C17xpwmp2g==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ jss "10.9.2"
+
+jss-plugin-nested@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-nested/-/jss-plugin-nested-10.9.2.tgz#3aa2502816089ecf3981e1a07c49b276d67dca63"
+ integrity sha512-VgiOWIC6bvgDaAL97XCxGD0BxOKM0K0zeB/ECyNaVF6FqvdGB9KBBWRdy2STYAss4VVA7i5TbxFZN+WSX1kfQA==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ jss "10.9.2"
+ tiny-warning "^1.0.2"
+
+jss-plugin-props-sort@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-props-sort/-/jss-plugin-props-sort-10.9.2.tgz#645f6c8f179309667b3e6212f66b59a32fb3f01f"
+ integrity sha512-AP1AyUTbi2szylgr+O0OB7gkIxEGzySLITZ2GpsaoX72YMCGI2jYAc+WUhPfvUnZYiauF4zTnN4V4TGuvFjJlw==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ jss "10.9.2"
+
+jss-plugin-rule-value-function@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.9.2.tgz#9afe07596e477123cbf11120776be6a64494541f"
+ integrity sha512-vf5ms8zvLFMub6swbNxvzsurHfUZ5Shy5aJB2gIpY6WNA3uLinEcxYyraQXItRHi5ivXGqYciFDRM2ZoVoRZ4Q==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ jss "10.9.2"
+ tiny-warning "^1.0.2"
+
+jss-plugin-vendor-prefixer@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.9.2.tgz#410a0f3b9f8dbbfba58f4d329134df4849aa1237"
+ integrity sha512-SxcEoH+Rttf9fEv6KkiPzLdXRmI6waOTcMkbbEFgdZLDYNIP9UKNHFy6thhbRKqv0XMQZdrEsbDyV464zE/dUA==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ css-vendor "^2.0.8"
+ jss "10.9.2"
+
jss-props-sort@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/jss-props-sort/-/jss-props-sort-6.0.0.tgz#9105101a3b5071fab61e2d85ea74cc22e9b16323"
@@ -9945,6 +10133,16 @@ jss-vendor-prefixer@^7.0.0:
dependencies:
css-vendor "^0.3.8"
+jss@10.9.2, jss@^10.5.1:
+ version "10.9.2"
+ resolved "https://registry.yarnpkg.com/jss/-/jss-10.9.2.tgz#9379be1f195ef98011dfd31f9448251bd61b95a9"
+ integrity sha512-b8G6rWpYLR4teTUbGd4I4EsnWjg7MN0Q5bSsjKhVkJVjhQDy2KzkbD2AW3TuT0RYZVmZZHKIrXDn6kjU14qkUg==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ csstype "^3.0.2"
+ is-in-browser "^1.1.3"
+ tiny-warning "^1.0.2"
+
jss@^9.8.7:
version "9.8.7"
resolved "https://registry.yarnpkg.com/jss/-/jss-9.8.7.tgz#ed9763fc0f2f0260fc8260dac657af61e622ce05"
@@ -10222,7 +10420,7 @@ lodash.camelcase@^4.3.0:
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
- integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+ integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
lodash.deburr@^4.1.0:
version "4.1.0"
@@ -10232,7 +10430,7 @@ lodash.deburr@^4.1.0:
lodash.flow@^3.1.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a"
- integrity sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o=
+ integrity sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==
lodash.get@^4.4.2:
version "4.4.2"
@@ -11932,6 +12130,11 @@ popmotion@^8.6.2:
stylefire "^4.1.3"
tslib "^1.9.1"
+popper.js@1.16.1-lts:
+ version "1.16.1-lts"
+ resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05"
+ integrity sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==
+
popper.js@^1.14.1, popper.js@^1.14.4:
version "1.15.0"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
@@ -12951,28 +13154,6 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.4,
source-map "^0.6.1"
supports-color "^6.1.0"
-preact-css-transition-group@^1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/preact-css-transition-group/-/preact-css-transition-group-1.3.0.tgz#06fe468b26f7802e95b829a762db0bc199aef399"
- integrity sha1-Bv5Giyb3gC6VuCmnYtsLwZmu85k=
-
-preact-render-to-string@^3.8.2:
- version "3.8.2"
- resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-3.8.2.tgz#bd72964d705a57da3a9e72098acaa073dd3ceff9"
- integrity sha512-przuZPajiurStGgxMoJP0EJeC4xj5CgHv+M7GfF3YxAdhGgEWAkhOSE0xympAFN20uMayntBZpttIZqqLl77fw==
- dependencies:
- pretty-format "^3.5.1"
-
-preact-transition-group@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/preact-transition-group/-/preact-transition-group-1.1.1.tgz#f0a49327ea515ece34ea2be864c4a7d29e5d6e10"
- integrity sha1-8KSTJ+pRXs406ivoZMSn0p5dbhA=
-
-preact@^8.3.1:
- version "8.4.2"
- resolved "https://registry.yarnpkg.com/preact/-/preact-8.4.2.tgz#1263b974a17d1ea80b66590e41ef786ced5d6a23"
- integrity sha512-TsINETWiisfB6RTk0wh3/mvxbGRvx+ljeBccZ4Z6MPFKgu/KFGyf2Bmw3Z/jlXhL5JlNKY6QAbA9PVyzIy9//A==
-
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -13018,11 +13199,6 @@ pretty-format@^24.8.0:
ansi-styles "^3.2.0"
react-is "^16.8.4"
-pretty-format@^3.5.1:
- version "3.8.0"
- resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
- integrity sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U=
-
pretty-hrtime@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
@@ -13563,11 +13739,21 @@ react-is@^16.6.0, react-is@^16.6.3, react-is@^16.7.0, react-is@^16.8.1, react-is
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==
+"react-is@^16.8.0 || ^17.0.0":
+ version "17.0.2"
+ resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
+ integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
+
react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
+react-lines-ellipsis@^0.15.3:
+ version "0.15.3"
+ resolved "https://registry.yarnpkg.com/react-lines-ellipsis/-/react-lines-ellipsis-0.15.3.tgz#eb081c12e58170b09c5bd9db2fdbde9b5a3ea6c0"
+ integrity sha512-+kIcUZJPI3QqazZbO0eeEUmfRjmYKjCRzSmXQL4otrJzwOU9MjvASGQd4SELOOBSy6oiygjfDuCocGMssX0oUQ==
+
react-markdown@^4.0.6:
version "4.0.8"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-4.0.8.tgz#e3621b5becaac82a651008d7bc8390d3e4e438c0"
@@ -13750,6 +13936,16 @@ react-transition-group@^2.2.1, react-transition-group@^2.5.0:
prop-types "^15.6.2"
react-lifecycles-compat "^3.0.4"
+react-transition-group@^4.4.0:
+ version "4.4.5"
+ resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
+ integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==
+ dependencies:
+ "@babel/runtime" "^7.5.5"
+ dom-helpers "^5.0.1"
+ loose-envify "^1.4.0"
+ prop-types "^15.6.2"
+
react-url-query@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/react-url-query/-/react-url-query-1.4.0.tgz#e0e639e25ec63e28c548140f4b12fba4c10cfcb2"
@@ -14062,6 +14258,11 @@ regenerator-runtime@^0.12.0, regenerator-runtime@^0.12.1:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
+regenerator-runtime@^0.13.11:
+ version "0.13.11"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
+ integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
+
regenerator-runtime@^0.13.2:
version "0.13.2"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz#32e59c9a6fb9b1a4aff09b4930ca2d4477343447"
@@ -16064,6 +16265,11 @@ tiny-warning@^1.0.0:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28"
integrity sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q==
+tiny-warning@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
+ integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
+
tinycolor2@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8"