Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
pytest backend/tests \
-n auto \
-x \
${{ github.ref == 'refs/heads/main' && '--cov=backend/src --cov-report=term-missing --cov-report=xml:backend/coverage.xml --cov-fail-under=80' || '-q' }}
${{ github.ref == 'refs/heads/main' && '--cov=backend/src --cov-report=term-missing --cov-report=xml:backend/coverage.xml --cov-fail-under=85' || '-q' }}

- name: Upload backend coverage artifact
if: github.ref == 'refs/heads/main' && always()
Expand Down Expand Up @@ -120,7 +120,7 @@ jobs:
--coverageReporters=text-summary \
--coverageReporters=lcov \
--maxWorkers=50% \
--coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
--coverageThreshold='{"global":{"branches":70,"functions":80,"lines":80,"statements":80}}'
else
CI=true npm test -- \
--watchAll=false \
Expand Down
12 changes: 12 additions & 0 deletions backend/src/api/controllers/admin_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ...auth.rbac import Role
from flask_jwt_extended import get_jwt_identity
from ...services import audit_service, settings_service
from src.socketio_server import emit_dashboard_refresh


def _safe_query_all(model):
Expand Down Expand Up @@ -77,6 +78,11 @@ def update_system_settings():
resource_type='settings',
metadata={'settings_updated': list(data.keys())}
)
emit_dashboard_refresh(
'settings_updated',
resource_type='settings',
payload={'settings_updated': list(data.keys())}
)

return jsonify({
'message': 'System settings updated successfully',
Expand Down Expand Up @@ -107,6 +113,12 @@ def update_user_role(user_id):
resource_id=user.id,
metadata={'old_role': old_role, 'new_role': user.role}
)
emit_dashboard_refresh(
'user_role_changed',
resource_type='user',
resource_id=user.id,
payload={'old_role': old_role, 'new_role': user.role}
)

return jsonify({
'message': 'User role updated successfully',
Expand Down
12 changes: 12 additions & 0 deletions backend/src/api/controllers/audit_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from flask import request, jsonify
from ...db.models import AuditLog, User
from ...services import settings_service


def _build_actor_name_map(logs):
Expand Down Expand Up @@ -33,6 +34,8 @@ def _serialize_audit_log(log, actor_name_map=None):

def get_audit_logs():
"""Get paginated and filtered audit logs"""
settings_service.cleanup_old_audit_logs()

action = request.args.get('action')
actor_id = request.args.get('actor')
from_date = request.args.get('from')
Expand Down Expand Up @@ -66,6 +69,15 @@ def get_audit_logs():
'current_page': page
})


def cleanup_audit_logs():
"""Delete expired audit logs using the configured retention window."""
deleted_count = settings_service.cleanup_old_audit_logs()
return jsonify({
'message': 'Audit log cleanup completed',
'deleted': deleted_count,
}), 200

def get_audit_log_by_id(log_id):
"""Get a specific audit log"""
log = AuditLog.query.get_or_404(log_id)
Expand Down
128 changes: 123 additions & 5 deletions backend/src/api/controllers/dashboard_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from flask_jwt_extended import get_jwt_identity, get_jwt
from ...db.models import db, User, Task, Project, TaskGitHubLink, GitHubRepository # Changed to relative import
from ...auth.rbac import Role # Changed to relative import
from datetime import datetime, timedelta
from ...services import settings_service
from ...services.task_rules import count_overdue_tasks, get_project_scope_ids
from datetime import datetime, timedelta, date
import traceback
import logging

Expand Down Expand Up @@ -276,8 +278,6 @@ def get_client_dashboard():
key=lambda task: getattr(task, 'updated_at', None) or getattr(task, 'created_at', None) or datetime.min,
reverse=True,
)
if not is_team_lead:
recent_tasks = recent_tasks[:10]

