From 2bd4f3458b4994315d44c9d10ff19c79f94563a2 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 01:28:08 +0000 Subject: [PATCH 01/12] feat: add private group visibility option to group schema --- ckanext/digitizationknowledge/schemas/groups/group.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ckanext/digitizationknowledge/schemas/groups/group.json b/ckanext/digitizationknowledge/schemas/groups/group.json index deb4c32..f4f14af 100644 --- a/ckanext/digitizationknowledge/schemas/groups/group.json +++ b/ckanext/digitizationknowledge/schemas/groups/group.json @@ -46,6 +46,15 @@ ], "validators": "boolean_validator", "output_validators": "boolean_validator" + }, + { + "field_name": "is_private", + "label": "Private", + "validators": "boolean_validator ignore_missing", + "form_snippet": "checkbox.html", + "output_validators": "boolean_validator", + "help_text": "If checked, this group will only be visible to its members." + } ] } \ No newline at end of file From b68434bd64fb38c36932722288a07232338aa4b9 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 01:28:29 +0000 Subject: [PATCH 02/12] feat: add private group authorization logic to restrict access to group_show Implemented custom authorization for group_show to handle private groups. Only group members and sysadmins can view private groups, while public groups follow default CKAN behavior. Added necessary imports for model, authz, and logic modules. --- ckanext/digitizationknowledge/logic/auth.py | 71 ++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index c1ae605..5bf1cdd 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -1,4 +1,7 @@ import ckan.plugins.toolkit as tk +from ckan import model +import ckan.authz as authz +import ckan.logic as logic @tk.auth_allow_anonymous_access @@ -6,7 +9,73 @@ def digitizationknowledge_get_sum(context, data_dict): return {"success": True} +def group_show(context, data_dict): + ''' + Override group_show auth. + + If group is private, only allow members and sysadmins. + Otherwise, fall back to default behavior. + ''' + # Get the group + group_id = data_dict.get('id') + user = context.get('user') + + + # We need the group object to check 'private' + group = model.Group.get(group_id) + if not group: + # Delegate to core if not found, it will handle it (404) + return {'success': True} + + # Check if 'is_private' is set in extras + is_private = group.extras.get('is_private', False) + + # Convert 'true'/'True' strings to bool if necessary (ckan sometimes stores extras as strings) + if isinstance(is_private, str): + is_private = is_private.lower() in ['true', '1', 'yes', 'on'] + + if is_private: + # Check if user is sysadmin + if authz.is_sysadmin(user): + return {'success': True} + + # Check if user is a member of the group + # We use logic.check_access logic essentially but here we are IN the check_access + + # Is there a user? + if not user: + return {'success': False, 'msg': 'Private group. Login required.'} + + user_id = authz.get_user_id_for_username(user, allow_none=True) + if not user_id: + return {'success': False, 'msg': 'User not found'} + + # Check membership + # We can check specific capacity if needed, but 'member' of any kind is likely enough + query = model.Session.query(model.Member) \ + .filter(model.Member.group_id == group.id) \ + .filter(model.Member.table_id == user_id) \ + .filter(model.Member.state == 'active') \ + .filter(model.Member.table_name == 'user') + + if query.count() > 0: + return {'success': True} + + return {'success': False, 'msg': 'Not authorized to view this private group'} + + # Check default permission if not private + # We cannot call existing 'group_show' easily without causing recursion if we registered this + # as the replacement. + # However, standard CKAN auth functions often check 'group_show' logic. + # The default 'group_show' logic in CKAN allows read if state is active. + + if group.state == 'active': + return {'success': True} + + return {'success': False, 'msg': 'Group is not active'} + + def get_auth_functions(): return { "digitizationknowledge_get_sum": digitizationknowledge_get_sum, - } + } \ No newline at end of file From 4ddde25e78c8c45b92df519c843f84d6cc9abfb3 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 03:44:50 +0000 Subject: [PATCH 03/12] refactor: simplified implementation of group_show authorization logic --- ckanext/digitizationknowledge/logic/auth.py | 70 +++++++-------------- ckanext/digitizationknowledge/plugin.py | 8 +-- 2 files changed, 28 insertions(+), 50 deletions(-) diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index 5bf1cdd..5a7e787 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -1,7 +1,9 @@ import ckan.plugins.toolkit as tk -from ckan import model +from ckan.types import AuthResult, Context, DataDict import ckan.authz as authz -import ckan.logic as logic +import ckan.logic.auth as logic_auth +from ckan.common import _ + @tk.auth_allow_anonymous_access @@ -9,17 +11,16 @@ def digitizationknowledge_get_sum(context, data_dict): return {"success": True} -def group_show(context, data_dict): +def group_show(context: Context, data_dict: DataDict) -> AuthResult: ''' - Override group_show auth. + Custom group_show that enforces membership for private groups. If group is private, only allow members and sysadmins. Otherwise, fall back to default behavior. ''' - # Get the group - group_id = data_dict.get('id') + user = context.get('user') - + group = logic_auth.get_group_object(context, data_dict) # We need the group object to check 'private' group = model.Group.get(group_id) @@ -33,49 +34,26 @@ def group_show(context, data_dict): # Convert 'true'/'True' strings to bool if necessary (ckan sometimes stores extras as strings) if isinstance(is_private, str): is_private = is_private.lower() in ['true', '1', 'yes', 'on'] - - if is_private: - # Check if user is sysadmin - if authz.is_sysadmin(user): - return {'success': True} - - # Check if user is a member of the group - # We use logic.check_access logic essentially but here we are IN the check_access - - # Is there a user? - if not user: - return {'success': False, 'msg': 'Private group. Login required.'} - - user_id = authz.get_user_id_for_username(user, allow_none=True) - if not user_id: - return {'success': False, 'msg': 'User not found'} - - # Check membership - # We can check specific capacity if needed, but 'member' of any kind is likely enough - query = model.Session.query(model.Member) \ - .filter(model.Member.group_id == group.id) \ - .filter(model.Member.table_id == user_id) \ - .filter(model.Member.state == 'active') \ - .filter(model.Member.table_name == 'user') - - if query.count() > 0: - return {'success': True} - - return {'success': False, 'msg': 'Not authorized to view this private group'} - - # Check default permission if not private - # We cannot call existing 'group_show' easily without causing recursion if we registered this - # as the replacement. - # However, standard CKAN auth functions often check 'group_show' logic. - # The default 'group_show' logic in CKAN allows read if state is active. - - if group.state == 'active': + + if not is_private and group.state == 'active': + # Public groups are visible to everyone return {'success': True} - - return {'success': False, 'msg': 'Group is not active'} + # Private groups require membership + authorized = authz.has_user_permission_for_group_or_org( + 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 get_auth_functions(): return { "digitizationknowledge_get_sum": digitizationknowledge_get_sum, + "group_show": group_show, } \ No newline at end of file diff --git a/ckanext/digitizationknowledge/plugin.py b/ckanext/digitizationknowledge/plugin.py index 14d42be..4b6f011 100644 --- a/ckanext/digitizationknowledge/plugin.py +++ b/ckanext/digitizationknowledge/plugin.py @@ -9,7 +9,7 @@ import ckanext.digitizationknowledge.helpers as helpers import ckanext.digitizationknowledge.views as views from ckanext.digitizationknowledge.logic import ( - validators + validators, auth # action, auth, validators ) @@ -17,7 +17,7 @@ class DigitizationknowledgePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) - # plugins.implements(plugins.IAuthFunctions) + plugins.implements(plugins.IAuthFunctions) # plugins.implements(plugins.IActions) plugins.implements(plugins.IBlueprint) # plugins.implements(plugins.IClick) @@ -93,8 +93,8 @@ 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 From 8a4cd6578b67e8b2b8feedc51db345405cd86d6a Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 03:51:05 +0000 Subject: [PATCH 04/12] refactor: remove duplicate group object retrieval in group_show authorization --- ckanext/digitizationknowledge/logic/auth.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index 5a7e787..9b359bb 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -22,12 +22,6 @@ def group_show(context: Context, data_dict: DataDict) -> AuthResult: user = context.get('user') group = logic_auth.get_group_object(context, data_dict) - # We need the group object to check 'private' - group = model.Group.get(group_id) - if not group: - # Delegate to core if not found, it will handle it (404) - return {'success': True} - # Check if 'is_private' is set in extras is_private = group.extras.get('is_private', False) From 951f666d1ee426489859e6e1ef2c51588743ce88 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 03:53:24 +0000 Subject: [PATCH 05/12] refactor: add anonymous access decorator to group_show authorization --- ckanext/digitizationknowledge/logic/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index 9b359bb..1e65e39 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -11,6 +11,7 @@ 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. From 3a8620cebf60ca6fb37e4fc6a818f96ed202c5f0 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 03:57:59 +0000 Subject: [PATCH 06/12] refactor: add null check for group extras in group_show authorization --- ckanext/digitizationknowledge/logic/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index 1e65e39..991a284 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -24,7 +24,7 @@ def group_show(context: Context, data_dict: DataDict) -> AuthResult: group = logic_auth.get_group_object(context, data_dict) # Check if 'is_private' is set in extras - is_private = group.extras.get('is_private', False) + is_private = group.extras.get('is_private', False) if group.extras else False # Convert 'true'/'True' strings to bool if necessary (ckan sometimes stores extras as strings) if isinstance(is_private, str): From 2317a2ef0777694a321b948345070d9c7a34627e Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 9 Jan 2026 04:05:38 +0000 Subject: [PATCH 07/12] refactor: improve code formatting and simplify private group check in group_show authorization --- ckanext/digitizationknowledge/logic/auth.py | 32 +++++++++------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/ckanext/digitizationknowledge/logic/auth.py b/ckanext/digitizationknowledge/logic/auth.py index 991a284..618b847 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -11,40 +11,36 @@ 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. + 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 'is_private' is set in extras - is_private = group.extras.get('is_private', False) if group.extras else False - - # Convert 'true'/'True' strings to bool if necessary (ckan sometimes stores extras as strings) - if isinstance(is_private, str): - is_private = is_private.lower() in ['true', '1', 'yes', 'on'] - - if not is_private and group.state == 'active': - # Public groups are visible to everyone + is_private = False + if 'is_private' in group.extras: + val = group.extras['is_private'] + if isinstance(val, str): + is_private = val.lower() in ['true', '1', 'yes', 'on'] + else: + is_private = bool(val) + + # Public groups: allow if active and not private + if group.state == 'active' and not is_private: return {'success': True} - # Private groups require membership + # Private groups or inactive: require membership authorized = authz.has_user_permission_for_group_or_org( 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) - } + else: + return {'success': False, 'msg': _('User %s not authorized to read group %s') % (user, group.id)} def get_auth_functions(): From 687c60e04fa4c57291f67158af05e3f60f2fc2d7 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Thu, 12 Feb 2026 21:06:51 +0000 Subject: [PATCH 08/12] feat: work in progress for implementation of private groups --- ckanext/digitizationknowledge/helpers.py | 79 +++++++++- ckanext/digitizationknowledge/logic/action.py | 92 +++++++++++ ckanext/digitizationknowledge/logic/auth.py | 146 ++++++++++++++++-- ckanext/digitizationknowledge/plugin.py | 9 +- .../schemas/groups/group.json | 20 ++- .../templates/group/snippets/group_item.html | 9 +- .../templates/group/snippets/info.html | 74 +++++---- 7 files changed, 370 insertions(+), 59 deletions(-) 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..e61a650 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,50 @@ 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 + """ + # Get all groups from the core action + all_groups = original_action(context, data_dict) + + user = context.get('user') + + # Sysadmins see everything + if authz.is_sysadmin(user): + return all_groups + + # Check if all_fields was requested (returns dicts vs just names) + all_fields = data_dict.get('all_fields', False) + + 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 618b847..504f261 100644 --- a/ckanext/digitizationknowledge/logic/auth.py +++ b/ckanext/digitizationknowledge/logic/auth.py @@ -5,12 +5,50 @@ 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 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. @@ -21,30 +59,114 @@ def group_show(context: Context, data_dict: DataDict) -> AuthResult: user = context.get('user') group = logic_auth.get_group_object(context, data_dict) - # Check if 'is_private' is set in extras - is_private = False - if 'is_private' in group.extras: - val = group.extras['is_private'] - if isinstance(val, str): - is_private = val.lower() in ['true', '1', 'yes', 'on'] - else: - is_private = bool(val) + # Check if group is private + is_private = _is_group_private(group) - # Public groups: allow if active and not private + # Public groups: allow if active if group.state == 'active' and not is_private: return {'success': True} # Private groups or inactive: require membership - authorized = authz.has_user_permission_for_group_or_org( - group.id, user, 'read') + 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 read group %s') % (user, group.id)} + 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 4b6f011..c045dbc 100644 --- a/ckanext/digitizationknowledge/plugin.py +++ b/ckanext/digitizationknowledge/plugin.py @@ -9,8 +9,7 @@ import ckanext.digitizationknowledge.helpers as helpers import ckanext.digitizationknowledge.views as views from ckanext.digitizationknowledge.logic import ( - validators, auth - # action, auth, validators + validators, auth, action ) @@ -18,7 +17,7 @@ class DigitizationknowledgePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IAuthFunctions) - # plugins.implements(plugins.IActions) + plugins.implements(plugins.IActions) plugins.implements(plugins.IBlueprint) # plugins.implements(plugins.IClick) plugins.implements(plugins.ITemplateHelpers) @@ -98,8 +97,8 @@ def get_auth_functions(self): # 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 f4f14af..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" }, { @@ -50,11 +52,21 @@ { "field_name": "is_private", "label": "Private", - "validators": "boolean_validator ignore_missing", - "form_snippet": "checkbox.html", + "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 From ca71d6c4886d1d261f1f23cb71883b5be1d25ff8 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Thu, 12 Feb 2026 21:58:59 +0000 Subject: [PATCH 09/12] fix: private groups feature works The group feature does not break when accessing it as an anonymous user. --- ckanext/digitizationknowledge/logic/action.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ckanext/digitizationknowledge/logic/action.py b/ckanext/digitizationknowledge/logic/action.py index e61a650..4174eb1 100644 --- a/ckanext/digitizationknowledge/logic/action.py +++ b/ckanext/digitizationknowledge/logic/action.py @@ -78,17 +78,18 @@ def group_list(original_action, context, data_dict): - Regular users see public groups + private groups they're members of - Anonymous users see only public groups """ - # Get all groups from the core action - all_groups = original_action(context, data_dict) - user = context.get('user') - # Sysadmins see everything + # Sysadmins see everything — no need to modify context if authz.is_sysadmin(user): - return all_groups + return original_action(context, data_dict) - # Check if all_fields was requested (returns dicts vs just names) - all_fields = data_dict.get('all_fields', False) + # 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: From 4cc607e56cd6dd7b2941ed55c45edf4b160d279c Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 13 Feb 2026 01:01:51 +0000 Subject: [PATCH 10/12] fix: updates to resources page --- .../templates/package/resources.html | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 ckanext/digitizationknowledge/templates/package/resources.html 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 %} +
    + {% set can_edit = h.check_access('package_update', {'id':pkg.id }) %} + {% for resource in pkg.resources %} + {% snippet 'package/snippets/resource_item.html', pkg=pkg, res=resource, url_is_edit=true, can_edit=can_edit %} + {% endfor %} +
