diff --git a/ckanext/datavicmain/cli/maintain.py b/ckanext/datavicmain/cli/maintain.py index 1fe0f094..8973d90d 100644 --- a/ckanext/datavicmain/cli/maintain.py +++ b/ckanext/datavicmain/cli/maintain.py @@ -3,23 +3,21 @@ import copy import datetime import logging -import csv -import openpyxl import mimetypes from os import path, stat -from typing import Any -from sqlalchemy.orm import Query from itertools import groupby +from os import path +from typing import Any from urllib.parse import urlparse -import click -import tqdm - import ckan.logic.validators as validators import ckan.model as model import ckan.plugins.toolkit as tk +import click +import openpyxl +import tqdm from ckan.lib.munge import munge_title_to_name from ckan.lib.search import rebuild from ckan.lib.uploader import get_resource_uploader @@ -29,6 +27,8 @@ from ckanext.harvest.model import HarvestObject, HarvestSource from ckanext.datastore.backend import get_all_resources_ids_in_datastore +from ckanext.harvest.model import HarvestObject, HarvestSource +from sqlalchemy.orm import Query log = logging.getLogger(__name__) @@ -576,7 +576,7 @@ def _search_in_batch(datasets: list[dict[str, Any]]) -> dict[str, dict[str, str] def _get_default_values_for_missing_fields( dataset: dict[str, Any], field: str -) -> "str": +) -> str: """Get values for missing fields""" if field == "date_created_data_asset": return _get_date_created(dataset) @@ -590,6 +590,7 @@ def _get_default_values_for_missing_fields( return "official" elif field == "category": return _get_category(dataset) + return "" def _get_date_created(pkg: dict[str, Any]) -> str: @@ -615,7 +616,7 @@ def _get_category(pkg: dict[str, Any]) -> str: """ if groups := pkg.get("groups"): return groups[0].get("id") - return + return "" @maintain.command(u"update-broken-urls", @@ -624,7 +625,8 @@ def update_broken_urls(): """Change resources urls' protocols from http to https listed in XLSX file""" file = path.join( - path.dirname(__file__), "data/DTF Content list bulk URL change 20231017.xlsx" + path.dirname(__file__), + "data/DTF Content list bulk URL change 20231017.xlsx", ) wb = openpyxl.load_workbook(file) ws = wb.active @@ -641,15 +643,14 @@ def update_broken_urls(): if not resource: click.secho( - f"Resource <{title}> with URL <{url}> does not exist", - fg="red" + f"Resource <{title}> with URL <{url}> does not exist", fg="red" ) continue resource.url = row[XLSX_IDX_NEW_URL].value click.secho( f"URL of resource <{title}> has been updated to <{resource.url}>", - fg="green" + fg="green", ) model.Session.commit() @@ -825,3 +826,18 @@ def get_resources_by_size(empty: bool, limit: bool, restricted: bool): f"Found {resources.count()} resources...", fg="green", ) + + +@maintain.command("make-datatables-view-prioritized") +def make_datatables_view_prioritized(): + """Check if there are resources that have recline_view and datatables_view and + reorder them so that datatables_view is first.""" + resources = model.Session.query(Resource).all() + number_reordered = 0 + for resource in tqdm.tqdm(resources): + result = tk.get_action("datavic_datatables_view_prioritize")( + {"ignore_auth": True}, {"resource_id": resource.id} + ) + if result.get("updated"): + number_reordered += 1 + click.secho(f"Reordered {number_reordered} resources", fg="green") diff --git a/ckanext/datavicmain/config.py b/ckanext/datavicmain/config.py index 5a3ca737..412b7530 100644 --- a/ckanext/datavicmain/config.py +++ b/ckanext/datavicmain/config.py @@ -4,6 +4,7 @@ CONFIG_PAGES_BASE_URL = "ckan.pages.base_url" CONFIG_DTV_URL = "ckanext.datavicmain.dtv.url" +CONFIG_DTV_MAX_SIZE_LIMIT = "ckanext.datavicmain.dtv.max_size_limit" CONFIG_DTV_EXTERNAL_LINK = "ckanext.datavicmain.dtv.external_link" @@ -15,5 +16,9 @@ def get_dtv_url() -> str: return tk.config.get(CONFIG_DTV_URL, "") +def get_dtv_max_size_limit() -> int: + return tk.config.get(CONFIG_DTV_MAX_SIZE_LIMIT, "157286400") + + def get_dtv_external_link() -> str: - return tk.config.get(CONFIG_DTV_EXTERNAL_LINK, "") \ No newline at end of file + return tk.config.get(CONFIG_DTV_EXTERNAL_LINK, "") diff --git a/ckanext/datavicmain/helpers.py b/ckanext/datavicmain/helpers.py index 99b979c4..1f6d4ba6 100644 --- a/ckanext/datavicmain/helpers.py +++ b/ckanext/datavicmain/helpers.py @@ -1,5 +1,4 @@ from __future__ import annotations -from json import tool import math import os @@ -8,26 +7,23 @@ import logging import json import base64 -from typing import Any +from typing import Any, Optional from urllib.parse import urlsplit, urljoin from flask import Blueprint -import ckan.plugins as plugins import ckan.model as model import ckan.authz as authz import ckan.plugins.toolkit as toolkit from ckanext.harvest.model import HarvestObject from ckanext.activity.model.activity import Activity -from ckanext.mailcraft.utils import get_mailer -from ckanext.mailcraft.exception import MailerException -from . import utils, const +from . import utils, const, config as conf from ckanext.datavicmain.config import get_dtv_url, get_dtv_external_link -mailer = get_mailer() + log = logging.getLogger(__name__) WORKFLOW_STATUS_OPTIONS = [ "draft", @@ -368,6 +364,43 @@ def datavic_org_uploads_allowed(org_id: str) -> bool: return flake["data"].get(org.id, False) +def get_group(group: Optional[str] = None, + include_datasets: bool = False) -> dict[str, Any]: + if group is None: + return {} + try: + return toolkit.get_action("group_show")( + {}, + {"id": group, "include_datasets": include_datasets} + ) + except (toolkit.NotFound, toolkit.ValidationError, toolkit.NotAuthorized): + return {} + + +def dtv_exceeds_max_size_limit(resource_id: str) -> bool: + """Check if DTV resource exceeds the maximum file size limit + + Args: + resource_id (str): DTV resource id + + Returns: + bool: return True if dtv resource exceeds maximum file size limit set + in ckan config "ckanext.datavicmain.dtv.max_size_limit", + otherwise - False + """ + try: + resource = toolkit.get_action("resource_show")({}, {"id": resource_id}) + except (toolkit.ObjectNotFound, toolkit.NotAuthorized): + return True + + limit = conf.get_dtv_max_size_limit() + filesize = resource.get("filesize") + if filesize and int(filesize) >= int(limit): + return True + + return False + + def datavic_get_org_roles() -> list[str]: return ["admin", "editor", "member"] @@ -560,7 +593,7 @@ def _group_tree_parents(id_, type_="organization"): def add_current_organisation( - avalable_organisations: list[dict[str, Any]], current_org: dict[str, Any] + available_organisations: list[dict[str, Any]], current_org: dict[str, Any] ): """When user doesn't have an access to an organisation, it won't be included for a list of available organisations. Include it there, but check if it's @@ -568,19 +601,19 @@ def add_current_organisation( current_org_included = False - for organization in avalable_organisations: + for organization in available_organisations: if organization["id"] == current_org["id"]: current_org_included = True break if not current_org_included: - avalable_organisations.append(current_org) + available_organisations.append(current_org) - return avalable_organisations + return available_organisations def datavic_max_image_size(): - """Return max size for image configurate for portal""" + """Return max size for image configuration for portal""" return toolkit.config["ckan.max_image_size"] @@ -658,3 +691,33 @@ def datavic_allowable_parent_orgs(org_id: str = None) -> list[dict[str, Any]]: continue orgs.append(org) return orgs + + +def has_user_capacity( + org_id: str, + current_user_id: str, + capacity: Optional[str] = None) -> bool: + """Check if the current user has an appropriate capacity in the certain organization + + Args: + org_id (str): the id or name of the organization + current_user_id (str): the id or name of the user + capacity (str): restrict the members returned to those with a given capacity, + e.g. 'member', 'editor', 'admin', 'public', 'private' + (optional, default: None) + + Returns: + bool: True for success, False otherwise + """ + try: + members = toolkit.get_action("member_list")( + {}, + {"id": org_id, "object_type": "user", "capacity": capacity} + ) + members_id = [member[0] for member in members] + if current_user_id in members_id: + return True + except (toolkit.ObjectNotFound, toolkit.NotAuthorized): + return False + + return False diff --git a/ckanext/datavicmain/iar_ckan_dataset.yaml b/ckanext/datavicmain/iar_ckan_dataset.yaml index d37182c5..4d80c020 100644 --- a/ckanext/datavicmain/iar_ckan_dataset.yaml +++ b/ckanext/datavicmain/iar_ckan_dataset.yaml @@ -22,6 +22,7 @@ dataset_fields: - field_name: alias preset: dataset_alias + display_group: General - field_name: notes label: Description @@ -152,6 +153,7 @@ dataset_fields: label: "Off" validators: default(true) boolean_validator output_validators: boolean_validator + display_group: General # Security fields diff --git a/ckanext/datavicmain/logic/action.py b/ckanext/datavicmain/logic/action.py index bbf8a96f..99a0585a 100644 --- a/ckanext/datavicmain/logic/action.py +++ b/ckanext/datavicmain/logic/action.py @@ -94,7 +94,10 @@ def organization_update(next_, context, data_dict): except ckanapi.NotFound: continue - patch = {f: result[f] for f in tracked_fields if f in result} + patch = { + f: result[f] + for f in tracked_fields if f in result + } if 'image_url' in tracked_fields and result.get('image_display_url'): grp_uloader: uploader.PUploader = uploader.get_uploader('group') @@ -106,19 +109,14 @@ def organization_update(next_, context, data_dict): ckan.call_action('organization_patch', data_dict=patch, files={ "image_upload": (result['image_url'], file_data)}) else: - ckan.action.organization_patch( - id=remote["id"], - **patch, - ) - - return result + ckan.action.organization_patch(id=remote["id"], **patch) def _is_org_changed( - old_org: dict[str, Any], new_org: dict[str, Any], tracked_fields: list[str] + old_org: model.Group, new_org: dict[str, Any], tracked_fields: list[str] ) -> bool: for field_name in tracked_fields: - if old_org.get(field_name) != new_org.get(field_name): + if old_org.__dict__.get(field_name) != new_org.get(field_name): return True return False @@ -151,14 +149,22 @@ def organization_list( """Restrict organisations. Force all_fields and include_extras, because we need visibility field to be here. Throw out extra fields later if it's not all_fields initially""" - all_fields = data_dict.pop("all_fields", False) + all_fields = data_dict.get("all_fields", False) - data_dict.update({"all_fields": True, "include_extras": True}) + if all_fields: + data_dict.update({"include_extras": True}) context["_skip_restriction_check"] = True org_list: types.ActionResult.OrganizationList = next_(context, data_dict) + if not all_fields: + orgs = model.Session.query(model.Group)\ + .filter(model.Group.name.in_(org_list)) + + # Intead of all all_fields, lets get the ID from the Objet as it much faster + org_list = [{'id': org.id , 'name': org.name} for org in orgs] + filtered_orgs = _hide_restricted_orgs(context, org_list) if not all_fields: @@ -221,7 +227,7 @@ def _show_errors_in_sibling_resources( "list[type.ErrorDict]", valid_errors.error_dict['resources'])[-1] except (KeyError, IndexError): error_dict = valid_errors.error_dict - + pkg_dict = toolkit.get_action("package_show")( context, {"id": data_dict["package_id"]} ) @@ -362,3 +368,57 @@ def send_delwp_data_request(context, data_dict): return {"success": False} return {"success": True} + + +@validate(vic_schema.datatables_view_prioritize) +def datavic_datatables_view_prioritize( + context: Context, data_dict: DataDict +) -> types.DataDict: + """Check if the datatables view is prioritized over the recline view. + If not, swap their order. + """ + toolkit.check_access("vic_datatables_view_prioritize", context, data_dict) + + resource_id = data_dict["resource_id"] + res_views = sorted( + model.Session.query(model.ResourceView) + .filter(model.ResourceView.resource_id == resource_id) + .all(), + key=lambda x: x.order, + ) + datatables_views = _filter_views(res_views, "datatables_view") + recline_views = _filter_views(res_views, "recline_view") + + if not ( + datatables_views + and recline_views + and datatables_views[0].order > recline_views[0].order + ): + return {"updated": False} + + datatables_views[0].order, recline_views[0].order = ( + recline_views[0].order, + datatables_views[0].order, + ) + order = [view.id for view in sorted(res_views, key=lambda x: x.order)] + toolkit.get_action("resource_view_reorder")( + {"ignore_auth": True}, {"id": resource_id, "order": order} + ) + return {"updated": True} + + +@toolkit.chained_action +def resource_view_create(next_, context, data_dict): + result = next_(context, data_dict) + if data_dict["view_type"] == "datatables_view": + toolkit.get_action("datavic_datatables_view_prioritize")( + {"ignore_auth": True}, {"resource_id": data_dict["resource_id"]} + ) + return result + + +def _filter_views( + res_views: list[model.ResourceView], view_type: str +) -> list[model.ResourceView]: + """Return a list of views with the given view type.""" + return [view for view in res_views if view.view_type == view_type] diff --git a/ckanext/datavicmain/logic/auth.py b/ckanext/datavicmain/logic/auth.py index 43c575ad..6cadc5b6 100644 --- a/ckanext/datavicmain/logic/auth.py +++ b/ckanext/datavicmain/logic/auth.py @@ -85,3 +85,57 @@ def user_show(context: Context, data_dict: DataDict) -> AuthResult: return {"success": True} return {"success": False} + + +def _has_user_capacity_in_org(org_id: str, roles: list) -> bool: + """ Check if the current user has the necessary capacity in the certain + organization + + Args: + org_id (str): id of the organization + roles (list): list of necessary member roles in the organization + + Returns: + bool: True if the current user has the necessary capacity in the certain + organization, False - otherwise + """ + if authz.users_role_for_group_or_org( + group_id=org_id, + user_name=tk.current_user.name) in roles: + return True + return False + + +@tk.chained_auth_function +def package_activity_list(next_auth, context, data_dict): + pkg_dict = tk.get_action("package_show")( + context, + {"id": data_dict["id"]}, + ) + allowed_roles = ["admin", "editor"] + is_user_collaborator = authz.user_is_collaborator_on_dataset( + tk.current_user.id, pkg_dict["id"], allowed_roles + ) + has_user_capacity = _has_user_capacity_in_org( + pkg_dict["owner_org"], allowed_roles + ) + + if has_user_capacity or is_user_collaborator: + return next_auth(context, data_dict) + return {"success": False} + + +@tk.chained_auth_function +def organization_activity_list(next_auth, context, data_dict): + allowed_roles = ["admin", "editor"] + if _has_user_capacity_in_org(data_dict["id"], allowed_roles): + return next_auth(context, data_dict) + return {"success": False} + + +@tk.chained_auth_function +def organization_activity_list(next_auth, context, data_dict): + allowed_roles = ["admin", "editor"] + if _has_user_capacity_in_org(data_dict["id"], allowed_roles): + return next_auth(context, data_dict) + return {"success": False} diff --git a/ckanext/datavicmain/logic/schema.py b/ckanext/datavicmain/logic/schema.py index 27dd86f3..08d04128 100644 --- a/ckanext/datavicmain/logic/schema.py +++ b/ckanext/datavicmain/logic/schema.py @@ -47,3 +47,12 @@ def delwp_data_request_schema( "message": [not_missing, unicode_safe], "package_id": [not_missing, unicode_safe, package_id_or_name_exists], } + + +@validator_args +def datatables_view_prioritize(not_empty): + return { + "resource_id": [ + not_empty, + ], + } diff --git a/ckanext/datavicmain/organisation_schema.yaml b/ckanext/datavicmain/organisation_schema.yaml index 8ef1ee04..d33769db 100644 --- a/ckanext/datavicmain/organisation_schema.yaml +++ b/ckanext/datavicmain/organisation_schema.yaml @@ -10,6 +10,7 @@ fields: form_attrs: data-module: slug-preview-target form_placeholder: My Organization + required: true - field_name: name label: URL @@ -28,10 +29,6 @@ fields: validators: scheming_required form_placeholder: http://example.com/my-image.jpg - - field_name: parent - label: Parent - form_snippet: org_hierarchy.html - - field_name: visibility label: Visibility form_snippet: vic_org_visibility.html @@ -43,3 +40,9 @@ fields: label: Unrestricted - value: restricted label: Restricted + + - field_name: parent + label: Parent + form_snippet: org_hierarchy.html + validators: ignore_empty + required: true diff --git a/ckanext/datavicmain/plugins.py b/ckanext/datavicmain/plugins.py index 484c3313..ae7df7e7 100644 --- a/ckanext/datavicmain/plugins.py +++ b/ckanext/datavicmain/plugins.py @@ -5,8 +5,6 @@ import calendar import logging from typing import Any -from six import text_type -from typing import Any from datetime import datetime import ckan.authz as authz @@ -242,6 +240,8 @@ def get_helpers(self): 'get_digital_twin_resources': helpers.get_digital_twin_resources, 'url_for_dtv_config': helpers.url_for_dtv_config, "datavic_org_uploads_allowed": helpers.datavic_org_uploads_allowed, + "get_group": helpers.get_group, + "dtv_exceeds_max_size_limit": helpers.dtv_exceeds_max_size_limit, "datavic_user_is_a_member_of_org": helpers.datavic_user_is_a_member_of_org, "datavic_is_pending_request_to_join_org": helpers.datavic_is_pending_request_to_join_org, "datavic_is_org_restricted": helpers.datavic_is_org_restricted, @@ -258,6 +258,7 @@ def get_helpers(self): "datavic_get_org_roles": helpers.datavic_get_org_roles, "datavic_get_user_roles_in_org": helpers.datavic_get_user_roles_in_org, "datavic_allowable_parent_orgs": helpers.datavic_allowable_parent_orgs, + "has_user_capacity": helpers.has_user_capacity, } ## IConfigurer interface ## diff --git a/ckanext/datavicmain/templates/admin/trash.html b/ckanext/datavicmain/templates/admin/trash.html index 3da96d85..eef68359 100644 --- a/ckanext/datavicmain/templates/admin/trash.html +++ b/ckanext/datavicmain/templates/admin/trash.html @@ -4,7 +4,7 @@ {% set truncate = truncate or 180 %} {% set truncate_title = truncate_title or 80 %}