diff --git a/ckanext/digitizationknowledge/helpers.py b/ckanext/digitizationknowledge/helpers.py index 7f79954..19edf06 100644 --- a/ckanext/digitizationknowledge/helpers.py +++ b/ckanext/digitizationknowledge/helpers.py @@ -1,15 +1,82 @@ import ckan.plugins.toolkit as toolkit import ckan.model as model +import ckan.authz as authz +from sqlalchemy import and_, not_, exists from typing import Any -# Define Template helper functions + +def is_group_private(group): + """ + Template helper to check if a group is private. + + Args: + group: A group dict or group object + + Returns: + bool: True if group is marked as private + """ + # Handle dict format (from group_show) + if isinstance(group, dict): + # Check direct field (may be present in some cases) + if 'is_private' in group: + val = group['is_private'] + if isinstance(val, str): + return val.lower() in ['true', '1', 'yes', 'on'] + return bool(val) + + # Check extras list format + extras = group.get('extras', []) + for extra in extras: + if extra.get('key') == 'is_private': + val = extra.get('value') + if isinstance(val, str): + return val.lower() in ['true', '1', 'yes', 'on'] + return bool(val) + + # Handle model object format + elif hasattr(group, 'extras') and 'is_private' in group.extras: + val = group.extras['is_private'] + if isinstance(val, str): + return val.lower() in ['true', '1', 'yes', 'on'] + return bool(val) + + return False + + +def user_can_view_group(group_name_or_id): + """ + Template helper to check if current user can view a group. + + Args: + group_name_or_id: Group name or ID + + Returns: + bool: True if user can view the group + """ + try: + user = toolkit.current_user.name if toolkit.current_user.is_authenticated else None + context = {'user': user} + toolkit.check_access('group_show', context, {'id': group_name_or_id}) + return True + except toolkit.NotAuthorized: + return False + + def get_custom_featured_groups(count: int = 1): ''' Returns a list of featured groups using the is_featured field. + Excludes private groups from featured listings. Efficiently queries database first to find featured groups, then gets full details. ''' try: + # Subquery to find private groups + private_groups_subquery = model.Session.query(model.GroupExtra.group_id).filter( + model.GroupExtra.key == 'is_private', + model.GroupExtra.value.in_(['True', 'true', '1', 'yes']) + ).subquery() + # Query database directly for featured group names (fast!) + # Exclude private groups from featured results query = model.Session.query(model.Group.name).join( model.GroupExtra, model.Group.id == model.GroupExtra.group_id @@ -17,7 +84,9 @@ def get_custom_featured_groups(count: int = 1): model.Group.is_organization == False, model.Group.state == 'active', model.GroupExtra.key == 'is_featured', - model.GroupExtra.value.in_(['True', 'true', '1', 'yes']) + model.GroupExtra.value.in_(['True', 'true', '1', 'yes']), + # Exclude private groups + ~model.Group.id.in_(private_groups_subquery) ).distinct().limit(count) featured_names = [name for name, in query.all()] @@ -44,6 +113,7 @@ def get_custom_featured_groups(count: int = 1): except Exception: return [] + def get_custom_featured_organizations(count: int = 1): ''' Returns a list of featured organizations using the is_featured field. @@ -85,10 +155,11 @@ def get_custom_featured_organizations(count: int = 1): except Exception: return [] + def get_helpers(): return { "get_custom_featured_groups": get_custom_featured_groups, "get_custom_featured_organizations": get_custom_featured_organizations, + "is_group_private": is_group_private, + "user_can_view_group": user_can_view_group, } - - # nameCallableFromTemplate:nameOfFunction diff --git a/ckanext/digitizationknowledge/logic/action.py b/ckanext/digitizationknowledge/logic/action.py index 1c1e05b..4174eb1 100644 --- a/ckanext/digitizationknowledge/logic/action.py +++ b/ckanext/digitizationknowledge/logic/action.py @@ -1,7 +1,56 @@ import ckan.plugins.toolkit as tk +import ckan.authz as authz +import ckan.model as model import ckanext.digitizationknowledge.logic.schema as schema +def _is_group_private_by_id(group_id): + """ + Check if a group is private by querying its extras. + + Args: + group_id: The group ID or name + + Returns: + bool: True if group is marked as private + """ + try: + # Query group extras for is_private field + group = model.Group.get(group_id) + if not group: + return False + + extra = model.Session.query(model.GroupExtra).filter( + model.GroupExtra.group_id == group.id, + model.GroupExtra.key == 'is_private' + ).first() + + if extra: + val = extra.value + if isinstance(val, str): + return val.lower() in ['true', '1', 'yes', 'on'] + return bool(val) + return False + except Exception: + return False + + +def _user_is_member_of_group(user, group_id): + """ + Check if a user is a member of a group. + + Args: + user: Username string + group_id: The group ID + + Returns: + bool: True if user is a member + """ + if not user: + return False + return authz.has_user_permission_for_group_or_org(group_id, user, 'read') + + @tk.side_effect_free def digitizationknowledge_get_sum(context, data_dict): tk.check_access( @@ -19,7 +68,51 @@ def digitizationknowledge_get_sum(context, data_dict): } +@tk.side_effect_free +@tk.chained_action +def group_list(original_action, context, data_dict): + """ + Override default group_list to filter out private groups for non-members. + + - Sysadmins see all groups + - Regular users see public groups + private groups they're members of + - Anonymous users see only public groups + """ + user = context.get('user') + + # Sysadmins see everything — no need to modify context + if authz.is_sysadmin(user): + return original_action(context, data_dict) + + # For all other users: bypass auth in the core action to prevent + # group_show from throwing NotAuthorized on private groups when + # all_fields=True. We filter private groups out ourselves below. + safe_context = context.copy() + safe_context['ignore_auth'] = True + all_groups = original_action(safe_context, data_dict) + + filtered_groups = [] + for group in all_groups: + # Get group ID/name depending on response format + if isinstance(group, dict): + group_id = group.get('id') or group.get('name') + else: + group_id = group # Just a string name + + # Check if this group is private + if _is_group_private_by_id(group_id): + # Private group - only include if user is a member + if user and _user_is_member_of_group(user, group_id): + filtered_groups.append(group) + else: + # Public group - include for everyone + filtered_groups.append(group) + + return filtered_groups + + def get_actions(): return { 'digitizationknowledge_get_sum': digitizationknowledge_get_sum, + 'group_list': group_list, } diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index c1ae605..504f261 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -1,4 +1,46 @@ import ckan.plugins.toolkit as tk +from ckan.types import AuthResult, Context, DataDict +import ckan.authz as authz +import ckan.logic.auth as logic_auth +from ckan.common import _ + + +def _is_group_private(group): + """ + Check if group has is_private extra set to true. + + Args: + group: A CKAN Group model object + + Returns: + bool: True if group is marked as private + """ + if hasattr(group, 'extras') and 'is_private' in group.extras: + val = group.extras['is_private'] + if isinstance(val, str): + return val.lower() in ['true', '1', 'yes', 'on'] + return bool(val) + return False + + +def _user_has_group_permission(group_id, user, permission): + """ + Check if user has a specific permission for a group. + + Args: + group_id: The group ID + user: Username string + permission: Permission to check (e.g., 'read', 'update', 'delete', 'manage_group') + + Returns: + bool: True if user has the permission + """ + # Sysadmins always have permission + if authz.is_sysadmin(user): + return True + + # Check if user has the specific permission for this group + return authz.has_user_permission_for_group_or_org(group_id, user, permission) @tk.auth_allow_anonymous_access @@ -6,7 +48,125 @@ def digitizationknowledge_get_sum(context, data_dict): return {"success": True} +@tk.auth_allow_anonymous_access +def group_show(context: Context, data_dict: DataDict) -> AuthResult: + ''' + Custom group_show that enforces membership for private groups. + + If group is private, only allow members and sysadmins. + Otherwise, fall back to default behavior. + ''' + user = context.get('user') + group = logic_auth.get_group_object(context, data_dict) + + # Check if group is private + is_private = _is_group_private(group) + + # Public groups: allow if active + if group.state == 'active' and not is_private: + return {'success': True} + + # Private groups or inactive: require membership + authorized = _user_has_group_permission(group.id, user, 'read') + if authorized: + return {'success': True} + else: + return { + 'success': False, + 'msg': _('User %s not authorized to read group %s') % (user, group.id) + } + + +def group_update(context: Context, data_dict: DataDict) -> AuthResult: + ''' + Custom group_update that allows group admins (owners) to update private groups. + + Group admins and sysadmins can update any group. + ''' + user = context.get('user') + group = logic_auth.get_group_object(context, data_dict) + + authorized = _user_has_group_permission(group.id, user, 'update') + if authorized: + return {'success': True} + else: + return { + 'success': False, + 'msg': _('User %s not authorized to update group %s') % (user, group.id) + } + + +def group_delete(context: Context, data_dict: DataDict) -> AuthResult: + ''' + Custom group_delete that allows group admins (owners/creators) to delete private groups. + + Group admins and sysadmins can delete groups. + ''' + user = context.get('user') + group = logic_auth.get_group_object(context, data_dict) + + # Check if user has delete permission (group admins do) + authorized = _user_has_group_permission(group.id, user, 'delete') + if authorized: + return {'success': True} + else: + return { + 'success': False, + 'msg': _('User %s not authorized to delete group %s') % (user, group.id) + } + + +def group_member_create(context: Context, data_dict: DataDict) -> AuthResult: + ''' + Custom group_member_create that allows group admins to add members to private groups. + ''' + user = context.get('user') + group = logic_auth.get_group_object(context, data_dict) + + # Check if user can manage group members + authorized = _user_has_group_permission(group.id, user, 'manage_group') + if authorized: + return {'success': True} + else: + return { + 'success': False, + 'msg': _('User %s not authorized to add members to group %s') % (user, group.id) + } + + +def group_member_delete(context: Context, data_dict: DataDict) -> AuthResult: + ''' + Custom group_member_delete that allows group admins to remove members from private groups. + ''' + user = context.get('user') + group = logic_auth.get_group_object(context, data_dict) + + # Check if user can manage group members + authorized = _user_has_group_permission(group.id, user, 'manage_group') + if authorized: + return {'success': True} + else: + return { + 'success': False, + 'msg': _('User %s not authorized to remove members from group %s') % (user, group.id) + } + + +@tk.auth_allow_anonymous_access +def group_list(context: Context, data_dict: DataDict) -> AuthResult: + ''' + Allow anyone to call group_list - filtering is done in the action. + ''' + return {'success': True} + + def get_auth_functions(): return { "digitizationknowledge_get_sum": digitizationknowledge_get_sum, - } + "group_show": group_show, + "group_update": group_update, + "group_delete": group_delete, + "group_member_create": group_member_create, + "group_member_delete": group_member_delete, + "group_list": group_list, + } \ No newline at end of file diff --git a/ckanext/digitizationknowledge/plugin.py b/ckanext/digitizationknowledge/plugin.py index 14d42be..c045dbc 100644 --- a/ckanext/digitizationknowledge/plugin.py +++ b/ckanext/digitizationknowledge/plugin.py @@ -9,16 +9,15 @@ import ckanext.digitizationknowledge.helpers as helpers import ckanext.digitizationknowledge.views as views from ckanext.digitizationknowledge.logic import ( - validators - # action, auth, validators + validators, auth, action ) class DigitizationknowledgePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) - # plugins.implements(plugins.IAuthFunctions) - # plugins.implements(plugins.IActions) + plugins.implements(plugins.IAuthFunctions) + plugins.implements(plugins.IActions) plugins.implements(plugins.IBlueprint) # plugins.implements(plugins.IClick) plugins.implements(plugins.ITemplateHelpers) @@ -93,13 +92,13 @@ def update_config(self, config_): # IAuthFunctions - # def get_auth_functions(self): - # return auth.get_auth_functions() + def get_auth_functions(self): + return auth.get_auth_functions() # IActions - # def get_actions(self): - # return action.get_actions() + def get_actions(self): + return action.get_actions() # IBlueprint diff --git a/ckanext/digitizationknowledge/schemas/groups/group.json b/ckanext/digitizationknowledge/schemas/groups/group.json index deb4c32..8dbe3d6 100644 --- a/ckanext/digitizationknowledge/schemas/groups/group.json +++ b/ckanext/digitizationknowledge/schemas/groups/group.json @@ -7,7 +7,9 @@ "label": "Name", "validators": "ignore_missing unicode_safe", "form_snippet": "large_text.html", - "form_attrs": {"data-module": "slug-preview-target"}, + "form_attrs": { + "data-module": "slug-preview-target" + }, "form_placeholder": "My Organization" }, { @@ -46,6 +48,25 @@ ], "validators": "boolean_validator", "output_validators": "boolean_validator" + }, + { + "field_name": "is_private", + "label": "Private", + "form_placeholder": "Should it be private?", + "preset": "radio", + "choices": [ + { + "value": true, + "label": "Yes" + }, + { + "value": false, + "label": "No" + } + ], + "validators": "boolean_validator", + "output_validators": "boolean_validator", + "help_text": "If checked, this group will only be visible to its members." } ] } \ No newline at end of file diff --git a/ckanext/digitizationknowledge/templates/group/snippets/group_item.html b/ckanext/digitizationknowledge/templates/group/snippets/group_item.html index 276aad3..4d1c3d0 100644 --- a/ckanext/digitizationknowledge/templates/group/snippets/group_item.html +++ b/ckanext/digitizationknowledge/templates/group/snippets/group_item.html @@ -20,7 +20,14 @@ {{ group.name }} {% endblock %} {% block title %} -