+{% 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 From 16a9f713b305d067d03ad3b133207bb1e9d71ce2 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 13 Feb 2026 01:20:37 +0000 Subject: [PATCH 11/12] feat: Implement a site-wide notification. --- .../digitizationknowledge/templates/page.html | 245 ++++++++++-------- 1 file changed, 130 insertions(+), 115 deletions(-) diff --git a/ckanext/digitizationknowledge/templates/page.html b/ckanext/digitizationknowledge/templates/page.html index 0edf2f0..26afb47 100644 --- a/ckanext/digitizationknowledge/templates/page.html +++ b/ckanext/digitizationknowledge/templates/page.html @@ -2,137 +2,152 @@ {%- block page -%} - {% block skip %} - - {% endblock %} +{% block skip %} + +{% 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 From 28c0fb7c0ed55980af39c5226295ac7f82bad489 Mon Sep 17 00:00:00 2001 From: "Fritz J. Pichardo Marcano" Date: Fri, 13 Feb 2026 01:28:07 +0000 Subject: [PATCH 12/12] fix: update alert color of notice --- ckanext/digitizationknowledge/templates/page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ckanext/digitizationknowledge/templates/page.html b/ckanext/digitizationknowledge/templates/page.html index 26afb47..01e4982 100644 --- a/ckanext/digitizationknowledge/templates/page.html +++ b/ckanext/digitizationknowledge/templates/page.html @@ -36,7 +36,7 @@
-