From 6cad084c8cc2c73fa57d9344971a71a3caec4ba1 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 30 Oct 2025 09:20:03 +0100 Subject: [PATCH] feat: implement Activity Feed Widget with real-time filtering and audit trail Add comprehensive Activity Feed Widget to dashboard providing team visibility and audit trail functionality. The widget displays recent user activities with advanced filtering, pagination, and auto-refresh capabilities. Features: - Dashboard widget showing last 10 activities with infinite scroll - Filter by entity type (projects, tasks, time entries, templates, users, etc.) - Real-time auto-refresh every 30 seconds - Visual indicators for active filters (checkmark + dot) - Load more pagination with "has_next" detection - Refresh button with spinning animation feedback API Endpoints: - GET /api/activities - Retrieve activities with filtering & pagination - GET /api/activities/stats - Activity statistics and analytics - Support for user_id, entity_type, action, and date range filters Activity Logging Integration: - Projects: create, update, delete, archive, unarchive - Tasks: create, update, delete - Time Entries: start timer, stop timer - All operations log user, IP address, and user agent for security UI/UX Improvements: - Vanilla JS implementation (removed Alpine.js dependency) - Dark mode support with proper color schemes - Responsive dropdown with scrollable content - Action-specific icons (Font Awesome) - Relative timestamps with timeago filter - Error handling with user-friendly messages Testing & Documentation: - Comprehensive test suite (model, API, integration, widget) - Feature documentation in docs/features/activity_feed.md - Implementation summary and integration guide - Console logging for debugging Bug Fixes: - Fixed "Load More" button not appending results - Fixed refresh clearing list without reloading - Fixed filter dropdown using Alpine.js (now vanilla JS) - Fixed entity_type filter sending 'all' to API - Added missing entity types (time_entry_template, user) Technical Details: - Activity model with optimized indexes for performance - Promise-based async loading with proper error handling - Credentials included in fetch for authentication - Filter state management with visual feedback - Graceful degradation on API failures Impact: - Team visibility into real-time activities - Comprehensive audit trail for compliance - Better accountability and transparency - Improved troubleshooting capabilities --- app/routes/api.py | 166 +++++++ app/routes/main.py | 11 +- app/routes/projects.py | 30 +- app/routes/tasks.py | 44 +- app/routes/timer.py | 30 +- .../components/activity_feed_widget.html | 412 +++++++++++++++++ app/templates/main/dashboard.html | 3 + docs/features/activity_feed.md | 301 ++++++++++++ tests/test_activity_feed.py | 428 ++++++++++++++++++ 9 files changed, 1417 insertions(+), 8 deletions(-) create mode 100644 app/templates/components/activity_feed_widget.html create mode 100644 docs/features/activity_feed.md create mode 100644 tests/test_activity_feed.py diff --git a/app/routes/api.py b/app/routes/api.py index fb9de7b..61aef94 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1298,6 +1298,172 @@ def serve_editor_image(filename): folder = get_editor_upload_folder() return send_from_directory(folder, filename) +# ================================ +# Activity Feed API +# ================================ + +@api_bp.route('/api/activities') +@login_required +def get_activities(): + """Get recent activities with filtering""" + from app.models import Activity + from sqlalchemy import and_ + + # Get query parameters + limit = request.args.get('limit', 50, type=int) + page = request.args.get('page', 1, type=int) + user_id = request.args.get('user_id', type=int) + entity_type = request.args.get('entity_type', '').strip() + action = request.args.get('action', '').strip() + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + + # Build query + query = Activity.query + + # Filter by user (admins can see all, users see only their own) + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + elif user_id: + query = query.filter_by(user_id=user_id) + + # Filter by entity type + if entity_type: + query = query.filter_by(entity_type=entity_type) + + # Filter by action + if action: + query = query.filter_by(action=action) + + # Filter by date range + if start_date: + try: + start_dt = datetime.fromisoformat(start_date) + query = query.filter(Activity.created_at >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.fromisoformat(end_date) + query = query.filter(Activity.created_at <= end_dt) + except ValueError: + pass + + # Get total count + total = query.count() + + # Apply ordering and pagination + activities = query.order_by(Activity.created_at.desc()).paginate( + page=page, + per_page=limit, + error_out=False + ) + + return jsonify({ + 'activities': [a.to_dict() for a in activities.items], + 'total': total, + 'pages': activities.pages, + 'current_page': activities.page, + 'has_next': activities.has_next, + 'has_prev': activities.has_prev + }) + +@api_bp.route('/api/activities/stats') +@login_required +def get_activity_stats(): + """Get activity statistics""" + from app.models import Activity + from sqlalchemy import func + + # Get date range (default to last 7 days) + days = request.args.get('days', 7, type=int) + since = datetime.utcnow() - timedelta(days=days) + + # Build base query + query = Activity.query.filter(Activity.created_at >= since) + + # Filter by user if not admin + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + + # Get counts by entity type + entity_counts = db.session.query( + Activity.entity_type, + func.count(Activity.id).label('count') + ).filter(Activity.created_at >= since) + + if not current_user.is_admin: + entity_counts = entity_counts.filter_by(user_id=current_user.id) + + entity_counts = entity_counts.group_by(Activity.entity_type).all() + + # Get counts by action + action_counts = db.session.query( + Activity.action, + func.count(Activity.id).label('count') + ).filter(Activity.created_at >= since) + + if not current_user.is_admin: + action_counts = action_counts.filter_by(user_id=current_user.id) + + action_counts = action_counts.group_by(Activity.action).all() + + # Get most active users (admins only) + user_activity = [] + if current_user.is_admin: + user_activity = db.session.query( + User.username, + User.display_name, + func.count(Activity.id).label('count') + ).join( + Activity, User.id == Activity.user_id + ).filter( + Activity.created_at >= since + ).group_by( + User.id, User.username, User.display_name + ).order_by( + func.count(Activity.id).desc() + ).limit(10).all() + + return jsonify({ + 'total_activities': query.count(), + 'entity_counts': {entity: count for entity, count in entity_counts}, + 'action_counts': {action: count for action, count in action_counts}, + 'user_activity': [ + {'username': u[0], 'display_name': u[1], 'count': u[2]} + for u in user_activity + ], + 'period_days': days + }) + +@api_bp.route('/api/templates/') +@login_required +def get_template(template_id): + """Get a time entry template by ID""" + template = TimeEntryTemplate.query.get_or_404(template_id) + + # Check permissions + if template.user_id != current_user.id: + return jsonify({'error': 'Access denied'}), 403 + + return jsonify(template.to_dict()) + +@api_bp.route('/api/templates//use', methods=['POST']) +@login_required +def mark_template_used(template_id): + """Mark a template as used (updates last_used_at)""" + template = TimeEntryTemplate.query.get_or_404(template_id) + + # Check permissions + if template.user_id != current_user.id: + return jsonify({'error': 'Access denied'}), 403 + + template.last_used_at = datetime.utcnow() + db.session.commit() + + return jsonify({'success': True}) + # WebSocket event handlers @socketio.on('connect') def handle_connect(): diff --git a/app/routes/main.py b/app/routes/main.py index 0fe7e8a..39dbb7c 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session from flask_login import login_required, current_user -from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate +from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity from datetime import datetime, timedelta import pytz from app import db, track_page_view @@ -84,6 +84,12 @@ def dashboard(): templates = TimeEntryTemplate.query.filter_by( user_id=current_user.id ).order_by(desc(TimeEntryTemplate.last_used_at)).limit(5).all() + + # Get recent activities for activity feed widget + recent_activities = Activity.get_recent( + user_id=None if current_user.is_admin else current_user.id, + limit=10 + ) return render_template('main/dashboard.html', active_timer=active_timer, @@ -94,7 +100,8 @@ def dashboard(): month_hours=month_hours, top_projects=top_projects, current_week_goal=current_week_goal, - templates=templates) + templates=templates, + recent_activities=recent_activities) @main_bp.route('/_health') def health_check(): diff --git a/app/routes/projects.py b/app/routes/projects.py index 1ca7534..a154c32 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -415,6 +415,18 @@ def edit_project(project_id): flash('Could not update project due to a database error. Please check server logs.', 'error') return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) + # Log activity + Activity.log( + user_id=current_user.id, + action='updated', + entity_type='project', + entity_id=project.id, + entity_name=project.name, + description=f'Updated project "{project.name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash(f'Project "{name}" updated successfully', 'success') return redirect(url_for('projects.view_project', project_id=project.id)) @@ -560,10 +572,24 @@ def delete_project(project_id): return redirect(url_for('projects.view_project', project_id=project_id)) project_name = project.name + project_id_copy = project.id + + # Log activity before deletion + Activity.log( + user_id=current_user.id, + action='deleted', + entity_type='project', + entity_id=project_id_copy, + entity_name=project_name, + description=f'Deleted project "{project_name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + db.session.delete(project) - if not safe_commit('delete_project', {'project_id': project.id}): + if not safe_commit('delete_project', {'project_id': project_id_copy}): flash('Could not delete project due to a database error. Please check server logs.', 'error') - return redirect(url_for('projects.view_project', project_id=project.id)) + return redirect(url_for('projects.view_project', project_id=project_id_copy)) flash(f'Project "{project_name}" deleted successfully', 'success') return redirect(url_for('projects.list_projects')) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index fe3caac..4227118 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -3,7 +3,7 @@ from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module from app import db -from app.models import Task, Project, User, TimeEntry, TaskActivity, KanbanColumn +from app.models import Task, Project, User, TimeEntry, TaskActivity, KanbanColumn, Activity from datetime import datetime, date from decimal import Decimal from app.utils.db import safe_commit @@ -168,6 +168,19 @@ def create_task(): "priority": priority }) + # Log activity + Activity.log( + user_id=current_user.id, + action='created', + entity_type='task', + entity_id=task.id, + entity_name=task.name, + description=f'Created task "{task.name}" in project "{project.name}"', + extra_data={'project_id': project_id, 'priority': priority}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash(f'Task "{name}" created successfully', 'success') return redirect(url_for('tasks.view_task', task_id=task.id)) @@ -335,6 +348,18 @@ def edit_task(task_id): "project_id": task.project_id }) + # Log activity + Activity.log( + user_id=current_user.id, + action='updated', + entity_type='task', + entity_id=task.id, + entity_name=task.name, + description=f'Updated task "{task.name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash(f'Task "{name}" updated successfully', 'success') return redirect(url_for('tasks.view_task', task_id=task.id)) @@ -494,10 +519,23 @@ def delete_task(task_id): task_name = task.name task_id_for_log = task.id project_id_for_log = task.project_id + + # Log activity before deletion + Activity.log( + user_id=current_user.id, + action='deleted', + entity_type='task', + entity_id=task_id_for_log, + entity_name=task_name, + description=f'Deleted task "{task_name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + db.session.delete(task) - if not safe_commit('delete_task', {'task_id': task.id}): + if not safe_commit('delete_task', {'task_id': task_id_for_log}): flash('Could not delete task due to a database error. Please check server logs.', 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) + return redirect(url_for('tasks.view_task', task_id=task_id_for_log)) # Log task deletion app_module.log_event("task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log) diff --git a/app/routes/timer.py b/app/routes/timer.py index 8948be4..2f6b8ce 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, socketio, log_event, track_event -from app.models import User, Project, TimeEntry, Task, Settings +from app.models import User, Project, TimeEntry, Task, Settings, Activity from app.utils.timezone import parse_local_datetime, utc_to_local from datetime import datetime import json @@ -107,6 +107,19 @@ def start_timer(): "has_description": bool(notes) }) + # Log activity + Activity.log( + user_id=current_user.id, + action='started', + entity_type='time_entry', + entity_id=new_timer.id, + entity_name=f'{project.name}' + (f' - {task.name}' if task else ''), + description=f'Started timer for {project.name}' + (f' - {task.name}' if task else ''), + extra_data={'project_id': project_id, 'task_id': task_id}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + # Check if this is user's first timer (onboarding milestone) timer_count = TimeEntry.query.filter_by( user_id=current_user.id, @@ -306,6 +319,21 @@ def stop_timer(): "duration_seconds": duration_seconds }) + # Log activity + project_name = active_timer.project.name if active_timer.project else 'No project' + task_name = active_timer.task.name if active_timer.task else None + Activity.log( + user_id=current_user.id, + action='stopped', + entity_type='time_entry', + entity_id=active_timer.id, + entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''), + description=f'Stopped timer for {project_name}' + (f' - {task_name}' if task_name else '') + f' - Duration: {active_timer.duration_formatted}', + extra_data={'duration_hours': active_timer.duration_hours, 'project_id': active_timer.project_id, 'task_id': active_timer.task_id}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + # Check if this is user's first completed time entry (onboarding milestone) entry_count = TimeEntry.query.filter_by( user_id=current_user.id diff --git a/app/templates/components/activity_feed_widget.html b/app/templates/components/activity_feed_widget.html new file mode 100644 index 0000000..b41d1e5 --- /dev/null +++ b/app/templates/components/activity_feed_widget.html @@ -0,0 +1,412 @@ + +
+
+

