mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-04 02:30:01 -06:00
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
This commit is contained in:
@@ -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/<int:template_id>')
|
||||
@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/<int:template_id>/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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
412
app/templates/components/activity_feed_widget.html
Normal file
412
app/templates/components/activity_feed_widget.html
Normal file
@@ -0,0 +1,412 @@
|
||||
<!-- Activity Feed Widget -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
<i class="fas fa-stream mr-2"></i>
|
||||
{{ _('Recent Activity') }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Filter dropdown -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleFilterDropdown()" id="filter-dropdown-btn" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition relative">
|
||||
<i class="fas fa-filter"></i>
|
||||
<span id="filter-indicator" class="hidden absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full"></span>
|
||||
</button>
|
||||
<div id="filter-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700 max-h-96 overflow-y-auto">
|
||||
<div class="p-2">
|
||||
<button id="filter-all" onclick="filterActivities('all'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded font-medium flex items-center justify-between">
|
||||
<span><i class="fas fa-list text-gray-500 w-4"></i> {{ _('All Activities') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 my-1"></div>
|
||||
<button id="filter-project" onclick="filterActivities('project'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-folder text-blue-500 w-4"></i> {{ _('Projects') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<button id="filter-task" onclick="filterActivities('task'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-tasks text-green-500 w-4"></i> {{ _('Tasks') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<button id="filter-time_entry" onclick="filterActivities('time_entry'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-clock text-purple-500 w-4"></i> {{ _('Time Entries') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<button id="filter-time_entry_template" onclick="filterActivities('time_entry_template'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-clock text-teal-500 w-4"></i> {{ _('Time Templates') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<button id="filter-invoice" onclick="filterActivities('invoice'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-file-invoice text-yellow-500 w-4"></i> {{ _('Invoices') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<button id="filter-client" onclick="filterActivities('client'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-user-tie text-indigo-500 w-4"></i> {{ _('Clients') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
<button id="filter-user" onclick="filterActivities('user'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
|
||||
<span><i class="fas fa-user text-pink-500 w-4"></i> {{ _('Users') }}</span>
|
||||
<i class="fas fa-check text-primary hidden filter-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="refreshActivityFeed()" id="refresh-activity-btn" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activity-feed-container">
|
||||
{% if recent_activities %}
|
||||
<div class="space-y-3">
|
||||
{% for activity in recent_activities %}
|
||||
<div class="flex items-start gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<i class="{{ activity.get_icon() }}"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm">
|
||||
<span class="font-medium text-text-light dark:text-text-dark">
|
||||
{{ activity.user.display_name if activity.user.display_name else activity.user.username }}
|
||||
</span>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ activity.description }}
|
||||
</span>
|
||||
</p>
|
||||
{% if activity.extra_data %}
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if activity.extra_data.old_status and activity.extra_data.new_status %}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">{{ activity.extra_data.old_status }}</span>
|
||||
<i class="fas fa-arrow-right text-xs"></i>
|
||||
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">{{ activity.extra_data.new_status }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" title="{{ activity.created_at.strftime('%Y-%m-%d %H:%M:%S') }}">
|
||||
{{ activity.created_at|timeago if activity.created_at else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-stream text-4xl mb-3 opacity-50"></i>
|
||||
<p class="text-sm">{{ _('No recent activity') }}</p>
|
||||
<p class="text-xs mt-1">{{ _('Activity will appear here as you work') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="load-more-container" class="mt-4 pt-4 border-t border-border-light dark:border-border-dark" {% if not recent_activities %}style="display: none;"{% endif %}>
|
||||
<button onclick="loadMoreActivities()" id="load-more-activities" class="text-sm text-primary hover:text-primary-dark transition w-full text-center">
|
||||
{{ _('Load More') }} <i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let activityPage = 1;
|
||||
let activityFilter = ''; // Empty string means show all
|
||||
const activityLimit = 10;
|
||||
|
||||
function toggleFilterDropdown() {
|
||||
const dropdown = document.getElementById('filter-dropdown');
|
||||
if (dropdown) {
|
||||
dropdown.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeFilterDropdown() {
|
||||
const dropdown = document.getElementById('filter-dropdown');
|
||||
if (dropdown) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const dropdown = document.getElementById('filter-dropdown');
|
||||
const btn = document.getElementById('filter-dropdown-btn');
|
||||
if (dropdown && btn && !dropdown.contains(event.target) && !btn.contains(event.target)) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function filterActivities(entityType) {
|
||||
console.log('Filtering activities by:', entityType);
|
||||
activityFilter = entityType === 'all' ? '' : entityType;
|
||||
activityPage = 1;
|
||||
|
||||
// Update visual indicators
|
||||
updateFilterIndicators(entityType);
|
||||
|
||||
loadActivities(false).catch(error => {
|
||||
console.error('Filter failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateFilterIndicators(activeFilter) {
|
||||
// Hide all checkmarks
|
||||
document.querySelectorAll('.filter-check').forEach(check => {
|
||||
check.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show checkmark for active filter
|
||||
const filterButton = document.getElementById(`filter-${activeFilter}`);
|
||||
if (filterButton) {
|
||||
const checkmark = filterButton.querySelector('.filter-check');
|
||||
if (checkmark) {
|
||||
checkmark.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide filter indicator dot
|
||||
const indicator = document.getElementById('filter-indicator');
|
||||
if (indicator) {
|
||||
if (activeFilter === 'all' || activeFilter === '') {
|
||||
indicator.classList.add('hidden');
|
||||
} else {
|
||||
indicator.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshActivityFeed() {
|
||||
const btn = document.getElementById('refresh-activity-btn');
|
||||
if (btn) {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.classList.add('fa-spin');
|
||||
}
|
||||
}
|
||||
activityPage = 1;
|
||||
loadActivities(false).finally(() => {
|
||||
const btn = document.getElementById('refresh-activity-btn');
|
||||
if (btn) {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.classList.remove('fa-spin');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadMoreActivities() {
|
||||
activityPage++;
|
||||
loadActivities(true);
|
||||
}
|
||||
|
||||
async function loadActivities(append = false) {
|
||||
const container = document.getElementById('activity-feed-container');
|
||||
if (!container) {
|
||||
console.error('Activity feed container not found');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const loadMoreBtn = document.getElementById('load-more-activities');
|
||||
const loadMoreContainer = document.getElementById('load-more-container');
|
||||
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.disabled = true;
|
||||
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> {{ _('Loading...') }}';
|
||||
}
|
||||
|
||||
try {
|
||||
let url = `/api/activities?limit=${activityLimit}&page=${activityPage}`;
|
||||
// Only add entity_type filter if it's not empty
|
||||
if (activityFilter && activityFilter !== 'all') {
|
||||
url += `&entity_type=${activityFilter}`;
|
||||
}
|
||||
|
||||
console.log('Fetching activities from:', url);
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('API response not OK:', response.status, response.statusText);
|
||||
const errorText = await response.text();
|
||||
console.error('Error details:', errorText);
|
||||
throw new Error(`Failed to fetch activities: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Received activities:', data);
|
||||
|
||||
// Debug: Log entity types of received activities
|
||||
if (data.activities && data.activities.length > 0) {
|
||||
const entityTypes = data.activities.map(a => a.entity_type);
|
||||
console.log('Entity types in response:', entityTypes);
|
||||
} else {
|
||||
console.log('No activities received from API');
|
||||
}
|
||||
|
||||
if (!append) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
if (data.activities && data.activities.length > 0) {
|
||||
const activityHTML = data.activities.map(activity => createActivityHTML(activity)).join('');
|
||||
|
||||
if (append) {
|
||||
const spacer = container.querySelector('.space-y-3');
|
||||
if (spacer) {
|
||||
spacer.insertAdjacentHTML('beforeend', activityHTML);
|
||||
} else {
|
||||
container.innerHTML = '<div class="space-y-3">' + activityHTML + '</div>';
|
||||
}
|
||||
} else {
|
||||
container.innerHTML = '<div class="space-y-3">' + activityHTML + '</div>';
|
||||
}
|
||||
|
||||
// Show/hide load more button and container
|
||||
if (loadMoreContainer) {
|
||||
if (data.has_next) {
|
||||
loadMoreContainer.style.display = 'block';
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.disabled = false;
|
||||
loadMoreBtn.innerHTML = '{{ _('Load More') }} <i class="fas fa-chevron-down ml-1"></i>';
|
||||
}
|
||||
} else {
|
||||
loadMoreContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} else if (!append) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-stream text-4xl mb-3 opacity-50"></i>
|
||||
<p class="text-sm">{{ _('No recent activity') }}</p>
|
||||
<p class="text-xs mt-1">{{ _('Activity will appear here as you work') }}</p>
|
||||
</div>
|
||||
`;
|
||||
if (loadMoreContainer) {
|
||||
loadMoreContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// If appending and no results, hide the load more button
|
||||
if (loadMoreContainer) {
|
||||
loadMoreContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
console.error('Error loading activities:', error);
|
||||
if (!append) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-8 text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
|
||||
<p class="text-sm">{{ _('Failed to load activities') }}</p>
|
||||
<p class="text-xs mt-2">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.disabled = false;
|
||||
loadMoreBtn.innerHTML = '{{ _('Load More') }} <i class="fas fa-chevron-down ml-1"></i>';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
function createActivityHTML(activity) {
|
||||
const icon = getActivityIcon(activity.action);
|
||||
const displayName = activity.display_name || activity.username || 'Unknown';
|
||||
const timeAgo = formatTimeAgo(new Date(activity.created_at));
|
||||
const fullTime = new Date(activity.created_at).toLocaleString();
|
||||
|
||||
let extraDataHTML = '';
|
||||
if (activity.extra_data && activity.extra_data.old_status && activity.extra_data.new_status) {
|
||||
extraDataHTML = `
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">${activity.extra_data.old_status}</span>
|
||||
<i class="fas fa-arrow-right text-xs"></i>
|
||||
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">${activity.extra_data.new_status}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="flex items-start gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<i class="${icon}"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm">
|
||||
<span class="font-medium text-text-light dark:text-text-dark">${escapeHtml(displayName)}</span>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">${escapeHtml(activity.description)}</span>
|
||||
</p>
|
||||
${extraDataHTML}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" title="${fullTime}">${timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getActivityIcon(action) {
|
||||
const icons = {
|
||||
'created': 'fas fa-plus-circle text-green-500',
|
||||
'updated': 'fas fa-edit text-blue-500',
|
||||
'deleted': 'fas fa-trash text-red-500',
|
||||
'started': 'fas fa-play text-green-500',
|
||||
'stopped': 'fas fa-stop text-red-500',
|
||||
'completed': 'fas fa-check-circle text-green-500',
|
||||
'assigned': 'fas fa-user-plus text-blue-500',
|
||||
'commented': 'fas fa-comment text-gray-500',
|
||||
'sent': 'fas fa-paper-plane text-blue-500',
|
||||
'paid': 'fas fa-dollar-sign text-green-500',
|
||||
'archived': 'fas fa-archive text-gray-500',
|
||||
'unarchived': 'fas fa-box-open text-blue-500',
|
||||
'status_changed': 'fas fa-exchange-alt text-blue-500'
|
||||
};
|
||||
return icons[action] || 'fas fa-circle text-gray-500';
|
||||
}
|
||||
|
||||
function formatTimeAgo(date) {
|
||||
const seconds = Math.floor((new Date() - date) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
|
||||
if (seconds < 604800) return Math.floor(seconds / 86400) + 'd ago';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Initialize filter indicators on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateFilterIndicators('all');
|
||||
});
|
||||
|
||||
// Auto-refresh activity feed every 30 seconds
|
||||
setInterval(() => {
|
||||
if (activityPage === 1) {
|
||||
refreshActivityFeed();
|
||||
}
|
||||
}, 30000);
|
||||
</script>
|
||||
|
||||
@@ -177,6 +177,9 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Activity Feed Widget -->
|
||||
{% include 'components/activity_feed_widget.html' %}
|
||||
</div>
|
||||
<!-- Delete Entry Confirmation Dialogs -->
|
||||
{% for entry in recent_entries %}
|
||||
|
||||
301
docs/features/activity_feed.md
Normal file
301
docs/features/activity_feed.md
Normal file
@@ -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
|
||||
|
||||
428
tests/test_activity_feed.py
Normal file
428
tests/test_activity_feed.py
Normal file
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user