task_stats = {
'total': len(scoped_tasks),
Expand Down Expand Up @@ -345,6 +345,8 @@ def get_admin_dashboard():

# Log the request details for debugging
logger.info(f"Getting admin dashboard for user ID: {user_id}")

settings_service.cleanup_completed_projects()

# Get basic user info
user = User.query.get(user_id)
Expand All @@ -358,6 +360,8 @@ def get_admin_dashboard():
except Exception as e:
logger.error(f"Error querying tasks for admin dashboard (fallback to empty): {str(e)}")
all_tasks = []

admin_project_ids = get_project_scope_ids(user_id, user_role)

# Get user counts by role
try:
Expand All @@ -379,17 +383,131 @@ def get_admin_dashboard():
'todo': len([t for t in all_tasks if t.status == 'todo']),
'in_progress': len([t for t in all_tasks if t.status == 'in_progress']),
'review': len([t for t in all_tasks if t.status == 'review']),
'done': len([t for t in all_tasks if _is_completed_task(t)])
'done': len([t for t in all_tasks if _is_completed_task(t)]),
'overdue': count_overdue_tasks(all_tasks, project_ids=admin_project_ids),
}

# Get user's assigned tasks for "My Tasks" section
user_assigned_tasks = [t for t in all_tasks if getattr(t, 'assigned_to', None) == user_id]
user_assigned_tasks_sorted = sorted(
user_assigned_tasks,
key=lambda t: getattr(t, 'updated_at', None) or getattr(t, 'created_at', None) or datetime.min,
reverse=True
)

# For Team Leads: calculate scoped KPIs for managing projects
team_lead_kpis = {}
if user_role == Role.TEAM_LEAD.value:
# Get all projects
try:
all_projects = Project.query.all()
except Exception as e:
logger.error(f"Error fetching projects for TL dashboard: {str(e)}")
all_projects = []

# Find projects with active or on-hold status that TL is on
scoped_projects = []
for project in all_projects:
project_status = getattr(project, 'status', '').lower().replace('-', '_')
if project_status not in ['active', 'on_hold']:
continue

# Check if TL is on the project
team_members = getattr(project, 'team_members', []) or []
if hasattr(team_members, 'all'):
team_members = team_members.all()

is_member = any(
(getattr(m, 'id', None) == user_id or
getattr(m, 'user_id', None) == user_id)
for m in team_members if m is not None
)
is_creator = getattr(project, 'created_by', None) == user_id

if is_member or is_creator:
scoped_projects.append(project)

# Calculate TL-specific KPIs
scoped_project_ids = set(p.id for p in scoped_projects)
scoped_tasks = [t for t in all_tasks if getattr(t, 'project_id', None) in scoped_project_ids]

# KPI 1: Total in review tasks
in_review_count = len([t for t in scoped_tasks if getattr(t, 'status', '').lower() in ['review', 'in_review', 'in-review']])

# KPI 2: Total tasks due soon (within 7 days)
today = datetime.now().date()
week_later = today + timedelta(days=7)
due_soon_count = 0
for t in scoped_tasks:
deadline = getattr(t, 'deadline', None)
if deadline is None:
continue
if hasattr(deadline, 'date'):
deadline = deadline.date()
elif isinstance(deadline, str):
try:
deadline = datetime.fromisoformat(deadline).date()
except (ValueError, TypeError):
continue
else:
continue

task_status = getattr(t, 'status', '').lower().replace('-', '_')
if task_status in ['done', 'completed', 'review', 'in_review']:
continue

if today <= deadline <= week_later:
due_soon_count += 1

# KPI 3: Total overdue AND NOT (completed OR in-review)
overdue_not_complete_count = 0
for t in scoped_tasks:
deadline = getattr(t, 'deadline', None)
if deadline is None:
continue
if hasattr(deadline, 'date'):
deadline = deadline.date()
elif isinstance(deadline, str):
try:
deadline = datetime.fromisoformat(deadline).date()
except (ValueError, TypeError):
continue
else:
continue

if deadline >= today:
continue

task_status = getattr(t, 'status', '').lower().replace('-', '_')
if task_status in ['done', 'completed', 'review', 'in_review']:
continue

overdue_not_complete_count += 1

# KPI 4: Total current projects (active or on-hold)
current_projects_count = len(scoped_projects)

