From b0044743812adc38457c461f72bda2c1f27349bb Mon Sep 17 00:00:00 2001 From: David Walker Date: Sat, 19 Apr 2025 18:34:29 +0100 Subject: [PATCH] Added the abundance vs frequency scatter plot --- reports/README.md | 1 + reports/abundance_vs_frequency_scatter.ipynb | 191 ++++++++++ .../annual_category_location_heatmap.ipynb | 2 +- reports/category_life_list.ipynb | 11 +- reports/location_richness_map.ipynb | 6 +- reports/make_venv.bat | 38 +- reports/sql/abundance_frequency.sql | 7 + .../year_on_year_species_location_trend.ipynb | 2 +- src/naturerec_model/logic/naming.py | 60 ++-- src/naturerec_model/model/role.py | 50 +-- src/naturerec_model/model/user_role.py | 24 +- src/naturerec_web/auth/requires_roles.py | 104 +++--- .../auth/templates/auth/unauthorised.html | 16 +- .../categories/categories_blueprint.py | 166 ++++----- .../categories/templates/categories/list.html | 74 ++-- .../locations/locations_blueprint.py | 226 ++++++------ .../locations/templates/locations/list.html | 74 ++-- .../species/species_blueprint.py | 194 +++++----- .../species/templates/species/list.html | 82 ++--- src/naturerec_web/status/status_blueprint.py | 330 +++++++++--------- .../status/templates/status/list.html | 76 ++-- 21 files changed, 969 insertions(+), 765 deletions(-) create mode 100644 reports/abundance_vs_frequency_scatter.ipynb create mode 100644 reports/sql/abundance_frequency.sql diff --git a/reports/README.md b/reports/README.md index d58eff1..0f03393 100644 --- a/reports/README.md +++ b/reports/README.md @@ -6,6 +6,7 @@ The following reports are currently available: | Notebook | Report Type | | --- | --- | +| abundance_vs_frequency_scatter.ipynb | Abundance vs. frequency scatter plot for each species in a category at a location, indicating rarity at that location | | annual_category_location_heatmap.ipynb | Heatmap of number of sightings of each species in a category at a location during a specified year | | category_life_list.ipynb | Life list for the species in a category, including total sightings and location count | | location_richness_map.ipynb | Interactive map of species richness (number of unique species sighted) by location | diff --git a/reports/abundance_vs_frequency_scatter.ipynb b/reports/abundance_vs_frequency_scatter.ipynb new file mode 100644 index 0000000..18e10f7 --- /dev/null +++ b/reports/abundance_vs_frequency_scatter.ipynb @@ -0,0 +1,191 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Abundance vs Frequency Scatter Plot\n", + "\n", + "This notebook generates and exports a scatter plot of abundance vs frequency for all species in a category at a location.\n", + "\n", + "| Region on Plot | Interpretation |\n", + "| --- | --- |\n", + "| Bottom-left | Rare species — seen infrequently and in low numbers. Could be elusive, migratory, or genuinely uncommon |\n", + "| Top-right | Common species — seen often and in large numbers. Likely widespread and/or gregarious |\n", + "| High frequency, low abundance | Species often seen but in small groups or solo (e.g. a bird that’s always alone but spotted often) |\n", + "| Low frequency, high abundance | Species seen rarely, but in big flocks/groups when they do appear (e.g. irruptive species or migratory flocks) |\n", + "\n", + "To use it, update the location, category and required export format in the first code cell, below, before running the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Location to report on\n", + "location = \"\"\n", + "\n", + "# Category to report on\n", + "category = \"\"\n", + "\n", + "# Export format for the trend chart:\n", + "# PNG - export as PNG image\n", + "# PDF - export as PDF file\n", + "# - do not export\n", + "export_format = \"PNG\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import sqlparse\n", + "\n", + "# Read the query file\n", + "query_file_path = Path(\"sql\") / \"abundance_frequency.sql\"\n", + "with open(query_file_path.absolute(), \"r\") as f:\n", + " query = f.read().replace(\"\\n\", \" \")\n", + "\n", + "# Replace the location and year placeholders\n", + "query = query.replace(\"$LOCATION\", location) \\\n", + " .replace(\"$CATEGORY\", category)\n", + "\n", + "# Show a pretty-printed form of the query\n", + "print(sqlparse.format(query, reindent=True, keyword_case='upper'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import sqlite3\n", + "import os\n", + "\n", + "# Connect to the database, execute the query and read the results into a dataframe\n", + "database_path = os.environ[\"NATURE_RECORDER_DB\"]\n", + "connection = sqlite3.connect(database_path)\n", + "df = pd.read_sql_query(query, connection, parse_dates=[\"Date\"])\n", + "\n", + "# Check there is some data\n", + "if not df.shape[0]:\n", + " message = f\"No data found for category '{category}' at location '{location}'\"\n", + " raise ValueError(message)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import re\n", + "\n", + "# Calculate abundance and frequency\n", + "scatter_plot = (\n", + " df\n", + " .groupby(\"Species\")\n", + " .agg(\n", + " Abundance=(\"Count\", \"sum\"),\n", + " Frequency=(\"Species\", \"count\")\n", + " )\n", + " .reset_index()\n", + ")\n", + "\n", + "# Create the folder to hold exported reports\n", + "export_folder_path = Path(\"exported\")\n", + "export_folder_path.mkdir(parents=True, exist_ok=True)\n", + "\n", + "# Export the data to Excel\n", + "clean_location = re.sub(\"[^0-9a-zA-Z ]+\", \"\", location).replace(\" \", \"-\")\n", + "clean_category = re.sub(\"[^0-9a-zA-Z ]+\", \"\", category).replace(\" \", \"-\")\n", + "export_file_name = f\"{clean_category}-{clean_location}-Abundance-Frequency\"\n", + "export_file_path = export_folder_path / f\"{export_file_name}.xlsx\"\n", + "scatter_plot.to_excel(export_file_path.absolute(), sheet_name=\"Abundance vs Frequency\", index=False)\n", + "\n", + "# Print the scatter plot data\n", + "with pd.option_context('display.max_rows', None,\n", + " 'display.max_columns', None,\n", + " 'display.precision', 3,\n", + " ):\n", + " display(scatter_plot)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import seaborn as sns\n", + "import matplotlib.pyplot as plt\n", + "\n", + "plt.figure(figsize=(10, 6))\n", + "sns.scatterplot(data=scatter_plot, x='Frequency', y='Abundance', hue='Species', s=100)\n", + "\n", + "plt.title(f'Abundance vs Frequency for {category}')\n", + "plt.xlabel('Frequency (Number of Sightings)')\n", + "plt.ylabel('Abundance (Total Individuals)')\n", + "plt.grid(True)\n", + "\n", + "# Move legend below the plot, centered below the plot and with multiple columns\n", + "plt.legend(\n", + " title='Species',\n", + " bbox_to_anchor=(0.5, -0.25),\n", + " loc='upper center',\n", + " borderaxespad=0,\n", + " ncol=3\n", + ")\n", + "\n", + "# Export to PNG\n", + "if export_format.casefold() == \"png\":\n", + " export_file_path = export_folder_path / f\"{export_file_name}.png\"\n", + " plt.savefig(export_file_path.absolute(), format=\"png\", dpi=300, bbox_inches=\"tight\")\n", + "\n", + "# Export to PDF\n", + "if export_format.casefold() == \"pdf\":\n", + " export_file_path = export_folder_path / f\"{export_file_name}.pdf\"\n", + " plt.savefig(export_file_path.absolute(), format=\"pdf\", bbox_inches=\"tight\")\n", + "\n", + "# Show the plot\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.2" + }, + "orig_nbformat": 4, + "vscode": { + "interpreter": { + "hash": "f085c86085609b1ab2f295d8cd5b519618e19fd591a6919f4ec2f9290a6745f6" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/reports/annual_category_location_heatmap.ipynb b/reports/annual_category_location_heatmap.ipynb index 79dc902..2c4f134 100644 --- a/reports/annual_category_location_heatmap.ipynb +++ b/reports/annual_category_location_heatmap.ipynb @@ -117,7 +117,7 @@ "locations_list = \"-\".join(locations)\n", "clean_locations = re.sub(\"[^0-9a-zA-Z ]+\", \"\", locations_list).replace(\" \", \"-\")\n", "export_file_path = export_folder_path / f\"{year}-{category}-{clean_locations}-Heatmap.xlsx\"\n", - "heatmap_data.to_excel(export_file_path.absolute(), sheet_name=\"Sightings\")\n", + "heatmap_data.to_excel(export_file_path.absolute(), sheet_name=\"Sightings\", index=False)\n", "\n", "# Print the heatmap data\n", "with pd.option_context('display.max_rows', None,\n", diff --git a/reports/category_life_list.ipynb b/reports/category_life_list.ipynb index cecee1a..cc75363 100644 --- a/reports/category_life_list.ipynb +++ b/reports/category_life_list.ipynb @@ -109,13 +109,13 @@ "# Export the life list\n", "clean_category = re.sub(\"[^0-9a-zA-Z ]+\", \"\", category).replace(\" \", \"-\")\n", "export_file_path = export_folder_path / f\"{clean_category}-Life-List.xlsx\"\n", - "life_list.to_excel(export_file_path.absolute(), sheet_name=\"Sightings\")" + "life_list.to_excel(export_file_path.absolute(), sheet_name=\"Life List\", index=False)" ] } ], "metadata": { "kernelspec": { - "display_name": "venv", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -129,7 +129,12 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.13.2" + }, + "vscode": { + "interpreter": { + "hash": "7a792fcb311f9eb9f3c1b942a8c87ada8484712b89b670347c16a1088e0a1f69" + } } }, "nbformat": 4, diff --git a/reports/location_richness_map.ipynb b/reports/location_richness_map.ipynb index 9ddb6bd..39b0fe0 100644 --- a/reports/location_richness_map.ipynb +++ b/reports/location_richness_map.ipynb @@ -121,7 +121,7 @@ "clean_country = re.sub(\"[^0-9a-zA-Z ]+\", \"\", country).replace(\" \", \"-\")\n", "export_file_name = f\"{year}-{clean_country}-Richness\" if year else f\"{clean_country}-Richness\"\n", "export_file_path = export_folder_path / f\"{export_file_name}.xlsx\"\n", - "richness.to_excel(export_file_path.absolute(), sheet_name=\"Location Richness\")" + "richness.to_excel(export_file_path.absolute(), sheet_name=\"Location Richness\", index=False)" ] }, { @@ -170,7 +170,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -189,7 +189,7 @@ "orig_nbformat": 4, "vscode": { "interpreter": { - "hash": "f085c86085609b1ab2f295d8cd5b519618e19fd591a6919f4ec2f9290a6745f6" + "hash": "7a792fcb311f9eb9f3c1b942a8c87ada8484712b89b670347c16a1088e0a1f69" } } }, diff --git a/reports/make_venv.bat b/reports/make_venv.bat index 0d55132..6a08a52 100644 --- a/reports/make_venv.bat +++ b/reports/make_venv.bat @@ -1,19 +1,19 @@ -@ECHO OFF - -REM Deactivate and remove the old virtual environment, if present -ECHO Removing existing Virtual Environment, if present ... -deactivate > nul 2>&1 -RMDIR /S /Q venv - -REM Create a new environment and activate it -ECHO Creating new Virtual Environment ... -python -m venv venv -CALL venv\Scripts\activate.bat - -REM Make sure pip is up to date -python -m pip install --upgrade pip - -REM Install the requirements -python -m pip install -r requirements.txt - -ECHO ON +@ECHO OFF + +REM Deactivate and remove the old virtual environment, if present +ECHO Removing existing Virtual Environment, if present ... +deactivate > nul 2>&1 +RMDIR /S /Q venv + +REM Create a new environment and activate it +ECHO Creating new Virtual Environment ... +python -m venv venv +CALL venv\Scripts\activate.bat + +REM Make sure pip is up to date +python -m pip install --upgrade pip + +REM Install the requirements +python -m pip install -r requirements.txt + +ECHO ON diff --git a/reports/sql/abundance_frequency.sql b/reports/sql/abundance_frequency.sql new file mode 100644 index 0000000..0aab716 --- /dev/null +++ b/reports/sql/abundance_frequency.sql @@ -0,0 +1,7 @@ +SELECT l.Name AS 'Location', sp.Name AS 'Species', c.Name AS 'Category', DATE( s.Date ) AS 'Date', IFNULL( s.Number, 1 ) AS 'Count' +FROM SIGHTINGS s +INNER JOIN SPECIES sp ON sp.Id = s.SpeciesId +INNER JOIN CATEGORIES c ON c.Id = sp.CategoryId +INNER JOIN LOCATIONS l ON l.Id = s.LocationId +WHERE l.Name = '$LOCATION' +AND c.Name = "$CATEGORY"; diff --git a/reports/year_on_year_species_location_trend.ipynb b/reports/year_on_year_species_location_trend.ipynb index 2ce501a..a5acf93 100644 --- a/reports/year_on_year_species_location_trend.ipynb +++ b/reports/year_on_year_species_location_trend.ipynb @@ -109,7 +109,7 @@ " export_file_name = f\"{start_year}-{end_year}-{clean_species}\"\n", "\n", "export_file_path = export_folder_path / f\"{export_file_name}-Trend.xlsx\"\n", - "yearly_species_counts.to_excel(export_file_path.absolute(), sheet_name=\"Year On Year Trends\")\n", + "yearly_species_counts.to_excel(export_file_path.absolute(), sheet_name=\"Year On Year Trends\", index=False)\n", "\n", "# Print the data\n", "with pd.option_context('display.max_rows', None,\n", diff --git a/src/naturerec_model/logic/naming.py b/src/naturerec_model/logic/naming.py index 5a53006..122feae 100644 --- a/src/naturerec_model/logic/naming.py +++ b/src/naturerec_model/logic/naming.py @@ -1,30 +1,30 @@ -""" -Species and category common and scientific naming logic -""" - -class Casing: - TITLE_CASE = "title_case" - CAPITALISED = "capitalised" - - -def tidy_string(text, required_case): - """ - Tidy the case of the specified string and remove duplicate spaces - - :param name: Text string to tidy - :param required_case: Required casing - :returns: Tidied text string - """ - - tidied_text = None - - if text: - match required_case: - case Casing.TITLE_CASE: - tidied_text = " ".join(text.split()).title().replace("'S", "'s") - case Casing.CAPITALISED: - tidied_text = " ".join(text.split()).capitalize() - case _: - tidied_text = text - - return tidied_text +""" +Species and category common and scientific naming logic +""" + +class Casing: + TITLE_CASE = "title_case" + CAPITALISED = "capitalised" + + +def tidy_string(text, required_case): + """ + Tidy the case of the specified string and remove duplicate spaces + + :param name: Text string to tidy + :param required_case: Required casing + :returns: Tidied text string + """ + + tidied_text = None + + if text: + match required_case: + case Casing.TITLE_CASE: + tidied_text = " ".join(text.split()).title().replace("'S", "'s") + case Casing.CAPITALISED: + tidied_text = " ".join(text.split()).capitalize() + case _: + tidied_text = text + + return tidied_text diff --git a/src/naturerec_model/model/role.py b/src/naturerec_model/model/role.py index ee32f95..52f6ffd 100644 --- a/src/naturerec_model/model/role.py +++ b/src/naturerec_model/model/role.py @@ -1,25 +1,25 @@ -from sqlalchemy import Column, Integer, String, UniqueConstraint, CheckConstraint, DateTime -from .base import Base - - -class Role(Base): - """ - Class representing an application role - """ - __tablename__ = "Roles" - - #: Primary key - id = Column(Integer, primary_key=True) - #: Role Name - name = Column(String, unique=True, nullable=False) - #: Audit columns - created_by = Column(Integer, nullable=False) - updated_by = Column(Integer, nullable=False) - date_created = Column(DateTime, nullable=False) - date_updated = Column(DateTime, nullable=False) - - __table_args__ = (UniqueConstraint('name', name='ROLE_NAME_UX'), - CheckConstraint("LENGTH(TRIM(name)) > 0")) - - def __repr__(self): - return f"{type(self).__name__}(id={self.id!r}, username={self.name!r})" +from sqlalchemy import Column, Integer, String, UniqueConstraint, CheckConstraint, DateTime +from .base import Base + + +class Role(Base): + """ + Class representing an application role + """ + __tablename__ = "Roles" + + #: Primary key + id = Column(Integer, primary_key=True) + #: Role Name + name = Column(String, unique=True, nullable=False) + #: Audit columns + created_by = Column(Integer, nullable=False) + updated_by = Column(Integer, nullable=False) + date_created = Column(DateTime, nullable=False) + date_updated = Column(DateTime, nullable=False) + + __table_args__ = (UniqueConstraint('name', name='ROLE_NAME_UX'), + CheckConstraint("LENGTH(TRIM(name)) > 0")) + + def __repr__(self): + return f"{type(self).__name__}(id={self.id!r}, username={self.name!r})" diff --git a/src/naturerec_model/model/user_role.py b/src/naturerec_model/model/user_role.py index ca1f6ec..a839e43 100644 --- a/src/naturerec_model/model/user_role.py +++ b/src/naturerec_model/model/user_role.py @@ -1,12 +1,12 @@ -from sqlalchemy import Table, Column, Integer, DateTime, ForeignKey -from .base import Base - - -UserRole = Table( - "UserRoles", - Base.metadata, - Column("id", Integer, primary_key=True), - Column("user_id", Integer, ForeignKey("Users.id")), - Column("role_id", Integer, ForeignKey("Roles.id")), - Column('created_by', Integer, nullable=False), - Column('date_created', DateTime, nullable=False)) +from sqlalchemy import Table, Column, Integer, DateTime, ForeignKey +from .base import Base + + +UserRole = Table( + "UserRoles", + Base.metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("Users.id")), + Column("role_id", Integer, ForeignKey("Roles.id")), + Column('created_by', Integer, nullable=False), + Column('date_created', DateTime, nullable=False)) diff --git a/src/naturerec_web/auth/requires_roles.py b/src/naturerec_web/auth/requires_roles.py index 3a1f97a..5b3077f 100644 --- a/src/naturerec_web/auth/requires_roles.py +++ b/src/naturerec_web/auth/requires_roles.py @@ -1,52 +1,52 @@ -from functools import wraps -from flask import abort -from flask_login import current_user - - -def has_roles(roles): - """ - Return true if the current user has one of the roles in the supplied list - - :param roles: List of role names - :return: True if the user has one of the roles, False if not - """ - user_has_roles = False - if current_user.is_authenticated: - matching_roles = [r for r in current_user.roles if r.name in roles] - user_has_roles = len(matching_roles) > 0 - - return user_has_roles - - -def membership(): - """ - Return a tuple of booleans indicating user role membership - - :return: Tuple of booleans indicating Administrator, Reporter and Reader membership, in that order - """ - is_admin = has_roles(["Administrator"]) - is_reporter = has_roles(["Reporter"]) - is_reader = has_roles(["Reader"]) - return is_admin, is_reporter, is_reader - - -def requires_roles(roles): - """ - Decorator to confirm the current user has one of the roles in the supplied list - - :param roles: List of role names - :return: Decorator function - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - # Check for matches between the user's roles and the role list. If there - # are none, return a 401 - if not has_roles(roles): - return abort(401) - - return f(*args, **kwargs) - - return decorated_function - - return decorator +from functools import wraps +from flask import abort +from flask_login import current_user + + +def has_roles(roles): + """ + Return true if the current user has one of the roles in the supplied list + + :param roles: List of role names + :return: True if the user has one of the roles, False if not + """ + user_has_roles = False + if current_user.is_authenticated: + matching_roles = [r for r in current_user.roles if r.name in roles] + user_has_roles = len(matching_roles) > 0 + + return user_has_roles + + +def membership(): + """ + Return a tuple of booleans indicating user role membership + + :return: Tuple of booleans indicating Administrator, Reporter and Reader membership, in that order + """ + is_admin = has_roles(["Administrator"]) + is_reporter = has_roles(["Reporter"]) + is_reader = has_roles(["Reader"]) + return is_admin, is_reporter, is_reader + + +def requires_roles(roles): + """ + Decorator to confirm the current user has one of the roles in the supplied list + + :param roles: List of role names + :return: Decorator function + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # Check for matches between the user's roles and the role list. If there + # are none, return a 401 + if not has_roles(roles): + return abort(401) + + return f(*args, **kwargs) + + return decorated_function + + return decorator diff --git a/src/naturerec_web/auth/templates/auth/unauthorised.html b/src/naturerec_web/auth/templates/auth/unauthorised.html index f36f93e..4dafc48 100644 --- a/src/naturerec_web/auth/templates/auth/unauthorised.html +++ b/src/naturerec_web/auth/templates/auth/unauthorised.html @@ -1,8 +1,8 @@ -{% extends "layout.html" %} - -{% block content %} -
-

Unauthorized 401

-

You do not have permissions to complete that action

-
-{% endblock %} +{% extends "layout.html" %} + +{% block content %} +
+

Unauthorized 401

+

You do not have permissions to complete that action

+
+{% endblock %} diff --git a/src/naturerec_web/categories/categories_blueprint.py b/src/naturerec_web/categories/categories_blueprint.py index 160e573..1ccb64c 100644 --- a/src/naturerec_web/categories/categories_blueprint.py +++ b/src/naturerec_web/categories/categories_blueprint.py @@ -1,83 +1,83 @@ -""" -The categories blueprint supplies view functions and templates for species category management -""" - -from flask import Blueprint, render_template, request, redirect, abort -from flask_login import login_required, current_user -from naturerec_model.logic import list_categories, get_category, create_category, update_category, delete_category -from naturerec_web.auth import requires_roles -from naturerec_web.auth.requires_roles import has_roles -from naturerec_web.request_utils import get_posted_int - -categories_bp = Blueprint("categories", __name__, template_folder='templates') - - -def _render_category_editing_page(category_id, error): - """ - Helper to render the category editing page - - :param category_id: ID for the category to edit or None for addition - :param error: Error message to display on the page or None - :return: The rendered category editing template - """ - category = get_category(category_id) if category_id else None - return render_template("categories/edit.html", - category=category, - error=error) - - -@categories_bp.route("/list", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator", "Reporter", "Reader"]) -def list_all(): - """ - Show the page that lists all categories and is the entry point for adding new ones - - :return: The HTML for the category listing page - """ - error = None - is_admin = has_roles(["Administrator"]) - if request.method == "POST": - try: - if is_admin: - delete_record_id = get_posted_int("delete_record_id") - if delete_record_id: - delete_category(delete_record_id) - else: - abort(401) - except ValueError as e: - error = e - - return render_template("categories/list.html", - categories=list_categories(), - edit_enabled=is_admin, - error=error) - - -@categories_bp.route("/edit", defaults={"category_id": None}, methods=["GET", "POST"]) -@categories_bp.route("/add/", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator"]) -def edit(category_id): - """ - Serve the page to add new category or edit an existing one and handle the appropriate action - when the form is submitted - - :param category_id: ID for a category to edit or None to create a new category - :return: The HTML for the category entry page or a response object redirecting to the category list page - """ - if request.method == "POST": - # The "supports gender" flag is a check box and is only POSTed if it's checked. If it's unchecked, it - # won't appear in the form data - supports_gender = True if "supports_gender" in request.form else False - - try: - if category_id: - _ = update_category(category_id, request.form["name"], supports_gender, current_user) - else: - _ = create_category(request.form["name"], supports_gender, current_user) - return redirect("/categories/list") - except ValueError as e: - return _render_category_editing_page(category_id, e) - else: - return _render_category_editing_page(category_id, None) +""" +The categories blueprint supplies view functions and templates for species category management +""" + +from flask import Blueprint, render_template, request, redirect, abort +from flask_login import login_required, current_user +from naturerec_model.logic import list_categories, get_category, create_category, update_category, delete_category +from naturerec_web.auth import requires_roles +from naturerec_web.auth.requires_roles import has_roles +from naturerec_web.request_utils import get_posted_int + +categories_bp = Blueprint("categories", __name__, template_folder='templates') + + +def _render_category_editing_page(category_id, error): + """ + Helper to render the category editing page + + :param category_id: ID for the category to edit or None for addition + :param error: Error message to display on the page or None + :return: The rendered category editing template + """ + category = get_category(category_id) if category_id else None + return render_template("categories/edit.html", + category=category, + error=error) + + +@categories_bp.route("/list", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator", "Reporter", "Reader"]) +def list_all(): + """ + Show the page that lists all categories and is the entry point for adding new ones + + :return: The HTML for the category listing page + """ + error = None + is_admin = has_roles(["Administrator"]) + if request.method == "POST": + try: + if is_admin: + delete_record_id = get_posted_int("delete_record_id") + if delete_record_id: + delete_category(delete_record_id) + else: + abort(401) + except ValueError as e: + error = e + + return render_template("categories/list.html", + categories=list_categories(), + edit_enabled=is_admin, + error=error) + + +@categories_bp.route("/edit", defaults={"category_id": None}, methods=["GET", "POST"]) +@categories_bp.route("/add/", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator"]) +def edit(category_id): + """ + Serve the page to add new category or edit an existing one and handle the appropriate action + when the form is submitted + + :param category_id: ID for a category to edit or None to create a new category + :return: The HTML for the category entry page or a response object redirecting to the category list page + """ + if request.method == "POST": + # The "supports gender" flag is a check box and is only POSTed if it's checked. If it's unchecked, it + # won't appear in the form data + supports_gender = True if "supports_gender" in request.form else False + + try: + if category_id: + _ = update_category(category_id, request.form["name"], supports_gender, current_user) + else: + _ = create_category(request.form["name"], supports_gender, current_user) + return redirect("/categories/list") + except ValueError as e: + return _render_category_editing_page(category_id, e) + else: + return _render_category_editing_page(category_id, None) diff --git a/src/naturerec_web/categories/templates/categories/list.html b/src/naturerec_web/categories/templates/categories/list.html index 80f1635..ddf9735 100644 --- a/src/naturerec_web/categories/templates/categories/list.html +++ b/src/naturerec_web/categories/templates/categories/list.html @@ -1,37 +1,37 @@ -{% extends "layout.html" %} -{% block title %}Categories{% endblock %} - -{% block content %} -
- - -

Categories

- {% include "error.html" with context %} - {% if categories | length > 0 %} - {% include "categories/categories.html" with context %} - {% include "confirm.html" with context %} - {% else %} - There are no categories in the database - {% endif %} -
- {% if edit_enabled %} -
- -
- {% endif %} -{% endblock %} - -{% block scripts %} - - -{% endblock %} +{% extends "layout.html" %} +{% block title %}Categories{% endblock %} + +{% block content %} +
+ + +

Categories

+ {% include "error.html" with context %} + {% if categories | length > 0 %} + {% include "categories/categories.html" with context %} + {% include "confirm.html" with context %} + {% else %} + There are no categories in the database + {% endif %} +
+ {% if edit_enabled %} +
+ +
+ {% endif %} +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/src/naturerec_web/locations/locations_blueprint.py b/src/naturerec_web/locations/locations_blueprint.py index 7d21b5a..a4db161 100644 --- a/src/naturerec_web/locations/locations_blueprint.py +++ b/src/naturerec_web/locations/locations_blueprint.py @@ -1,113 +1,113 @@ -""" -The locations blueprint supplies view functions and templates for location management -""" - -from flask import Blueprint, render_template, request, redirect, abort -from flask_login import login_required, current_user -from naturerec_model.logic import list_locations, get_location, create_location, update_location, geocode_postcode, \ - delete_location -from naturerec_web.auth import requires_roles, has_roles -from naturerec_web.request_utils import get_posted_float, get_posted_int - -locations_bp = Blueprint("locations", __name__, template_folder='templates') - - -def _render_location_editing_page(location_id, error): - """ - Helper to render the location editing page - - :param location_id: ID for the location to edit or None for addition - :param error: Error message to display on the page or None - :return: The rendered location editing template - """ - location = get_location(location_id) if location_id else None - return render_template("locations/edit.html", - location=location, - error=error) - - -@locations_bp.route("/list", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator", "Reporter", "Reader"]) -def list_all(): - """ - Show the page that lists all locations and is the entry point for adding new ones - - :return: The HTML for the location listing page - """ - error = None - is_admin = has_roles(["Administrator"]) - if request.method == "POST": - try: - if is_admin: - delete_record_id = get_posted_int("delete_record_id") - if delete_record_id: - delete_location(delete_record_id) - else: - abort(401) - except ValueError as e: - error = e - - return render_template("locations/list.html", - locations=list_locations(), - edit_enabled=is_admin, - error=error) - - -@locations_bp.route("/edit", defaults={"location_id": None}, methods=["GET", "POST"]) -@locations_bp.route("/add/", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator"]) -def edit(location_id): - """ - Serve the page to add new location or edit an existing one and handle the appropriate action - when the form is submitted - - :param location_id: ID for a location to edit or None to create a new location - :return: The HTML for the location entry page or a response object redirecting to the location list page - """ - if request.method == "POST": - try: - if location_id: - _ = update_location(location_id, - request.form["name"], - request.form["county"], - request.form["country"], - current_user, - request.form["address"], - request.form["city"], - request.form["postcode"], - get_posted_float("latitude"), - get_posted_float("longitude")) - else: - _ = create_location(request.form["name"], - request.form["county"], - request.form["country"], - current_user, - request.form["address"], - request.form["city"], - request.form["postcode"], - get_posted_float("latitude"), - get_posted_float("longitude")) - return redirect("/locations/list") - except ValueError as e: - return _render_location_editing_page(location_id, e) - else: - return _render_location_editing_page(location_id, None) - - -@locations_bp.route("/geocode/", defaults={"country": "United Kingdom"}) -@locations_bp.route("/geocode//") -@login_required -def geocode(postcode, country): - """ - Query a postcode and return the latitude and longitude - - :param postcode: Postcode to query - :param country: Country where the postcode is located - :return: Dictionary containing the latitude and longitude for the postcode - """ - try: - return geocode_postcode(postcode, country) - except ValueError: - return {"latitude": "", "longitude": ""} +""" +The locations blueprint supplies view functions and templates for location management +""" + +from flask import Blueprint, render_template, request, redirect, abort +from flask_login import login_required, current_user +from naturerec_model.logic import list_locations, get_location, create_location, update_location, geocode_postcode, \ + delete_location +from naturerec_web.auth import requires_roles, has_roles +from naturerec_web.request_utils import get_posted_float, get_posted_int + +locations_bp = Blueprint("locations", __name__, template_folder='templates') + + +def _render_location_editing_page(location_id, error): + """ + Helper to render the location editing page + + :param location_id: ID for the location to edit or None for addition + :param error: Error message to display on the page or None + :return: The rendered location editing template + """ + location = get_location(location_id) if location_id else None + return render_template("locations/edit.html", + location=location, + error=error) + + +@locations_bp.route("/list", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator", "Reporter", "Reader"]) +def list_all(): + """ + Show the page that lists all locations and is the entry point for adding new ones + + :return: The HTML for the location listing page + """ + error = None + is_admin = has_roles(["Administrator"]) + if request.method == "POST": + try: + if is_admin: + delete_record_id = get_posted_int("delete_record_id") + if delete_record_id: + delete_location(delete_record_id) + else: + abort(401) + except ValueError as e: + error = e + + return render_template("locations/list.html", + locations=list_locations(), + edit_enabled=is_admin, + error=error) + + +@locations_bp.route("/edit", defaults={"location_id": None}, methods=["GET", "POST"]) +@locations_bp.route("/add/", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator"]) +def edit(location_id): + """ + Serve the page to add new location or edit an existing one and handle the appropriate action + when the form is submitted + + :param location_id: ID for a location to edit or None to create a new location + :return: The HTML for the location entry page or a response object redirecting to the location list page + """ + if request.method == "POST": + try: + if location_id: + _ = update_location(location_id, + request.form["name"], + request.form["county"], + request.form["country"], + current_user, + request.form["address"], + request.form["city"], + request.form["postcode"], + get_posted_float("latitude"), + get_posted_float("longitude")) + else: + _ = create_location(request.form["name"], + request.form["county"], + request.form["country"], + current_user, + request.form["address"], + request.form["city"], + request.form["postcode"], + get_posted_float("latitude"), + get_posted_float("longitude")) + return redirect("/locations/list") + except ValueError as e: + return _render_location_editing_page(location_id, e) + else: + return _render_location_editing_page(location_id, None) + + +@locations_bp.route("/geocode/", defaults={"country": "United Kingdom"}) +@locations_bp.route("/geocode//") +@login_required +def geocode(postcode, country): + """ + Query a postcode and return the latitude and longitude + + :param postcode: Postcode to query + :param country: Country where the postcode is located + :return: Dictionary containing the latitude and longitude for the postcode + """ + try: + return geocode_postcode(postcode, country) + except ValueError: + return {"latitude": "", "longitude": ""} diff --git a/src/naturerec_web/locations/templates/locations/list.html b/src/naturerec_web/locations/templates/locations/list.html index dbffd83..27d8f75 100644 --- a/src/naturerec_web/locations/templates/locations/list.html +++ b/src/naturerec_web/locations/templates/locations/list.html @@ -1,37 +1,37 @@ -{% extends "layout.html" %} -{% block title %}Locations{% endblock %} - -{% block content %} -
- - -

Locations

- {% include "error.html" with context %} - {% if locations | length > 0 %} - {% include "locations/locations.html" with context %} - {% include "confirm.html" with context %} - {% else %} - There are no locations in the database - {% endif %} -
- {% if edit_enabled %} -
- -
- {% endif %} -{% endblock %} - -{% block scripts %} - - -{% endblock %} +{% extends "layout.html" %} +{% block title %}Locations{% endblock %} + +{% block content %} +
+ + +

Locations

+ {% include "error.html" with context %} + {% if locations | length > 0 %} + {% include "locations/locations.html" with context %} + {% include "confirm.html" with context %} + {% else %} + There are no locations in the database + {% endif %} +
+ {% if edit_enabled %} +
+ +
+ {% endif %} +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/src/naturerec_web/species/species_blueprint.py b/src/naturerec_web/species/species_blueprint.py index 05857d7..3d440e4 100644 --- a/src/naturerec_web/species/species_blueprint.py +++ b/src/naturerec_web/species/species_blueprint.py @@ -1,97 +1,97 @@ -""" -The species blueprint supplies view functions and templates for species management -""" - -from flask import Blueprint, render_template, request, redirect, abort -from flask_login import login_required, current_user -from naturerec_model.logic import list_categories -from naturerec_model.logic import list_species, get_species, create_species, update_species, delete_species -from naturerec_web.auth import requires_roles, has_roles -from naturerec_web.request_utils import get_posted_int - -species_bp = Blueprint("species", __name__, template_folder='templates') - - -def _render_species_editing_page(species_id, error): - """ - Helper to render the species editing page - - :param species_id: ID for the species to edit or None for addition - :param error: Error message to display on the page or None - :return: The rendered species editing template - """ - species = get_species(species_id) if species_id else None - return render_template("species/edit.html", - categories=list_categories(), - category_id=species.categoryId if species else None, - species=species, - error=error) - - -def _render_species_list_page(category_id=None, error=None): - """ - Helper to render the species list page - - :param category_id: ID of the category for which to list species - :param error: Error message to show on the page - :return: Rendered species list template - """ - is_admin = has_roles(["Administrator"]) - species = list_species(category_id) if category_id else [] - return render_template("species/list.html", - categories=list_categories(), - category_id=category_id, - species=species, - edit_enabled=is_admin, - error=error) - - -@species_bp.route("/list", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator", "Reporter", "Reader"]) -def list_filtered_species(): - """ - Show the page that lists species with category selection option - - :return: The HTML for the species listing page - """ - if request.method == "POST": - error = None - try: - delete_record_id = get_posted_int("delete_record_id") - if delete_record_id: - if has_roles(["Administrator"]): - delete_species(delete_record_id) - else: - abort(401) - except ValueError as e: - error = e - - return _render_species_list_page(get_posted_int("category"), error) - else: - return _render_species_list_page() - - -@species_bp.route("/add", defaults={"species_id": None}, methods=["GET", "POST"]) -@species_bp.route("/edit/", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator"]) -def edit(species_id): - """ - Serve the page to add new species or edit an existing one and handle the appropriate action - when the form is submitted - - :param species_id: ID for a species to edit or None to create a new species - :return: The HTML for the species entry page or a response object redirecting to the category list page - """ - if request.method == "POST": - try: - if species_id: - _ = update_species(species_id, get_posted_int("category"), request.form["name"], request.form["scientific_name"], current_user) - else: - _ = create_species(get_posted_int("category"), request.form["name"], request.form["scientific_name"], current_user) - return redirect("/species/list") - except ValueError as e: - return _render_species_editing_page(species_id, e) - else: - return _render_species_editing_page(species_id, None) +""" +The species blueprint supplies view functions and templates for species management +""" + +from flask import Blueprint, render_template, request, redirect, abort +from flask_login import login_required, current_user +from naturerec_model.logic import list_categories +from naturerec_model.logic import list_species, get_species, create_species, update_species, delete_species +from naturerec_web.auth import requires_roles, has_roles +from naturerec_web.request_utils import get_posted_int + +species_bp = Blueprint("species", __name__, template_folder='templates') + + +def _render_species_editing_page(species_id, error): + """ + Helper to render the species editing page + + :param species_id: ID for the species to edit or None for addition + :param error: Error message to display on the page or None + :return: The rendered species editing template + """ + species = get_species(species_id) if species_id else None + return render_template("species/edit.html", + categories=list_categories(), + category_id=species.categoryId if species else None, + species=species, + error=error) + + +def _render_species_list_page(category_id=None, error=None): + """ + Helper to render the species list page + + :param category_id: ID of the category for which to list species + :param error: Error message to show on the page + :return: Rendered species list template + """ + is_admin = has_roles(["Administrator"]) + species = list_species(category_id) if category_id else [] + return render_template("species/list.html", + categories=list_categories(), + category_id=category_id, + species=species, + edit_enabled=is_admin, + error=error) + + +@species_bp.route("/list", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator", "Reporter", "Reader"]) +def list_filtered_species(): + """ + Show the page that lists species with category selection option + + :return: The HTML for the species listing page + """ + if request.method == "POST": + error = None + try: + delete_record_id = get_posted_int("delete_record_id") + if delete_record_id: + if has_roles(["Administrator"]): + delete_species(delete_record_id) + else: + abort(401) + except ValueError as e: + error = e + + return _render_species_list_page(get_posted_int("category"), error) + else: + return _render_species_list_page() + + +@species_bp.route("/add", defaults={"species_id": None}, methods=["GET", "POST"]) +@species_bp.route("/edit/", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator"]) +def edit(species_id): + """ + Serve the page to add new species or edit an existing one and handle the appropriate action + when the form is submitted + + :param species_id: ID for a species to edit or None to create a new species + :return: The HTML for the species entry page or a response object redirecting to the category list page + """ + if request.method == "POST": + try: + if species_id: + _ = update_species(species_id, get_posted_int("category"), request.form["name"], request.form["scientific_name"], current_user) + else: + _ = create_species(get_posted_int("category"), request.form["name"], request.form["scientific_name"], current_user) + return redirect("/species/list") + except ValueError as e: + return _render_species_editing_page(species_id, e) + else: + return _render_species_editing_page(species_id, None) diff --git a/src/naturerec_web/species/templates/species/list.html b/src/naturerec_web/species/templates/species/list.html index df82941..20bd947 100644 --- a/src/naturerec_web/species/templates/species/list.html +++ b/src/naturerec_web/species/templates/species/list.html @@ -1,41 +1,41 @@ -{% extends "layout.html" %} -{% block title %}Species{% endblock %} - -{% block content %} -
- - -

Species

- {% include "error.html" with context %} -
- {% include "category_selector.html" with context %} -
- {% if edit_enabled %} - - {% endif %} - -
-
- {% if species | length > 0 %} - {% include "species.html" with context %} - {% include "confirm.html" with context %} - {% elif category_id %} - There are no species in the database for the specified category - {% endif %} -
-{% endblock %} - -{% block scripts %} - - -{% endblock %} +{% extends "layout.html" %} +{% block title %}Species{% endblock %} + +{% block content %} +
+ + +

Species

+ {% include "error.html" with context %} +
+ {% include "category_selector.html" with context %} +
+ {% if edit_enabled %} + + {% endif %} + +
+
+ {% if species | length > 0 %} + {% include "species.html" with context %} + {% include "confirm.html" with context %} + {% elif category_id %} + There are no species in the database for the specified category + {% endif %} +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/src/naturerec_web/status/status_blueprint.py b/src/naturerec_web/status/status_blueprint.py index 218f15c..8939224 100644 --- a/src/naturerec_web/status/status_blueprint.py +++ b/src/naturerec_web/status/status_blueprint.py @@ -1,165 +1,165 @@ -""" -The status blueprint supplies view functions and templates for conservation status scheme management -""" - -from flask import Blueprint, render_template, request, redirect, session, abort -from flask_login import login_required, current_user -from naturerec_model.logic import list_status_schemes, get_status_scheme, create_status_scheme, update_status_scheme, \ - delete_status_scheme -from naturerec_model.logic import create_status_rating, update_status_rating, delete_status_rating -from naturerec_model.data_exchange import StatusImportHelper -from naturerec_web.auth import requires_roles, has_roles -from naturerec_web.request_utils import get_posted_int - -status_bp = Blueprint("status", __name__, template_folder='templates') - - -def _render_status_scheme_editing_page(status_scheme_id, error): - """ - Helper to render the consevation status scheme editing page - - :param status_scheme_id: ID for the conservation status scheme to edit or None for addition - :param error: Error message to display on the page or None - :return: The rendered editing template - """ - status_scheme = get_status_scheme(status_scheme_id) if status_scheme_id else None - return render_template("status/edit_scheme.html", - status_scheme=status_scheme, - edit_enabled=True, - error=error) - - -def _render_status_rating_editing_page(status_scheme_id, status_rating_id, error): - """ - Helper to render the consevation status rating editing page - - :param status_scheme_id: ID for the parent conservation status scheme - :param status_rating_id: ID for the conservation status rating to edit or None for addition - :param error: Error message to display on the page or None - :return: The rendered editing template - """ - status_scheme = get_status_scheme(status_scheme_id) if status_scheme_id else None - matching_ratings = [rating for rating in status_scheme.ratings if rating.id == status_rating_id] - status_rating = matching_ratings[0] if matching_ratings else None - return render_template("status/edit_rating.html", - status_scheme_id=status_scheme.id if status_scheme else None, - status_rating=status_rating, - edit_enabled=True, - error=error) - - -def _render_ratings_import_page(error): - """ - Helper to render the conservation status ratings import page - - :param error: Error message to display on the page or None - :return: The rendered import template - """ - return render_template("status/import.html", - error=error) - - -@status_bp.route("/list", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator", "Reporter", "Reader"]) -def list_all(): - """ - Show the page that lists all conservation status schemes and is the entry point for adding new ones - - :return: The HTML for the listing page - """ - error = None - is_admin = has_roles(["Administrator"]) - if request.method == "POST": - try: - if is_admin: - delete_record_id = get_posted_int("delete_record_id") - if delete_record_id: - delete_status_scheme(delete_record_id) - else: - abort(401) - except ValueError as e: - error = e - - message = session.pop("message") if "message" in session else None - return render_template("status/list.html", - status_schemes=list_status_schemes(), - message=message, - error=error, - edit_enabled=is_admin) - - -@status_bp.route("/edit", defaults={"status_scheme_id": None}, methods=["GET", "POST"]) -@status_bp.route("/edit/", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator"]) -def edit_scheme(status_scheme_id): - """ - Serve the page to add new conservation status rating or edit an existing one and handle the appropriate action - when the form is submitted - - :param status_scheme_id: ID for a conservation status scheme to edit or None to create a new one - :return: The HTML for the data entry page or a response object redirecting to the scheme list page - """ - if request.method == "POST": - try: - delete_record_id = get_posted_int("delete_record_id") - if delete_record_id: - delete_status_rating(delete_record_id) - return _render_status_scheme_editing_page(status_scheme_id, None) - elif status_scheme_id: - _ = update_status_scheme(status_scheme_id, request.form["name"], current_user) - else: - _ = create_status_scheme(request.form["name"], current_user) - return redirect("/status/list") - except ValueError as e: - return _render_status_scheme_editing_page(status_scheme_id, e) - else: - return _render_status_scheme_editing_page(status_scheme_id, None) - - -@status_bp.route("/add_rating/", defaults={"status_rating_id": None}, methods=["GET", "POST"]) -@status_bp.route("/edit_rating//", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator", "Reporter", "Reader"]) -def edit_rating(status_scheme_id, status_rating_id): - """ - Serve the page to add new rating or edit an existing one and handle the appropriate action - when the form is submitted - - :param status_scheme_id: ID for the parent conservation status scheme - :param status_rating_id: ID for a conservation status rating to edit or None to create a new one - :return: The HTML for the data entry page or a response object redirecting to the scheme list page - """ - if request.method == "POST": - try: - if status_rating_id: - _ = update_status_rating(status_rating_id, request.form["name"], current_user) - else: - _ = create_status_rating(status_scheme_id, request.form["name"], current_user) - return redirect(f"/status/edit/{status_scheme_id}") - except ValueError as e: - return _render_status_rating_editing_page(status_scheme_id, status_rating_id, e) - else: - return _render_status_rating_editing_page(status_scheme_id, status_rating_id, None) - - -@status_bp.route("/import", methods=["GET", "POST"]) -@login_required -@requires_roles(["Administrator"]) -def import_ratings(): - """ - Serve the page to import status ratings and handle the import when the form is submitted - - :return: The HTML for the import page or a response object redirecting to the scheme list page - """ - if request.method == "POST": - try: - importer = StatusImportHelper(request.files["csv_file_name"], current_user) - importer.start() - session["message"] = "Conservation status schemes and ratings are being imported in the background" - return redirect("/status/list") - except ValueError as e: - return _render_ratings_import_page(e) - else: - return _render_ratings_import_page(None) +""" +The status blueprint supplies view functions and templates for conservation status scheme management +""" + +from flask import Blueprint, render_template, request, redirect, session, abort +from flask_login import login_required, current_user +from naturerec_model.logic import list_status_schemes, get_status_scheme, create_status_scheme, update_status_scheme, \ + delete_status_scheme +from naturerec_model.logic import create_status_rating, update_status_rating, delete_status_rating +from naturerec_model.data_exchange import StatusImportHelper +from naturerec_web.auth import requires_roles, has_roles +from naturerec_web.request_utils import get_posted_int + +status_bp = Blueprint("status", __name__, template_folder='templates') + + +def _render_status_scheme_editing_page(status_scheme_id, error): + """ + Helper to render the consevation status scheme editing page + + :param status_scheme_id: ID for the conservation status scheme to edit or None for addition + :param error: Error message to display on the page or None + :return: The rendered editing template + """ + status_scheme = get_status_scheme(status_scheme_id) if status_scheme_id else None + return render_template("status/edit_scheme.html", + status_scheme=status_scheme, + edit_enabled=True, + error=error) + + +def _render_status_rating_editing_page(status_scheme_id, status_rating_id, error): + """ + Helper to render the consevation status rating editing page + + :param status_scheme_id: ID for the parent conservation status scheme + :param status_rating_id: ID for the conservation status rating to edit or None for addition + :param error: Error message to display on the page or None + :return: The rendered editing template + """ + status_scheme = get_status_scheme(status_scheme_id) if status_scheme_id else None + matching_ratings = [rating for rating in status_scheme.ratings if rating.id == status_rating_id] + status_rating = matching_ratings[0] if matching_ratings else None + return render_template("status/edit_rating.html", + status_scheme_id=status_scheme.id if status_scheme else None, + status_rating=status_rating, + edit_enabled=True, + error=error) + + +def _render_ratings_import_page(error): + """ + Helper to render the conservation status ratings import page + + :param error: Error message to display on the page or None + :return: The rendered import template + """ + return render_template("status/import.html", + error=error) + + +@status_bp.route("/list", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator", "Reporter", "Reader"]) +def list_all(): + """ + Show the page that lists all conservation status schemes and is the entry point for adding new ones + + :return: The HTML for the listing page + """ + error = None + is_admin = has_roles(["Administrator"]) + if request.method == "POST": + try: + if is_admin: + delete_record_id = get_posted_int("delete_record_id") + if delete_record_id: + delete_status_scheme(delete_record_id) + else: + abort(401) + except ValueError as e: + error = e + + message = session.pop("message") if "message" in session else None + return render_template("status/list.html", + status_schemes=list_status_schemes(), + message=message, + error=error, + edit_enabled=is_admin) + + +@status_bp.route("/edit", defaults={"status_scheme_id": None}, methods=["GET", "POST"]) +@status_bp.route("/edit/", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator"]) +def edit_scheme(status_scheme_id): + """ + Serve the page to add new conservation status rating or edit an existing one and handle the appropriate action + when the form is submitted + + :param status_scheme_id: ID for a conservation status scheme to edit or None to create a new one + :return: The HTML for the data entry page or a response object redirecting to the scheme list page + """ + if request.method == "POST": + try: + delete_record_id = get_posted_int("delete_record_id") + if delete_record_id: + delete_status_rating(delete_record_id) + return _render_status_scheme_editing_page(status_scheme_id, None) + elif status_scheme_id: + _ = update_status_scheme(status_scheme_id, request.form["name"], current_user) + else: + _ = create_status_scheme(request.form["name"], current_user) + return redirect("/status/list") + except ValueError as e: + return _render_status_scheme_editing_page(status_scheme_id, e) + else: + return _render_status_scheme_editing_page(status_scheme_id, None) + + +@status_bp.route("/add_rating/", defaults={"status_rating_id": None}, methods=["GET", "POST"]) +@status_bp.route("/edit_rating//", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator", "Reporter", "Reader"]) +def edit_rating(status_scheme_id, status_rating_id): + """ + Serve the page to add new rating or edit an existing one and handle the appropriate action + when the form is submitted + + :param status_scheme_id: ID for the parent conservation status scheme + :param status_rating_id: ID for a conservation status rating to edit or None to create a new one + :return: The HTML for the data entry page or a response object redirecting to the scheme list page + """ + if request.method == "POST": + try: + if status_rating_id: + _ = update_status_rating(status_rating_id, request.form["name"], current_user) + else: + _ = create_status_rating(status_scheme_id, request.form["name"], current_user) + return redirect(f"/status/edit/{status_scheme_id}") + except ValueError as e: + return _render_status_rating_editing_page(status_scheme_id, status_rating_id, e) + else: + return _render_status_rating_editing_page(status_scheme_id, status_rating_id, None) + + +@status_bp.route("/import", methods=["GET", "POST"]) +@login_required +@requires_roles(["Administrator"]) +def import_ratings(): + """ + Serve the page to import status ratings and handle the import when the form is submitted + + :return: The HTML for the import page or a response object redirecting to the scheme list page + """ + if request.method == "POST": + try: + importer = StatusImportHelper(request.files["csv_file_name"], current_user) + importer.start() + session["message"] = "Conservation status schemes and ratings are being imported in the background" + return redirect("/status/list") + except ValueError as e: + return _render_ratings_import_page(e) + else: + return _render_ratings_import_page(None) diff --git a/src/naturerec_web/status/templates/status/list.html b/src/naturerec_web/status/templates/status/list.html index adec801..1438e84 100644 --- a/src/naturerec_web/status/templates/status/list.html +++ b/src/naturerec_web/status/templates/status/list.html @@ -1,38 +1,38 @@ -{% extends "layout.html" %} -{% block title %}Conservation Status Schemes{% endblock %} - -{% block content %} -
- - -

Conservation Status Schemes

- {% include "error.html" with context %} - {% include "message.html" with context %} - {% if status_schemes | length > 0 %} - {% include "status/status_schemes.html" with context %} - {% include "confirm.html" with context %} - {% else %} - There are no conservation status schemes in the database - {% endif %} -
- {% if edit_enabled %} - - {% endif %} -{% endblock %} - -{% block scripts %} - - -{% endblock %} +{% extends "layout.html" %} +{% block title %}Conservation Status Schemes{% endblock %} + +{% block content %} +
+ + +

Conservation Status Schemes

+ {% include "error.html" with context %} + {% include "message.html" with context %} + {% if status_schemes | length > 0 %} + {% include "status/status_schemes.html" with context %} + {% include "confirm.html" with context %} + {% else %} + There are no conservation status schemes in the database + {% endif %} +
+ {% if edit_enabled %} + + {% endif %} +{% endblock %} + +{% block scripts %} + + +{% endblock %}