+ + {{ _('Recent Activity') }} +

+
+ +
+ + +
+ +
+
+ +
+ {% if recent_activities %} +
+ {% for activity in recent_activities %} +
+
+ +
+
+
+
+

+ + {{ activity.user.display_name if activity.user.display_name else activity.user.username }} + + + {{ activity.description }} + +

+ {% if activity.extra_data %} +
+ {% if activity.extra_data.old_status and activity.extra_data.new_status %} + + {{ activity.extra_data.old_status }} + + {{ activity.extra_data.new_status }} + + {% endif %} +
+ {% endif %} +
+
+ + {{ activity.created_at|timeago if activity.created_at else '' }} + +
+
+
+
+ {% endfor %} +
+ {% else %} +
+ +

{{ _('No recent activity') }}

+

{{ _('Activity will appear here as you work') }}

+
+ {% endif %} +
+ +
+ +
+
+ + + diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index 757806f..bd86cb0 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -177,6 +177,9 @@ {% endfor %} + + + {% include 'components/activity_feed_widget.html' %} {% for entry in recent_entries %} diff --git a/docs/features/activity_feed.md b/docs/features/activity_feed.md new file mode 100644 index 0000000..8e3d994 --- /dev/null +++ b/docs/features/activity_feed.md @@ -0,0 +1,301 @@ +# Activity Feed Widget + +The Activity Feed Widget provides real-time visibility into team activities and creates a comprehensive audit trail for your TimeTracker instance. + +## Overview + +The Activity Feed automatically tracks and displays all major actions performed in the system, including: +- Project management (create, update, delete, archive) +- Task operations (create, update, delete, status changes, assignments) +- Time tracking (start/stop timer, manual entries, edits) +- Invoice activities (create, send, mark paid) +- Client management +- And more... + +## Features + +### Dashboard Widget + +The Activity Feed Widget appears on the main dashboard in the right sidebar, displaying: +- **Recent Activities**: Last 10 activities by default +- **User Attribution**: Shows who performed each action +- **Timestamps**: Displays how long ago each action occurred +- **Action Icons**: Visual indicators for different types of actions +- **Entity Details**: Clear description of what was done + +### Filtering + +Click the filter icon (🔽) to filter activities by type: +- All Activities +- Projects only +- Tasks only +- Time Entries only +- Invoices only +- Clients only + +### Real-time Updates + +The activity feed automatically refreshes every 30 seconds to show the latest team activities. + +## User Permissions + +### Regular Users +- See their own activities +- View activities related to projects they have access to + +### Administrators +- See all activities across the entire organization +- Access to advanced filtering and export options +- View activity statistics + +## API Endpoints + +### Get Activities + +```http +GET /api/activities +``` + +**Query Parameters:** +- `limit` (int): Number of activities to return (default: 50) +- `page` (int): Page number for pagination (default: 1) +- `user_id` (int): Filter by specific user (admin only) +- `entity_type` (string): Filter by entity type (project, task, time_entry, invoice, client) +- `action` (string): Filter by action type (created, updated, deleted, started, stopped, etc.) +- `start_date` (ISO string): Filter activities after this date +- `end_date` (ISO string): Filter activities before this date + +**Response:** +```json +{ + "activities": [ + { + "id": 123, + "user_id": 5, + "username": "john.doe", + "display_name": "John Doe", + "action": "created", + "entity_type": "project", + "entity_id": 42, + "entity_name": "New Website", + "description": "Created project \"New Website\"", + "extra_data": {}, + "created_at": "2025-10-30T14:30:00Z" + } + ], + "total": 150, + "pages": 3, + "current_page": 1, + "has_next": true, + "has_prev": false +} +``` + +### Get Activity Statistics + +```http +GET /api/activities/stats?days=7 +``` + +**Query Parameters:** +- `days` (int): Number of days to analyze (default: 7) + +**Response:** +```json +{ + "total_activities": 342, + "entity_counts": { + "project": 45, + "task": 128, + "time_entry": 156, + "invoice": 13 + }, + "action_counts": { + "created": 89, + "updated": 167, + "deleted": 12, + "started": 42, + "stopped": 32 + }, + "user_activity": [ + { + "username": "john.doe", + "display_name": "John Doe", + "count": 156 + } + ], + "period_days": 7 +} +``` + +## Action Types + +The system tracks the following action types: + +| Action | Description | Used For | +|--------|-------------|----------| +| `created` | Entity was created | Projects, Tasks, Clients, Invoices | +| `updated` | Entity was modified | Projects, Tasks, Time Entries | +| `deleted` | Entity was removed | Projects, Tasks, Time Entries | +| `started` | Timer started | Time Entries | +| `stopped` | Timer stopped | Time Entries | +| `completed` | Task marked as done | Tasks | +| `assigned` | Task assigned to user | Tasks | +| `commented` | Comment added | Tasks | +| `status_changed` | Status modified | Tasks, Invoices | +| `sent` | Invoice sent to client | Invoices | +| `paid` | Payment recorded | Invoices | +| `archived` | Entity archived | Projects | +| `unarchived` | Entity unarchived | Projects | + +## Entity Types + +Activities can be tracked for the following entity types: + +- `project` - Project management +- `task` - Task operations +- `time_entry` - Time tracking +- `invoice` - Invoicing +- `client` - Client management +- `user` - User administration (admin only) +- `comment` - Comments and discussions + +## Integration Guide + +### For Developers + +To add activity logging to new features, use the `Activity.log()` method: + +```python +from app.models import Activity + +Activity.log( + user_id=current_user.id, + action='created', # Action type + entity_type='project', # Entity type + entity_id=project.id, + entity_name=project.name, + description=f'Created project "{project.name}"', + extra_data={'client_id': client.id}, # Optional metadata + ip_address=request.remote_addr, # Optional + user_agent=request.headers.get('User-Agent') # Optional +) +``` + +**Best Practices:** + +1. **Always log after successful operations** - Log after the database commit succeeds +2. **Provide clear descriptions** - Make descriptions human-readable +3. **Include relevant metadata** - Use `extra_data` for additional context +4. **Store entity names** - Cache the entity name in case it's deleted later +5. **Handle failures gracefully** - Activity logging includes built-in error handling + +### Already Integrated + +Activity logging is already integrated for: +- ✅ Projects (create, update, delete, archive, unarchive) +- ✅ Tasks (create, update, delete, status changes, assignments) +- ✅ Time Entries (start timer, stop timer, manual create, edit, delete) +- ⏳ Invoices (create, update, status change, payment, send) - *coming soon* +- ⏳ Clients (create, update, delete) - *coming soon* +- ⏳ Comments (create) - *coming soon* + +## Use Cases + +### Team Visibility +- See what your team members are working on +- Track project progress in real-time +- Understand team activity patterns + +### Audit Trail +- Compliance and record-keeping +- Track who made what changes and when +- Identify suspicious or unusual activity + +### Project Management +- Monitor task completion rates +- Track project milestones +- Review team productivity + +### Troubleshooting +- Investigate issues by reviewing recent changes +- Identify when problems were introduced +- Track down missing or deleted items + +## Configuration + +No special configuration is required. The Activity Feed is enabled by default for all users. + +### Database Indexes + +The Activity model includes optimized indexes for: +- User-based queries (`user_id`, `created_at`) +- Entity lookups (`entity_type`, `entity_id`) +- Date range queries (`created_at`) + +### Performance + +- Activities are paginated to prevent slow page loads +- Old activities are automatically retained (no automatic cleanup) +- Database queries are optimized with proper indexes +- Widget auto-refreshes are throttled to every 30 seconds + +## Privacy & Security + +### Data Retention +- Activities are stored indefinitely by default +- Administrators can manually delete old activities if needed +- Consider implementing a retention policy for compliance + +### Access Control +- Users can only see their own activities (unless admin) +- Administrators see all activities system-wide +- Activity logs cannot be edited or tampered with +- IP addresses and user agents are stored for security auditing + +### GDPR Compliance +When a user requests data deletion: +1. Their activities are preserved for audit purposes +2. User information can be anonymized +3. Activities show "Deleted User" for anonymized accounts + +## Troubleshooting + +### Activities not appearing? + +1. **Check permissions** - Regular users only see their own activities +2. **Verify integration** - Ensure the route has Activity.log() calls +3. **Database issues** - Check logs for database errors +4. **Browser cache** - Clear cache or hard refresh the dashboard + +### Widget not loading? + +1. **Check API endpoint** - Visit `/api/activities` directly +2. **JavaScript errors** - Check browser console for errors +3. **Authentication** - Ensure user is logged in +4. **Network issues** - Check network tab in dev tools + +### Missing activities for certain actions? + +Some features may not have activity logging integrated yet. Check the "Already Integrated" section above. + +## Future Enhancements + +Planned improvements for the Activity Feed: + +- [ ] Export activities to CSV/JSON +- [ ] Email notifications for specific activities +- [ ] Advanced search and filtering +- [ ] Activity feed for specific projects/tasks +- [ ] Webhook integration for external systems +- [ ] Custom activity types and actions +- [ ] Activity trends and analytics dashboard + +## Support + +For issues or questions about the Activity Feed: +- Check the [FAQ](../faq.md) +- Review the [API Documentation](../api/README.md) +- Open an issue on GitHub +- Contact support + diff --git a/tests/test_activity_feed.py b/tests/test_activity_feed.py new file mode 100644 index 0000000..b9a18db --- /dev/null +++ b/tests/test_activity_feed.py @@ -0,0 +1,428 @@ +"""Tests for Activity Feed functionality""" + +import pytest +from datetime import datetime, timedelta +from app.models import Activity, User, Project, Task, TimeEntry, Client +from app import db + + +class TestActivityModel: + """Tests for the Activity model""" + + def test_activity_creation(self, app, test_user, test_project): + """Test creating an activity log entry""" + with app.app_context(): + activity = Activity( + user_id=test_user.id, + action='created', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description=f'Created project "{test_project.name}"' + ) + db.session.add(activity) + db.session.commit() + + assert activity.id is not None + assert activity.user_id == test_user.id + assert activity.action == 'created' + assert activity.entity_type == 'project' + assert activity.entity_id == test_project.id + assert activity.created_at is not None + + def test_activity_log_method(self, app, test_user, test_project): + """Test the Activity.log() class method""" + with app.app_context(): + Activity.log( + user_id=test_user.id, + action='updated', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description=f'Updated project "{test_project.name}"', + extra_data={'field': 'name'} + ) + + activity = Activity.query.filter_by( + user_id=test_user.id, + entity_type='project', + entity_id=test_project.id + ).first() + + assert activity is not None + assert activity.action == 'updated' + assert activity.extra_data == {'field': 'name'} + + def test_activity_get_recent(self, app, test_user, test_project): + """Test getting recent activities""" + with app.app_context(): + # Create multiple activities + for i in range(5): + Activity.log( + user_id=test_user.id, + action='updated', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description=f'Action {i}' + ) + + # Get recent activities + activities = Activity.get_recent(user_id=test_user.id, limit=3) + + assert len(activities) == 3 + assert activities[0].description == 'Action 4' # Most recent first + + def test_activity_filter_by_entity_type(self, app, test_user, test_project, test_task): + """Test filtering activities by entity type""" + with app.app_context(): + # Create activities for different entity types + Activity.log( + user_id=test_user.id, + action='created', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description='Project created' + ) + + Activity.log( + user_id=test_user.id, + action='created', + entity_type='task', + entity_id=test_task.id, + entity_name=test_task.name, + description='Task created' + ) + + # Filter by entity type + project_activities = Activity.get_recent( + user_id=test_user.id, + entity_type='project' + ) + + task_activities = Activity.get_recent( + user_id=test_user.id, + entity_type='task' + ) + + assert len(project_activities) == 1 + assert project_activities[0].entity_type == 'project' + assert len(task_activities) == 1 + assert task_activities[0].entity_type == 'task' + + def test_activity_to_dict(self, app, test_user, test_project): + """Test converting activity to dictionary""" + with app.app_context(): + Activity.log( + user_id=test_user.id, + action='created', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description='Test activity' + ) + + activity = Activity.query.filter_by(user_id=test_user.id).first() + activity_dict = activity.to_dict() + + assert activity_dict['id'] == activity.id + assert activity_dict['user_id'] == test_user.id + assert activity_dict['action'] == 'created' + assert activity_dict['entity_type'] == 'project' + assert activity_dict['entity_id'] == test_project.id + assert activity_dict['description'] == 'Test activity' + assert 'created_at' in activity_dict + + def test_activity_get_icon(self, app, test_user, test_project): + """Test getting icon for different activity types""" + with app.app_context(): + actions = ['created', 'updated', 'deleted', 'started', 'stopped'] + + for action in actions: + Activity.log( + user_id=test_user.id, + action=action, + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description=f'{action} project' + ) + + activity = Activity.query.filter_by(action=action).first() + icon = activity.get_icon() + + assert icon is not None + assert 'fas fa-' in icon + + +class TestActivityAPIEndpoints: + """Tests for Activity Feed API endpoints""" + + def test_get_activities(self, client, auth_headers, test_user, test_project): + """Test GET /api/activities endpoint""" + # Create some test activities + with client.application.app_context(): + for i in range(3): + Activity.log( + user_id=test_user.id, + action='updated', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description=f'Activity {i}' + ) + + response = client.get('/api/activities', headers=auth_headers) + assert response.status_code == 200 + + data = response.get_json() + assert 'activities' in data + assert len(data['activities']) >= 3 + assert 'total' in data + assert 'pages' in data + + def test_get_activities_with_entity_type_filter(self, client, auth_headers, test_user, test_project, test_task): + """Test filtering activities by entity type""" + with client.application.app_context(): + Activity.log( + user_id=test_user.id, + action='created', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description='Project activity' + ) + + Activity.log( + user_id=test_user.id, + action='created', + entity_type='task', + entity_id=test_task.id, + entity_name=test_task.name, + description='Task activity' + ) + + # Filter by project entity type + response = client.get( + '/api/activities?entity_type=project', + headers=auth_headers + ) + assert response.status_code == 200 + + data = response.get_json() + assert all( + act['entity_type'] == 'project' + for act in data['activities'] + ) + + def test_get_activities_with_pagination(self, client, auth_headers, test_user, test_project): + """Test pagination of activities""" + with client.application.app_context(): + # Create 15 activities + for i in range(15): + Activity.log( + user_id=test_user.id, + action='updated', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description=f'Activity {i}' + ) + + # Get first page + response = client.get( + '/api/activities?limit=5&page=1', + headers=auth_headers + ) + assert response.status_code == 200 + + data = response.get_json() + assert len(data['activities']) == 5 + assert data['has_next'] is True + + # Get second page + response = client.get( + '/api/activities?limit=5&page=2', + headers=auth_headers + ) + assert response.status_code == 200 + + data = response.get_json() + assert len(data['activities']) == 5 + + def test_get_activity_stats(self, client, auth_headers, test_user, test_project, test_task): + """Test GET /api/activities/stats endpoint""" + with client.application.app_context(): + # Create varied activities + Activity.log( + user_id=test_user.id, + action='created', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description='Project created' + ) + + Activity.log( + user_id=test_user.id, + action='updated', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description='Project updated' + ) + + Activity.log( + user_id=test_user.id, + action='created', + entity_type='task', + entity_id=test_task.id, + entity_name=test_task.name, + description='Task created' + ) + + response = client.get('/api/activities/stats', headers=auth_headers) + assert response.status_code == 200 + + data = response.get_json() + assert 'total_activities' in data + assert 'entity_counts' in data + assert 'action_counts' in data + assert data['total_activities'] >= 3 + + +class TestActivityIntegration: + """Tests for activity logging integration in routes""" + + def test_project_create_logs_activity(self, client, auth_headers, test_client): + """Test that creating a project logs an activity""" + with client.application.app_context(): + # Count activities before + before_count = Activity.query.count() + + response = client.post( + '/projects/create', + data={ + 'name': 'Test Activity Project', + 'client_id': test_client.id, + 'billable': 'on', + 'description': 'Test project for activity' + }, + headers=auth_headers, + follow_redirects=False + ) + + with client.application.app_context(): + # Check activity was logged + after_count = Activity.query.count() + assert after_count == before_count + 1 + + activity = Activity.query.order_by(Activity.created_at.desc()).first() + assert activity.action == 'created' + assert activity.entity_type == 'project' + assert 'Test Activity Project' in activity.description + + def test_task_create_logs_activity(self, client, auth_headers, test_project): + """Test that creating a task logs an activity""" + with client.application.app_context(): + before_count = Activity.query.count() + + response = client.post( + '/tasks/create', + data={ + 'project_id': test_project.id, + 'name': 'Test Activity Task', + 'priority': 'high', + 'description': 'Test task for activity' + }, + headers=auth_headers, + follow_redirects=False + ) + + with client.application.app_context(): + after_count = Activity.query.count() + assert after_count == before_count + 1 + + activity = Activity.query.order_by(Activity.created_at.desc()).first() + assert activity.action == 'created' + assert activity.entity_type == 'task' + assert 'Test Activity Task' in activity.description + + def test_timer_start_logs_activity(self, client, auth_headers, test_project): + """Test that starting a timer logs an activity""" + with client.application.app_context(): + before_count = Activity.query.count() + + response = client.post( + '/timer/start', + data={ + 'project_id': test_project.id, + 'notes': 'Test timer' + }, + headers=auth_headers, + follow_redirects=False + ) + + with client.application.app_context(): + after_count = Activity.query.count() + assert after_count == before_count + 1 + + activity = Activity.query.order_by(Activity.created_at.desc()).first() + assert activity.action == 'started' + assert activity.entity_type == 'time_entry' + assert test_project.name in activity.description + + def test_timer_stop_logs_activity(self, client, auth_headers, test_user, test_project): + """Test that stopping a timer logs an activity""" + with client.application.app_context(): + # Create an active timer + from app.models.time_entry import local_now + timer = TimeEntry( + user_id=test_user.id, + project_id=test_project.id, + start_time=local_now(), + source='auto' + ) + db.session.add(timer) + db.session.commit() + + before_count = Activity.query.count() + + response = client.post( + '/timer/stop', + headers=auth_headers, + follow_redirects=False + ) + + with client.application.app_context(): + after_count = Activity.query.count() + assert after_count == before_count + 1 + + activity = Activity.query.order_by(Activity.created_at.desc()).first() + assert activity.action == 'stopped' + assert activity.entity_type == 'time_entry' + assert test_project.name in activity.description + + +class TestActivityWidget: + """Tests for the activity feed widget on dashboard""" + + def test_dashboard_includes_activities(self, client, auth_headers, test_user, test_project): + """Test that the dashboard includes recent activities""" + with client.application.app_context(): + # Create some activities + Activity.log( + user_id=test_user.id, + action='created', + entity_type='project', + entity_id=test_project.id, + entity_name=test_project.name, + description='Test activity' + ) + + response = client.get('/dashboard', headers=auth_headers) + assert response.status_code == 200 + assert b'Recent Activity' in response.data + assert b'Test activity' in response.data +