{{ group.display_name }}

+

+ {{ group.display_name }} + {% if h.is_group_private(group) %} + + {{ _('Private') }} + + {% endif %} +

{% endblock %} {% block description %} {% if group.description %} diff --git a/ckanext/digitizationknowledge/templates/group/snippets/info.html b/ckanext/digitizationknowledge/templates/group/snippets/info.html index 7c9911d..e0a51a1 100644 --- a/ckanext/digitizationknowledge/templates/group/snippets/info.html +++ b/ckanext/digitizationknowledge/templates/group/snippets/info.html @@ -4,33 +4,39 @@
- {% block inner %} - {% block image %} -
- - {{ group.name }} - -
- {% endblock %} - {% block heading %} -

- {{ group.display_name }} - {% if group.state == 'deleted' %} + {% block inner %} + {% block image %} +
+ + {{ group.name }} + +
+ {% endblock %} + {% block heading %} +

+ {{ group.display_name }} + {% if h.is_group_private(group) %} + + {{ _('Private') }} + + {% endif %} + {% if group.state == 'deleted' %} [{{ _('Deleted') }}] - {% endif %} -

- {% endblock %} - {% block description %} - {% if group.description %} + {% endif %} + + {% endblock %} + {% block description %} + {% if group.description %}

{{ h.markdown_extract(group.description, 180) }}