team_lead_kpis = {
'in_review_tasks': in_review_count,
'due_soon_tasks': due_soon_count,
'overdue_not_complete_tasks': overdue_not_complete_count,
'current_projects': current_projects_count,
}

# Format response data
dashboard_data = {
'users': user_counts,
'tasks': task_stats,
'projects': {
'total': Project.query.count()
}
},
'my_assigned_tasks': [_task_to_dashboard_item(t) for t in user_assigned_tasks_sorted],
}

# Add Team Lead KPIs if applicable
if team_lead_kpis:
dashboard_data['team_lead_kpis'] = team_lead_kpis

# Include recent projects (top 3 by updated_at)
try:
recent_projects_query = Project.query.order_by(Project.updated_at.desc()).limit(3).all()
Expand Down
29 changes: 28 additions & 1 deletion backend/src/api/controllers/projects_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from ...db.models import db, Project, Task, User # Changed to relative import
from ...auth.rbac import Role # Changed to relative import
from ..validators.project_validator import validate_project_data # Changed to relative import
from ...services import audit_service
from ...services import audit_service, settings_service
from ...socketio_server import emit_dashboard_refresh
import json
import time
import uuid
Expand Down Expand Up @@ -42,6 +43,8 @@ def get_all_projects():
user_id = get_jwt_identity()['user_id']
claims = get_jwt()
user_role = claims.get('role')

settings_service.cleanup_completed_projects()

# Apply role-based access control for projects
if user_role in [Role.ADMIN.value, Role.TEAM_LEAD.value]:
Expand Down Expand Up @@ -205,6 +208,12 @@ def create_project():
resource_type='project',
resource_id=new_project.id
)
emit_dashboard_refresh(
'project_created',
resource_type='project',
resource_id=new_project.id,
payload={'status': new_project.status}
)
# region agent log
_debug_log(
'H1-H2',
Expand Down Expand Up @@ -260,6 +269,19 @@ def update_project(project_id):
project.team_members.append(member)

db.session.commit()

audit_service.record(
action='project_updated',
resource_type='project',
resource_id=project.id,
metadata={'status': project.status, 'team_members': [member.id for member in _relationship_items(project.team_members)]}
)
emit_dashboard_refresh(
'project_updated',
resource_type='project',
resource_id=project.id,
payload={'status': project.status}
)

return jsonify({
'message': 'Project updated successfully',
Expand All @@ -282,6 +304,11 @@ def delete_project(project_id):
resource_type='project',
resource_id=project_id
)
emit_dashboard_refresh(
'project_deleted',
resource_type='project',
resource_id=project_id
)

# Updated to return 204
return '', 204
Expand Down
28 changes: 28 additions & 0 deletions backend/src/api/controllers/report_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from ...db.models import db, Report, User
from ...auth.rbac import Role
from ..validators.report_validator import validate_report_data
from ...services import audit_service
from src.socketio_server import emit_dashboard_refresh

def save_report():
"""Controller function to save a generated report"""
Expand All @@ -28,6 +30,19 @@ def save_report():

db.session.add(report)
db.session.commit()

audit_service.record(
action='report_created',
resource_type='report',
resource_id=report.id,
metadata={'report_type': report.report_type, 'date_range': report.date_range}
)
emit_dashboard_refresh(
'report_created',
resource_type='report',
resource_id=report.id,
payload={'report_type': report.report_type, 'date_range': report.date_range}
)

return jsonify({
'message': 'Report saved successfully',
Expand Down Expand Up @@ -131,6 +146,19 @@ def delete_report(report_id):

db.session.delete(report)
db.session.commit()

audit_service.record(
action='report_deleted',
resource_type='report',
resource_id=report_id,
metadata={'report_type': report.report_type, 'date_range': report.date_range}
)
emit_dashboard_refresh(
'report_deleted',
resource_type='report',
resource_id=report_id,
payload={'report_type': report.report_type, 'date_range': report.date_range}
)

return jsonify({
'message': 'Report deleted successfully'
Expand Down
Loading
Loading