-
-
-
-
+
+
+
+
+
+
-
-
Notification Settings
-
- {[{ key: 'email_notifications', label: 'Email Notifications' },
- { key: 'task_assignments', label: 'Task Assignment Alerts' },
- { key: 'project_updates', label: 'Project Update Alerts' }].map(({ key, label }) => (
-
- {label}
- handleNestedToggle('notification_settings', key)} />
-
- ))}
+
+
+
Access & Registration
+
Controls whether new users can sign up without admin approval.
-
+
+
+
Allow self-registration
+
When off, only admins can create accounts.
+
+
handleChange('allow_self_registration', !isEnabled('allow_self_registration', true))} />
+
+
+
+
+
+
Retention & Cleanup
+
Audit logs use their own retention period. Completed projects use a separate retention period and are deleted with their tasks, comments, and GitHub links when they expire.
+
+
+
+
+
+
+
+
+
+
Auto-delete completed projects
+
Deletes completed projects and their related data after the project retention period expires.
+
+
handleChange('auto_archive_completed_projects', !isEnabled('auto_archive_completed_projects', true))} />
+
+
+
+
+
+
+
+
+
+
+
+
+
Task Alerts
+
Sends one overdue reminder per task when users load tasks and dashboards.
+
+
+
+
Notify on overdue tasks
+
Only sends once per task.
+
+
handleChange('notify_on_overdue_tasks', !isEnabled('notify_on_overdue_tasks', true))} />
+
+
- { (dashboardData?._myAssignedTasks && dashboardData._myAssignedTasks.length > 0) ? (
+ {(() => {
+ const activeTasks = (dashboardData?.my_assigned_tasks || []).filter(
+ (t) => !['done', 'completed'].includes((t.status || '').toLowerCase())
+ );
+ return activeTasks.length > 0 ? (
- {dashboardData._myAssignedTasks.map((task) => (
+ {activeTasks.map((task) => (
-
@@ -497,15 +555,16 @@ const AdminDashboard = () => {
))}
- ) : (
+ ) : (
No tasks found
-
You don't have any tasks assigned yet.
+
You don't have any active tasks assigned yet.
- )}
+ );
+ })()}
diff --git a/frontend/src/pages/BasicDashboard.jsx b/frontend/src/pages/BasicDashboard.jsx
index e57ff7c..86b2e49 100644
--- a/frontend/src/pages/BasicDashboard.jsx
+++ b/frontend/src/pages/BasicDashboard.jsx
@@ -96,8 +96,9 @@ const BasicDashboard = () => {
};
// Use full tasks list if provided by API, otherwise fall back to recentTasks
- // Limit to last 10 most recent tasks, sorted by most recent first
+ // Filter out completed tasks, limit to last 10 most recent tasks, sorted by most recent first
const orderedTasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || []))
+ .filter((task) => !['done', 'completed'].includes((task.status || '').toLowerCase()))
.sort((a, b) => {
const dateA = new Date(a.updated_at || a.created_at || 0).getTime();
const dateB = new Date(b.updated_at || b.created_at || 0).getTime();
diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx
index e483b84..0737963 100644
--- a/frontend/src/pages/TaskList.jsx
+++ b/frontend/src/pages/TaskList.jsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { taskService } from '../services/utils/api';
+import { taskService, userService } from '../services/utils/api';
import { useAuth } from '../context/AuthContext';
import LoadingSpinner from '../components/LoadingSpinner';
@@ -11,6 +11,7 @@ const TaskList = () => {
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState(null);
+ const [userMap, setUserMap] = useState({});
const [filters, setFilters] = useState({
status: 'all',
priority: 'all',
@@ -23,11 +24,26 @@ const TaskList = () => {
const urlParams = new URLSearchParams(window.location.search);
const requestedAssignee = urlParams.get('assigned_to') || urlParams.get('assignee');
- // Fetch tasks once; honor deep-link assignee query if provided
+ // Fetch users and tasks once; honor deep-link assignee query if provided
useEffect(() => {
+ const fetchUsersAndTasks = async () => {
+ try {
+ const usersData = await userService.getAllUsers();
+ const users = Array.isArray(usersData?.users) ? usersData.users : Array.isArray(usersData) ? usersData : [];
+ const userById = {};
+ users.forEach(user => {
+ userById[user.id] = user.name;
+ });
+ setUserMap(userById);
+ } catch (err) {
+ console.error('Failed to fetch users:', err);
+ }
+ };
+
const initialUrlParams = new URLSearchParams(window.location.search);
const initialAssignee = initialUrlParams.get('assigned_to') || initialUrlParams.get('assignee');
+ fetchUsersAndTasks();
if (initialAssignee) {
setFilters((prev) => ({ ...prev, scope: 'my' }));
fetchTasks({ assigned_to: initialAssignee });
@@ -326,6 +342,9 @@ const TaskList = () => {
Priority
|
+
+ Assignee
+ |
Progress
|
@@ -368,6 +387,11 @@ const TaskList = () => {
{priorityInfo.icon} {priorityInfo.text}
+
+
+ {task.assignee?.name || task.assigned_to_name || (task.assigned_to ? userMap[task.assigned_to] || `User #${task.assigned_to}` : '—')}
+
+ |
From ddafd4af4a91edd9cc59567fa83145de7717ba77 Mon Sep 17 00:00:00 2001
From: Ahmed Ikram <2571642@dundee.ac.uk>
Date: Fri, 8 May 2026 12:38:45 +0100
Subject: [PATCH 03/12] feat: Enhance task and user notifications with detailed
context and role-based recipients
---
.../src/api/controllers/tasks_controller.py | 52 +++-
.../src/api/controllers/users_controller.py | 61 +++-
.../src/services/notification_recipients.py | 277 +++++++++++++++++
backend/src/services/notification_service.py | 279 ++++++++++++++++++
frontend/src/pages/TaskList.jsx | 25 +-
5 files changed, 676 insertions(+), 18 deletions(-)
create mode 100644 backend/src/services/notification_recipients.py
diff --git a/backend/src/api/controllers/tasks_controller.py b/backend/src/api/controllers/tasks_controller.py
index 0adf150..398f362 100644
--- a/backend/src/api/controllers/tasks_controller.py
+++ b/backend/src/api/controllers/tasks_controller.py
@@ -4,7 +4,7 @@
from datetime import datetime
from flask import request, jsonify
from flask_jwt_extended import get_jwt_identity, get_jwt
-from ...db.models import db, Task, User # Changed to relative import
+from ...db.models import db, Task, User, Project # Changed to relative import
from ...auth.rbac import Role # Changed to relative import
from ..validators.task_validator import validate_task_data # Changed to relative import
from ...services import audit_service, settings_service
@@ -214,6 +214,19 @@ def create_new_task():
db.session.add(new_task)
db.session.commit()
+ # Fetch project and assignee names for notification context
+ project_name = None
+ assignee_name = None
+ try:
+ if new_task.project_id:
+ project = Project.query.get(new_task.project_id)
+ project_name = project.name if project else None
+ if new_task.assigned_to:
+ assignee = User.query.get(new_task.assigned_to)
+ assignee_name = assignee.name if assignee else None
+ except Exception:
+ pass
+
audit_service.record(
action='task_created',
resource_type='task',
@@ -229,12 +242,14 @@ def create_new_task():
)
_run_notification(
- NotificationService.task_created_notification,
+ NotificationService.task_created_notification_v2,
new_task.id,
new_task.title,
new_task.project_id,
user_id,
- new_task.assigned_to
+ assignee_id=new_task.assigned_to,
+ project_name=project_name,
+ assignee_name=assignee_name
)
return jsonify({
@@ -264,26 +279,47 @@ def update_task_by_id(task_id):
if not can_update_task:
return jsonify({'message': 'You can only update tasks assigned to you'}), 403
+ # Track which fields changed for notification
+ changed_fields = {}
+
# Update allowed fields
if 'title' in data:
+ if task.title != data['title']:
+ changed_fields['title'] = (task.title, data['title'])
task.title = data['title']
if 'description' in data:
+ if task.description != data['description']:
+ changed_fields['description'] = (task.description, data['description'])
task.description = data['description']
if 'status' in data:
+ if task.status != data['status']:
+ changed_fields['status'] = (task.status, data['status'])
task.status = data['status']
if 'progress' in data:
+ if task.progress != data['progress']:
+ changed_fields['progress'] = (task.progress, data['progress'])
task.progress = data['progress']
if 'priority' in data:
+ if task.priority != data['priority']:
+ changed_fields['priority'] = (task.priority, data['priority'])
task.priority = data['priority']
if 'deadline' in data:
+ if task.deadline != data['deadline']:
+ changed_fields['deadline'] = (task.deadline, data['deadline'])
task.deadline = data['deadline']
if 'project_id' in data:
- task.project_id = _coerce_int(data['project_id'])
+ new_project_id = _coerce_int(data['project_id'])
+ if task.project_id != new_project_id:
+ changed_fields['project_id'] = (task.project_id, new_project_id)
+ task.project_id = new_project_id
if 'assigned_to' in data:
# Only TL or Admins can change the assignee
if can_assign_task:
- task.assigned_to = _coerce_int(data['assigned_to'])
+ new_assignee = _coerce_int(data['assigned_to'])
+ if task.assigned_to != new_assignee:
+ changed_fields['assigned_to'] = (task.assigned_to, new_assignee)
+ task.assigned_to = new_assignee
elif _coerce_int(data['assigned_to']) != task.assigned_to:
return jsonify({'message': 'You do not have permission to reassign tasks'}), 403
@@ -304,13 +340,13 @@ def update_task_by_id(task_id):
)
_run_notification(
- NotificationService.task_updated_notification,
+ NotificationService.task_updated_notification_v2,
task.id,
task.title,
task.project_id,
user_id,
- old_assignee_id,
- task.assigned_to
+ assignee_id=task.assigned_to,
+ changed_fields=changed_fields if changed_fields else None
)
return jsonify({
diff --git a/backend/src/api/controllers/users_controller.py b/backend/src/api/controllers/users_controller.py
index 8829202..4035aa3 100644
--- a/backend/src/api/controllers/users_controller.py
+++ b/backend/src/api/controllers/users_controller.py
@@ -36,6 +36,8 @@ def create_user():
# Check if email is already taken
if User.query.filter_by(email=data['email']).first():
return jsonify({'message': 'Email already in use'}), 409
+
+ admin_user_id = get_jwt_identity()['user_id']
# Create new user
new_user = User(
@@ -61,6 +63,15 @@ def create_user():
payload={'role': new_user.role}
)
+ # Notify admins about new user
+ from ...services.notification_service import NotificationService
+ NotificationService.user_crud_notification(
+ action_type='user_created',
+ affected_user_name=new_user.name,
+ affected_user_role=new_user.role,
+ admin_user_id=admin_user_id
+ )
+
return jsonify({
'message': 'User created successfully',
'user': {
@@ -97,23 +108,33 @@ def update_user(user_id):
return validation_result
user = User.query.get_or_404(user_id)
+ admin_user_id = get_jwt_identity()['user_id']
- # Update allowed fields
- if 'name' in data:
+ # Track what fields are being changed
+ changed_fields = {}
+
+ # Update allowed fields and track changes
+ if 'name' in data and user.name != data['name']:
+ changed_fields['name'] = (user.name, data['name'])
user.name = data['name']
- if 'email' in data:
+ if 'email' in data and user.email != data['email']:
# Check if email is already taken by another user
existing_user = User.query.filter_by(email=data['email']).first()
if existing_user and existing_user.id != user_id:
return jsonify({'message': 'Email already in use'}), 409
+ changed_fields['email'] = (user.email, data['email'])
user.email = data['email']
- if 'role' in data:
+ if 'role' in data and user.role != data['role']:
+ changed_fields['role'] = (user.role, data['role'])
user.role = data['role']
if 'password' in data and data['password']:
+ changed_fields['password'] = ('***', '***')
user.password = hash_password(data['password'])
- if 'github_username' in data:
+ if 'github_username' in data and user.github_username != data['github_username']:
+ changed_fields['github_username'] = (user.github_username, data['github_username'])
user.github_username = data['github_username']
- if 'avatar' in data and hasattr(user, 'avatar'):
+ if 'avatar' in data and hasattr(user, 'avatar') and user.avatar != data['avatar']:
+ changed_fields['avatar'] = ('...', '...')
user.avatar = data['avatar']
db.session.commit()
@@ -131,6 +152,24 @@ def update_user(user_id):
payload={'role': user.role}
)
+ # Notify admins about user update/role change
+ from ...services.notification_service import NotificationService
+ if 'role' in changed_fields:
+ NotificationService.user_crud_notification(
+ action_type='user_role_changed',
+ affected_user_name=user.name,
+ affected_user_role=user.role,
+ changed_fields=changed_fields if changed_fields else None,
+ admin_user_id=admin_user_id
+ )
+ else:
+ NotificationService.user_crud_notification(
+ action_type='user_updated',
+ affected_user_name=user.name,
+ changed_fields=changed_fields if changed_fields else None,
+ admin_user_id=admin_user_id
+ )
+
return jsonify({
'message': 'User updated successfully',
'user': {
@@ -144,6 +183,8 @@ def update_user(user_id):
def delete_user(user_id):
"""Controller function to delete a user (admin only)"""
user = User.query.get_or_404(user_id)
+ admin_user_id = get_jwt_identity()['user_id']
+ user_name = user.name
db.session.delete(user)
db.session.commit()
@@ -159,6 +200,14 @@ def delete_user(user_id):
resource_id=user_id
)
+ # Notify admins about user deletion
+ from ...services.notification_service import NotificationService
+ NotificationService.user_crud_notification(
+ action_type='user_deleted',
+ affected_user_name=user_name,
+ admin_user_id=admin_user_id
+ )
+
return jsonify({'message': 'User deleted successfully'})
def get_current_user_profile():
diff --git a/backend/src/services/notification_recipients.py b/backend/src/services/notification_recipients.py
new file mode 100644
index 0000000..3473166
--- /dev/null
+++ b/backend/src/services/notification_recipients.py
@@ -0,0 +1,277 @@
+"""
+Notification recipient calculator for role-based notification filtering.
+Determines who should receive notifications based on user roles, project scope, and action type.
+"""
+
+from ..db.models import Task, Project, User
+from .task_rules import get_project_scope_ids
+
+
+def _to_int(value):
+ """Safe int conversion"""
+ try:
+ return int(value) if value is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
+def get_tls_for_project(project_id):
+ """Get all Team Leads assigned to a project"""
+ project_id = _to_int(project_id)
+ if project_id is None:
+ return []
+
+ try:
+ project = Project.query.get(project_id)
+ if not project:
+ return []
+
+ team_leads = []
+ members = getattr(project, 'team_members', []) or []
+ if hasattr(members, 'all'):
+ members = members.all()
+
+ for member in members:
+ if member is None:
+ continue
+ role = getattr(member, 'role', None)
+ if role == 'team_lead':
+ team_leads.append(member.id)
+
+ return team_leads
+ except Exception:
+ return []
+
+
+def get_admins_for_project(project_id):
+ """Get the admin (creator) of a project"""
+ project_id = _to_int(project_id)
+ if project_id is None:
+ return []
+
+ try:
+ project = Project.query.get(project_id)
+ if not project:
+ return []
+
+ creator_id = getattr(project, 'created_by', None)
+ if creator_id:
+ return [creator_id]
+ return []
+ except Exception:
+ return []
+
+
+def get_all_admins():
+ """Get all admin users in the system"""
+ try:
+ admins = User.query.filter_by(role='admin').all()
+ return [admin.id for admin in admins]
+ except Exception:
+ return []
+
+
+def get_recipients_for_task_assign(task_id, assignee_id, project_id, assigner_id):
+ """
+ Get recipients for task assignment notification.
+
+ Recipients:
+ - The person being assigned (if not the assigner)
+ """
+ assignee_id = _to_int(assignee_id)
+ assigner_id = _to_int(assigner_id)
+
+ if assignee_id is None or assignee_id == assigner_id:
+ return []
+
+ return [assignee_id]
+
+
+def get_recipients_for_task_create(task_id, project_id, creator_id, assignee_id=None):
+ """
+ Get recipients for task creation notification.
+
+ Recipients:
+ - The assignee (if assigned)
+ - All Team Leads of the project
+ - The project creator (admin)
+ """
+ project_id = _to_int(project_id)
+ creator_id = _to_int(creator_id)
+ assignee_id = _to_int(assignee_id)
+
+ recipients = set()
+
+ # Notify the assignee
+ if assignee_id is not None and assignee_id != creator_id:
+ recipients.add(assignee_id)
+
+ # Notify project TLs
+ if project_id:
+ tls = get_tls_for_project(project_id)
+ for tl_id in tls:
+ if tl_id != creator_id and tl_id != assignee_id:
+ recipients.add(tl_id)
+
+ # Notify project creator (admin)
+ admins = get_admins_for_project(project_id)
+ for admin_id in admins:
+ if admin_id != creator_id and admin_id != assignee_id:
+ recipients.add(admin_id)
+
+ return list(recipients)
+
+
+def get_recipients_for_task_update(task_id, project_id, updater_id, assignee_id=None):
+ """
+ Get recipients for task update notification.
+
+ Recipients:
+ - The assigned person (if task is assigned to them and they're not the updater)
+ - All Team Leads of the project (for visibility of project-wide task updates)
+ - Project admin
+ """
+ project_id = _to_int(project_id)
+ updater_id = _to_int(updater_id)
+ assignee_id = _to_int(assignee_id)
+
+ recipients = set()
+
+ # Notify the assignee
+ if assignee_id is not None and assignee_id != updater_id:
+ recipients.add(assignee_id)
+
+ # Notify project TLs and project admin
+ if project_id:
+ tls = get_tls_for_project(project_id)
+ for tl_id in tls:
+ if tl_id != updater_id:
+ recipients.add(tl_id)
+
+ admins = get_admins_for_project(project_id)
+ for admin_id in admins:
+ if admin_id != updater_id:
+ recipients.add(admin_id)
+
+ return list(recipients)
+
+
+def get_recipients_for_overdue_task(task_id, project_id, assignee_id):
+ """
+ Get recipients for overdue task notification.
+
+ Recipients:
+ - The assigned person (if assigned)
+ - All Team Leads of the project
+ - All Admins
+ """
+ project_id = _to_int(project_id)
+ assignee_id = _to_int(assignee_id)
+
+ recipients = set()
+
+ # Notify the assignee
+ if assignee_id is not None:
+ recipients.add(assignee_id)
+
+ # Notify project TLs and project admin
+ if project_id:
+ tls = get_tls_for_project(project_id)
+ for tl_id in tls:
+ recipients.add(tl_id)
+
+ admins = get_admins_for_project(project_id)
+ for admin_id in admins:
+ recipients.add(admin_id)
+
+ # Also notify all app-wide admins
+ all_admins = get_all_admins()
+ for admin_id in all_admins:
+ recipients.add(admin_id)
+
+ return list(recipients)
+
+
+def get_recipients_for_project_member_add(project_id, new_member_id, adder_id):
+ """
+ Get recipients for project member add notification.
+
+ Recipients:
+ - The new member
+ - All existing Team Leads in the project
+ - Project admin
+ """
+ project_id = _to_int(project_id)
+ new_member_id = _to_int(new_member_id)
+ adder_id = _to_int(adder_id)
+
+ recipients = set()
+
+ # Notify the new member
+ if new_member_id is not None:
+ recipients.add(new_member_id)
+
+ # Notify project TLs and admin
+ if project_id:
+ tls = get_tls_for_project(project_id)
+ for tl_id in tls:
+ if tl_id != new_member_id and tl_id != adder_id:
+ recipients.add(tl_id)
+
+ admins = get_admins_for_project(project_id)
+ for admin_id in admins:
+ if admin_id != new_member_id and admin_id != adder_id:
+ recipients.add(admin_id)
+
+ return list(recipients)
+
+
+def get_recipients_for_report_available(project_id, creator_id):
+ """
+ Get recipients for 'report available for download' notification.
+
+ Recipients:
+ - The creator (if they're a TL/Admin)
+ - All Team Leads of the project
+ - All Admins
+ """
+ project_id = _to_int(project_id)
+ creator_id = _to_int(creator_id)
+
+ recipients = set()
+
+ # Notify the creator
+ if creator_id is not None:
+ recipients.add(creator_id)
+
+ # Notify project TLs
+ if project_id:
+ tls = get_tls_for_project(project_id)
+ for tl_id in tls:
+ recipients.add(tl_id)
+
+ admins = get_admins_for_project(project_id)
+ for admin_id in admins:
+ recipients.add(admin_id)
+
+ # Also notify all app-wide admins
+ all_admins = get_all_admins()
+ for admin_id in all_admins:
+ recipients.add(admin_id)
+
+ return list(recipients)
+
+
+def get_recipients_for_user_crud(action_type, affected_user_id):
+ """
+ Get recipients for user CRUD operations (create, update, delete, role change).
+
+ Recipients:
+ - All Admins only
+
+ Args:
+ action_type: 'user_created', 'user_updated', 'user_deleted', 'user_role_changed'
+ affected_user_id: The ID of the user being created/updated/deleted
+ """
+ # Only notify admins about user CRUD
+ return get_all_admins()
diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py
index 4fdd30d..595d649 100644
--- a/backend/src/services/notification_service.py
+++ b/backend/src/services/notification_service.py
@@ -308,3 +308,282 @@ def task_overdue_notification(task_id, task_name, project_id, recipient_user_id,
reference_id=task_id,
task_id=task_id,
)
+
+ @staticmethod
+ def send_to_recipients(recipient_user_ids, notification_type, title, message, reference_id=None, task_id=None):
+ """
+ Send notification to a specific list of recipients.
+
+ Args:
+ recipient_user_ids: List of user IDs to send notification to
+ notification_type: Type of notification
+ title: Notification title
+ message: Notification content
+ reference_id: ID of the related object
+ task_id: Optional task ID related to the notification
+ """
+ if not recipient_user_ids:
+ return []
+
+ notifications = []
+ for user_id in recipient_user_ids:
+ notification = NotificationService.send_to_user(
+ user_id=user_id,
+ notification_type=notification_type,
+ title=title,
+ message=message,
+ reference_id=reference_id,
+ task_id=task_id
+ )
+ if notification:
+ notifications.append(notification)
+
+ return notifications
+
+ @staticmethod
+ def task_created_notification_v2(task_id, task_name, project_id, created_by_user_id, assignee_id=None,
+ project_name=None, assignee_name=None, recipient_user_ids=None):
+ """
+ Send notification for task creation using role-based recipients with detailed context.
+
+ Args:
+ task_id: Task ID
+ task_name: Task name
+ project_id: Project ID
+ created_by_user_id: User who created the task
+ assignee_id: User assigned to the task (if any)
+ project_name: Name of the project (fetched if None)
+ assignee_name: Name of the assignee (fetched if None)
+ recipient_user_ids: Explicit list of user IDs to notify (if None, calculates from recipients module)
+ """
+ # Fetch project name if not provided
+ if project_name is None and project_id:
+ try:
+ from ..db.models import Project
+ project = Project.query.get(project_id)
+ project_name = project.name if project else None
+ except Exception:
+ project_name = None
+
+ # Fetch assignee name if not provided
+ if assignee_name is None and assignee_id:
+ try:
+ from ..db.models import User
+ assignee = User.query.get(assignee_id)
+ assignee_name = assignee.name if assignee else None
+ except Exception:
+ assignee_name = None
+
+ if recipient_user_ids is None:
+ from .notification_recipients import get_recipients_for_task_create
+ recipient_user_ids = get_recipients_for_task_create(
+ task_id=task_id,
+ project_id=project_id,
+ creator_id=created_by_user_id,
+ assignee_id=assignee_id
+ )
+
+ # Build context-rich message
+ project_context = f" in {project_name}" if project_name else ""
+ assignee_context = f" assigned to {assignee_name}" if assignee_name else ""
+ message = f'New task "{task_name}"{project_context}{assignee_context}'
+
+ return NotificationService.send_to_recipients(
+ recipient_user_ids=recipient_user_ids,
+ notification_type='task_created',
+ title='New Task Created',
+ message=message,
+ reference_id=task_id,
+ task_id=task_id
+ )
+
+ @staticmethod
+ def task_updated_notification_v2(task_id, task_name, project_id, updated_by_user_id, assignee_id=None,
+ changed_fields=None, project_name=None, recipient_user_ids=None):
+ """
+ Send notification for task updates using role-based recipients with specific change details.
+
+ Args:
+ task_id: Task ID
+ task_name: Task name
+ project_id: Project ID
+ updated_by_user_id: User who updated the task
+ assignee_id: Current assignee of the task (if any)
+ changed_fields: Dict with field names as keys and (old_value, new_value) tuples as values
+ e.g. {'status': ('todo', 'in_progress'), 'priority': ('low', 'high')}
+ project_name: Name of the project (fetched if None)
+ recipient_user_ids: Explicit list of user IDs to notify (if None, calculates from recipients module)
+ """
+ # Fetch project name if not provided
+ if project_name is None and project_id:
+ try:
+ from ..db.models import Project
+ project = Project.query.get(project_id)
+ project_name = project.name if project else None
+ except Exception:
+ project_name = None
+
+ if recipient_user_ids is None:
+ from .notification_recipients import get_recipients_for_task_update
+ recipient_user_ids = get_recipients_for_task_update(
+ task_id=task_id,
+ project_id=project_id,
+ updater_id=updated_by_user_id,
+ assignee_id=assignee_id
+ )
+
+ # Build specific message about what changed
+ project_context = f" in {project_name}" if project_name else ""
+
+ if changed_fields:
+ # Get the most important change to highlight
+ important_fields = ['status', 'assigned_to', 'deadline', 'priority']
+ main_change = None
+
+ for field in important_fields:
+ if field in changed_fields:
+ old_val, new_val = changed_fields[field]
+ if field == 'status':
+ main_change = f"status changed to {new_val}"
+ elif field == 'assigned_to':
+ # Try to get assignee name
+ try:
+ from ..db.models import User
+ assignee = User.query.get(new_val) if new_val else None
+ assignee_name = assignee.name if assignee else "someone"
+ except Exception:
+ assignee_name = "someone"
+ main_change = f"assigned to {assignee_name}"
+ elif field == 'deadline':
+ main_change = f"deadline updated to {new_val}"
+ elif field == 'priority':
+ main_change = f"priority set to {new_val}"
+ break
+
+ if main_change:
+ message = f'Task "{task_name}"{project_context} - {main_change}'
+ else:
+ # If no important field changed, list what did
+ changed_list = ', '.join(changed_fields.keys())
+ message = f'Task "{task_name}"{project_context} updated ({changed_list})'
+ else:
+ message = f'Task "{task_name}"{project_context} was updated'
+
+ return NotificationService.send_to_recipients(
+ recipient_user_ids=recipient_user_ids,
+ notification_type='task_updated',
+ title='Task Updated',
+ message=message,
+ reference_id=task_id,
+ task_id=task_id
+ )
+
+ @staticmethod
+ def user_crud_notification(action_type, affected_user_name, affected_user_role=None,
+ changed_fields=None, admin_user_id=None, recipient_user_ids=None):
+ """
+ Send notification for user CRUD operations with specific details.
+
+ Args:
+ action_type: 'user_created', 'user_updated', 'user_deleted', 'user_role_changed'
+ affected_user_name: Name of the user being affected
+ affected_user_role: Role of the user (for create/role_change)
+ changed_fields: Dict with what was changed (for updates)
+ admin_user_id: User ID performing the action (excluded from recipients)
+ recipient_user_ids: Explicit list of user IDs to notify (if None, gets all admins)
+ """
+ if recipient_user_ids is None:
+ from .notification_recipients import get_recipients_for_user_crud
+ recipient_user_ids = get_recipients_for_user_crud(action_type, None)
+
+ # Exclude the admin who performed the action
+ if admin_user_id:
+ recipient_user_ids = [uid for uid in recipient_user_ids if uid != admin_user_id]
+
+ action_titles = {
+ 'user_created': 'New User Created',
+ 'user_updated': 'User Updated',
+ 'user_deleted': 'User Deleted',
+ 'user_role_changed': 'User Role Changed'
+ }
+
+ # Build specific messages
+ if action_type == 'user_created':
+ role_info = f" as {affected_user_role}" if affected_user_role else ""
+ action_messages = f'New user "{affected_user_name}"{role_info} created'
+ elif action_type == 'user_role_changed':
+ role_info = f" to {affected_user_role}" if affected_user_role else ""
+ action_messages = f'User "{affected_user_name}" role changed{role_info}'
+ elif action_type == 'user_updated':
+ if changed_fields:
+ changed_list = ', '.join(changed_fields.keys())
+ action_messages = f'User "{affected_user_name}" updated ({changed_list})'
+ else:
+ action_messages = f'User "{affected_user_name}" was updated'
+ elif action_type == 'user_deleted':
+ action_messages = f'User "{affected_user_name}" was deleted'
+ else:
+ action_messages = f'User operation on "{affected_user_name}"'
+
+ return NotificationService.send_to_recipients(
+ recipient_user_ids=recipient_user_ids,
+ notification_type=action_type,
+ title=action_titles.get(action_type, 'User Operation'),
+ message=action_messages,
+ reference_id=None
+ )
+
+ @staticmethod
+ def report_available_notification(report_id, report_type, project_id, creator_id, recipient_user_ids=None):
+ """
+ Send notification for report availability.
+
+ Args:
+ report_id: Report ID
+ report_type: Type of report ('tasks', 'developers', 'github', etc.)
+ project_id: Project ID the report is for
+ creator_id: User who created the report
+ recipient_user_ids: Explicit list of user IDs to notify (if None, calculates from recipients module)
+ """
+ if recipient_user_ids is None:
+ from .notification_recipients import get_recipients_for_report_available
+ recipient_user_ids = get_recipients_for_report_available(
+ project_id=project_id,
+ creator_id=creator_id
+ )
+
+ return NotificationService.send_to_recipients(
+ recipient_user_ids=recipient_user_ids,
+ notification_type='report_available',
+ title='Report Available for Download',
+ message=f'Your {report_type} report is ready to download',
+ reference_id=report_id
+ )
+
+ @staticmethod
+ def project_member_added_notification(project_id, new_member_name, new_member_id, adder_id, recipient_user_ids=None):
+ """
+ Send notification when a new member is added to a project.
+
+ Args:
+ project_id: Project ID
+ new_member_name: Name of the new member
+ new_member_id: User ID of the new member
+ adder_id: User ID of the person adding the member
+ recipient_user_ids: Explicit list of user IDs to notify
+ """
+ if recipient_user_ids is None:
+ from .notification_recipients import get_recipients_for_project_member_add
+ recipient_user_ids = get_recipients_for_project_member_add(
+ project_id=project_id,
+ new_member_id=new_member_id,
+ adder_id=adder_id
+ )
+
+ return NotificationService.send_to_recipients(
+ recipient_user_ids=recipient_user_ids,
+ notification_type='project_member_added',
+ title='New Member Added to Project',
+ message=f'{new_member_name} was added to your project',
+ reference_id=project_id
+ )
diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx
index 0737963..fba89dc 100644
--- a/frontend/src/pages/TaskList.jsx
+++ b/frontend/src/pages/TaskList.jsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { taskService, userService } from '../services/utils/api';
+import { taskService, userService, projectService } from '../services/utils/api';
import { useAuth } from '../context/AuthContext';
import LoadingSpinner from '../components/LoadingSpinner';
@@ -12,6 +12,7 @@ const TaskList = () => {
const [updating, setUpdating] = useState(false);
const [error, setError] = useState(null);
const [userMap, setUserMap] = useState({});
+ const [projectMap, setProjectMap] = useState({});
const [filters, setFilters] = useState({
status: 'all',
priority: 'all',
@@ -26,7 +27,7 @@ const TaskList = () => {
// Fetch users and tasks once; honor deep-link assignee query if provided
useEffect(() => {
- const fetchUsersAndTasks = async () => {
+ const fetchUsersAndProjects = async () => {
try {
const usersData = await userService.getAllUsers();
const users = Array.isArray(usersData?.users) ? usersData.users : Array.isArray(usersData) ? usersData : [];
@@ -35,15 +36,23 @@ const TaskList = () => {
userById[user.id] = user.name;
});
setUserMap(userById);
+
+ const projectsData = await projectService.getAllProjects();
+ const projects = Array.isArray(projectsData?.projects) ? projectsData.projects : Array.isArray(projectsData) ? projectsData : [];
+ const projectById = {};
+ projects.forEach((project) => {
+ projectById[project.id] = project.name;
+ });
+ setProjectMap(projectById);
} catch (err) {
- console.error('Failed to fetch users:', err);
+ console.error('Failed to fetch users or projects:', err);
}
};
const initialUrlParams = new URLSearchParams(window.location.search);
const initialAssignee = initialUrlParams.get('assigned_to') || initialUrlParams.get('assignee');
- fetchUsersAndTasks();
+ fetchUsersAndProjects();
if (initialAssignee) {
setFilters((prev) => ({ ...prev, scope: 'my' }));
fetchTasks({ assigned_to: initialAssignee });
@@ -345,6 +354,9 @@ const TaskList = () => {
Assignee
|
+
+ Project
+ |
Progress
|
@@ -392,6 +404,11 @@ const TaskList = () => {
{task.assignee?.name || task.assigned_to_name || (task.assigned_to ? userMap[task.assigned_to] || `User #${task.assigned_to}` : '—')}
|
+
+
+ {task.project_name || task.project?.name || (task.project_id ? projectMap[task.project_id] || `Project #${task.project_id}` : '—')}
+
+ |
From 1de76bf13f380bc07b67b3c01dd7ae14cdf0b015 Mon Sep 17 00:00:00 2001
From: Ahmed Ikram <2571642@dundee.ac.uk>
Date: Fri, 8 May 2026 12:49:35 +0100
Subject: [PATCH 04/12] feat: Enhance task filtering and sorting options in
TaskList component
---
.../api/controllers/dashboard_controller.py | 2 -
.../src/api/controllers/tasks_controller.py | 4 +-
.../src/services/notification_recipients.py | 5 +-
backend/src/services/notification_service.py | 10 +-
.../unit/controllers/test_users_controller.py | 6 +-
frontend/src/pages/BasicDashboard.jsx | 23 ++--
frontend/src/pages/TaskList.jsx | 101 +++++++++++++++++-
frontend/src/tests/pages/TaskList.test.jsx | 5 +-
8 files changed, 123 insertions(+), 33 deletions(-)
diff --git a/backend/src/api/controllers/dashboard_controller.py b/backend/src/api/controllers/dashboard_controller.py
index e88291a..ceebcbf 100644
--- a/backend/src/api/controllers/dashboard_controller.py
+++ b/backend/src/api/controllers/dashboard_controller.py
@@ -278,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),
diff --git a/backend/src/api/controllers/tasks_controller.py b/backend/src/api/controllers/tasks_controller.py
index 398f362..2172f15 100644
--- a/backend/src/api/controllers/tasks_controller.py
+++ b/backend/src/api/controllers/tasks_controller.py
@@ -219,10 +219,10 @@ def create_new_task():
assignee_name = None
try:
if new_task.project_id:
- project = Project.query.get(new_task.project_id)
+ project = db.session.get(Project, new_task.project_id)
project_name = project.name if project else None
if new_task.assigned_to:
- assignee = User.query.get(new_task.assigned_to)
+ assignee = db.session.get(User, new_task.assigned_to)
assignee_name = assignee.name if assignee else None
except Exception:
pass
diff --git a/backend/src/services/notification_recipients.py b/backend/src/services/notification_recipients.py
index 3473166..b3793ec 100644
--- a/backend/src/services/notification_recipients.py
+++ b/backend/src/services/notification_recipients.py
@@ -4,6 +4,7 @@
"""
from ..db.models import Task, Project, User
+from ..db.db_connection import db
from .task_rules import get_project_scope_ids
@@ -22,7 +23,7 @@ def get_tls_for_project(project_id):
return []
try:
- project = Project.query.get(project_id)
+ project = db.session.get(Project, project_id)
if not project:
return []
@@ -50,7 +51,7 @@ def get_admins_for_project(project_id):
return []
try:
- project = Project.query.get(project_id)
+ project = db.session.get(Project, project_id)
if not project:
return []
diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py
index 595d649..87bd6d7 100644
--- a/backend/src/services/notification_service.py
+++ b/backend/src/services/notification_service.py
@@ -359,8 +359,7 @@ def task_created_notification_v2(task_id, task_name, project_id, created_by_user
# Fetch project name if not provided
if project_name is None and project_id:
try:
- from ..db.models import Project
- project = Project.query.get(project_id)
+ project = db.session.get(Project, project_id)
project_name = project.name if project else None
except Exception:
project_name = None
@@ -369,7 +368,7 @@ def task_created_notification_v2(task_id, task_name, project_id, created_by_user
if assignee_name is None and assignee_id:
try:
from ..db.models import User
- assignee = User.query.get(assignee_id)
+ assignee = db.session.get(User, assignee_id)
assignee_name = assignee.name if assignee else None
except Exception:
assignee_name = None
@@ -417,8 +416,7 @@ def task_updated_notification_v2(task_id, task_name, project_id, updated_by_user
# Fetch project name if not provided
if project_name is None and project_id:
try:
- from ..db.models import Project
- project = Project.query.get(project_id)
+ project = db.session.get(Project, project_id)
project_name = project.name if project else None
except Exception:
project_name = None
@@ -449,7 +447,7 @@ def task_updated_notification_v2(task_id, task_name, project_id, updated_by_user
# Try to get assignee name
try:
from ..db.models import User
- assignee = User.query.get(new_val) if new_val else None
+ assignee = db.session.get(User, new_val) if new_val else None
assignee_name = assignee.name if assignee else "someone"
except Exception:
assignee_name = "someone"
diff --git a/backend/tests/unit/controllers/test_users_controller.py b/backend/tests/unit/controllers/test_users_controller.py
index 2ad2f39..3267564 100644
--- a/backend/tests/unit/controllers/test_users_controller.py
+++ b/backend/tests/unit/controllers/test_users_controller.py
@@ -83,7 +83,7 @@ def test_get_user_by_id(app, mock_db, mock_user):
assert data['user']['name'] == "Test User"
assert data['user']['email'] == "test@example.com"
-def test_update_user_success(app, mock_db, mock_user):
+def test_update_user_success(app, mock_db, mock_user, mock_jwt_identity):
with app.test_request_context(json={'name': 'Updated Name', 'email': 'new@example.com'}):
with patch('backend.src.api.controllers.users_controller.User.query') as mock_query:
# Configure the mock query
@@ -104,7 +104,7 @@ def test_update_user_success(app, mock_db, mock_user):
assert mock_user.email == 'new@example.com'
assert mock_db.session.commit.called
-def test_update_user_email_exists(app, mock_db, mock_user):
+def test_update_user_email_exists(app, mock_db, mock_user, mock_jwt_identity):
with app.test_request_context(json={'email': 'existing@example.com'}):
with patch('backend.src.api.controllers.users_controller.User.query') as mock_query:
# Configure the mock query - email already taken
@@ -125,7 +125,7 @@ def test_update_user_email_exists(app, mock_db, mock_user):
assert 'Email already in use' in data['message']
assert status == 409
-def test_delete_user(app, mock_db, mock_user):
+def test_delete_user(app, mock_db, mock_user, mock_jwt_identity):
with app.test_request_context():
with patch('backend.src.api.controllers.users_controller.User.query') as mock_query:
# Configure the mock query
diff --git a/frontend/src/pages/BasicDashboard.jsx b/frontend/src/pages/BasicDashboard.jsx
index 86b2e49..54e810f 100644
--- a/frontend/src/pages/BasicDashboard.jsx
+++ b/frontend/src/pages/BasicDashboard.jsx
@@ -5,7 +5,6 @@ import LoadingSpinner from '../components/LoadingSpinner';
import { useAuth } from '../context/AuthContext';
const panelClass = "bg-slate-900/70 border border-slate-800/70 rounded-2xl overflow-hidden shadow-md backdrop-blur-sm";
-const panelHeaderClass = "px-6 py-5 border-b border-slate-800/70 flex justify-between items-center";
const sectionTitleClass = "text-lg font-semibold text-slate-100";
const getCount = (counts, keys, fallback = 0) => {
@@ -95,16 +94,14 @@ const BasicDashboard = () => {
fetchDashboardData();
};
- // Use full tasks list if provided by API, otherwise fall back to recentTasks
- // Filter out completed tasks, limit to last 10 most recent tasks, sorted by most recent first
- const orderedTasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || []))
+ // Get all tasks, filter out completed, sorted by most recent
+ const tasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || []))
.filter((task) => !['done', 'completed'].includes((task.status || '').toLowerCase()))
.sort((a, b) => {
const dateA = new Date(a.updated_at || a.created_at || 0).getTime();
const dateB = new Date(b.updated_at || b.created_at || 0).getTime();
return dateB - dateA;
});
- const tasksToShow = isTeamLead ? orderedTasksToShow : orderedTasksToShow.slice(0, 10);
return (
@@ -233,14 +230,16 @@ const BasicDashboard = () => {
-
-
- My Tasks
- Your latest assigned work, with the same fields you see on the tasks page.
+
+
+
+ My Tasks
+ Your latest assigned work, with the same fields you see on the tasks page.
+
+
+ View all tasks
+
-
- View all tasks
-
{tasksToShow.length > 0 ? (
diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx
index fba89dc..903fde2 100644
--- a/frontend/src/pages/TaskList.jsx
+++ b/frontend/src/pages/TaskList.jsx
@@ -17,7 +17,10 @@ const TaskList = () => {
status: 'all',
priority: 'all',
search: '',
- scope: 'all'
+ scope: 'all',
+ project: 'all',
+ sortBy: 'recent',
+ assignee: 'all'
});
const canCreateTasks = Boolean(currentUser);
@@ -161,6 +164,11 @@ const TaskList = () => {
return false;
}
+ // Filter by project
+ if (filters.project !== 'all' && Number(task.project_id) !== Number(filters.project)) {
+ return false;
+ }
+
// Filter by search text
if (filters.search && !task.title.toLowerCase().includes(filters.search.toLowerCase())) {
return false;
@@ -177,8 +185,30 @@ const TaskList = () => {
return false;
}
}
+
+ // Filter by assignee
+ if (filters.assignee !== 'all' && Number(task.assigned_to) !== Number(filters.assignee)) {
+ return false;
+ }
return true;
+ }).sort((a, b) => {
+ switch (filters.sortBy) {
+ case 'deadline':
+ const deadlineA = a.deadline ? new Date(a.deadline).getTime() : Infinity;
+ const deadlineB = b.deadline ? new Date(b.deadline).getTime() : Infinity;
+ return deadlineA - deadlineB;
+ case 'priority':
+ const priorityOrder = { high: 1, medium: 2, low: 3 };
+ const priorityA = priorityOrder[(a.priority || 'medium').toLowerCase()] || 2;
+ const priorityB = priorityOrder[(b.priority || 'medium').toLowerCase()] || 2;
+ return priorityA - priorityB;
+ case 'progress':
+ return (b.progress || 0) - (a.progress || 0);
+ case 'recent':
+ default:
+ return (new Date(b.updated_at || b.created_at || 0).getTime()) - (new Date(a.updated_at || a.created_at || 0).getTime());
+ }
});
if (loading) {
@@ -238,7 +268,7 @@ const TaskList = () => {
)}
{/* Filters */}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(filters.status !== 'all' || filters.priority !== 'all' || filters.project !== 'all' || filters.search || filters.assignee !== 'all') && (
+
+ )}
+
{/* Scope Toggle for Developers */}
@@ -323,9 +416,9 @@ const TaskList = () => {
No tasks found matching your filters
- {filters.status !== 'all' || filters.priority !== 'all' || filters.search ? (
+ {filters.status !== 'all' || filters.priority !== 'all' || filters.search || filters.project !== 'all' || filters.assignee !== 'all' ? (
|