mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-09 13:48:46 -06:00
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
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""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
|
|
|