From 3104378ceaa32556b85d7605d7ebd93abcfccab5 Mon Sep 17 00:00:00 2001 From: Inna Kocherzhuk Date: Fri, 22 Nov 2024 13:51:55 +0100 Subject: [PATCH] [OSDEV-1429] Block list uploading during release process (#415) [OSDEV-1429](https://opensupplyhub.atlassian.net/browse/OSDEV-1429) The list upload switcher has been created to disable the `Submit` button on the List Contribute page through the Switch page in the Django admin panel during the release process and implemented a check on the list upload endpoint. [OSDEV-1429]: https://opensupplyhub.atlassian.net/browse/OSDEV-1429?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Screenshot 2024-11-20 at 15 25 33 --------- Co-authored-by: Inessa Druzhkova Co-authored-by: Inessa Druzhkova --- doc/release/RELEASE-NOTES.md | 4 +- src/django/api/exceptions.py | 10 +- .../management/commands/enable_switches.py | 1 + .../0161_create_bdisable_list_uploading.py | 26 ++ .../api/tests/test_facility_list_create.py | 31 +++ .../views/facility/facility_list_view_set.py | 7 + .../SubmitListUploadingButton.test.js | 258 ++++++++++++++++++ src/react/src/components/Button.jsx | 3 +- src/react/src/components/ContributeForm.jsx | 62 ++++- src/react/src/util/constants.jsx | 4 + src/react/src/util/propTypes.js | 2 + 11 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 src/django/api/migrations/0161_create_bdisable_list_uploading.py create mode 100644 src/react/src/__tests__/components/SubmitListUploadingButton.test.js diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index 7f38f3a31..b0450fc3c 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html #### Migrations: * 0159_alter_status_of_moderation_events_table.py - This migration alters status of api_moderationevent table. * 0160_allow_null_parsing_errors_in_facilitylist.py - This migration allows empty parsing_errors in api_facilitylist. +* 0161_create_disable_list_uploading_switch.py - This migration creates disable_list_uploading switch in the Django admin panel and record in the waffle_switch table. #### Scheme changes * [OSDEV-1346](https://opensupplyhub.atlassian.net/browse/OSDEV-1346) - Alter status options for api_moderationevent table. @@ -21,7 +22,8 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html ### Code/API changes * [OSDEV-1346](https://opensupplyhub.atlassian.net/browse/OSDEV-1346) - Create GET request for `v1/moderation-events` endpoint. -* [OSDEV-1332](https://opensupplyhub.atlassian.net/browse/OSDEV-1332) - Introduced new `PATCH api/v1/moderation-events/{moderation_id}` endpoint +* [OSDEV-1429](https://opensupplyhub.atlassian.net/browse/OSDEV-1429) - The list upload switcher has been created to disable the `Submit` button on the List Contribute page through the Switch page in the Django admin panel during the release process. Implemented a check on the list upload endpoint. +* [OSDEV-1332](https://opensupplyhub.atlassian.net/browse/OSDEV-1332) - Introduced new `PATCH api/v1/moderation-events/{moderation_id}` endpoint to modify moderation event `status`. * [OSDEV-1347](https://opensupplyhub.atlassian.net/browse/OSDEV-1347) - Create GET request for `v1/moderation-events/{moderation_id}` endpoint. diff --git a/src/django/api/exceptions.py b/src/django/api/exceptions.py index 6ad6a89a8..55d9eefe5 100644 --- a/src/django/api/exceptions.py +++ b/src/django/api/exceptions.py @@ -1,7 +1,15 @@ from rest_framework.exceptions import APIException +from rest_framework import status class BadRequestException(APIException): - status_code = 400 + status_code = status.HTTP_400_BAD_REQUEST default_detail = 'Bad request' default_code = 'bad_request' + + +class ServiceUnavailableException(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = 'Service is temporarily unavailable due to maintenance \ + work. Please try again later.' + default_code = 'service_unavailable' diff --git a/src/django/api/management/commands/enable_switches.py b/src/django/api/management/commands/enable_switches.py index 8764dd735..e30721e25 100644 --- a/src/django/api/management/commands/enable_switches.py +++ b/src/django/api/management/commands/enable_switches.py @@ -12,3 +12,4 @@ def handle(self, *args, **options): call_command('waffle_switch', 'report_a_facility', 'on') call_command('waffle_switch', 'embedded_map', 'on') call_command('waffle_switch', 'extended_profile', 'on') + call_command('waffle_switch', 'disable_list_uploading', 'off') diff --git a/src/django/api/migrations/0161_create_bdisable_list_uploading.py b/src/django/api/migrations/0161_create_bdisable_list_uploading.py new file mode 100644 index 000000000..dcdabdcf1 --- /dev/null +++ b/src/django/api/migrations/0161_create_bdisable_list_uploading.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.17 on 2024-11-22 09:50 + +from django.db import migrations + + +def create_disable_list_uploading_switch(apps, schema_editor): + Switch = apps.get_model('waffle', 'Switch') + Switch.objects.create(name='disable_list_uploading', active=False) + + +def delete_disable_list_uploading_switch(apps, schema_editor): + Switch = apps.get_model('waffle', 'Switch') + Switch.objects.get(name='disable_list_uploading').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0160_allow_null_parsing_errors_in_facilitylist'), + ] + + operations = [ + migrations.RunPython( + create_disable_list_uploading_switch, + delete_disable_list_uploading_switch,) + ] diff --git a/src/django/api/tests/test_facility_list_create.py b/src/django/api/tests/test_facility_list_create.py index c62fbf50a..46084d4f2 100644 --- a/src/django/api/tests/test_facility_list_create.py +++ b/src/django/api/tests/test_facility_list_create.py @@ -9,6 +9,7 @@ from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase +from waffle.testutils import override_switch from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse @@ -245,3 +246,33 @@ def test_list_request_by_user_with_no_contributor_returns_400(self): Contributor.objects.all().delete() response = self.client.get(reverse("facility-list-list")) self.assertEqual(response.status_code, 400) + + @override_switch("disable_list_uploading", active=True) + def test_no_upload_when_disable_list_uploading_switch_active(self): + previous_list_count = FacilityList.objects.all().count() + previous_source_count = Source.objects.all().count() + expected = ["Open Supply Hub is undergoing maintenance and not \ + accepting new data at the moment. Please try again in a \ + few minutes."] + + response = self.client.post( + reverse("facility-list-list"), + {"file": self.test_file}, + format="multipart", + ) + + self.assertEqual(response.status_code, + status.HTTP_503_SERVICE_UNAVAILABLE) + + error_message = json.loads(response.content)['detail'] + expected_message = " ".join(expected[0].split()) + self.assertEqual( + " ".join(error_message.split()), + expected_message + ) + self.assertEqual( + FacilityList.objects.all().count(), previous_list_count + ) + self.assertEqual( + Source.objects.all().count(), previous_source_count + ) diff --git a/src/django/api/views/facility/facility_list_view_set.py b/src/django/api/views/facility/facility_list_view_set.py index 9120da43d..a29c87735 100644 --- a/src/django/api/views/facility/facility_list_view_set.py +++ b/src/django/api/views/facility/facility_list_view_set.py @@ -2,6 +2,7 @@ import os import logging from functools import reduce +from waffle import switch_is_active from oar.settings import ( MAX_UPLOADED_FILE_SIZE_IN_BYTES, @@ -27,6 +28,7 @@ FacilityListItemsQueryParams, ProcessingAction, ) +from api.exceptions import ServiceUnavailableException from ...facility_history import create_dissociate_match_change_reason from ...mail import send_facility_list_rejection_email from ...models.contributor.contributor import Contributor @@ -89,6 +91,11 @@ def create(self, request): "is_public": true } """ + if switch_is_active('disable_list_uploading'): + raise ServiceUnavailableException('Open Supply Hub is undergoing \ + maintenance and not accepting new \ + data at the moment. Please try again \ + in a few minutes.') if 'file' not in request.data: raise ValidationError('No file specified.') uploaded_file = request.data['file'] diff --git a/src/react/src/__tests__/components/SubmitListUploadingButton.test.js b/src/react/src/__tests__/components/SubmitListUploadingButton.test.js new file mode 100644 index 000000000..02bbcf606 --- /dev/null +++ b/src/react/src/__tests__/components/SubmitListUploadingButton.test.js @@ -0,0 +1,258 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { Provider } from "react-redux"; +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ContributeList from '../../components/Contribute'; +import { MAINTENANCE_MESSAGE } from '../../util/constants'; + +jest.mock('@material-ui/core/Popper', () => ({ children }) => children); +jest.mock('@material-ui/core/Portal', () => ({ children }) => children); + +afterEach(() => { + jest.resetAllMocks(); +}); + +const createMockStore = (featureFlags, baseState) => { + const middlewares = [thunk]; + const mockStore = configureMockStore(middlewares); + + return mockStore({ + ...baseState, + featureFlags: { + flags: featureFlags, + fetching: false, + fetchingFeatureFlags: false + } + }); +}; + +describe('SubmitListUploadingButton component without DISABLE_LIST_UPLOADING', () => { + const features = { + disable_list_uploading: false, + }; + + const user = { + id: 57658, + email: '', + isModerationMode: false, + name: '', + description: '', + website: '', + contributorType: '', + otherContributorType: '', + currentPassword: '', + newPassword: '', + confirmNewPassword: '', + facilityLists: [], + }; + const initialState = { + auth: { + user: { user }, + session: { fetching: false }, + }, + upload: { + form: { name:'', description:'', filename:'', replaces:0 }, + fetching: false, + error: null, + }, + facilityLists: { facilityLists: [ + { + "id": 8573, + "name": "Apparel", + "description": "Test description", + "file_name": "Template_Excel.xlsx", + "is_active": false, + "is_public": true, + "item_count": 0, + "items_url": "/api/facility-lists/8573/items/", + "statuses": [], + "status_counts": { + "UPLOADED": 1, + "PARSED": 1, + "GEOCODED": 1, + "GEOCODED_NO_RESULTS": 1, + "MATCHED": 1, + "POTENTIAL_MATCH": 1, + "CONFIRMED_MATCH": 1, + "ERROR": 1, + "ERROR_PARSING": 1, + "ERROR_GEOCODING": 1, + "ERROR_MATCHING": 1, + "DUPLICATE": 1, + "DELETED": 1, + "ITEM_REMOVED": 1 + }, + "contributor_id": 2371, + "created_at": "2024-01-13T10:12:05.895143Z", + "match_responsibility": "moderator", + "status": "AUTOMATIC", + "status_change_reason": "test", + "file": "/Template_Excel_KdIAiX9.xlsx", + "parsing_errors": [] + },], + fetchingFacilityLists: false,}, + embeddedMap: { isEmbeded:true }, + fetching:false, + error: null, + fetchingFacilityLists:false, + }; + const newPreloadedState = { + userHasSignedIn: true, + fetchingSessionSignIn: false, + }; + const store = createMockStore(features, initialState); + + const renderComponent = (props = {}) => + render( + + + + , + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders without crashing", () => { + renderComponent(); + }); + + test('should render the SUBMIT Button when activeFeatureFlags NOT include DISABLE_LIST_UPLOADING',async () => { + const {getByRole} = renderComponent(); + + const button = getByRole('button', { name: 'SUBMIT' }); + + expect(button).toBeInTheDocument(); + expect(button).not.toHaveAttribute('disabled'); + expect(button).not.toBeDisabled(); + }); +}); + +describe('SubmitListUploadingButton component with DISABLE_LIST_UPLOADING', () => { + const features = { + extended_profile: true, + disable_list_uploading: true, + }; + const user = { + id: 96565, + email: 'test@gmail.com', + isModerationMode: true, + name: 'TestName', + description: 'test description', + website: 'https://test.pl', + contributorType: 'test type', + otherContributorType: 'new type', + currentPassword: 'pass', + newPassword: 'pass1', + confirmNewPassword: 'pass1', + facilityLists: [], + }; + const initialState = { + auth: { + user: { user }, + session: { fetching: false }, + }, + upload: { + form: { name:'List name', description:'List description', filename:'file name', replaces:1 }, + fetching: false, + error: null, + }, + facilityLists: { facilityLists: [ + { + "id": 3648, + "name": "Clothes", + "description": "No description", + "file_name": "OSHub_Data_Template_Excel.xlsx", + "is_active": false, + "is_public": true, + "item_count": 0, + "items_url": "/api/facility-lists/3648/items/", + "statuses": [], + "status_counts": { + "UPLOADED": 0, + "PARSED": 0, + "GEOCODED": 0, + "GEOCODED_NO_RESULTS": 0, + "MATCHED": 0, + "POTENTIAL_MATCH": 0, + "CONFIRMED_MATCH": 0, + "ERROR": 0, + "ERROR_PARSING": 0, + "ERROR_GEOCODING": 0, + "ERROR_MATCHING": 0, + "DUPLICATE": 0, + "DELETED": 0, + "ITEM_REMOVED": 0 + }, + "contributor_id": 7742, + "created_at": "2024-01-24T11:22:05.895943Z", + "match_responsibility": "moderator", + "status": "REJECTED", + "status_change_reason": "", + "file": "/OSHub_Data_Template_Excel_KdIAiX9.xlsx", + "parsing_errors": [] + },], + fetchingFacilityLists: false,}, + embeddedMap: { isEmbeded:false }, + fetching:false, + error: null, + fetchingFacilityLists:false, + }; + const preloadedState = { + userHasSignedIn: true, + fetchingSessionSignIn: false, + }; + const store = createMockStore(features, initialState); + + const renderComponent = (props = {}) => + render( + + + + , + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render the disabled SUBMIT Button when activeFeatureFlags include DISABLE_LIST_UPLOADING', () => { + const {getByRole} = renderComponent(); + const submitButton = getByRole('button', { name: 'SUBMIT' }); + + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveAttribute('disabled'); + expect(submitButton).toBeDisabled(); + }); + + test('shows tooltip on hover SUBMIT Button', async () => { + const {getByRole} = renderComponent(); + const button = getByRole('button', { name:'SUBMIT' }); + + expect(button).toHaveTextContent('SUBMIT'); + expect(button).toBeDisabled(); + + const noTooltipElement = document.querySelector(`[title="${ + MAINTENANCE_MESSAGE}"]`); + + expect(noTooltipElement).toBeInTheDocument(); + fireEvent.mouseOver(button); + + const tooltip = document.querySelector('[aria-describedby^="mui-tooltip-"]'); + + expect(tooltip).toBeInTheDocument(); + fireEvent.mouseOut(button); + + const noTooltipElementAfter = document.querySelector(`[title="${ + MAINTENANCE_MESSAGE}"]`); + + expect(noTooltipElementAfter).toBeInTheDocument(); + }); +}); diff --git a/src/react/src/components/Button.jsx b/src/react/src/components/Button.jsx index 52021877e..dffc318ca 100644 --- a/src/react/src/components/Button.jsx +++ b/src/react/src/components/Button.jsx @@ -40,7 +40,7 @@ class Button extends PureComponent { Button.propTypes = { Icon: PropTypes.func, - onClick: PropTypes.func.isRequired, + onClick: PropTypes.func, text: PropTypes.string.isRequired, disabled: PropTypes.bool, style: PropTypes.object, // eslint-disable-line react/forbid-prop-types @@ -50,6 +50,7 @@ Button.defaultProps = { Icon: null, disabled: false, style: {}, + onClick: () => {}, }; export default Button; diff --git a/src/react/src/components/ContributeForm.jsx b/src/react/src/components/ContributeForm.jsx index 1b9aaa76d..334a735d8 100644 --- a/src/react/src/components/ContributeForm.jsx +++ b/src/react/src/components/ContributeForm.jsx @@ -12,9 +12,11 @@ import { connect } from 'react-redux'; import CircularProgress from '@material-ui/core/CircularProgress'; import MaterialButton from '@material-ui/core/Button'; import { toast } from 'react-toastify'; - +import { withStyles } from '@material-ui/core/styles'; +import Tooltip from '@material-ui/core/Tooltip'; import ControlledTextInput from './ControlledTextInput'; import Button from './Button'; +import FeatureFlag from './FeatureFlag'; import ContributeFormSelectListToReplace from './ContributeFormSelectListToReplace'; import ListUploadErrors from './ListUploadErrors'; @@ -27,7 +29,12 @@ import { makeFacilityListItemsDetailLink, } from '../util/util'; -import { contributeFormFields, contributeFieldsEnum } from '../util/constants'; +import { + contributeFormFields, + contributeFieldsEnum, + DISABLE_LIST_UPLOADING, + MAINTENANCE_MESSAGE, +} from '../util/constants'; import { useFileUploadHandler } from '../util/hooks'; @@ -47,6 +54,18 @@ import { import { facilityListPropType } from '../util/propTypes'; +const StyledTooltip = withStyles({ + tooltip: { + color: 'rgba(0, 0, 0, 0.8)', + fontSize: '0.875rem', + backgroundColor: 'white', + border: 'solid rgba(0, 0, 0, 0.25)', + borderRadius: '10px', + padding: '10px', + lineHeight: '1', + }, +})(Tooltip); + const contributeFormStyles = Object.freeze({ fileNameText: Object.freeze({ color: COLOURS.LIGHT_BLUE, @@ -59,6 +78,9 @@ const contributeFormStyles = Object.freeze({ display: 'none', visibility: 'hidden', }), + inline: Object.freeze({ + display: 'inline-block', + }), }); const ContributeForm = ({ @@ -151,18 +173,38 @@ const ContributeForm = ({ /> {replacesSection} -
+
{errorMessages} {fetching ? ( ) : ( -
+ + )}
diff --git a/src/react/src/util/constants.jsx b/src/react/src/util/constants.jsx index 974468310..d1bc71e7a 100644 --- a/src/react/src/util/constants.jsx +++ b/src/react/src/util/constants.jsx @@ -549,6 +549,7 @@ export const REPORT_A_FACILITY = 'report_a_facility'; export const EMBEDDED_MAP_FLAG = 'embedded_map'; export const EXTENDED_PROFILE_FLAG = 'extended_profile'; export const DEFAULT_SEARCH_TEXT = 'Facility Name or OS ID'; +export const DISABLE_LIST_UPLOADING = 'disable_list_uploading'; export const DEFAULT_COUNTRY_CODE = 'IE'; @@ -1361,3 +1362,6 @@ export const MODERATION_STATUS_COLORS = Object.freeze({ [MODERATION_STATUSES_ENUM.APPROVED]: COLOURS.MINT_GREEN, [MODERATION_STATUSES_ENUM.REJECTED]: COLOURS.LIGHT_RED, }); + +export const MAINTENANCE_MESSAGE = + 'Open Supply Hub is undergoing maintenance and not accepting new data at the moment. Please try again in a few minutes.'; diff --git a/src/react/src/util/propTypes.js b/src/react/src/util/propTypes.js index 6cd186eae..df14cf9e7 100644 --- a/src/react/src/util/propTypes.js +++ b/src/react/src/util/propTypes.js @@ -28,6 +28,7 @@ import { REPORT_A_FACILITY, EMBEDDED_MAP_FLAG, EXTENDED_PROFILE_FLAG, + DISABLE_LIST_UPLOADING, facilityClaimStatusChoicesEnum, } from './constants'; @@ -354,6 +355,7 @@ export const featureFlagPropType = oneOf([ EMBEDDED_MAP_FLAG, EXTENDED_PROFILE_FLAG, ALLOW_LARGE_DOWNLOADS, + DISABLE_LIST_UPLOADING, ]); export const facilityClaimsListPropType = arrayOf(