diff --git a/docs/ban_management_guide.rst b/docs/ban_management_guide.rst new file mode 100644 index 00000000..2e268327 --- /dev/null +++ b/docs/ban_management_guide.rst @@ -0,0 +1,272 @@ +Ban Management Web Interface +============================ + +The ban management system provides a comprehensive web interface for administrators +to manage problematic users without resorting to permanent account restrictions. + +## Accessing Ban Management + +### Prerequisites +- User must have "Review comments" permission +- Ban system must be enabled in Discussion Control Panel + +### Main Ban Management View + +Access: `@@ban-management` + +**Features:** +- Quick ban form for immediate user restrictions +- Active bans overview with detailed information +- Bulk actions for maintenance + +**Quick Ban Form:** +- User ID field (required) +- Ban type selector: + * Cooldown Ban: Temporary comment restriction + * Shadow Ban: Hidden comments (user unaware) + * Permanent Ban: Complete comment restriction +- Duration field (for non-permanent bans) +- Reason field for documentation + +**Active Bans Table:** +- User information +- Ban type and status +- Creation and expiration dates +- Remaining time calculation +- Moderator who created the ban +- Reason for the ban +- Quick unban action + +### Individual User Ban Form + +Access: `@@ban-user-form?user_id=username` + +**Features:** +- User information verification +- Detailed ban type descriptions +- Duration configuration +- Mandatory reason field + +**Ban Type Descriptions:** + +*Cooldown Ban:* +- Temporarily prevents user from commenting +- Automatic expiration +- Clear notification to user with remaining time +- Useful for "cooling off" heated discussions + +*Shadow Ban:* +- Comments appear published to the author +- Hidden from all other users +- Can be time-limited or indefinite +- Effective for managing trolls without confrontation + +*Permanent Ban:* +- Complete restriction from commenting +- Requires explicit administrative action to lift +- Clear notification of permanent status +- Reserved for severe violations + +## User Experience + +### For Banned Users + +**Cooldown Ban:** +``` +"You are temporarily banned from commenting for 2 hours and 15 minutes. +Reason: Excessive off-topic comments." +``` + +**Shadow Ban (if notifications enabled):** +``` +"Your comments are currently under review. +Reason: Suspected automated posting." +``` + +**Permanent Ban:** +``` +"You have been permanently banned from commenting. +Reason: Violation of community guidelines." +``` + +### For Other Users + +**Shadow Banned Comments:** +- Comments from shadow banned users are invisible +- No indication that comments were hidden +- Maintains normal conversation flow + +## Administrative Workflow + +### Typical Moderation Workflow + +1. **Identify Problematic User** + - Review reported comments + - Observe patterns of behavior + - Consider escalation path + +2. **Choose Appropriate Ban Type** + - **First offense:** Cooldown (1-24 hours) + - **Repeated issues:** Shadow ban (24-72 hours) + - **Severe violations:** Permanent ban + +3. **Apply Ban** + - Document clear reason + - Set appropriate duration + - Monitor for circumvention + +4. **Follow Up** + - Review ban effectiveness + - Adjust duration if needed + - Consider lifting for good behavior + +### Bulk Operations + +**Cleanup Expired Bans:** +- Removes inactive bans from storage +- Improves system performance +- Provides count of cleaned items + +## Integration Points + +### Comment Moderation View + +Ban management links are integrated into: +- Comment moderation interface +- User profile pages (if available) +- Content management workflow + +### Status Checking + +**User Ban Status API:** +Access: `@@user-ban-status` + +Returns JSON with current user's ban information: +```json +{ + "banned": true, + "ban_type": "cooldown", + "can_comment": false, + "reason": "Spam posting", + "expires_date": "2024-01-15T10:30:00" +} +``` + +## Configuration Options + +### Discussion Control Panel Settings + +**Enable User Ban System:** +- Master switch for ban functionality +- Default: Disabled + +**Notify Users of Shadow Bans:** +- Controls shadow ban visibility to users +- Default: Disabled (true shadow bans) + +**Default Cooldown Duration:** +- Hours for cooldown bans when not specified +- Default: 24 hours + +## Security Considerations + +### Permission Model +- Uses existing "Review comments" permission +- No additional permissions required +- Follows Plone security model + +### Data Protection +- Ban data stored in portal annotations +- Automatic cleanup of expired bans +- No personal data beyond user ID + +### Audit Trail +- All bans include moderator ID +- Creation timestamps recorded +- Reason field for documentation + +## Troubleshooting + +### Common Issues + +**Ban Not Taking Effect:** +- Check ban system is enabled +- Verify user has permission +- Clear caches if needed + +**User Can Still Comment After Ban:** +- Check ban type (shadow bans allow commenting) +- Verify ban hasn't expired +- Check for permission overrides + +**Missing Ban Management Views:** +- Verify "Review comments" permission +- Check ZCML configuration +- Restart instance if needed + +### Performance Considerations + +- Expired bans are cleaned automatically +- Large numbers of bans may impact performance +- Regular cleanup recommended for high-volume sites + +## Monitoring and Reporting + +### Ban Statistics + +Monitor ban usage through: +- Active bans count +- Ban type distribution +- Average ban duration +- Moderator activity + +### Effectiveness Metrics + +Track ban effectiveness by: +- Repeat offender rates +- Comment quality improvements +- User behavior changes +- Community feedback + +## Best Practices + +### Ban Duration Guidelines + +**Cooldown Bans:** +- Minor issues: 1-6 hours +- Moderate issues: 12-24 hours +- Serious issues: 2-7 days + +**Shadow Bans:** +- Testing period: 24-48 hours +- Suspected automation: 3-7 days +- Behavioral modification: 1-2 weeks + +**Permanent Bans:** +- Reserved for severe violations +- Document thoroughly +- Provide appeal process + +### Communication + +**Documentation:** +- Always provide clear reason +- Use consistent language +- Reference community guidelines + +**User Communication:** +- Explain ban duration and reason +- Provide improvement guidelines +- Offer appeal process if applicable + +### Regular Maintenance + +**Weekly Tasks:** +- Review active bans +- Clean up expired bans +- Monitor ban effectiveness + +**Monthly Tasks:** +- Analyze ban patterns +- Update guidelines if needed +- Train new moderators diff --git a/docs/ban_system_usage.rst b/docs/ban_system_usage.rst new file mode 100644 index 00000000..b637cf66 --- /dev/null +++ b/docs/ban_system_usage.rst @@ -0,0 +1,207 @@ +""" +User Ban Management System Usage Examples +======================================== + +This file demonstrates how to use the comprehensive user ban management system +implemented in plone.app.discussion. + +## Overview + +The ban system provides three types of bans: + +1. **Cooldown Bans**: Temporary restrictions that automatically expire +2. **Shadow Bans**: Comments appear published to the author but are hidden from others +3. **Permanent Bans**: Complete restriction from commenting until manually lifted + +## Basic Usage + +### Enabling the Ban System + +First, enable the ban system in the discussion control panel: + +```python +from plone.registry.interfaces import IRegistry +from plone.app.discussion.interfaces import IDiscussionSettings +from zope.component import getUtility + +# Get the registry +registry = getUtility(IRegistry) +settings = registry.forInterface(IDiscussionSettings, check=False) + +# Enable the ban system +settings.ban_enabled = True +settings.shadow_ban_notification_enabled = False # Optional: hide shadow bans from users +settings.default_cooldown_duration = 24 # Default duration in hours +``` + +### Basic Ban Operations + +```python +from plone.app.discussion.ban import get_ban_manager, BAN_TYPE_COOLDOWN, BAN_TYPE_SHADOW, BAN_TYPE_PERMANENT + +# Get a ban manager for your content object +ban_manager = get_ban_manager(context) + +# Ban a user for 24 hours (cooldown) +cooldown_ban = ban_manager.ban_user( + user_id="problematic_user", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + reason="Repeatedly posting spam comments", + duration_hours=24 +) + +# Shadow ban a user (comments hidden from others) +shadow_ban = ban_manager.ban_user( + user_id="suspicious_user", + ban_type=BAN_TYPE_SHADOW, + moderator_id="admin", + reason="Suspected trolling behavior", + duration_hours=72 # 3 days +) + +# Permanently ban a user +permanent_ban = ban_manager.ban_user( + user_id="banned_user", + ban_type=BAN_TYPE_PERMANENT, + moderator_id="admin", + reason="Severe violation of community guidelines" +) +``` + +### Checking Ban Status + +```python +# Check if a user is banned +is_banned = ban_manager.is_user_banned("username") + +# Get ban details +ban = ban_manager.get_user_ban("username") +if ban: + print(f"User banned: {ban.ban_type}") + print(f"Reason: {ban.reason}") + if ban.expires_date: + print(f"Expires: {ban.expires_date}") + +# Check if user can comment (considers all ban types) +from plone.app.discussion.ban import can_user_comment, is_comment_visible + +can_comment = can_user_comment(context, "username") +comment_visible = is_comment_visible(context, "username") +``` + +### Managing Bans + +```python +# Get all active bans +active_bans = ban_manager.get_active_bans() +for ban in active_bans: + print(f"{ban.user_id}: {ban.ban_type} (expires: {ban.expires_date})") + +# Unban a user +ban_manager.unban_user("username", "admin") + +# Clean up expired bans +expired_count = ban_manager.cleanup_expired_bans() +print(f"Cleaned up {expired_count} expired bans") +``` + +## Web Interface Usage + +### Ban Management View + +Access the ban management interface at: +- `http://yoursite.com/@@ban-management` + +This provides: +- Quick ban form for users +- List of active bans with details +- Bulk actions (cleanup expired bans) + +### Individual User Ban Form + +Ban a specific user at: +- `http://yoursite.com/@@ban-user-form?user_id=username` + +Features: +- User information display +- Ban type selection with descriptions +- Duration setting for temporary bans +- Reason field + +## Advanced Usage + +### Custom Ban Duration + +```python +from datetime import datetime, timedelta + +# Custom expiration date +custom_expiry = datetime.now() + timedelta(days=7, hours=12) +ban_manager.ban_user( + user_id="user", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + expires_date=custom_expiry +) +``` + +### Integration with Comment Form + +The ban system automatically integrates with the comment form. When a banned user +tries to comment: + +```python +# This is handled automatically in CommentForm.handleComment() +from plone.app.discussion.browser.ban_integration import check_user_ban_before_comment + +# Returns False if user is banned, shows appropriate message +allowed = check_user_ban_before_comment(comment_form, data) +``` + +### Filtering Shadow Banned Comments + +```python +# Filter comments to hide shadow banned users' comments +from plone.app.discussion.browser.ban_integration import filter_shadow_banned_comments + +comments = conversation.getComments() +visible_comments = filter_shadow_banned_comments(comments, context) +``` + +## Permissions + +The ban system uses the existing "Review comments" permission: +- Users with this permission can ban/unban other users +- Regular users cannot see ban management interfaces + +## Notifications + +Ban notifications are shown to users via status messages: + +- **Cooldown Ban**: Shows remaining time +- **Shadow Ban**: Optional notification (configurable) +- **Permanent Ban**: Clear notification of permanent status + +## Storage + +Bans are stored in portal annotations using the key: +`plone.app.discussion:conversation` + +Data persists across restarts and is automatically cleaned up for expired bans. + +## Error Handling + +The system gracefully handles: +- Missing ban system (ImportError protection) +- Invalid user IDs +- Expired bans (automatic cleanup) +- Permission checks + +## Migration + +When upgrading from older versions: +1. Enable the ban system in the control panel +2. Existing comments remain unaffected +3. Ban data is stored separately from comment data +""" diff --git a/news/275.feature b/news/275.feature new file mode 100644 index 00000000..44ac0b74 --- /dev/null +++ b/news/275.feature @@ -0,0 +1 @@ +Adds user ban management system @rohnsha0 \ No newline at end of file diff --git a/src/plone/app/discussion/ban.py b/src/plone/app/discussion/ban.py new file mode 100644 index 00000000..a50c589c --- /dev/null +++ b/src/plone/app/discussion/ban.py @@ -0,0 +1,364 @@ +"""User ban management system for plone.app.discussion""" + +from datetime import datetime +from datetime import timedelta +from persistent import Persistent +from plone.app.discussion.interfaces import _ +from plone.app.discussion.interfaces import IDiscussionSettings +from plone.app.discussion.vocabularies import BAN_TYPE_PERMANENT +from plone.app.discussion.vocabularies import BAN_TYPE_SHADOW +from plone.base.utils import safe_text +from plone.registry.interfaces import IRegistry +from Products.CMFCore.utils import getToolByName +from zope import schema +from zope.annotation.interfaces import IAnnotations +from zope.component import getUtility +from zope.interface import implementer +from zope.interface import Interface + +import logging + + +logger = logging.getLogger("plone.app.discussion.ban") + +# Annotation key for storing ban data +BAN_ANNOTATION_KEY = "plone.app.discussion.bans" + + +class IBan(Interface): + """Represents a user ban.""" + + user_id = schema.TextLine( + title=_("User ID"), + description=_("The ID of the banned user"), + required=True, + ) + + ban_type = schema.Choice( + title=_("Ban Type"), + vocabulary="plone.app.discussion.vocabularies.BanVocabulary", + required=True, + ) + + created_date = schema.Datetime( + title=_("Created Date"), + description=_("When the ban was created"), + required=True, + ) + + expires_date = schema.Datetime( + title=_("Expiration Date"), + description=_("When the ban expires (None for permanent bans)"), + required=False, + ) + + reason = schema.TextLine( + title=_("Reason"), + description=_("Reason for the ban"), + required=False, + ) + + moderator_id = schema.TextLine( + title=_("Moderator ID"), + description=_("ID of the moderator who created the ban"), + required=True, + ) + + +@implementer(IBan) +class Ban(Persistent): + """Implementation of a user ban.""" + + def __init__( + self, + user_id, + ban_type, + moderator_id, + reason=None, + duration_hours=None, + expires_date=None, + ): + self.user_id = safe_text(user_id) + self.ban_type = ban_type + self.moderator_id = safe_text(moderator_id) + self.reason = safe_text(reason) if reason else None + self.created_date = datetime.now() + + # Permanent bans have no expiration + if ban_type == BAN_TYPE_PERMANENT: + self.expires_date = None + return + + # Use provided expiration date if available + if expires_date: + self.expires_date = expires_date + return + + # Calculate expiration based on duration + hours_to_add = duration_hours + if not hours_to_add: + registry = getUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings, check=False) + hours_to_add = getattr(settings, "default_cooldown_duration", 24) + + self.expires_date = self.created_date + timedelta(hours=hours_to_add) + + def is_active(self): + """Check if the ban is currently active.""" + # Permanent bans are always active + if self.ban_type == BAN_TYPE_PERMANENT: + return True + + # Check if the ban has expired + return self.expires_date and datetime.now() <= self.expires_date + + def get_remaining_time(self): + """Get remaining time for temporary bans.""" + # No remaining time for permanent bans or missing expiration + if self.ban_type == BAN_TYPE_PERMANENT or not self.expires_date: + return None + + # Calculate remaining time + now = datetime.now() + if now > self.expires_date: + return None + + return self.expires_date - now + + def __repr__(self): + return f"" + + +class IBanManager(Interface): + """Interface for managing user bans.""" + + def ban_user( + user_id, + ban_type, + moderator_id, + reason=None, + duration_hours=None, + expires_date=None, + ): + """Ban a user with the specified parameters.""" + + def unban_user(user_id, moderator_id): + """Remove all active bans for a user.""" + + def is_user_banned(user_id): + """Check if a user is currently banned.""" + + def get_user_ban(user_id): + """Get the active ban for a user, if any.""" + + def get_all_bans(): + """Get all bans (active and expired).""" + + def get_active_bans(): + """Get all currently active bans.""" + + def cleanup_expired_bans(): + """Remove expired bans from storage.""" + + +@implementer(IBanManager) +class BanManager: + """Manages user bans using portal annotations.""" + + def __init__(self, context): + self.context = context + self.portal = getToolByName(context, "portal_url").getPortalObject() + + def _get_ban_storage(self): + """Get the ban storage from portal annotations.""" + annotations = IAnnotations(self.portal) + if BAN_ANNOTATION_KEY not in annotations: + annotations[BAN_ANNOTATION_KEY] = {} + return annotations[BAN_ANNOTATION_KEY] + + def ban_user( + self, + user_id, + ban_type, + moderator_id, + reason=None, + duration_hours=None, + expires_date=None, + ): + """Ban a user with the specified parameters.""" + # Validate user_id + user_id = safe_text(user_id) if user_id else None + if not user_id: + logger.warning("Attempted to ban a user with empty user_id") + return None + + # Validate moderator_id + moderator_id = safe_text(moderator_id) if moderator_id else None + if not moderator_id: + logger.warning(f"Attempted to ban user {user_id} without moderator ID") + return None + + # Remove any existing bans for this user first + self.unban_user(user_id, moderator_id) + + # Create new ban object + ban = Ban( + user_id=user_id, + ban_type=ban_type, + moderator_id=moderator_id, + reason=reason, + duration_hours=duration_hours, + expires_date=expires_date, + ) + + # Store ban in annotation storage + storage = self._get_ban_storage() + storage[user_id] = ban + + logger.info(f"User {user_id} banned by {moderator_id} ({ban_type})") + return ban + + def unban_user(self, user_id, moderator_id): + """Remove all active bans for a user.""" + user_id = safe_text(user_id) if user_id else None + if not user_id: + return None + + storage = self._get_ban_storage() + if user_id in storage: + old_ban = storage[user_id] + del storage[user_id] + logger.info(f"User {user_id} unbanned by {moderator_id}") + return old_ban + return None + + def is_user_banned(self, user_id): + """Check if a user is currently banned.""" + ban = self.get_user_ban(user_id) + return ban is not None and ban.is_active() + + def get_user_ban(self, user_id): + """Get the active ban for a user, if any.""" + user_id = safe_text(user_id) if user_id else None + if not user_id: + return None + + storage = self._get_ban_storage() + ban = storage.get(user_id) + + if not ban: + return None + + # Return ban if active + if ban.is_active(): + return ban + elif ban and not ban.is_active(): + # Clean up expired ban + del storage[user_id] + + return None + + def get_all_bans(self): + """Get all bans (active and expired).""" + storage = self._get_ban_storage() + return list(storage.values()) + + def get_active_bans(self): + """Get all currently active bans.""" + return [ban for ban in self.get_all_bans() if ban.is_active()] + + def cleanup_expired_bans(self): + """Remove expired bans from storage.""" + storage = self._get_ban_storage() + now = datetime.now() + expired_users = [] + + # Find all expired bans + for user_id, ban in list(storage.items()): + # Check expiration without calling is_active() to avoid redundant datetime.now() calls + if ( + ban.ban_type != BAN_TYPE_PERMANENT + and ban.expires_date + and now > ban.expires_date + ): + expired_users.append(user_id) + + for user_id in expired_users: + del storage[user_id] + logger.info(f"Cleaned up expired ban for user {user_id}") + + return len(expired_users) + + def clear_all_bans(self): + """Remove all bans from storage. + + This method is intended to be used during uninstallation and by the + ban management view to clean up all ban data. + """ + storage = self._get_ban_storage() + count = len(storage) + if count > 0: + storage.clear() + logger.info(f"Cleared all {count} user bans") + return count + + +def get_ban_manager(context): + """Get a BanManager instance for the given context.""" + return BanManager(context) + + +def clear_all_bans(context): + """Utility function to clear all user bans. + + This is primarily used during uninstallation and by the ban management + view to clean up ban data. + """ + ban_manager = get_ban_manager(context) + return ban_manager.clear_all_bans() + + +def is_user_banned(context, user_id): + """Convenience function to check if a user is banned.""" + if not user_id: + return False + ban_manager = get_ban_manager(context) + return ban_manager.is_user_banned(user_id) + + +def get_user_ban_info(context, user_id): + """Get ban information for a user.""" + if not user_id: + return None + ban_manager = get_ban_manager(context) + return ban_manager.get_user_ban(user_id) + + +def can_user_comment(context, user_id): + """Check if a user can comment (not banned or shadow banned).""" + if not user_id: + return True + + ban_manager = get_ban_manager(context) + ban = ban_manager.get_user_ban(user_id) + + if not ban or not ban.is_active(): + return True + + # Shadow banned users can "comment" but comments are hidden + return ban.ban_type == BAN_TYPE_SHADOW + + +def is_comment_visible(context, user_id): + """Check if comments from a user should be visible to others.""" + if not user_id: + return True + + ban_manager = get_ban_manager(context) + ban = ban_manager.get_user_ban(user_id) + + if not ban or not ban.is_active(): + return True + + # Only shadow bans hide comments + return ban.ban_type != BAN_TYPE_SHADOW diff --git a/src/plone/app/discussion/browser/ban.py b/src/plone/app/discussion/browser/ban.py new file mode 100644 index 00000000..16f44c82 --- /dev/null +++ b/src/plone/app/discussion/browser/ban.py @@ -0,0 +1,490 @@ +"""Browser views for user ban management.""" + +from AccessControl import getSecurityManager +from AccessControl import Unauthorized +from Acquisition import aq_inner +from datetime import datetime +from plone.app.discussion.ban import get_ban_manager +from plone.app.discussion.interfaces import _ +from plone.app.discussion.interfaces import IBanUserSchema +from plone.app.discussion.interfaces import IDiscussionSettings +from plone.app.discussion.interfaces import IUnbanUserSchema +from plone.app.discussion.vocabularies import BAN_TYPE_COOLDOWN +from plone.app.discussion.vocabularies import BAN_TYPE_PERMANENT +from plone.app.discussion.vocabularies import BAN_TYPE_SHADOW +from plone.registry.interfaces import IRegistry +from plone.z3cform.layout import wrap_form +from Products.CMFCore.utils import getToolByName +from Products.Five.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from z3c.form import button +from z3c.form import field +from z3c.form import form +from zExceptions import NotFound +from zope.component import getUtility + + +PERMISSION_MANAGE_BANS = "Manage user bans" + + +class BanManagementMixin: + """Mixin class with common ban management functionality.""" + + def _get_ban_manager(self): + """Get ban manager for the current context.""" + return get_ban_manager(self.context) + + def _get_current_moderator_id(self): + """Get the current user's ID as moderator.""" + membership = getToolByName(self.context, "portal_membership") + moderator = membership.getAuthenticatedMember() + return moderator.getId() + + def format_time(self, date): + """Format a datetime object using Plone's localized time formatter. + + Uses long_format=True to include hours, minutes and seconds. + """ + portal = getToolByName(self.context, "portal_url").getPortalObject() + return portal.restrictedTraverse("@@plone").toLocalizedTime( + date, long_format=True + ) + + def _validate_user_id(self, user_id): + """Validate and return stripped user ID.""" + user_id = user_id.strip() if user_id else "" + if not user_id: + IStatusMessage(self.request).add(_("User ID is required."), type="error") + return None + return user_id + + def _redirect_to_ban_management(self): + """Redirect to ban management view.""" + self.request.response.redirect( + self.context.absolute_url() + "/@@ban-management" + ) + + def _create_ban_success_message(self, user_id, ban_type, duration=None): + """Create appropriate success message for ban creation.""" + message_map = { + BAN_TYPE_PERMANENT: _( + "User ${user_id} has been permanently banned.", + mapping={"user_id": user_id}, + ), + BAN_TYPE_SHADOW: _( + "User ${user_id} has been shadow banned.", + mapping={"user_id": user_id}, + ), + BAN_TYPE_COOLDOWN: _( + "User ${user_id} has been banned for ${duration} hours.", + mapping={"user_id": user_id, "duration": duration or 24}, + ), + } + return message_map.get(ban_type, message_map[BAN_TYPE_COOLDOWN]) + + def _unban_user_with_messages(self, user_id): + """Unban a user and show appropriate status messages.""" + user_id = self._validate_user_id(user_id) + if not user_id: + return False + + # Check if the user_id exists in the system + membership = getToolByName(self.context, "portal_membership") + member = membership.getMemberById(user_id) + if not member: + IStatusMessage(self.request).add( + _( + "User ${user_id} does not exist in the system.", + mapping={"user_id": user_id}, + ), + type="error", + ) + return False + + moderator_id = self._get_current_moderator_id() + ban_manager = self._get_ban_manager() + old_ban = ban_manager.unban_user(user_id, moderator_id) + + if old_ban is None: + IStatusMessage(self.request).add( + _("No ban found for ${user_id}", mapping={"user_id": user_id}), + type="error", + ) + return False + return True + + +class UserBanForm(form.Form, BanManagementMixin): + + fields = field.Fields(IBanUserSchema) + ignoreContext = True + label = "Ban User" + + def updateWidgets(self): + super().updateWidgets() + + @button.buttonAndHandler("Save") + def handleSave(self, action): + data, errors = self.extractData() + if errors: + return False + + user_id = data.get("user_id", "").strip() + ban_type = data.get("ban_type", BAN_TYPE_COOLDOWN) + reason = data.get("reason") + reason = reason.strip() if reason else "" + duration_hours = data.get("duration_hours") + + user_id = self._validate_user_id(user_id) + if not user_id: + return + + # Check if the user_id exists in the system + membership = getToolByName(self.context, "portal_membership") + member = membership.getMemberById(user_id) + if not member: + IStatusMessage(self.request).add( + _( + "User ${user_id} does not exist in the system.", + mapping={"user_id": user_id}, + ), + type="error", + ) + return + + # Check if the user is already banned + ban_manager = self._get_ban_manager() + existing_ban = ban_manager.get_user_ban(user_id) + if existing_ban and existing_ban.is_active(): + IStatusMessage(self.request).add( + _( + "User ${user_id} is already banned. Unban first if you want to change the ban type.", + mapping={"user_id": user_id}, + ), + type="error", + ) + return + + # Get current user as moderator + moderator_id = self._get_current_moderator_id() + + # Parse duration for temporary bans + duration = None + if ban_type != BAN_TYPE_PERMANENT and duration_hours: + try: + duration = int(duration_hours) + if duration <= 0: + raise ValueError("Duration must be positive") + except ValueError: + IStatusMessage(self.request).add( + _("Invalid duration. Please enter a positive number of hours."), + type="error", + ) + return + + # Create the ban + ban_manager = self._get_ban_manager() + try: + ban_manager.ban_user( + user_id=user_id, + ban_type=ban_type, + moderator_id=moderator_id, + reason=reason, + duration_hours=duration, + ) + + # Success message using mixin method + msg = self._create_ban_success_message(user_id, ban_type, duration) + IStatusMessage(self.request).add(msg, type="info") + + except Exception as e: + IStatusMessage(self.request).add( + _("Error banning user: ${error}", mapping={"error": str(e)}), + type="error", + ) + + self._redirect_to_ban_management() + + @button.buttonAndHandler("Cancel") + def handleCancel(self, action): + self._redirect_to_ban_management() + + +class UserUnbanForm(form.Form, BanManagementMixin): + """Form for unbanning a user.""" + + fields = field.Fields(IUnbanUserSchema) + ignoreContext = True + label = "Unban User" + + def updateWidgets(self): + super().updateWidgets() + + @button.buttonAndHandler("Unban") + def handleUnban(self, action): + data, errors = self.extractData() + if errors: + return False + + user_id = data.get("user_id", "") + + if self._unban_user_with_messages(user_id): + self._redirect_to_ban_management() + + +class BanManagementView(BrowserView, BanManagementMixin): + """View for managing user bans.""" + + template = ViewPageTemplateFile("ban_management.pt") + + def __call__(self): + """Process form submissions and render template.""" + if not self.can_manage_bans(): + raise Unauthorized("You do not have permission to manage bans.") + if self.is_ban_enabled() is False: + raise NotFound("Ban management is not enabled on this site.") + + if self.request.method == "POST": + self.process_form() + + return self.template() + + def is_ban_enabled(self): + """Check if the ban feature is enabled.""" + + registry = getUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings, check=False) + if not getattr(settings, "ban_enabled", False): + return False + return True + + def can_manage_bans(self): + """Check if current user can manage bans.""" + return getSecurityManager().checkPermission( + PERMISSION_MANAGE_BANS, aq_inner(self.context) + ) + + def process_form(self): + """Process ban management form submissions.""" + action = self.request.form.get("action") + + if action == "unban_user": + self.unban_user() + elif action == "cleanup_expired": + self.cleanup_expired_bans() + elif action == "unban_selected": + self.unban_selected_users() + elif action == "empty_ban_list": + self.empty_ban_list() + + def empty_ban_list(self): + """Remove all bans from the ban list.""" + from plone.app.discussion.ban import clear_all_bans + + count = clear_all_bans(self.context) + + IStatusMessage(self.request).add( + _("Removed all ${count} bans.", mapping={"count": count}), type="info" + ) + + def unban_user(self): + """Unban a user.""" + user_id = self.request.form.get("unban_user_id", "").strip() + + # Unban the user (validation happens in _unban_user_with_messages) + self._unban_user_with_messages(user_id) + + def cleanup_expired_bans(self): + """Clean up expired bans.""" + ban_manager = self._get_ban_manager() + count = ban_manager.cleanup_expired_bans() + + IStatusMessage(self.request).add( + _("Cleaned up ${count} expired bans.", mapping={"count": count}), + type="info", + ) + + def unban_selected_users(self): + """Unban all selected users.""" + selected_bans = self.request.form.get("selected_bans", []) + if not selected_bans: + IStatusMessage(self.request).add( + _("Please select at least one user to unban."), type="warning" + ) + return + + moderator_id = self._get_current_moderator_id() + ban_manager = self._get_ban_manager() + + unbanned_count = 0 + for user_id in selected_bans: + if ban_manager.unban_user(user_id, moderator_id): + unbanned_count += 1 + + if unbanned_count: + IStatusMessage(self.request).add( + _("Unbanned ${count} users.", mapping={"count": unbanned_count}), + type="info", + ) + else: + IStatusMessage(self.request).add( + _("No users were unbanned."), type="warning" + ) + + def get_active_bans(self): + """Get all active bans for display, with filtering and sorting.""" + ban_manager = self._get_ban_manager() + all_active_bans = ban_manager.get_active_bans() + + # Apply search filter + search_query = self.request.form.get("search_query", "").strip().lower() + filter_type = self.request.form.get("filter_type", "") + sort_by = self.request.form.get("sort_by", "date_desc") + + # Filter bans based on search query + if search_query: + filtered_bans = [] + for ban in all_active_bans: + # Search in user_id + if search_query in ban.user_id.lower(): + filtered_bans.append(ban) + continue + + # Search in moderator_id + if search_query in ban.moderator_id.lower(): + filtered_bans.append(ban) + continue + + # Search in reason if it exists + if ( + hasattr(ban, "reason") + and ban.reason + and search_query in ban.reason.lower() + ): + filtered_bans.append(ban) + continue + + # Search in user display name + user_display = self.get_user_display_name(ban.user_id).lower() + if search_query in user_display: + filtered_bans.append(ban) + continue + else: + filtered_bans = all_active_bans + + # Filter by ban type if specified + if filter_type: + filtered_bans = [ + ban + for ban in filtered_bans + if ban.ban_type.lower() == filter_type.lower() + ] + + # Apply sorting + if sort_by == "date_asc": + filtered_bans.sort(key=lambda ban: ban.created_date) + elif sort_by == "date_desc": + filtered_bans.sort(key=lambda ban: ban.created_date, reverse=True) + elif sort_by == "user_asc": + filtered_bans.sort(key=lambda ban: ban.user_id.lower()) + elif sort_by == "user_desc": + filtered_bans.sort(key=lambda ban: ban.user_id.lower(), reverse=True) + elif sort_by == "expires_asc": + # Sort by expiration date with permanent bans at the end + def get_expiration_key(ban): + if ban.ban_type == BAN_TYPE_PERMANENT: + # Use max datetime for permanent bans + return datetime.max + return ban.expires_date or datetime.max + + filtered_bans.sort(key=get_expiration_key) + elif sort_by == "expires_desc": + # Sort by expiration date with permanent bans at the beginning + def get_expiration_key(ban): + if ban.ban_type == BAN_TYPE_PERMANENT: + # Use min datetime for permanent bans to sort them first + return datetime.min + return ban.expires_date or datetime.min + + filtered_bans.sort(key=get_expiration_key, reverse=True) + + return filtered_bans + + def get_ban_type_display(self, ban_type): + """Get display name for ban type.""" + display_map = { + BAN_TYPE_COOLDOWN: _("Cooldown Ban"), + BAN_TYPE_SHADOW: _("Shadow Ban"), + BAN_TYPE_PERMANENT: _("Permanent Ban"), + } + return display_map.get(ban_type, ban_type) + + def get_user_display_name(self, user_id): + """Get display name for a user.""" + if not user_id: + return "" + + # Get member information + membership = getToolByName(self.context, "portal_membership") + member = membership.getMemberById(user_id) + + # If we have a valid member with a fullname, use it + if member: + fullname = member.getProperty("fullname", "").strip() + if fullname: + return f"{fullname} ({user_id})" + + return user_id + + +class UserBanStatusView(BrowserView, BanManagementMixin): + """View to check if current user is banned.""" + + def __call__(self): + """Return ban status information as JSON.""" + membership = getToolByName(self.context, "portal_membership") + member = membership.getAuthenticatedMember() + + # Default response for anonymous users or no ban + default_response = { + "banned": False, + "ban_type": None, + "can_comment": True, + "reason": None, + "created_date": None, + "expires_date": None, + } + + # Return default response for anonymous users + if not member or membership.isAnonymousUser(): + return default_response + + # Get ban information + user_id = member.getId() + ban_manager = self._get_ban_manager() + ban = ban_manager.get_user_ban(user_id) + + # Return default response if no active ban + if not ban or not ban.is_active(): + return default_response + + # Build response with ban information + result = { + "banned": True, + "ban_type": ban.ban_type, + "can_comment": ban.ban_type == BAN_TYPE_SHADOW, + "reason": ban.reason, + "created_date": ban.created_date.isoformat() if ban.created_date else None, + "expires_date": None, + } + + # Add expiration info for temporary bans + if ban.ban_type != BAN_TYPE_PERMANENT and ban.expires_date: + result["expires_date"] = ban.expires_date.isoformat() + + return result + + +UserBanFormView = wrap_form(UserBanForm) +UserUnbanFormView = wrap_form(UserUnbanForm) diff --git a/src/plone/app/discussion/browser/ban_integration.py b/src/plone/app/discussion/browser/ban_integration.py new file mode 100644 index 00000000..fb541991 --- /dev/null +++ b/src/plone/app/discussion/browser/ban_integration.py @@ -0,0 +1,186 @@ +"""Comment form integration for ban management.""" + +from plone.app.discussion.ban import can_user_comment +from plone.app.discussion.ban import get_ban_manager +from plone.app.discussion.ban import is_comment_visible +from plone.app.discussion.interfaces import _ +from plone.app.discussion.interfaces import IDiscussionSettings +from plone.app.discussion.vocabularies import BAN_TYPE_COOLDOWN +from plone.app.discussion.vocabularies import BAN_TYPE_PERMANENT +from plone.registry.interfaces import IRegistry +from Products.CMFCore.utils import getToolByName +from Products.statusmessages.interfaces import IStatusMessage +from zope.component import getUtility + + +def check_user_ban_before_comment(comment_form, data): + """Check if user is banned before allowing comment submission. + + This function should be called from the comment form's handleComment method. + Returns True if comment should be allowed, False otherwise. + """ + context = comment_form.context + request = comment_form.request + + # Check if ban system is enabled + registry = getUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings, check=False) + if not getattr(settings, "ban_enabled", False): + return True # Ban system disabled, allow comment + + # Get current user + membership = getToolByName(context, "portal_membership") + if membership.isAnonymousUser(): + return True # Anonymous users are not subject to bans + + member = membership.getAuthenticatedMember() + user_id = member.getId() + + # Check if user can comment (considering bans) + if not can_user_comment(context, user_id): + ban_manager = get_ban_manager(context) + ban = ban_manager.get_user_ban(user_id) + + if ban and ban.is_active(): + # User is banned, show appropriate message + if ban.ban_type == BAN_TYPE_PERMANENT: + message = _( + "You have been permanently banned from commenting. Reason: ${reason}", + mapping={"reason": ban.reason or _("No reason provided")}, + ) + elif ban.ban_type == BAN_TYPE_COOLDOWN: + remaining = ban.get_remaining_time() + if remaining: + hours = int(remaining.total_seconds() // 3600) + minutes = int((remaining.total_seconds() % 3600) // 60) + if hours > 0: + time_str = _( + "${hours} hours and ${minutes} minutes", + mapping={"hours": hours, "minutes": minutes}, + ) + else: + time_str = _("${minutes} minutes", mapping={"minutes": minutes}) + + message = _( + "You are temporarily banned from commenting for ${time}. Reason: ${reason}", + mapping={ + "time": time_str, + "reason": ban.reason or _("No reason provided"), + }, + ) + else: + message = _( + "Your comment ban has expired. Please refresh the page." + ) + else: + message = _("You are currently banned from commenting.") + + IStatusMessage(request).add(message, type="error") + return False + + return True + + +def process_shadow_banned_comment(comment, context): + """Process a comment from a shadow banned user. + + This function should be called after a comment is created to handle + shadow ban logic. + """ + # Get comment author + author_id = getattr(comment, "creator", None) or getattr( + comment, "author_username", None + ) + if not author_id: + return # Can't determine author + + # Check if author is shadow banned + if not is_comment_visible(context, author_id): + # Comment is from shadow banned user + # We don't need to do anything special here since visibility + # will be handled by the catalog and views + pass + + +def filter_shadow_banned_comments(comments, context): + """Filter out comments from shadow banned users for non-authors. + + Args: + comments: Iterable of comment objects + context: Current context object + + Returns: + Filtered list of comments + """ + membership = getToolByName(context, "portal_membership") + current_user = None + + if not membership.isAnonymousUser(): + member = membership.getAuthenticatedMember() + current_user = member.getId() + + filtered_comments = [] + + for comment in comments: + # Get comment author + author_id = getattr(comment, "creator", None) or getattr( + comment, "author_username", None + ) + + if not author_id: + # Anonymous comment or can't determine author + filtered_comments.append(comment) + continue + + # If current user is the comment author, always show their comments + if current_user == author_id: + filtered_comments.append(comment) + continue + + # Check if comment should be visible (not from shadow banned user) + if is_comment_visible(context, author_id): + filtered_comments.append(comment) + # If not visible, skip this comment (shadow banned) + + return filtered_comments + + +def get_ban_status_for_user(context, user_id): + """Get ban status information for a specific user. + + Returns a dict with ban information or None if not banned. + """ + ban_manager = get_ban_manager(context) + ban = ban_manager.get_user_ban(user_id) + + if not ban or not ban.is_active(): + return None + + status = { + "banned": True, + "ban_type": ban.ban_type, + "reason": ban.reason, + "created_date": ban.created_date, + "moderator_id": ban.moderator_id, + } + + if ban.ban_type != BAN_TYPE_PERMANENT: + status["expires_date"] = ban.expires_date + + return status + + +def add_ban_info_to_comment_data(comment_data, context): + """Add ban information to comment data for templates. + + This can be used to show ban status in comment listings. + """ + author_id = comment_data.get("creator") or comment_data.get("author_username") + if not author_id: + return comment_data + + ban_status = get_ban_status_for_user(context, author_id) + if ban_status: + comment_data["author_ban_status"] = ban_status + + return comment_data diff --git a/src/plone/app/discussion/browser/ban_management.pt b/src/plone/app/discussion/browser/ban_management.pt new file mode 100644 index 00000000..4019a037 --- /dev/null +++ b/src/plone/app/discussion/browser/ban_management.pt @@ -0,0 +1,882 @@ + + + + User Ban Management + + + + + + + + + +
+ +
+
+

