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 +