diff --git a/landa/api.py b/landa/api.py index b03d7b28..686919eb 100644 --- a/landa/api.py +++ b/landa/api.py @@ -3,6 +3,11 @@ import frappe +from landa.water_body_management.doctype.water_body.water_body import ( + build_water_body_cache, + build_water_body_data, +) + @frappe.whitelist(allow_guest=True, methods=["GET"]) def organization(id: str = None) -> List[Dict]: @@ -71,56 +76,21 @@ def organization(id: str = None) -> List[Dict]: @frappe.whitelist(allow_guest=True, methods=["GET"]) def water_body(id: str = None, fishing_area: str = None) -> List[Dict]: """Return a list of water bodies with fish species and special provisions.""" - filters = [ - ["Water Body", "is_active", "=", 1], - ["Water Body", "display_in_fishing_guide", "=", 1], - ] - if id and isinstance(id, str): - filters.append(["Water Body", "name", "=", id]) + if id: + # We do not cache ID since it's uniqueness makes the API performant + return build_water_body_data(id, fishing_area) - if fishing_area and isinstance(fishing_area, str): - filters.append(["Water Body", "fishing_area", "=", fishing_area]) + key = fishing_area or "all" + cache_exists = frappe.cache().hexists("water_body_data", key) + + if not cache_exists: + # Build the cache (for future calls) + build_water_body_cache(fishing_area) + + # return the cached result + return get_water_body_cache(key) - water_bodies = frappe.get_all( - "Water Body", - filters=filters, - fields=[ - "name as id", - "title", - "fishing_area", - "fishing_area_name", - "organization", - "organization_name", - "has_master_key_system", - "guest_passes_available", - "general_public_information", - "current_public_information", - "water_body_size as size", - "water_body_size_unit as size_unit", - "location", - ], - ) - for water_body in water_bodies: - water_body["fish_species"] = frappe.get_all( - "Fish Species Table", - filters={"parent": water_body["id"]}, - pluck="fish_species", - ) - - water_body["special_provisions"] = frappe.get_all( - "Water Body Special Provision Table", - filters={"parent": water_body["id"]}, - fields=["water_body_special_provision as id", "short_code"], - ) - - water_body["organizations"] = frappe.get_all( - "Water Body Management Local Organization", - filters={"water_body": water_body["id"]}, - fields=["organization as id", "organization_name"], - ) - - if water_body.location: - water_body["geojson"] = json.loads(water_body.location) - - return water_bodies +def get_water_body_cache(key: str) -> List[Dict]: + """Return a **CACHED** list of water bodies with fish species and special provisions.""" + return frappe.cache().hget("water_body_data", key) diff --git a/landa/hooks.py b/landa/hooks.py index cfe3e93d..04a8a82a 100644 --- a/landa/hooks.py +++ b/landa/hooks.py @@ -288,6 +288,9 @@ "on_update": "landa.organization_management.user.user.on_update", "on_trash": "landa.organization_management.user.user.on_trash", }, + "Workspace": { + "validate": "landa.workspace.validate", + }, } # Scheduled Tasks diff --git a/landa/patches.txt b/landa/patches.txt index 96ae801f..991aaee7 100644 --- a/landa/patches.txt +++ b/landa/patches.txt @@ -23,3 +23,5 @@ landa.patches.set_billing_and_shipping_defaults landa.patches.delete_old_scheduled_job_logs landa.patches.update_system_settings landa.patches.delete_customized_workspaces # 2023-06-06 +landa.patches.build_water_body_cache +landa.patches.set_hide_custom_in_user_workspaces diff --git a/landa/patches/build_water_body_cache.py b/landa/patches/build_water_body_cache.py new file mode 100644 index 00000000..11b132e1 --- /dev/null +++ b/landa/patches/build_water_body_cache.py @@ -0,0 +1,11 @@ +import frappe + +from landa.water_body_management.doctype.water_body.water_body import build_water_body_cache + + +def execute(): + build_water_body_cache() # Cache all Water Bodies + + fishing_areas = frappe.get_all("Fishing Area", pluck="name") + for area in fishing_areas: + build_water_body_cache(fishing_area=area) # Cache Fishing Area wise diff --git a/landa/patches/set_hide_custom_in_user_workspaces.py b/landa/patches/set_hide_custom_in_user_workspaces.py new file mode 100644 index 00000000..d684713f --- /dev/null +++ b/landa/patches/set_hide_custom_in_user_workspaces.py @@ -0,0 +1,17 @@ +import frappe + +from landa.workspace import LANDA_WORKSPACES + + +def execute(): + """Hide custom reports in existing customized landa workspaces.""" + for workspace in frappe.get_all( + "Workspace", + filters={ + "for_user": ("is", "set"), + "hide_custom": 0, + "extends": ("in", LANDA_WORKSPACES), + }, + pluck="name", + ): + frappe.db.set_value("Workspace", workspace, "hide_custom", 1) diff --git a/landa/translations/de.csv b/landa/translations/de.csv index fa297be8..d8635c89 100644 --- a/landa/translations/de.csv +++ b/landa/translations/de.csv @@ -22,3 +22,4 @@ Current Public Information will be removed on this date,Aktuelle Informationen w Current Information Expires On,Aktuelle Informationen zurücksetzen am, Fish Species Short Codes,Fischart Kürzel, Water Body Export,Export Gewässerverzeichnis, +No permission to set Member Function Category {0},"Sie haben nicht die nötigen Rechte, um eine Mitgliedsfunktion der Kategorie {0} zu vergeben", diff --git a/landa/water_body_management/doctype/catch_log_entry/catch_log_entry.js b/landa/water_body_management/doctype/catch_log_entry/catch_log_entry.js index 0f2bf614..50d3d299 100644 --- a/landa/water_body_management/doctype/catch_log_entry/catch_log_entry.js +++ b/landa/water_body_management/doctype/catch_log_entry/catch_log_entry.js @@ -9,7 +9,13 @@ frappe.ui.form.on("Catch Log Entry", { }, onload: (frm) => { if (frm.is_new() && !frm.doc.year) { - frm.set_value("year", moment().year() - 1); + const today = new Date(); + const current_year = today.getFullYear(); + const current_month = today.getMonth(); + + if (!frm.doc.year) { + frm.set_value("year", current_month < 6 ? current_year - 1 : current_year); + } } }, refresh: (frm) => { diff --git a/landa/water_body_management/doctype/water_body/water_body.py b/landa/water_body_management/doctype/water_body/water_body.py index dbb9b1b5..fa87b903 100644 --- a/landa/water_body_management/doctype/water_body/water_body.py +++ b/landa/water_body_management/doctype/water_body/water_body.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Real Experts GmbH and contributors # For license information, please see license.txt - +from collections import defaultdict +from json import loads +from typing import Dict, List import frappe from frappe import _ @@ -11,6 +13,12 @@ class WaterBody(Document): + def on_update(self): + rebuild_water_body_cache(self.fishing_area) + + def on_trash(self): + rebuild_water_body_cache(self.fishing_area) + def validate(self): self.validate_edit_access() self.validate_blacklisted_fish_species() @@ -36,6 +44,19 @@ def validate_blacklisted_fish_species(self): ) +def rebuild_water_body_cache(fishing_area: str = None): + """ + Rebuilds water body cache for all water bodies **AND** fishing area wise. + """ + # Invalidate Cache + frappe.cache().hdel("water_body_data", "all") + build_water_body_cache() + + if fishing_area: + frappe.cache().hdel("water_body_data", fishing_area) + build_water_body_cache(fishing_area=fishing_area) + + def remove_outdated_information(): for name in frappe.get_all( "Water Body", @@ -49,3 +70,147 @@ def remove_outdated_information(): water_body.current_public_information = None water_body.current_information_expires_on = None water_body.save() + + +def build_water_body_cache(fishing_area: str = None): + """ + Build the water body cache for all water bodies **OR** fishing area wise. + """ + water_bodies = build_water_body_data(fishing_area=fishing_area) + key = fishing_area or "all" + frappe.cache().hset("water_body_data", key, water_bodies) + + +def build_water_body_data(id: str = None, fishing_area: str = None) -> List[Dict]: + """ + Return a list of water bodies with fish species and special provisions + """ + result = query_water_body_data(id=id, fishing_area=fishing_area) + return consolidate_water_body_data(water_body_data=result) + + +def query_water_body_data(id: str = None, fishing_area: str = None) -> List[Dict]: + water_body = frappe.qb.DocType("Water Body") + fish_species_table = frappe.qb.DocType("Fish Species Table") + wb_provision_table = frappe.qb.DocType("Water Body Special Provision Table") + wb_local_org_table = frappe.qb.DocType("Water Body Management Local Organization") + + query = ( + frappe.qb.from_(water_body) + .left_join(fish_species_table) + .on(fish_species_table.parent == water_body.name) + .left_join(wb_provision_table) + .on(wb_provision_table.parent == water_body.name) + .left_join(wb_local_org_table) + .on(wb_local_org_table.water_body == water_body.name) + .select( + water_body.name.as_("id"), + water_body.title, + water_body.fishing_area, + water_body.fishing_area_name, + water_body.organization, + water_body.organization_name, + water_body.has_master_key_system, + water_body.guest_passes_available, + water_body.general_public_information, + water_body.current_public_information, + water_body.water_body_size.as_("size"), + water_body.water_body_size_unit.as_("size_unit"), + water_body.location, + fish_species_table.fish_species, + wb_provision_table.water_body_special_provision, + wb_provision_table.short_code, + wb_local_org_table.organization.as_("local_organization"), + wb_local_org_table.organization_name.as_("local_organization_name"), + ) + .where(water_body.is_active == 1) + .where(water_body.display_in_fishing_guide == 1) + ) + + if id and isinstance(id, str): + query = query.where(water_body.name == id) + + if fishing_area and isinstance(fishing_area, str): + query = query.where(water_body.fishing_area == fishing_area) + + return query.run(as_dict=True) + + +def consolidate_water_body_data(water_body_data: List[Dict]) -> List[Dict]: + """ + Deduplicate the water body data such that each water body has a list of unique + fish species, special provisions and local organizations. + """ + water_body_map = {} # {water_body_name: water_body_data} + fish_species_map, provision_map, local_org_map = ( + defaultdict(list), + defaultdict(list), + defaultdict(list), + ) + + for entry in water_body_data: + water_body_name = entry.get("id") + if not water_body_name in water_body_map: + # Add entry to map if it does not exist + water_body_map[water_body_name] = init_row(water_body_row=entry) + + result_entry = water_body_map[water_body_name] + + # Add unique child table and Water Body Management Local Organization data + fish_species = entry.get("fish_species") + add_to_map(fish_species, "fish_species", entry, fish_species_map, result_entry) + + provision = entry.get("water_body_special_provision") + add_to_map(provision, "special_provisions", entry, provision_map, result_entry) + + org = entry.get("local_organization") + add_to_map(org, "organizations", entry, local_org_map, result_entry) + + return [water_body_map.get(key) for key in water_body_map] + + +def init_row(water_body_row: Dict) -> Dict: + # Prepare row to have Water Body data (excluding child tables) + water_body_copy = water_body_row.copy() + location = water_body_copy.pop("location", None) + + if location and isinstance(location, str): + water_body_copy["geojson"] = loads(location) + + for field in ( + "fish_species", + "water_body_special_provision", + "short_code", + "local_organization", + "local_organization_name", + ): + water_body_copy.pop(field) # Remove child table fields + + for field in ("fish_species", "special_provisions", "organizations"): + # Re-insert child table fields as lists + water_body_copy[field] = [] + + return water_body_copy + + +def add_to_map(value, field, water_body, checking_map, result_map): + """ + Add the value to the `result_map` if it does not exist in the `checking_map`. + Also update the `checking_map` with the value. + """ + water_body_name = water_body.get("id") + checking_result_map = checking_map[water_body_name] + + if not value or (value in checking_result_map): + return + + checking_result_map.append(value) + + if field == "fish_species": + result_map[field].append(value) + elif field == "special_provisions": + result_map[field].append({"id": value, "short_code": water_body.get("short_code")}) + else: + result_map[field].append( + {"id": value, "organization_name": water_body.get("local_organization_name")} + ) diff --git a/landa/water_body_management/doctype/water_body_management_local_organization/water_body_management_local_organization.py b/landa/water_body_management/doctype/water_body_management_local_organization/water_body_management_local_organization.py index d3f5a6b6..1b5e0cc0 100644 --- a/landa/water_body_management/doctype/water_body_management_local_organization/water_body_management_local_organization.py +++ b/landa/water_body_management/doctype/water_body_management_local_organization/water_body_management_local_organization.py @@ -5,6 +5,12 @@ # import frappe from frappe.model.document import Document +from landa.water_body_management.doctype.water_body.water_body import rebuild_water_body_cache + class WaterBodyManagementLocalOrganization(Document): - pass + def on_update(self): + rebuild_water_body_cache(self.fishing_area) + + def on_trash(self): + rebuild_water_body_cache(self.fishing_area) diff --git a/landa/workspace.py b/landa/workspace.py new file mode 100644 index 00000000..d736686b --- /dev/null +++ b/landa/workspace.py @@ -0,0 +1,13 @@ +from frappe.desk.doctype.workspace.workspace import Workspace + +LANDA_WORKSPACES = ( + "Water Body Management", + "Organization Management", + "Order Management", +) + + +def validate(doc: Workspace, method=None) -> None: + # Custom reports (possibly of other users) should not be visible + if doc.for_user and doc.hide_custom == 0 and doc.extends in LANDA_WORKSPACES: + doc.hide_custom = 1 diff --git a/pyproject.toml b/pyproject.toml index 2da10f1c..edb9699a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,10 @@ dependencies = [ "thefuzz", ] +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + [tool.black] line-length = 99