From cc1f6eded44f8c00f8d14646e63faffcc0b92cb4 Mon Sep 17 00:00:00 2001 From: David Walker Date: Wed, 2 Feb 2022 20:52:51 +0000 Subject: [PATCH] Added ability to export life list --- docker/Dockerfile | 10 ++-- .../naturerec_model/data_exchange/index.rst | 1 + .../data_exchange/life_list_export_helper.rst | 5 ++ features/export.feature | 2 + features/life_list.feature | 18 ++++++- features/steps/common.py | 11 +++++ features/steps/export.py | 11 ----- features/steps/life_list.py | 26 +++++++++- setup.py | 2 +- src/naturerec_model/data_exchange/__init__.py | 4 +- .../data_exchange/life_list_export_helper.py | 49 +++++++++++++++++++ src/naturerec_web/export/export_blueprint.py | 39 ++++++++++++++- .../export/templates/export/life_list.html | 20 ++++++++ .../life_list/templates/life_list/list.html | 10 +++- .../species/templates/species/list.html | 10 +++- .../templates/category_selector.html | 28 ++++------- .../test_life_list_export_helper.py | 45 +++++++++++++++++ tests/naturerec_model/logic/test_sightings.py | 4 +- 18 files changed, 252 insertions(+), 43 deletions(-) create mode 100644 docs/source/naturerec_model/data_exchange/life_list_export_helper.rst create mode 100644 src/naturerec_model/data_exchange/life_list_export_helper.py create mode 100644 src/naturerec_web/export/templates/export/life_list.html create mode 100644 tests/naturerec_model/data_exchange/test_life_list_export_helper.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 754b7c1..517560c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,15 +1,15 @@ FROM python:3.10-slim-bullseye AS runtime -COPY naturerecorderpy-1.0.15.0 /opt/naturerecorderpy-1.0.15.0 +COPY naturerecorderpy-1.0.16.0 /opt/naturerecorderpy-1.0.16.0 -WORKDIR /opt/naturerecorderpy-1.0.15.0 +WORKDIR /opt/naturerecorderpy-1.0.16.0 RUN apt-get update -y RUN pip install -r requirements.txt -RUN pip install nature_recorder-1.0.15-py3-none-any.whl +RUN pip install nature_recorder-1.0.16-py3-none-any.whl -ENV NATURE_RECORDER_DATA_FOLDER=/var/opt/naturerecorderpy-1.0.15.0 -ENV NATURE_RECORDER_DB=/var/opt/naturerecorderpy-1.0.15.0/naturerecorder.db +ENV NATURE_RECORDER_DATA_FOLDER=/var/opt/naturerecorderpy-1.0.16.0 +ENV NATURE_RECORDER_DB=/var/opt/naturerecorderpy-1.0.16.0/naturerecorder.db ENTRYPOINT [ "python" ] CMD [ "-m", "naturerec_web" ] diff --git a/docs/source/naturerec_model/data_exchange/index.rst b/docs/source/naturerec_model/data_exchange/index.rst index 47bdbe4..a7d8917 100644 --- a/docs/source/naturerec_model/data_exchange/index.rst +++ b/docs/source/naturerec_model/data_exchange/index.rst @@ -10,3 +10,4 @@ The data_exchange Package status_import_helper sightings_import_helper sightings_export_helper + life_list_export_helper diff --git a/docs/source/naturerec_model/data_exchange/life_list_export_helper.rst b/docs/source/naturerec_model/data_exchange/life_list_export_helper.rst new file mode 100644 index 0000000..173bd33 --- /dev/null +++ b/docs/source/naturerec_model/data_exchange/life_list_export_helper.rst @@ -0,0 +1,5 @@ +life_list_export_helper.py +========================== + +.. automodule:: naturerec_model.data_exchange.life_list_export_helper + :members: diff --git a/features/export.feature b/features/export.feature index f1a8c35..f4e674d 100644 --- a/features/export.feature +++ b/features/export.feature @@ -1,4 +1,5 @@ Feature: Export Sightings + @export Scenario: Export unfiltered sightings Given A set of sightings | Date | Location | Category | Species | Number | Gender | WithYoung | @@ -14,6 +15,7 @@ Feature: Export Sightings 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 | diff --git a/features/life_list.feature b/features/life_list.feature index fded98c..92b7ca2 100644 --- a/features/life_list.feature +++ b/features/life_list.feature @@ -13,7 +13,7 @@ Feature: Life List And I click on the "List Species" button Then There will be 1 species in the life list - Scenario: Life list contains no entries + Scenario: Life list contains no entries Given A set of categories | Category | | Birds | @@ -22,3 +22,19 @@ Feature: Life List 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 | + | 10/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | + | 01/01/2022 | Test Location | Amphibians | Frog | 1 | Unknown | No | + + 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/steps/common.py b/features/steps/common.py index 9966048..7171576 100644 --- a/features/steps/common.py +++ b/features/steps/common.py @@ -119,6 +119,7 @@ def _(context, 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: @@ -155,3 +156,13 @@ def _(context, title): """ 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/export.py b/features/steps/export.py index 23361f0..4bab07c 100644 --- a/features/steps/export.py +++ b/features/steps/export.py @@ -1,4 +1,3 @@ -import time 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 @@ -32,13 +31,3 @@ def _(context): @then("The export starts") def _(context): confirm_span_exists(context, "Matching sightings are exporting in the background", 1) - - -@then("There will be {number} sightings in the export file") -@then("There will be {number} sighting in the export file") -def _(context, number): - 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/life_list.py b/features/steps/life_list.py index 496ef15..22eed3f 100644 --- a/features/steps/life_list.py +++ b/features/steps/life_list.py @@ -1,5 +1,6 @@ from behave import when, then -from helpers import confirm_table_row_count, confirm_span_exists +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") @@ -9,6 +10,24 @@ def _(context): 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): @@ -18,3 +37,8 @@ def _(context, number): @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/setup.py b/setup.py index 6fb51a6..4abd48b 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def find_package_files(directory, remove_root): setuptools.setup( name="nature_recorder", - version="1.0.15", + version="1.0.16", description="Wildlife sightings database", packages=setuptools.find_packages("src"), include_package_data=True, diff --git a/src/naturerec_model/data_exchange/__init__.py b/src/naturerec_model/data_exchange/__init__.py index acafc2e..2344658 100644 --- a/src/naturerec_model/data_exchange/__init__.py +++ b/src/naturerec_model/data_exchange/__init__.py @@ -1,10 +1,12 @@ from .status_import_helper import StatusImportHelper from .sightings_import_helper import SightingsImportHelper from .sightings_export_helper import SightingsExportHelper +from .life_list_export_helper import LifeListExportHelper __all__ = [ "StatusImportHelper", "SightingsImportHelper", - "SightingsExportHelper" + "SightingsExportHelper", + "LifeListExportHelper" ] diff --git a/src/naturerec_model/data_exchange/life_list_export_helper.py b/src/naturerec_model/data_exchange/life_list_export_helper.py new file mode 100644 index 0000000..27f5c5e --- /dev/null +++ b/src/naturerec_model/data_exchange/life_list_export_helper.py @@ -0,0 +1,49 @@ +""" +This module implements a helper that will export a life list to a CSV format file on a background thread +""" + +import csv +import os +from .data_exchange_helper_base import DataExchangeHelperBase +from ..model import get_data_path +from ..logic.sightings import life_list + + +class LifeListExportHelper(DataExchangeHelperBase): + JOB_NAME = "Life List export" + COLUMN_NAMES = ["Category", "Species"] + + def __init__(self, filename, category_id): + super().__init__(self.export) + self._filename = filename + self._category_id = category_id + self.create_job_status() + + def __repr__(self): + return f"{type(self).__name__}(" \ + f"filename={self._filename!r}, " \ + f"category_id={self._category_id!r})" + + def export(self): + """ + Retrieve the life list matching the criteria passed to the init method and write it to file in CSV format + """ + with open(self.get_file_export_path(), mode='wt', newline='', encoding="UTF-8") as f: + writer = csv.writer(f) + writer.writerow(self.COLUMN_NAMES) + + species = life_list(self._category_id) + for species in species: + writer.writerow([species.category.name, species.name]) + + def get_file_export_path(self): + """ + Construct and return the full path to the export file + + :return: Full path to the export file + """ + export_folder = os.path.join(get_data_path(), "exports") + if not os.path.exists(export_folder): + os.makedirs(export_folder) + + return os.path.join(export_folder, self._filename) diff --git a/src/naturerec_web/export/export_blueprint.py b/src/naturerec_web/export/export_blueprint.py index 348929e..49e71c1 100644 --- a/src/naturerec_web/export/export_blueprint.py +++ b/src/naturerec_web/export/export_blueprint.py @@ -6,7 +6,7 @@ from flask import Blueprint, render_template, request from naturerec_model.logic import list_locations from naturerec_model.logic import list_categories -from naturerec_model.data_exchange import SightingsExportHelper +from naturerec_model.data_exchange import SightingsExportHelper, LifeListExportHelper from naturerec_model.model import Sighting @@ -49,6 +49,7 @@ def _render_export_filters_page(from_date=None, :param location_id: Include sightings at this location :param category_id: Species category for the selected species :param species_id: Include sightings for this species + :param message: Message to display on the page :return: The HTML for the rendered sightings export page """ return render_template("export/filters.html", @@ -65,6 +66,19 @@ def _render_export_filters_page(from_date=None, edit_enabled=True) +def _render_life_list_export_filters_page(category_id=None, message=None): + """ + + :param category_id: Species category for the selected category + :param message: Message to display on the page + :return: The HTML for the rendered life list export page + """ + return render_template("export/life_list.html", + message=message, + categories=list_categories(), + category_id=category_id) + + @export_bp.route("/filters", methods=["GET", "POST"]) def export(): """ @@ -90,3 +104,26 @@ def export(): return _render_export_filters_page(from_date, to_date, location_id, category_id, species_id, message) else: return _render_export_filters_page() + + +@export_bp.route("/life_list", methods=["GET", "POST"]) +def export_life_list(): + """ + Show the page that presents filters for exporting life lists + + :return: The HTML for the life list export page + """ + if request.method == "POST": + # Get the export parameters + filename = request.form["filename"] + category_id = _get_filter_int("category") + + # Kick off the export + exporter = LifeListExportHelper(filename, category_id) + exporter.start() + + # Go to the life list export page + message = "The selected life list is exporting in the background" + return _render_life_list_export_filters_page(category_id, message) + else: + return _render_life_list_export_filters_page() diff --git a/src/naturerec_web/export/templates/export/life_list.html b/src/naturerec_web/export/templates/export/life_list.html new file mode 100644 index 0000000..746e373 --- /dev/null +++ b/src/naturerec_web/export/templates/export/life_list.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} +{% block title %}Export Life List{% endblock %} + +{% block content %} +
+

Life List

+
+
+ + +
+ {% include "category_selector.html" with context %} +
+ +
+
+ {% include "message.html" with context %} +
+{% endblock %} diff --git a/src/naturerec_web/life_list/templates/life_list/list.html b/src/naturerec_web/life_list/templates/life_list/list.html index 0045600..c540243 100644 --- a/src/naturerec_web/life_list/templates/life_list/list.html +++ b/src/naturerec_web/life_list/templates/life_list/list.html @@ -4,7 +4,15 @@ {% block content %}

Life List

- {% include "category_selector.html" with context %} +
+ {% include "category_selector.html" with context %} +
+ + +
+
{% if species | length > 0 %}

There are {{ species | length }} species in the life list for "{{ category.name }}"

{% include "species.html" with context %} diff --git a/src/naturerec_web/species/templates/species/list.html b/src/naturerec_web/species/templates/species/list.html index d7ea26c..d52b598 100644 --- a/src/naturerec_web/species/templates/species/list.html +++ b/src/naturerec_web/species/templates/species/list.html @@ -4,7 +4,15 @@ {% block content %}

Species

- {% include "category_selector.html" with context %} +
+ {% include "category_selector.html" with context %} +
+ + +
+
{% if species | length > 0 %} {% include "species.html" with context %} {% elif category_id %} diff --git a/src/naturerec_web/templates/category_selector.html b/src/naturerec_web/templates/category_selector.html index aebbe6c..4f4bb42 100644 --- a/src/naturerec_web/templates/category_selector.html +++ b/src/naturerec_web/templates/category_selector.html @@ -1,19 +1,11 @@ -
-
- - -
-
- - -
+
+ +
diff --git a/tests/naturerec_model/data_exchange/test_life_list_export_helper.py b/tests/naturerec_model/data_exchange/test_life_list_export_helper.py new file mode 100644 index 0000000..0f5afda --- /dev/null +++ b/tests/naturerec_model/data_exchange/test_life_list_export_helper.py @@ -0,0 +1,45 @@ +import unittest +import datetime +import csv +from src.naturerec_model.model import create_database, Gender +from src.naturerec_model.logic import create_category +from src.naturerec_model.logic import create_species +from src.naturerec_model.logic import create_location +from src.naturerec_model.logic import create_sighting +from src.naturerec_model.logic import list_job_status +from src.naturerec_model.data_exchange import LifeListExportHelper + + +class TestSightingsExportHelper(unittest.TestCase): + def setUp(self) -> None: + create_database() + self._category = create_category("Birds") + self._gull = create_species(self._category.id, "Black-Headed Gull") + self._location = create_location(name="Radley Lakes", county="Oxfordshire", country="United Kingdom") + _ = create_sighting(self._location.id, self._gull.id, datetime.date(2021, 12, 14), None, Gender.UNKNOWN, False) + + def test_can_export_life_list(self): + # Export the sightings + exporter = LifeListExportHelper(filename="life_list_export.csv", category_id=self._category.id) + exporter.start() + exporter.join() + + # Read the file + rows = [] + with open(exporter.get_file_export_path(), mode="rt", encoding="UTF-8") as f: + reader = csv.reader(f) + for row in reader: + rows.append(row) + + self.assertEqual(2, len(rows)) + self.assertEqual(LifeListExportHelper.COLUMN_NAMES, rows[0]) + self.assertEqual(2, len(rows[1])) + self.assertEqual("Birds", rows[1][0]) + self.assertEqual("Black-Headed Gull", rows[1][1]) + + # Confirm the job status record was created + job_statuses = list_job_status() + self.assertEqual(1, len(job_statuses)) + self.assertEqual(LifeListExportHelper.JOB_NAME, job_statuses[0].name) + self.assertIsNotNone(job_statuses[0].display_end_date) + self.assertIsNone(job_statuses[0].error) diff --git a/tests/naturerec_model/logic/test_sightings.py b/tests/naturerec_model/logic/test_sightings.py index 5fb7cda..a800a6f 100644 --- a/tests/naturerec_model/logic/test_sightings.py +++ b/tests/naturerec_model/logic/test_sightings.py @@ -54,8 +54,8 @@ def test_can_update_sighting_location(self): sighting = session.query(Sighting).one() location = create_location(name="Brock Hill", city="Lyndhurst", county="Hampshire", country="United Kingdom") - update_sighting(sighting.id, location.id, sighting.species.id, datetime.date(2021, 12, 14), None, Gender.UNKNOWN, - False) + update_sighting(sighting.id, location.id, sighting.species.id, datetime.date(2021, 12, 14), None, + Gender.UNKNOWN, False) updated = get_sighting(sighting.id) self.assertEqual("Birds", updated.species.category.name)