{% link_for _('read more'), named_route='group.about', id=group.name %}

- {% endif %} - {% endblock %} - {% if show_nums %} + {% endif %} + {% endblock %} + {% if show_nums %} {% block nums %} {% set num_followers = h.follow_count('group', group.id) %}
@@ -49,20 +55,22 @@

{% endblock %} {% block follow %} - {% if current_user.is_authenticated %} - {% if error_message %} -
{{ error_message }}
- {% endif %} - {% if am_following %} - Unfollow - {% else %} - Follow - {% endif %} - {% endif %} + {% if current_user.is_authenticated %} + {% if error_message %} +
{{ error_message }}
+ {% endif %} + {% if am_following %} + Unfollow + {% else %} + Follow + {% endif %} + {% endif %} + {% endblock %} + {% endif %} {% endblock %} - {% endif %} - {% endblock %}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/ckanext/digitizationknowledge/templates/package/resources.html b/ckanext/digitizationknowledge/templates/package/resources.html new file mode 100644 index 0000000..f70ac6c --- /dev/null +++ b/ckanext/digitizationknowledge/templates/package/resources.html @@ -0,0 +1,18 @@ +{% ckan_extends %} + +{% block subtitle %}{{ _('Versions') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} + +{% block primary_content_inner %} +{% if pkg.resources %} + +{% else %} +{% trans url=h.url_for(pkg.type ~ '_resource.new', id=pkg.name) %} +

This resource has no versions, why not add some?

+{% endtrans %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/ckanext/digitizationknowledge/templates/page.html b/ckanext/digitizationknowledge/templates/page.html index 0edf2f0..01e4982 100644 --- a/ckanext/digitizationknowledge/templates/page.html +++ b/ckanext/digitizationknowledge/templates/page.html @@ -2,137 +2,152 @@ {%- block page -%} - {% block skip %} -
{{ _('Skip to main content') }}
- {% endblock %} +{% block skip %} +
{{ _('Skip to main content') }}
+{% endblock %} - {# - Override the header on a page by page basis by extending this block. If - making sitewide header changes it is preferable to override the header.html - file. - #} - {%- block header %} - {% include "header.html" %} - {% endblock -%} +{# +Override the header on a page by page basis by extending this block. If +making sitewide header changes it is preferable to override the header.html +file. +#} +{%- block header %} +{% include "header.html" %} +{% endblock -%} - {# The content block allows you to replace the content of the page if needed #} - {%- block content %} - {% block maintag %}
{% endblock %} -
- {% block main_content %} - {% block flash %} -
- {% block flash_inner %} - {% for category, message in h.get_flashed_messages(with_categories=true) %} -
- {{ h.literal(message) }} -
- {% endfor %} - {% endblock %} -
- {% endblock %} +{# The content block allows you to replace the content of the page if needed #} +{%- block content %} +{% block maintag %}
{% endblock %} +
+ {% block main_content %} + {% block flash %} +
+ {% block flash_inner %} + {% for category, message in h.get_flashed_messages(with_categories=true) %} +
+ {{ h.literal(message) }} +
+ {% endfor %} + {% endblock %} +
- {% block toolbar %} - - {% endblock %} + {% set notification_text = g.site_intro_text %} + {% if notification_text %} +
+ +
+ {% endif %} + {% endblock %} -
-
- {# - The pre_primary block can be used to add content to before the - rendering of the main content columns of the page. - #} - {% block pre_primary %} - {% endblock %} + {% block toolbar %} + + {% endblock %} + +
+
+ {# + The pre_primary block can be used to add content to before the + rendering of the main content columns of the page. + #} + {% block pre_primary %} + {% endblock %} - {% block secondary %} - + {% endblock %} - {% block primary %} -
- {# - The primary_content block can be used to add content to the page. - This is the main block that is likely to be used within a template. + {% block primary %} +
+ {# + The primary_content block can be used to add content to the page. + This is the main block that is likely to be used within a template. - Example: + Example: - {% block primary_content %} -

My page content

-

Some content for the page

- {% endblock %} - #} - {% block primary_content %} -
- {% block page_header %} - - {% endblock %} -
- {% if self.page_primary_action() | trim %} -
- {% block page_primary_action %}{% endblock %} -
- {% endif %} - {% block primary_content_inner %} - {% endblock %} -
-
- {% endblock %} + {% block primary_content %} +

My page content

+

Some content for the page

+ {% endblock %} + #} + {% block primary_content %} +
+ {% block page_header %} + {% endblock %} -
+
+ {% if self.page_primary_action() | trim %} +
+ {% block page_primary_action %}{% endblock %} +
+ {% endif %} + {% block primary_content_inner %} + {% endblock %} +
+ + {% endblock %} +
{% endblock %}
+ {% endblock %}
-
- {% endblock -%} +
+
+{% endblock -%} - {# - Override the footer on a page by page basis by extending this block. If - making sitewide header changes it is preferable to override the footer.html-u - file. - #} - {%- block footer %} - {% include "footer.html" %} - {% endblock -%} +{# +Override the footer on a page by page basis by extending this block. If +making sitewide header changes it is preferable to override the footer.html-u +file. +#} +{%- block footer %} +{% include "footer.html" %} +{% endblock -%} {%- endblock -%} {%- block scripts %} - {% asset 'base/main' %} - {% asset 'base/ckan' %} - {{ super() }} -{% endblock -%} +{% asset 'base/main' %} +{% asset 'base/ckan' %} +{{ super() }} +{% endblock -%} \ No newline at end of file