My page content
-Some content for the page
- {% endblock %} - #} - {% block primary_content %} -My page content
+Some content for the page
+ {% endblock %} + #} + {% block primary_content %} +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 @@
{% endblock %}
{% block title %}
-
-
-
+
+ {{ 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) %}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 %} - - {% 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 %}Some content for the page
- {% endblock %} - #} - {% block primary_content %} -Some content for the page
+ {% endblock %} + #} + {% block primary_content %} +