User Ban Management

+
+ Manage user bans to control access to commenting. View active bans, create new restrictions, and monitor ban statistics. +
+
+
+ + +
+
+ Search and Filter +
+
+ +
+
+ + +
+ +
+ Active filters: + + + query + + × + + + + + Type + + × + + + + + Sort + + × + + +
+ + + Clear all + +
+ + +
+ +
+
+
+ Ban Statistics +
+
+
+
+
+
0
+ Cooldown +
+
+
+
+
0
+ Shadow +
+
+
+
0
+ Permanent +
+
+
+
+
+ + +
+
+
+ Quick Actions +
+
+
+
+ + Ban User + +
+ + Restrict a user's commenting ability + +
+
+
+ + Quick Unban + +
+ + Remove restrictions for a user + +
+
+
+
+
+
+
+ + +
+
+
+
+ Active Bans +
+
+ 0 + bans +
+
+
+ + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
UserTypeCreatedExpiresReasonModerator
+
+ + +
+
+
+ User +
+
+ Type + + Created + + Expires + _ + + Reason + + Moderator +
+
+
+ + + +
+ + +
+
+

+ No bans match your search criteria +

+

+ No active bans found +

+

There are currently no active user bans. Users can comment freely.

+
+
+
+ + + + + +
+
+ + + + + + + diff --git a/src/plone/app/discussion/browser/comments.py b/src/plone/app/discussion/browser/comments.py index e7f82293..7cc0713e 100644 --- a/src/plone/app/discussion/browser/comments.py +++ b/src/plone/app/discussion/browser/comments.py @@ -3,6 +3,9 @@ from Acquisition import aq_inner from DateTime import DateTime from plone.app.discussion import _ +from plone.app.discussion.browser.ban_integration import check_user_ban_before_comment +from plone.app.discussion.browser.ban_integration import filter_shadow_banned_comments +from plone.app.discussion.browser.ban_integration import process_shadow_banned_comment from plone.app.discussion.browser.utils import format_author_name_with_suffix from plone.app.discussion.browser.validator import CaptchaValidator from plone.app.discussion.interfaces import ICaptcha @@ -245,6 +248,10 @@ def handleComment(self, action): if errors: return + # Check for user bans before processing comment + if not check_user_ban_before_comment(self, data): + return + # Validate Captcha registry = queryUtility(IRegistry) settings = registry.forInterface(IDiscussionSettings, check=False) @@ -274,6 +281,9 @@ def handleComment(self, action): # Add a comment to the conversation comment_id = conversation.addComment(comment) + # Process shadow banned comments + process_shadow_banned_comment(comment, context) + # Redirect after form submit: # If a user posts a comment and moderation is enabled, a message is # shown to the user that his/her comment awaits moderation. If the user @@ -471,12 +481,29 @@ def published_replies(): r["workflow_status"] = workflow_status yield r + def published_replies_filtered(): + # Generator that returns published replies, filtering shadow banned comments + published_comments = [r["comment"] for r in published_replies()] + filtered_comments = filter_shadow_banned_comments( + published_comments, context + ) + + # Rebuild the thread structure with filtered comments + for r in conversation.getThreads(): + comment_obj = r["comment"] + if comment_obj in filtered_comments: + workflow_status = wf.getInfoFor(comment_obj, "review_state") + if workflow_status == "published": + r = r.copy() + r["workflow_status"] = workflow_status + yield r + # Return all direct replies if len(conversation.objectIds()): if workflow_actions: return replies_with_workflow_actions() else: - return published_replies() + return published_replies_filtered() def get_commenter_home_url(self, username=None): if username is None: diff --git a/src/plone/app/discussion/browser/configure.zcml b/src/plone/app/discussion/browser/configure.zcml index 6fd48771..29876afa 100644 --- a/src/plone/app/discussion/browser/configure.zcml +++ b/src/plone/app/discussion/browser/configure.zcml @@ -157,6 +157,37 @@ permission="cmf.ManagePortal" /> + + + + + + + + + + + + + + diff --git a/src/plone/app/discussion/profiles/default/rolemap.xml b/src/plone/app/discussion/profiles/default/rolemap.xml index 8aad7ab1..bd5453ac 100644 --- a/src/plone/app/discussion/profiles/default/rolemap.xml +++ b/src/plone/app/discussion/profiles/default/rolemap.xml @@ -41,5 +41,12 @@ + + + + + diff --git a/src/plone/app/discussion/setuphandlers.py b/src/plone/app/discussion/setuphandlers.py index 19afede9..6078e5c0 100644 --- a/src/plone/app/discussion/setuphandlers.py +++ b/src/plone/app/discussion/setuphandlers.py @@ -56,4 +56,14 @@ def post_install(context): def post_uninstall(context): """Post uninstall script""" + # Remove discussion behavior from content types remove_discussion_behavior_to_default_types(context) + + # Clean up all banned user data + try: + from plone.app.discussion.ban import clear_all_bans + + clear_all_bans(context) + except (ImportError, AttributeError): + # If the ban module is not available or function not found, just continue + pass diff --git a/src/plone/app/discussion/tests/test_ban.py b/src/plone/app/discussion/tests/test_ban.py new file mode 100644 index 00000000..78e3e7b6 --- /dev/null +++ b/src/plone/app/discussion/tests/test_ban.py @@ -0,0 +1,419 @@ +"""Tests for the ban management system.""" + +from datetime import datetime +from datetime import timedelta +from plone.app.discussion.ban import BanManager +from plone.app.discussion.ban import can_user_comment +from plone.app.discussion.ban import get_ban_manager +from plone.app.discussion.ban import is_comment_visible +from plone.app.discussion.interfaces import IDiscussionSettings +from plone.app.discussion.testing import PLONE_APP_DISCUSSION_INTEGRATION_TESTING +from plone.app.discussion.vocabularies import BAN_TYPE_COOLDOWN +from plone.app.discussion.vocabularies import BAN_TYPE_PERMANENT +from plone.app.discussion.vocabularies import BAN_TYPE_SHADOW +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.registry.interfaces import IRegistry +from zope.annotation.interfaces import IAnnotations +from zope.component import queryUtility + +import unittest + + +class TestBanManager(unittest.TestCase): + """Test the BanManager class.""" + + layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable the ban system in registry + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IDiscussionSettings, check=False) + self.settings.ban_enabled = True + + # Get a ban manager + self.ban_manager = get_ban_manager(self.portal) + + def test_ban_manager_creation(self): + """Test creating a BanManager.""" + self.assertTrue(isinstance(self.ban_manager, BanManager)) + + def test_ban_storage_initialization(self): + """Test that the ban storage is properly initialized.""" + storage = self.ban_manager._get_ban_storage() + self.assertTrue(hasattr(storage, "__getitem__")) + self.assertTrue(hasattr(storage, "__setitem__")) + self.assertTrue(hasattr(storage, "__delitem__")) + + def test_ban_user(self): + """Test banning a user.""" + # Ban a user + ban = self.ban_manager.ban_user( + user_id="testuser", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + reason="Test reason", + duration_hours=24, + ) + + # Check that the ban was created correctly + self.assertEqual(ban.user_id, "testuser") + self.assertEqual(ban.ban_type, BAN_TYPE_COOLDOWN) + self.assertEqual(ban.moderator_id, "admin") + self.assertEqual(ban.reason, "Test reason") + + # Check if the ban was stored properly + stored_ban = self.ban_manager.get_user_ban("testuser") + self.assertEqual(stored_ban.user_id, "testuser") + self.assertEqual(stored_ban.ban_type, BAN_TYPE_COOLDOWN) + + def test_is_user_banned(self): + """Test checking if a user is banned.""" + # Initially the user is not banned + self.assertFalse(self.ban_manager.is_user_banned("testuser")) + + # Ban the user + self.ban_manager.ban_user( + user_id="testuser", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + # Now the user should be banned + self.assertTrue(self.ban_manager.is_user_banned("testuser")) + + def test_unban_user(self): + """Test unbanning a user.""" + # Ban a user + self.ban_manager.ban_user( + user_id="testuser", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + # Verify the user is banned + self.assertTrue(self.ban_manager.is_user_banned("testuser")) + + # Unban the user + old_ban = self.ban_manager.unban_user("testuser", "admin") + + # Check that unban returned the removed ban + self.assertEqual(old_ban.user_id, "testuser") + + # Verify the user is no longer banned + self.assertFalse(self.ban_manager.is_user_banned("testuser")) + self.assertIsNone(self.ban_manager.get_user_ban("testuser")) + + def test_get_active_bans(self): + """Test retrieving active bans.""" + # Create several bans + self.ban_manager.ban_user( + user_id="user1", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + self.ban_manager.ban_user( + user_id="user2", + ban_type=BAN_TYPE_SHADOW, + moderator_id="admin", + duration_hours=48, + ) + self.ban_manager.ban_user( + user_id="user3", + ban_type=BAN_TYPE_PERMANENT, + moderator_id="admin", + ) + + # Create an expired ban + past_date = datetime.now() - timedelta(hours=1) + self.ban_manager.ban_user( + user_id="expired_user", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + expires_date=past_date, + ) + + # Get active bans + active_bans = self.ban_manager.get_active_bans() + + # There should be 3 active bans + self.assertEqual(len(active_bans), 3) + + # Check user IDs of active bans + active_user_ids = [ban.user_id for ban in active_bans] + self.assertIn("user1", active_user_ids) + self.assertIn("user2", active_user_ids) + self.assertIn("user3", active_user_ids) + self.assertNotIn("expired_user", active_user_ids) + + def test_cleanup_expired_bans(self): + """Test cleaning up expired bans.""" + # Create an active ban + self.ban_manager.ban_user( + user_id="active_user", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + # Create expired bans + past_date = datetime.now() - timedelta(hours=1) + self.ban_manager.ban_user( + user_id="expired_user1", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + expires_date=past_date, + ) + self.ban_manager.ban_user( + user_id="expired_user2", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + expires_date=past_date, + ) + + # Clean up expired bans + removed_count = self.ban_manager.cleanup_expired_bans() + + # 2 bans should have been removed + self.assertEqual(removed_count, 2) + + # Check that expired bans were removed + self.assertIsNone(self.ban_manager.get_user_ban("expired_user1")) + self.assertIsNone(self.ban_manager.get_user_ban("expired_user2")) + + # Check that active ban remains + self.assertIsNotNone(self.ban_manager.get_user_ban("active_user")) + + def test_clear_all_bans(self): + """Test clearing all bans.""" + # Create several bans + self.ban_manager.ban_user( + user_id="user1", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + self.ban_manager.ban_user( + user_id="user2", + ban_type=BAN_TYPE_SHADOW, + moderator_id="admin", + duration_hours=48, + ) + self.ban_manager.ban_user( + user_id="user3", + ban_type=BAN_TYPE_PERMANENT, + moderator_id="admin", + ) + + # Verify users are banned + self.assertTrue(self.ban_manager.is_user_banned("user1")) + self.assertTrue(self.ban_manager.is_user_banned("user2")) + self.assertTrue(self.ban_manager.is_user_banned("user3")) + + # Clear all bans + count = self.ban_manager.clear_all_bans() + + # Should have removed 3 bans + self.assertEqual(count, 3) + + # Verify no users are banned anymore + self.assertFalse(self.ban_manager.is_user_banned("user1")) + self.assertFalse(self.ban_manager.is_user_banned("user2")) + self.assertFalse(self.ban_manager.is_user_banned("user3")) + + # Storage should be empty + storage = self.ban_manager._get_ban_storage() + self.assertEqual(len(storage), 0) + + +class TestBanHelperFunctions(unittest.TestCase): + """Test the ban helper functions.""" + + layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable the ban system in registry + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IDiscussionSettings, check=False) + self.settings.ban_enabled = True + + # Get a ban manager + self.ban_manager = get_ban_manager(self.portal) + + def test_get_ban_manager(self): + """Test getting a ban manager for a context.""" + ban_manager = get_ban_manager(self.portal) + self.assertTrue(isinstance(ban_manager, BanManager)) + + def test_is_user_banned_helper(self): + """Test is_user_banned helper function.""" + from plone.app.discussion.ban import is_user_banned + + # Initially the user is not banned + self.assertFalse(is_user_banned(self.portal, "testuser")) + + # Ban the user + self.ban_manager.ban_user( + user_id="testuser", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + # Now the user should be banned + self.assertTrue(is_user_banned(self.portal, "testuser")) + + def test_can_user_comment(self): + """Test can_user_comment function.""" + # Initially, users can comment + self.assertTrue(can_user_comment(self.portal, "regular_user")) + self.assertTrue(can_user_comment(self.portal, "shadow_banned_user")) + self.assertTrue(can_user_comment(self.portal, "cooldown_user")) + + # Ban users with different ban types + self.ban_manager.ban_user( + user_id="cooldown_user", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + self.ban_manager.ban_user( + user_id="shadow_banned_user", + ban_type=BAN_TYPE_SHADOW, + moderator_id="admin", + duration_hours=24, + ) + + self.ban_manager.ban_user( + user_id="permanent_user", + ban_type=BAN_TYPE_PERMANENT, + moderator_id="admin", + ) + + # Check if users can comment based on their ban status + self.assertTrue(can_user_comment(self.portal, "regular_user")) # Not banned + self.assertTrue( + can_user_comment(self.portal, "shadow_banned_user") + ) # Shadow bans can comment + self.assertFalse( + can_user_comment(self.portal, "cooldown_user") + ) # Cooldown bans can't comment + self.assertFalse( + can_user_comment(self.portal, "permanent_user") + ) # Permanent bans can't comment + + def test_is_comment_visible(self): + """Test is_comment_visible function.""" + # Initially, comments from all users are visible + self.assertTrue(is_comment_visible(self.portal, "regular_user")) + self.assertTrue(is_comment_visible(self.portal, "shadow_banned_user")) + self.assertTrue(is_comment_visible(self.portal, "cooldown_user")) + + # Ban users with different ban types + self.ban_manager.ban_user( + user_id="cooldown_user", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + self.ban_manager.ban_user( + user_id="shadow_banned_user", + ban_type=BAN_TYPE_SHADOW, + moderator_id="admin", + duration_hours=24, + ) + + self.ban_manager.ban_user( + user_id="permanent_user", + ban_type=BAN_TYPE_PERMANENT, + moderator_id="admin", + ) + + # Check comment visibility based on user's ban status + self.assertTrue(is_comment_visible(self.portal, "regular_user")) # Not banned + self.assertFalse( + is_comment_visible(self.portal, "shadow_banned_user") + ) # Shadow banned comments are hidden + self.assertTrue( + is_comment_visible(self.portal, "cooldown_user") + ) # Cooldown ban comments still visible + self.assertTrue( + is_comment_visible(self.portal, "permanent_user") + ) # Permanent ban comments still visible + + +class TestBanIntegration(unittest.TestCase): + """Integration tests for the ban system.""" + + layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable the ban system in registry + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IDiscussionSettings, check=False) + self.settings.ban_enabled = True + self.settings.default_cooldown_duration = 12 # Set default duration to 12 hours + + # Get a ban manager + self.ban_manager = get_ban_manager(self.portal) + + def test_default_settings_from_registry(self): + """Test that ban uses default settings from registry.""" + # Ban a user without specifying duration + ban = self.ban_manager.ban_user( + user_id="testuser", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + ) + + # Check that the default duration from registry was used + time_diff = ban.expires_date - ban.created_date + hours_diff = time_diff.total_seconds() / 3600 + self.assertAlmostEqual(hours_diff, 12, delta=0.1) + + def test_ban_persistence(self): + """Test that bans are persisted in annotations.""" + # Ban a user + self.ban_manager.ban_user( + user_id="testuser", + ban_type=BAN_TYPE_COOLDOWN, + moderator_id="admin", + duration_hours=24, + ) + + # Get annotations directly and check if the ban is there + from plone.app.discussion.ban import BAN_ANNOTATION_KEY + + annotations = IAnnotations(self.portal) + self.assertIn(BAN_ANNOTATION_KEY, annotations) + + ban_storage = annotations[BAN_ANNOTATION_KEY] + self.assertIn("testuser", ban_storage) + self.assertEqual(ban_storage["testuser"].ban_type, BAN_TYPE_COOLDOWN) + + def test_shadow_ban_notification_setting(self): + """Test the shadow ban notification setting.""" + # Set notification setting + self.settings.shadow_ban_notification_enabled = True + + # This doesn't directly affect any behavior in the ban module + # It's used by the UI layer to determine whether to show notifications + # So we just test that the setting can be changed + self.assertTrue(self.settings.shadow_ban_notification_enabled) + + self.settings.shadow_ban_notification_enabled = False + self.assertFalse(self.settings.shadow_ban_notification_enabled) diff --git a/src/plone/app/discussion/tests/test_permissions.py b/src/plone/app/discussion/tests/test_permissions.py index ce14146b..624ae114 100644 --- a/src/plone/app/discussion/tests/test_permissions.py +++ b/src/plone/app/discussion/tests/test_permissions.py @@ -17,3 +17,14 @@ def test_permissions_site_administrator_role(self): "Site Administrator" not in rolesForPermissionOn("Reply to item", self.layer["portal"]) ) + + def test_manage_bans_permission(self): + # Test that the manage bans permission is assigned to the proper roles + roles = rolesForPermissionOn("Manage user bans", self.layer["portal"]) + + # Ensure Site Administrator and Manager roles have the permission + self.assertIn("Manager", roles) + self.assertIn("Site Administrator", roles) + + # Moderators (with the Reviewer role) should have this permission + self.assertIn("Reviewer", roles) diff --git a/src/plone/app/discussion/upgrades.py b/src/plone/app/discussion/upgrades.py index cab45926..c5de74d1 100644 --- a/src/plone/app/discussion/upgrades.py +++ b/src/plone/app/discussion/upgrades.py @@ -120,3 +120,30 @@ def set_timezone_on_dates(context): def set_discussion_behavior(context): """Add the discussion behavior to all default types, if they exist.""" add_discussion_behavior_to_default_types(context) + + +def upgrade_ban_system_registry(context): + """Add ban system settings to the registry.""" + logger.info("Upgrading ban system registry settings") + registry = getUtility(IRegistry) + + # Ensure the discussion settings interface is registered + registry.registerInterface(IDiscussionSettings) + + # Set default values for ban system if not already set + settings = registry.forInterface(IDiscussionSettings, check=False) + + # Initialize ban system settings with safe defaults + if not hasattr(settings, "ban_enabled"): + settings.ban_enabled = False + logger.info("Added ban_enabled setting (default: False)") + + if not hasattr(settings, "shadow_ban_notification_enabled"): + settings.shadow_ban_notification_enabled = False + logger.info("Added shadow_ban_notification_enabled setting (default: False)") + + if not hasattr(settings, "default_cooldown_duration"): + settings.default_cooldown_duration = 24 + logger.info("Added default_cooldown_duration setting (default: 24 hours)") + + logger.info("Ban system registry upgrade completed") diff --git a/src/plone/app/discussion/vocabularies.py b/src/plone/app/discussion/vocabularies.py index 3d28ee49..0d01f878 100644 --- a/src/plone/app/discussion/vocabularies.py +++ b/src/plone/app/discussion/vocabularies.py @@ -3,6 +3,10 @@ from zope.schema.vocabulary import SimpleVocabulary +BAN_TYPE_COOLDOWN = "cooldown" +BAN_TYPE_SHADOW = "shadow" +BAN_TYPE_PERMANENT = "permanent" + HAS_CAPTCHA = False try: import plone.formwidget.captcha # noqa @@ -87,3 +91,17 @@ def text_transform_vocabulary(context): ) ) return SimpleVocabulary(terms) + + +def ban_type_vocabulary(context): + """Vocabulary for ban types.""" + terms = [ + SimpleTerm( + value=BAN_TYPE_COOLDOWN, token=BAN_TYPE_COOLDOWN, title=_("Cooldown") + ), + SimpleTerm(value=BAN_TYPE_SHADOW, token=BAN_TYPE_SHADOW, title=_("Shadow")), + SimpleTerm( + value=BAN_TYPE_PERMANENT, token=BAN_TYPE_PERMANENT, title=_("Permanent") + ), + ] + return SimpleVocabulary(terms)