diff --git a/docs/make_docs.sh b/docs/make_docs.sh old mode 100644 new mode 100755 index 7ddb4ca..7ea2a74 --- a/docs/make_docs.sh +++ b/docs/make_docs.sh @@ -1,8 +1,18 @@ -#!/bin/zsh -f +#!/bin/bash -f -source ../venv/bin/activate -PROJECTDIR=${0:a:h}/.. -export PYTHONPATH="$PROJECTDIR/src:$PROJECTDIR/tests" -echo "Python path is set to: $PYTHONPATH" +DOCS_FOLDER="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +PROJECT_ROOT="$DOCS_FOLDER/.." +export PYTHONPATH="$PROJECT_ROOT/src" + +echo "Documents Folder : $DOCS_FOLDER" +echo "Project Root : $PROJECT_ROOT" +echo "Python Path : $PYTHONPATH" + +# The assumption is there is already a virtual environment with the requirements to +# run the analyser installed. This simply activates it and adds in the Sphinx requirements +. "$PROJECT_ROOT/venv/bin/activate" +pip install -r requirements.txt + +# Build the documentation make html deactivate diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..ba8fa51 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,9 @@ +alabaster==0.7.12 +Sphinx +sphinx-rtd-theme +sphinxcontrib-applehelp +sphinxcontrib-devhelp +sphinxcontrib-htmlhelp +sphinxcontrib-jsmath +sphinxcontrib-qthelp +sphinxcontrib-serializinghtml diff --git a/docs/source/index.rst b/docs/source/index.rst index 38c7bcb..13136d9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,7 +7,6 @@ Nature Recorder naturerec_model/index naturerec_web/index - locust_tests/index Indices and tables diff --git a/docs/source/locust_tests/index.rst b/docs/source/locust_tests/index.rst deleted file mode 100644 index c3538d8..0000000 --- a/docs/source/locust_tests/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -Locust Load and Performance Tests -================================= - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - locustfile \ No newline at end of file diff --git a/docs/source/locust_tests/locustfile.rst b/docs/source/locust_tests/locustfile.rst deleted file mode 100644 index 9a2a2d8..0000000 --- a/docs/source/locust_tests/locustfile.rst +++ /dev/null @@ -1,5 +0,0 @@ -locustfile.py -============= - -.. automodule:: locust_tests.locustfile - :members: \ No newline at end of file diff --git a/features/categories.feature b/features/categories.feature deleted file mode 100644 index 1952e01..0000000 --- a/features/categories.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: Category management - Scenario: List categories when there are some categories in the database - Given A set of categories - | Category | - | Birds | - - When I navigate to the category list page - Then There will be 1 category in the category list - - Scenario: List categories when there are none in the database - Given There are no "categories" in the database - When I navigate to the category list page - Then The category list will be empty - - Scenario: Add a category - Given I navigate to the category list page - When I click on the "Add Category" button - And I fill in the Category details - | Category | - | Amphibians | - - And I click on the "Add Category" button - Then There will be 1 category in the category list diff --git a/features/conservation_status.feature b/features/conservation_status.feature deleted file mode 100644 index ba078d6..0000000 --- a/features/conservation_status.feature +++ /dev/null @@ -1,35 +0,0 @@ -Feature: Conservation status scheme management - Scenario: List conservation status schemes - Given A set of conservation status schemes - | Scheme | - | BOCC5 | - - When I navigate to the conservation status schemes page - Then There will be 1 scheme in the schemes list - - Scenario: Add conservation status scheme - Given I navigate to the conservation status schemes page - When I click on the "Add Conservation Status Scheme" button - And I enter the conservation status scheme name - | Scheme | - | BOCC5 | - - And I click on the "Add Conservation Status Scheme" button - Then There will be 1 scheme in the schemes list - - Scenario: Add a conservation status rating - Given A set of conservation status schemes - | Scheme | - | BOCC5 | - - When I navigate to the conservation status schemes page - And I click on the "edit" icon - And I click on the "Add Status Rating" button - Then I am taken to the "Add Conservation Status Rating" page - - When I enter the conservation status rating name - | Rating | - | Red | - - And I click on the "Add Conservation Status Rating" button - Then There will be 1 rating in the ratings list diff --git a/features/environment.py b/features/environment.py deleted file mode 100644 index 615a0c4..0000000 --- a/features/environment.py +++ /dev/null @@ -1,127 +0,0 @@ -import os -import time -import platform -from sqlalchemy import text -from src.naturerec_model.model import create_database -from src.naturerec_model.logic import create_user -from behave import fixture, use_fixture -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException -from flask_app_runner import FlaskAppRunner -from src.naturerec_web import create_app -from src.naturerec_model.model.database import Engine -from src.naturerec_model.model.utils import get_project_path - - -MAXIMUM_PAGE_LOAD_TIME = 5 - - -@fixture -def start_flask_server(context): - """ - Start the Nature Recorder web application on a background thread - - :param context: - """ - context.flask_runner = FlaskAppRunner("127.0.0.1", 5000, create_app("development")) - context.flask_runner.start() - yield context.flask_runner - - # As this behaves like a context manager, the following is called after the after_all() hook - context.flask_runner.stop_server() - context.flask_runner.join() - - -@fixture -def start_selenium_browser(context): - """ - Start a web browser to run the behave tests - - :param context: Behave context - """ - # Determine the OS and create an appropriate browser instance - os_name = platform.system() - if os_name == "Darwin": - context.browser = webdriver.Safari() - elif os_name == "Windows": - context.browser = webdriver.Edge() - else: - raise NotImplementedError() - - context.browser.implicitly_wait(MAXIMUM_PAGE_LOAD_TIME) - yield context.browser - - # As this behaves like a context manager, the following is called after the after_all() hook - context.browser.close() - - -@fixture -def create_test_database(_): - """ - Create and populate the test database - - :param _: Behave context (not used) - """ - create_database() - create_user("behave", "password") - - -@fixture -def login(context): - """ - Log in to the application - - :param context: Behave context - """ - # Browse to the login page and enter the username and password - url = context.flask_runner.make_url("auth/login") - context.browser.get(url) - context.browser.find_element(By.NAME, "username").send_keys("behave") - context.browser.find_element(By.NAME, "password").send_keys("password") - - # Click the "login" button - xpath = f"//*[text()='Login']" - elements = context.browser.find_elements(By.XPATH, xpath) - for element in elements: - try: - element.click() - except (ElementNotInteractableException, NoSuchElementException): - pass - - time.sleep(1) - - -def before_all(context): - """ - Set up the test environment before any scenarios are run - - :param context: Behave context - """ - use_fixture(create_test_database, context) - use_fixture(start_flask_server, context) - use_fixture(start_selenium_browser, context) - use_fixture(login, context) - - -def before_scenario(context, scenario): - """ - Initialise the database for every scenario - - :param context: Behave context (not used) - :param scenario: Behave scenario - """ - clear_down_script = os.path.join(get_project_path(), "features", "sql", "clear_database.sql") - with open(clear_down_script, mode="rt", encoding="utf-8") as f: - for statement in f.readlines(): - if statement: - Engine.execute(text(statement)) - - -def after_all(_): - """ - Tear down the test environment after all scenarios have run - - :param _: Behave context (not used) - """ - pass diff --git a/features/export.feature b/features/export.feature deleted file mode 100644 index 63d4d6d..0000000 --- a/features/export.feature +++ /dev/null @@ -1,32 +0,0 @@ -Feature: Export Sightings - @export - Scenario: Export unfiltered sightings - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | 10/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | 01/01/2022 | Test Location | Amphibians | Frog | 1 | Unknown | No | More notes | - - When I navigate to the export page - And I enter the export properties - | Filename | Location | Category | Species | - | sightings.csv | | | | - - And I click on the "Export Sightings" button - Then The export starts - And There will be 2 sightings in the export file - - @export - Scenario: Export filtered sightings - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | 10/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | 01/01/2022 | Test Location | Amphibians | Frog | 1 | Unknown | No | More notes | - - When I navigate to the export page - And I enter the export properties - | Filename | Location | Category | Species | - | sightings.csv | Test Location | Birds | Blackbird | - - And I click on the "Export Sightings" button - Then The export starts - And There will be 1 sighting in the export file diff --git a/features/flask_app_runner.py b/features/flask_app_runner.py deleted file mode 100644 index b8c2672..0000000 --- a/features/flask_app_runner.py +++ /dev/null @@ -1,58 +0,0 @@ -import threading -from typing import Optional -from werkzeug.serving import make_server - - -class FlaskAppRunner(threading.Thread): - def __init__(self, host, port, flask_app): - """ - Initialiser - - :param host: Hostname to serve on - :param port: Port number to serve on - :param flask_app: The Flask application to run - """ - threading.Thread.__init__(self) - self._exception = None - self._host = host - self._port = port - self._server = make_server(host, port, flask_app) - self._context = flask_app.app_context() - self._context.push() - - def run(self, *args, **kwargs): - """ - Run the web site on a background thread - - :param args: Variable positional arguments - :param kwargs: Variable keyword arguments - """ - try: - self._server.serve_forever() - except BaseException as e: - # If we get an error during the run, capture it. join(), below, then raises it in the calling - # thread - self._exception = e - - def join(self, timeout: Optional[float] = ...) -> None: - """ - If we have an exception, raise it in the calling thread when joined - """ - threading.Thread.join(self) - if self._exception: - raise self._exception - - def stop_server(self): - """ - Stop the Flask application - """ - self._server.shutdown() - - def make_url(self, relative_url): - """ - Return the absolute URL given a URL that's relative to the root of the site - - :param relative_url: Relative URL - :return: Absolute URL corresponding to the relative URL - """ - return f"http://{self._host}:{self._port}/{relative_url}" diff --git a/features/jobs.feature b/features/jobs.feature deleted file mode 100644 index 654c5b6..0000000 --- a/features/jobs.feature +++ /dev/null @@ -1,6 +0,0 @@ -Feature: Background job management - Scenario: View recent background jobs - Given The jobs list is empty - And I have started a sightings export - When I navigate to the job list page - Then There will be 1 job in the jobs list diff --git a/features/life_list.feature b/features/life_list.feature deleted file mode 100644 index 9e00611..0000000 --- a/features/life_list.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: Life List - The life list for a category of species consists of the unique list of species in that - category recorded in the database - - Scenario: Life list contains species - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | 01/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | 01/01/2022 | Test Location | Amphibians | Frog | 1 | Unknown | No | More notes | - - When I navigate to the life list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then There will be 1 species in the life list - - Scenario: Life list contains no entries - Given A set of categories - | Category | - | Birds | - - When I navigate to the life list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then The life list will be empty - - @export - Scenario: Export life list - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | 10/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | 01/01/2022 | Test Location | Amphibians | Frog | 1 | Unknown | No | More notes | - - When I navigate to the export life list page - And I enter the life list export properties - | Filename | Category | - | birds_life_list.csv | Birds | - - And I click on the "Export Life List" button - Then The life list export starts - And There will be 1 entry in the export file \ No newline at end of file diff --git a/features/locations.feature b/features/locations.feature deleted file mode 100644 index 1abb0ef..0000000 --- a/features/locations.feature +++ /dev/null @@ -1,24 +0,0 @@ -Feature: Location management - - Scenario: List locations when there are some locations in the database - Given A set of locations - | Name | Address | City | County | Postcode | Country | Latitude | Longitude | - | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | - - When I navigate to the locations list page - Then There will be 1 location in the locations list - - Scenario: List locations when there are none in the database - Given There are no "locations" in the database - When I navigate to the locations list page - Then The locations list will be empty - - Scenario: Add a location - Given I navigate to the locations list page - When I click on the "Add Location" button - And I fill in the location details - | Name | Address | City | County | Postcode | Country | Latitude | Longitude | - | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | - - And I click on the "Add Location" button - Then There will be 1 location in the locations list diff --git a/features/reports.feature b/features/reports.feature deleted file mode 100644 index a43384d..0000000 --- a/features/reports.feature +++ /dev/null @@ -1,34 +0,0 @@ -Feature: Reporting - Scenario: Report on sightings of species at a location - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | TODAY | Test Location | Birds | Woodpigeon | 1 | Unknown | No | Some notes | - | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | More notes | - | TODAY | Test Location | Birds | Robin | 1 | Unknown | No | It's a robin! | - | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | Squirrel! | - - When I navigate to the location report page - And I fill in the location report details - | Location | Category | From | - | Test Location | Birds | 01/01/2022 | - - # Selenium web-driver doesn't like clicking on the generate report button so the step code for the form-fill - # step sends an ENTER after entering the from date, which submits the form instead - # And I click on the "Generate Report" button - Then There will be 3 results in the report table - - Scenario: Report on sightings of species over time - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | TODAY | Test Location | Birds | Woodpigeon | 1 | Unknown | No | Some notes | - | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | - - When I navigate to the species report page - And I fill in the species report details - | Location | Category | Species | From | - | Test Location | Birds | Woodpigeon | 01/01/2022 | - - # Selenium web-driver doesn't like clicking on the generate report button so the step code for the form-fill - # step sends an ENTER after entering the from date, which submits the form instead - # And I click on the "Generate Report" button - Then There will be 1 result in the report table diff --git a/features/sightings.feature b/features/sightings.feature deleted file mode 100644 index 911852e..0000000 --- a/features/sightings.feature +++ /dev/null @@ -1,49 +0,0 @@ -Feature: Sightings Management - Scenario: List today's sightings - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | - - When I navigate to the sightings page - Then There will be 2 sightings in the sightings list - - Scenario: List filtered sightings - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | - - When I navigate to the sightings page - And I fill in the sightings filter form - | Location | Category | Species | - | Test Location | Mammals | Grey Squirrel | - - And I click on the "Filter Sightings" button - Then There will be 1 sighting in the sightings list - - Scenario: List today's sightings when there are none - Given There are no "sightings" in the database - When I navigate to the sightings page - Then The sightings list will be empty - - Scenario: Create sighting - Given A set of locations - | Name | Address | City | County | Postcode | Country | Latitude | Longitude | - | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | - - And A set of categories - | Category | - | Birds | - - And A set of species - | Category | Species | - | Birds | Black-Headed Gull | - - When I navigate to the sightings entry page - And I fill in the sighting details - | Date | Location | Category | Species | Number | Gender | WithYoung | - | TODAY | Farmoor Reservoir | Birds | Black-Headed Gull | 1 | Unknown | No | - - And I click on the "Add Sighting" button - Then The sighting will be added to the database diff --git a/features/species.feature b/features/species.feature deleted file mode 100644 index 5800ea0..0000000 --- a/features/species.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: Species management - - Scenario: List species when there are some species in the database - Given A set of species - | Category | Species | - | Birds | Red Kite | - | Amphibians | Frog | - - When I navigate to the species list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then There will be 1 species in the species list - - Scenario: List species when there are none in the database - Given A set of categories - | Category | - | Birds | - - And There are no "species" in the database - When I navigate to the species list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then The species list will be empty - - Scenario: Add a species - Given A set of categories - | Category | - | Birds | - - When I navigate to the species list page - And I click on the "Add Species" button - And I fill in the Species details - | Category | Species | - | Birds | Sparrowhawk | - - And I click on the "Add Species" button - And I navigate to the species list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then There will be 1 species in the species list diff --git a/features/sql/clear_database.sql b/features/sql/clear_database.sql deleted file mode 100644 index 4475c7f..0000000 --- a/features/sql/clear_database.sql +++ /dev/null @@ -1,7 +0,0 @@ -DELETE FROM SPECIESSTATUSRATINGS; -DELETE FROM STATUSRATINGS; -DELETE FROM STATUSSCHEMES; -DELETE FROM SIGHTINGS; -DELETE FROM SPECIES; -DELETE FROM CATEGORIES; -DELETE FROM LOCATIONS; diff --git a/features/steps/__init__.py b/features/steps/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/features/steps/categories.py b/features/steps/categories.py deleted file mode 100644 index b40ee8d..0000000 --- a/features/steps/categories.py +++ /dev/null @@ -1,32 +0,0 @@ -import time -from behave import given, when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count, confirm_span_exists - - -@given("I navigate to the category list page") -@when("I navigate to the category list page") -def _(context): - url = context.flask_runner.make_url("categories/list") - context.browser.get(url) - assert "Categories" in context.browser.title - - -@when("I fill in the category details") -def _(context): - # Having clicked, we need to sleep this thread to allow the server round trip to refresh the page. - # WebDriverWait won't work in this context - time.sleep(1) - row = context.table.rows[0] - context.browser.find_element(By.NAME, "name").send_keys(row["Category"]) - - -@then("There will be {number} categories in the category list") -@then("There will be {number} category in the category list") -def _(context, number): - confirm_table_row_count(context, number, 1) - - -@then("The category list will be empty") -def _(context): - confirm_span_exists(context, "There are no categories in the database", 1) diff --git a/features/steps/common.py b/features/steps/common.py deleted file mode 100644 index a0c7732..0000000 --- a/features/steps/common.py +++ /dev/null @@ -1,169 +0,0 @@ -import time -from behave import given, when, then -from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException -from selenium.webdriver.common.by import By -from src.naturerec_model.model import Gender -from src.naturerec_model.logic import create_sighting -from helpers import get_date_from_string, select_option -from helpers import create_test_location, create_test_category, create_test_species, create_test_scheme - - -@given("A set of locations") -def _(context): - """ - Create one or more locations presented in a data table in the following form: - - | Name | Address | City | County | Postcode | Country | Latitude | Longitude | - | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | - - :param context: Behave context - """ - for row in context.table: - _ = create_test_location(row["Name"]) - - -@given("A set of categories") -def _(context): - """ - Create one or more categories presented in a data table in the following form: - - | Category | - | Birds | - - :param context: Behave context - """ - for row in context.table: - _ = create_test_category(row["Category"]) - - -@given("A set of species") -def _(context): - """ - Create one or more species presented in a data table in the following form: - - | Category | Species | - | Birds | Red Kite | - | Amphibians | Frog | - - :param context: Behave context - """ - for row in context.table: - category = create_test_category(row["Category"]) - _ = create_test_species(row["Species"], category.id) - - -@given("A set of sightings") -def _(context): - """ - Create one or more sightings presented in a data table in the following form: - - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | 01/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - - :param context: Behave context - """ - for row in context.table: - sighting_date = get_date_from_string(row["Date"]) - location = create_test_location(row["Location"]) - category = create_test_category(row["Category"]) - species = create_test_species(row["Species"], category.id) - gender = [key for key, value in Gender.gender_map().items() if value == row["Gender"]][0] - with_young = 1 if row["WithYoung"] == "Yes" else 0 - notes = row["Notes"] - _ = create_sighting(location.id, species.id, sighting_date, int(row["Number"]), gender, with_young, notes) - - -@given("A set of conservation status schemes") -def _(context): - """ - Create one or more conservation status schemes presented in a data table in the following form: - - | Scheme | - | BOCC5 | - - :param context: Behave context - """ - for row in context.table: - _ = create_test_scheme(row["Scheme"]) - - -@given("There are no \"{item_type}\" in the database") -def _(_, item_type): - """ - Step that takes no action when there are no locations, categories etc. in the database. The - before_scenario() method takes care of this, so no action is required. This step definition - is provided solely to make the scenarios make sense - - :param _: Behave context (ignore) - :param item_type: Item type (not used) - """ - pass - - -@when("I select \"{selection}\" as the \"{selector}\"") -def _(context, selection, selector): - """ - Select list selector - - :param context: Behave context - :param selection: Visible text for the selection - :param selector: Name of the select list element - """ - select_option(context, selector, selection, None) - - -@when("I click on the \"{button_text}\" button") -def _(context, button_text): - """ - Button clicker based on the button text - - :param context: Behave context - :param button_text: Button text - """ - time.sleep(1) - xpath = f"//*[text()='{button_text}']" - elements = context.browser.find_elements(By.XPATH, xpath) - for element in elements: - try: - element.click() - except (ElementNotInteractableException, NoSuchElementException): - pass - - -@when("I click on the \"{icon_type}\" icon") -def _(context, icon_type): - """ - Icon clicker based on the icon type text - - :param context: Behave context - :param icon_type: Type of icon to click - """ - class_name = f"fa-{icon_type}" - elements = context.browser.find_elements(By.CLASS_NAME, class_name) - for element in elements: - try: - element.click() - except (ElementNotInteractableException, NoSuchElementException): - pass - - -@then("I am taken to the \"{title}\" page") -def _(context, title): - """ - Wait for a page to serve as the result of a previous step executing then confirm it's title - - :param context: Behave context - :param title: Expected page title text - """ - time.sleep(1) - assert title in context.browser.title - - -@then("There will be {number} {item_type} in the export file") -@then("There will be {number} {item_type} in the export file") -def _(context, number, item_type): - time.sleep(2) - with open(context.export_filepath, mode="rt", encoding="utf-8") as f: - lines = f.readlines() - # Number of lines plus 1 to account for the headers - assert len(lines) == int(number) + 1 diff --git a/features/steps/conservation_status.py b/features/steps/conservation_status.py deleted file mode 100644 index e1fcab6..0000000 --- a/features/steps/conservation_status.py +++ /dev/null @@ -1,36 +0,0 @@ -import time -from behave import given, when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count - - -@given("I navigate to the conservation status schemes page") -@when("I navigate to the conservation status schemes page") -def _(context): - url = context.flask_runner.make_url("status/list") - context.browser.get(url) - assert "Conservation Status Schemes" in context.browser.title - - -@when("I enter the conservation status scheme name") -def _(context): - # This action's preceded by clicking on a button, so give the page a moment to serve - time.sleep(1) - row = context.table.rows[0] - context.browser.find_element(By.NAME, "name").send_keys(row["Scheme"]) - - -@when("I enter the conservation status rating name") -def _(context): - # This action's preceded by clicking on a button, so give the page a moment to serve - time.sleep(1) - row = context.table.rows[0] - context.browser.find_element(By.NAME, "name").send_keys(row["Rating"]) - - -@then("There will be {number} schemes in the schemes list") -@then("There will be {number} scheme in the schemes list") -@then("There will be {number} ratings in the ratings list") -@then("There will be {number} rating in the ratings list") -def _(context, number): - confirm_table_row_count(context, number, 1) diff --git a/features/steps/export.py b/features/steps/export.py deleted file mode 100644 index 4bab07c..0000000 --- a/features/steps/export.py +++ /dev/null @@ -1,33 +0,0 @@ -from behave import when, then -from selenium.webdriver.common.by import By -from helpers import select_option, confirm_span_exists, get_export_filepath, delete_export_file - - -@when("I navigate to the export page") -def _(context): - url = context.flask_runner.make_url("export/filters") - context.browser.get(url) - assert "Export Sightings" in context.browser.title - - -@when("I enter the export properties") -def _(context): - row = context.table.rows[0] - # We're about to do an export, so if the file already exists then delete it at this stage - context.export_filepath = get_export_filepath(row["Filename"]) - delete_export_file(row["Filename"]) - - context.browser.find_element(By.NAME, "filename").send_keys(row["Filename"]) - if row["Location"].strip(): - select_option(context, "location", row["Location"], 0) - - if row["Category"].strip(): - select_option(context, "category", row["Category"], 0) - - if row["Species"].strip(): - select_option(context, "species", row["Species"], 1) - - -@then("The export starts") -def _(context): - confirm_span_exists(context, "Matching sightings are exporting in the background", 1) diff --git a/features/steps/helpers.py b/features/steps/helpers.py deleted file mode 100644 index abda42d..0000000 --- a/features/steps/helpers.py +++ /dev/null @@ -1,165 +0,0 @@ -import time -import datetime -import os -from selenium.webdriver.common.by import By -from selenium.webdriver.support.select import Select -from src.naturerec_model.model import Sighting -from src.naturerec_model.model.utils import get_data_path -from src.naturerec_model.logic import get_location, create_location -from src.naturerec_model.logic import get_category, create_category -from src.naturerec_model.logic import get_species, create_species -from src.naturerec_model.logic import get_status_scheme, create_status_scheme - - -def get_date_from_string(date_string): - """ - Given a date string, return the corresponding date - - :param date_string: Representation of a date as DD/MM/YYYY - :return: The date object corresponding the specified date - """ - if date_string.casefold() == "TODAY".casefold(): - return datetime.datetime.today().date() - else: - return datetime.datetime.strptime(date_string, Sighting.DATE_IMPORT_FORMAT).date() - - -def create_test_location(name): - """ - Create a named location, if it doesn't already exist - - :param name: Location name - :return: Instance of the Location() class - """ - try: - location = get_location(name) - except ValueError: - location = create_location(name, "Oxfordshire", "United Kingdom") - return location - - -def create_test_category(name): - """ - Create a named category, if it doesn't already exist - - :param name: Category name - :return: Instance of the Category() class - """ - try: - category = get_category(name) - except ValueError: - category = create_category(name) - return category - - -def create_test_species(name, category_id): - """ - Create a named species, if it doesn't already exist - - :param name: Species name - :param category_id: Category the species belongs to - :return: Instance of the Species() class - """ - try: - species = get_species(name) - except ValueError: - species = create_species(category_id, name) - return species - - -def create_test_scheme(name): - """ - Create a named conservation status scheme, if it doesn't already exist - - :param name: Scheme name - :return: Instance of the StatusScheme() class - """ - try: - scheme = get_status_scheme(name) - except ValueError: - scheme = create_status_scheme(name) - return scheme - - -def select_option(context, element, text, delay): - """ - Select an option in a select list based on the visible text - - :param context: Behave context - :param element: Name of the HTML select element - :param text: Visible text for the option to select - :param delay: Time, in seconds, to wait before making the selection or 0/None for no delay - """ - # If requested, wait for the specified delay, to allow the select list to be rendered - if delay: - time.sleep(delay) - - # Click on the select element, first, to make its options visible and ready to interact with - select_element = context.browser.find_element(By.NAME, element) - select_element.click() - - # Create a select object from the element and select the requested value - selector = Select(select_element) - selector.select_by_visible_text(text) - - -def confirm_table_row_count(context, expected, delay): - """ - Find a results table in the current page, count the number of rows in the table body - and confirm they match the expected count - - :param context: Behave context, which contains a member for the Selenium browser driver - :param expected: The expected row count - :param delay: THe number of seconds to wait before attempting the check (or 0/None for no delay) - """ - # If requested, wait for the specified delay, to allow the table to be rendered - if delay: - time.sleep(delay) - - # Find the table on the page - table = context.browser.find_element(By.CLASS_NAME, "striped") - table_body = table.find_element(By.XPATH, ".//tbody") - table_rows = table_body.find_elements(By.XPATH, ".//tr") - - # Confirm - expected = int(expected) - actual = len(table_rows) - assert actual == expected - - -def confirm_span_exists(context, text, delay): - """ - Confirm that a span containing the specified text exists on the page - - :param context: Behave context, which contains a member for the Selenium browser driver - :param text: The expected text in the span - :param delay: THe number of seconds to wait before attempting the check (or 0/None for no delay) - """ - # If requested, wait for the specified delay, to allow the span to be rendered - if delay: - time.sleep(delay) - - # Find the span with the specified text - xpath = f"//span[text()='{text}']" - _ = context.browser.find_element(By.XPATH, xpath) - - -def get_export_filepath(filename): - """ - Given a filename for sightings export, return the full path to the exported CSV file - - :param filename: Export file name - :return: Full path to the export file with that name - """ - return os.path.join(get_data_path(), "exports", filename) - - -def delete_export_file(filename): - """ - Delete the export file with the specified name - - :param filename: Export file name - """ - filepath = get_export_filepath(filename) - if os.path.exists(filepath): - os.unlink(filepath) diff --git a/features/steps/jobs.py b/features/steps/jobs.py deleted file mode 100644 index 43afdd2..0000000 --- a/features/steps/jobs.py +++ /dev/null @@ -1,42 +0,0 @@ -import datetime -from behave import given, when, then -from helpers import confirm_table_row_count, create_test_location, create_test_category, create_test_species -from src.naturerec_model.model import Gender, Session, JobStatus -from src.naturerec_model.logic import create_sighting -from src.naturerec_model.data_exchange import SightingsExportHelper - -@given("The jobs list is empty") -def _(context): - with Session.begin() as session: - jobs = session.query(JobStatus).all() - for job in jobs: - session.delete(job) - - -@given("I have started a sightings export") -def _(context): - # Create a sighting to export - sighting_date = datetime.datetime.today().date() - location = create_test_location("Farmoor Reservoir") - category = create_test_category("Birds") - species = create_test_species("Cormorant", category.id) - gender = [key for key, value in Gender.gender_map().items() if value == "Unknown"][0] - _ = create_sighting(location.id, species.id, sighting_date, None, gender, 0, None) - - # Kick off the export - exporter = SightingsExportHelper("sightings.csv", None, None, None, None) - exporter.start() - exporter.join() - - -@when("I navigate to the job list page") -def _(context): - url = context.flask_runner.make_url("jobs/list") - context.browser.get(url) - assert "Background Job Status" in context.browser.title - - -@then("There will be {number} jobs in the jobs list") -@then("There will be {number} job in the jobs list") -def _(context, number): - confirm_table_row_count(context, number, 1) diff --git a/features/steps/life_list.py b/features/steps/life_list.py deleted file mode 100644 index 22eed3f..0000000 --- a/features/steps/life_list.py +++ /dev/null @@ -1,44 +0,0 @@ -from behave import when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count, confirm_span_exists, get_export_filepath, delete_export_file, select_option - - -@when("I navigate to the life list page") -def _(context): - url = context.flask_runner.make_url("life_list/list") - context.browser.get(url) - assert "Life List" in context.browser.title - - -@when("I navigate to the export life list page") -def _(context): - url = context.flask_runner.make_url("export/life_list") - context.browser.get(url) - assert "Export Life List" in context.browser.title - - -@when("I enter the life list export properties") -def _(context): - row = context.table.rows[0] - # We're about to do an export, so if the file already exists then delete it at this stage - context.export_filepath = get_export_filepath(row["Filename"]) - delete_export_file(row["Filename"]) - - context.browser.find_element(By.NAME, "filename").send_keys(row["Filename"]) - select_option(context, "category", row["Category"], 0) - - -@then("There will be {number} species in the life list") -@then("There will be {number} species in the life list") -def _(context, number): - confirm_table_row_count(context, number, 1) - - -@then("The life list will be empty") -def _(context): - confirm_span_exists(context, "There are no species in the database for the specified category", 1) - - -@then("The life list export starts") -def _(context): - confirm_span_exists(context, "The selected life list is exporting in the background", 1) diff --git a/features/steps/locations.py b/features/steps/locations.py deleted file mode 100644 index 472b961..0000000 --- a/features/steps/locations.py +++ /dev/null @@ -1,39 +0,0 @@ -import time -from behave import given, when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count, confirm_span_exists - - -@given("I navigate to the locations list page") -@when("I navigate to the locations list page") -def _(context): - url = context.flask_runner.make_url("locations/list") - context.browser.get(url) - assert "Locations" in context.browser.title - - -@when("I fill in the location details") -def _(context): - # Having clicked, we need to sleep this thread to allow the server round trip to refresh the page. - # WebDriverWait won't work in this context - time.sleep(1) - row = context.table.rows[0] - context.browser.find_element(By.NAME, "name").send_keys(row["Name"]) - context.browser.find_element(By.NAME, "address").send_keys(row["Address"]) - context.browser.find_element(By.NAME, "city").send_keys(row["City"]) - context.browser.find_element(By.NAME, "county").send_keys(row["County"]) - context.browser.find_element(By.NAME, "postcode").send_keys(row["Postcode"]) - context.browser.find_element(By.NAME, "country").send_keys(row["Country"]) - context.browser.find_element(By.NAME, "latitude").send_keys(row["Latitude"]) - context.browser.find_element(By.NAME, "longitude").send_keys(row["Longitude"]) - - -@then("There will be {number} locations in the locations list") -@then("There will be {number} location in the locations list") -def _(context, number): - confirm_table_row_count(context, number, 1) - - -@then("The locations list will be empty") -def _(context): - confirm_span_exists(context, "There are no locations in the database", 1) diff --git a/features/steps/reports.py b/features/steps/reports.py deleted file mode 100644 index c0b51b3..0000000 --- a/features/steps/reports.py +++ /dev/null @@ -1,50 +0,0 @@ -from behave import when, then -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from features.steps.helpers import select_option, get_date_from_string, confirm_table_row_count -from src.naturerec_model.model import Sighting - - -@when("I navigate to the location report page") -def _(context): - url = context.flask_runner.make_url("reports/location") - context.browser.get(url) - assert "Location Report" in context.browser.title - - -@when("I navigate to the species report page") -def _(context): - url = context.flask_runner.make_url("reports/species") - context.browser.get(url) - assert "Species by Date Report" in context.browser.title - - -@when("I fill in the location report details") -def _(context): - row = context.table.rows[0] - select_option(context, "location", row["Location"], None) - select_option(context, "category", row["Category"], None) - from_date = get_date_from_string(row["From"]).strftime(Sighting.DATE_DISPLAY_FORMAT) - context.browser.find_element(By.NAME, "from_date").send_keys(from_date) - # With the date-picker in place, use ESC to close it then ENTER to submit the form - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ESCAPE) - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ENTER) - - -@when("I fill in the species report details") -def _(context): - row = context.table.rows[0] - select_option(context, "location", row["Location"], None) - select_option(context, "category", row["Category"], None) - select_option(context, "species", row["Species"], 1) - from_date = get_date_from_string(row["From"]).strftime(Sighting.DATE_DISPLAY_FORMAT) - context.browser.find_element(By.NAME, "from_date").send_keys(from_date) - # With the date-picker in place, use ESC to close it then ENTER to submit the form - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ESCAPE) - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ENTER) - - -@then("There will be {number} results in the report table") -@then("There will be {number} result in the report table") -def _(context, number): - confirm_table_row_count(context, number, 5) diff --git a/features/steps/sightings.py b/features/steps/sightings.py deleted file mode 100644 index 4a85417..0000000 --- a/features/steps/sightings.py +++ /dev/null @@ -1,66 +0,0 @@ -import datetime -from behave import when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count, confirm_span_exists, select_option -from src.naturerec_model.model import Sighting - - -@when("I navigate to the sightings page") -def _(context): - url = context.flask_runner.make_url("sightings/list") - context.browser.get(url) - assert "Sightings" in context.browser.title - - -@when("I fill in the sightings filter form") -def _(context): - row = context.table.rows[0] - select_option(context, "location", row["Location"], None) - select_option(context, "category", row["Category"], None) - select_option(context, "species", row["Species"], 1) - - -@when("I navigate to the sightings entry page") -def _(context): - url = context.flask_runner.make_url("/sightings/edit") - context.browser.get(url) - assert "Add Sighting" in context.browser.title - - -@when("I fill in the sighting details") -def _(context): - # Capture the sighting details for use in the confirmation step - row = context.table.rows[0] - context.sighting_species = row["Species"] - context.sighting_location = row["Location"] - context.sighting_date = datetime.datetime.today().date().strftime(Sighting.DATE_DISPLAY_FORMAT) - - # Select the values - category = row["Category"] - select_option(context, "location", row["Location"], None) - select_option(context, "category", category, None) - select_option(context, "species", row["Species"], 1) - - # Some controls are only displayed for certain categories - if category.casefold() in ["birds", "mammals"]: - context.browser.find_element(By.NAME, "number").send_keys(row["Number"]) - select_option(context, "gender", row["Gender"], 1) - context.browser.find_element(By.NAME, "with_young").send_keys("") - select_option(context, "with_young", row["WithYoung"], 1) - - -@then("There will be {number} sightings in the sightings list") -@then("There will be {number} sighting in the sightings list") -def _(context, number): - confirm_table_row_count(context, number, 1) - - -@then("The sightings list will be empty") -def _(context): - confirm_span_exists(context, "There are no sightings in the database matching the specified criteria", 1) - - -@then("The sighting will be added to the database") -def _(context): - text = f"Added sighting of {context.sighting_species} at {context.sighting_location} on {context.sighting_date}" - confirm_span_exists(context, text, 1) diff --git a/features/steps/species.py b/features/steps/species.py deleted file mode 100644 index e4ef25e..0000000 --- a/features/steps/species.py +++ /dev/null @@ -1,33 +0,0 @@ -import time -from behave import when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count, confirm_span_exists, select_option - - -@when("I navigate to the species list page") -def _(context): - url = context.flask_runner.make_url("species/list") - context.browser.get(url) - time.sleep(1) - assert "Species" in context.browser.title - - -@when("I fill in the species details") -def _(context): - # Having clicked, we need to sleep this thread to allow the server round trip to refresh the page. - # WebDriverWait won't work in this context - time.sleep(1) - row = context.table.rows[0] - select_option(context, "category", row["Category"], 0) - context.browser.find_element(By.NAME, "name").send_keys(row["Species"]) - - -@then("There will be {number} species in the species list") -@then("There will be {number} species in the species list") -def _(context, number): - confirm_table_row_count(context, number, 1) - - -@then("The species list will be empty") -def _(context): - confirm_span_exists(context, "There are no species in the database for the specified category", 1) diff --git a/requirements.txt b/requirements.txt index 7816d0a..fa9fbc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,21 @@ -alabaster==0.7.12 alembic==1.11.2 async-generator==1.10 attrs==21.4.0 Babel==2.9.1 beautifulsoup4==4.11.1 -behave==1.2.6 -Brotli==1.0.9 certifi==2024.7.4 cffi==1.15.0 charset-normalizer==2.0.8 click==8.0.3 colorama==0.4.6 -ConfigArgParse==1.5.3 -coverage==6.2 +coverage==7.8.0 cryptography==44.0.1 cycler==0.11.0 docutils==0.17.1 Flask==2.2.5 -Flask-BasicAuth==0.2.0 -Flask-Cors==5.0.0 Flask-Login==0.6.3 Flask-WTF==1.2.1 fonttools==4.43.0 -gevent==24.2.1 -geventhttpclient==2.0.11 greenlet==3.0.3 h11==0.12.0 idna==3.7 @@ -31,21 +23,16 @@ imagesize==1.3.0 itsdangerous==2.0.1 Jinja2==3.1.6 kiwisolver==1.3.2 -locust==2.5.1 Mako==1.2.4 MarkupSafe==2.1.2 matplotlib==3.5.1 -msgpack==1.0.3 numpy==1.23.1 outcome==1.1.0 packaging==21.3 pandas==1.3.5 -parse==1.19.0 -parse-type==0.5.2 pdfkit==1.0.0 pgeocode==0.3.0 pillow==10.3.0 -psutil==5.9.0 pycountry==20.7.3 pycparser==2.21 Pygments==2.15.1 @@ -54,24 +41,13 @@ pyparsing==3.0.6 python-dateutil==2.8.2 python-dotenv==0.19.2 pytz==2021.3 -pywin32==306; sys_platform=='win32' -pyzmq==26.2.0 requests==2.32.3 -roundrobin==0.0.2 selenium==4.1.0 six==1.16.0 sniffio==1.2.0 snowballstemmer==2.2.0 sortedcontainers==2.4.0 soupsieve==2.3.2.post1 -Sphinx==4.3.0 -sphinx-rtd-theme==1.0.0 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.0 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 SQLAlchemy==1.4.27 trio==0.19.0 trio-websocket==0.9.2 @@ -81,5 +57,3 @@ urllib3-secure-extra==0.1.0 Werkzeug==3.0.6 wsproto==1.0.0 WTForms==3.0.1 -zope.event==4.5.0 -zope.interface==5.4.0 diff --git a/run_behave.bat b/run_behave.bat deleted file mode 100644 index 01051c4..0000000 --- a/run_behave.bat +++ /dev/null @@ -1,11 +0,0 @@ -@ECHO OFF -SET PROJECT_ROOT=%~p0 -CALL %PROJECT_ROOT%\venv\Scripts\activate.bat -SET PYTHONPATH=%PROJECT_ROOT%src;%PROJECT_ROOT%tests -SET NATUREREC_SAME_PAGE_REPORT="" - -ECHO Project root = %PROJECT_ROOT% -ECHO Python Path = %PYTHONPATH% - -behave -ECHO ON diff --git a/run_coverage.sh b/run_coverage.sh new file mode 100755 index 0000000..96bba05 --- /dev/null +++ b/run_coverage.sh @@ -0,0 +1,13 @@ +#!/bin/sh -f + +export PROJECT_ROOT=$( cd "$( dirname "$0" )" && pwd ) +. $PROJECT_ROOT/venv/bin/activate +export PYTHONPATH=$PROJECT_ROOT/src +export NATURE_RECORDER_DB="$PROJECT_ROOT/data/naturerecorder_test.db" + +echo "Project root = $PROJECT_ROOT" +echo "Python Path = $PYTHONPATH" +echo "Test Database = $NATURE_RECORDER_DB" + +coverage run --branch --source src -m unittest discover +coverage html -d cov_html diff --git a/run_locust.bat b/run_locust.bat deleted file mode 100644 index 442d1d9..0000000 --- a/run_locust.bat +++ /dev/null @@ -1,11 +0,0 @@ -@ECHO OFF -SET PROJECT_ROOT=%~p0 -CALL %PROJECT_ROOT%\venv\Scripts\activate.bat -SET PYTHONPATH=%PROJECT_ROOT%src;%PROJECT_ROOT%tests - -ECHO Project root = %PROJECT_ROOT% -ECHO Python Path = %PYTHONPATH% - -CD tests\locust_tests -locust -f locustfile.py -ECHO ON diff --git a/tests/locust_tests/__init__.py b/tests/locust_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/locust_tests/flask_app_runner.py b/tests/locust_tests/flask_app_runner.py deleted file mode 100644 index 7d6bc74..0000000 --- a/tests/locust_tests/flask_app_runner.py +++ /dev/null @@ -1,49 +0,0 @@ -import threading -from typing import Optional -from werkzeug.serving import make_server - - -class FlaskAppRunner(threading.Thread): - def __init__(self, host, port, flask_app): - """ - Initialiser - - :param host: Hostname to serve on - :param port: Port number to serve on - :param flask_app: The Flask application to run - """ - threading.Thread.__init__(self) - self._exception = None - self._host = host - self._port = port - self._server = make_server(host, port, flask_app) - self._context = flask_app.app_context() - self._context.push() - - def run(self, *args, **kwargs): - """ - Run the web site on a background thread - - :param args: Variable positional arguments - :param kwargs: Variable keyword arguments - """ - try: - self._server.serve_forever() - except BaseException as e: - # If we get an error during the run, capture it. join(), below, then raises it in the calling - # thread - self._exception = e - - def join(self, timeout: Optional[float] = ...) -> None: - """ - If we have an exception, raise it in the calling thread when joined - """ - threading.Thread.join(self) - if self._exception: - raise self._exception - - def stop_server(self): - """ - Stop the Flask application - """ - self._server.shutdown() diff --git a/tests/locust_tests/locust.conf b/tests/locust_tests/locust.conf deleted file mode 100644 index 7477a37..0000000 --- a/tests/locust_tests/locust.conf +++ /dev/null @@ -1,7 +0,0 @@ -locustfile = locustfile.py -headless = false -expect-workers = 5 -host = http://127.0.0.1:5000 -users = 1 -spawn-rate = 10 -run-time = 1m diff --git a/tests/locust_tests/locustfile.py b/tests/locust_tests/locustfile.py deleted file mode 100644 index 6cbb80b..0000000 --- a/tests/locust_tests/locustfile.py +++ /dev/null @@ -1,306 +0,0 @@ -import os -import time -import datetime -from bs4 import BeautifulSoup -from random import randrange -from locust import HttpUser, task, between, events -from locust_tests.flask_app_runner import FlaskAppRunner -from naturerec_model.model import create_database, get_data_path, Sighting -from naturerec_model.logic import list_locations, list_categories, list_species, create_user -from naturerec_model.data_exchange import SightingsImportHelper, StatusImportHelper -from naturerec_web import create_app - -TEST_USER_NAME = "locust" -TEST_PASSWORD = "password" - - -flask_runner = FlaskAppRunner("127.0.0.1", 5000, create_app()) - - -@events.test_start.add_listener -def on_test_start(environment, **kwargs): - """ - Before any tests run, start the Flask application - - :param environment: Ignored - :param kwargs: Ignored - """ - # Reset the database - create_database() - - # Create a login - create_user(TEST_USER_NAME, TEST_PASSWORD) - - # Import some sample sightings - sightings_file = os.path.join(get_data_path(), "imports", "locust_sightings.csv") - with open(sightings_file, mode="rt", encoding="utf-8") as f: - importer = SightingsImportHelper(f) - importer.start() - importer.join() - - # Import a sample conservation status scheme - scheme_file = os.path.join(get_data_path(), "imports", "locust_bocc5.csv") - with open(scheme_file, mode="rt", encoding="utf-8") as f: - importer = StatusImportHelper(f) - importer.start() - importer.join() - - # Start the site - global flask_runner - flask_runner.start() - - -@events.test_stop.add_listener -def on_test_stop(environment, **kwargs): - """ - When the tests complete, stop the Flask application - - :param environment: Ignored - :param kwargs: Ignored - """ - global flask_runner - flask_runner.stop_server() - flask_runner.join() - - -class NatureRecorderUser(HttpUser): - """ - Locust load test, targeting the Nature Recorder application hosted locally in the flask development server. The - tests are weighted as follows: - - +----------------------------------+----+ - | Test | % | - +----------------------------------+----+ - | go_to_home_page | 1 | - +----------------------------------+----+ - | list_locations | 1 | - +----------------------------------+----+ - | add_location | 1 | - +----------------------------------+----+ - | list_categories | 1 | - +----------------------------------+----+ - | add_category | 1 | - +----------------------------------+----+ - | list_species | 1 | - +----------------------------------+----+ - | add_species | 1 | - +----------------------------------+----+ - | list_sightings | 20 | - +----------------------------------+----+ - | add_sighting | 70 | - +----------------------------------+----+ - | list_conservation_status_schemes | 1 | - +----------------------------------+----+ - | show_life_list | 1 | - +----------------------------------+----+ - | list_recent_background_jobs | 1 | - +----------------------------------+----+ - """ - - #: Simulated users will wait between 1 and 5 seconds per task - wait_time = between(1, 5) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._locations = None - self._categories = None - - def on_start(self): - """ - Establish some facts about the data so the tests can simulate realistic actions - """ - self._locations = list_locations() - self._categories = list_categories() - self._login() - - @task - def go_to_home_page(self): - """ - Task to open the home page of the application - """ - self.client.get("/") - - @task - def list_locations(self): - """ - Task to simulate listing the locations - """ - self.client.get("/locations/list") - - @task - def add_location(self): - """ - Task to simulate adding a location - """ - csrf_token = self._get_csrf_token_for_form("/locations/edit") - name = self._get_name("Location") - county = self._get_name("County") - country = self._get_name("Country") - self.client.post("/locations/edit", data={ - "name": name, - "address": "", - "city": "", - "county": county, - "postcode": "", - "country": country, - "latitude": "", - "longitude": "", - "csrf_token": csrf_token - }) - - @task - def list_categories(self): - """ - Task to simulate listing the categories - """ - self.client.get("/categories/list") - - @task - def add_category(self): - """ - Task to simulate adding a category - """ - csrf_token = self._get_csrf_token_for_form("/categories/edit") - name = self._get_name("Category") - self.client.post("/categories/edit", data={ - "name": name, - "csrf_token": csrf_token - }) - - @task - def list_species(self): - """ - Task to simulate listing the species belonging to a selected category - """ - csrf_token = self._get_csrf_token_for_form("/species/list") - category_id = self._get_random_category_id() - self.client.post("/species/list", data={ - "category": str(category_id), - "csrf_token": csrf_token - }) - - @task - def add_species(self): - """ - Task to simulate adding a species - """ - csrf_token = self._get_csrf_token_for_form("/species/add") - category_id = self._get_random_category_id() - name = self._get_name("Species") - self.client.post("/species/add", data={ - "category": str(category_id), - "name": name, - "csrf_token": csrf_token - }) - - @task(20) - def list_sightings(self): - """ - Task to simulate listing the sightings - """ - self.client.get("/sightings/list") - - @task(70) - def add_sighting(self): - """ - Task to simulate adding a new sighting - """ - csrf_token = self._get_csrf_token_for_form("/sightings/edit") - sighting_date = datetime.datetime.today().strftime(Sighting.DATE_DISPLAY_FORMAT) - location_id = self._get_random_location_id() - category_id = self._get_random_category_id() - species_id = self._get_random_species_id(category_id) - self.client.post("/sightings/edit", data={ - "date": sighting_date, - "location": str(location_id), - "category": str(category_id), - "species": str(species_id), - "number": "1", - "gender": "0", - "with_young": "0", - "notes": "", - "csrf_token": csrf_token - }) - - @task - def list_conservation_status_schemes(self): - """ - Task to simulate listing the conservation status schemes - """ - self.client.get("/status/list") - - @task - def show_life_list(self): - """ - Task to simulate showing the life list for a category - """ - csrf_token = self._get_csrf_token_for_form("/life_list/list") - category_id = self._get_random_category_id() - self.client.post("/life_list/list", data={ - "category": str(category_id), - "csrf_token": csrf_token - }) - - @task - def list_recent_background_jobs(self): - """ - Task to simulate listing the recent background jobs - """ - self.client.get("/jobs/list") - - def _login(self): - """ - Log in using the test account - """ - csrf_token = self._get_csrf_token_for_form("/auth/login") - self.client.post("/auth/login", data={ - "username": TEST_USER_NAME, - "password": TEST_PASSWORD, - "csrf_token": csrf_token - }) - - def _get_csrf_token_for_form(self, url): - """ - Request a page containing a form and return the CSRF token from it - - :param url: URL for the page containing the form - :return: CSFR token - """ - response = self.client.get(url) - form_data = BeautifulSoup(response.text, "html.parser") - csrf_token_field = form_data.find(attrs={"name": "csrf_token"}) - return csrf_token_field["value"] - - def _get_random_location_id(self): - """ - Return a random location ID for an existing location - """ - index = randrange(0, len(self._locations)) - return self._locations[index].id - - def _get_random_category_id(self): - """ - Return a random category ID for an existing category - """ - index = randrange(0, len(self._categories)) - return self._categories[index].id - - @staticmethod - def _get_random_species_id(category_id): - """ - Return a random species ID for species in the specified category - - :param category_id: Category ID from which to select a species - """ - species = list_species(category_id) - index = randrange(0, len(species)) - return species[index].id - - def _get_name(self, prefix): - """ - Construct a unique name for a new record with the specified prefix - - :param prefix: Prefix indicating the record type - :return: Unique record name - """ - return f"{prefix} - {id(self)} - {int(time.time())}"