Skip to content

Commit efc4c8f

Browse files
feat: implement team activity timeline endpoint and enhance audit log… (#221)
* feat: implement team activity timeline endpoint and enhance audit logging - Added TeamActivityTimelineView to provide a comprehensive timeline of activities related to tasks assigned to a team, including assignment, unassignment, executor changes, and status changes. - Updated urls.py to include the new route for accessing the team activity timeline. - Enhanced AuditLogModel to include additional fields for tracking status and assignment changes, improving the granularity of logged actions. - Updated TaskAssignmentService and TaskService to log relevant actions to the audit log, ensuring better tracking of task assignments and status changes. These changes improve team management and accountability by providing a detailed activity log for team-related tasks. * refactor: remove unused AuditLogModel import in team views - Deleted the unused import of AuditLogModel from team.py to clean up the code and improve maintainability. This change contributes to a more organized codebase by eliminating unnecessary dependencies. * fix: correct formatting in TeamActivityTimelineView response - Added a missing comma in the OpenApiResponse description for clarity. - Reformatted the retrieval of previous executor names to enhance readability by breaking long lines into multiple lines. These changes improve the consistency and maintainability of the API response structure. * refactor: improve code formatting in task_service.py - Standardized the use of double quotes for string literals in the TaskService class. - This change enhances code consistency and readability without affecting functionality. --------- Co-authored-by: Amit Prakash <[email protected]>
1 parent ef47809 commit efc4c8f

File tree

6 files changed

+147
-7
lines changed

6 files changed

+147
-7
lines changed

todo/models/audit_log.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88
class AuditLogModel(Document):
99
collection_name: ClassVar[str] = "audit_logs"
1010

11-
task_id: PyObjectId
12-
team_id: PyObjectId
11+
task_id: PyObjectId | None = None
12+
team_id: PyObjectId | None = None
1313
previous_executor_id: PyObjectId | None = None
14-
new_executor_id: PyObjectId
15-
spoc_id: PyObjectId
16-
action: str = "reassign_executor"
14+
new_executor_id: PyObjectId | None = None
15+
spoc_id: PyObjectId | None = None
16+
action: str # e.g., "assigned_to_team", "unassigned_from_team", "status_changed", "reassign_executor"
1717
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
18+
# For status changes
19+
status_from: str | None = None
20+
status_to: str | None = None
21+
# For assignment changes
22+
assignee_from: PyObjectId | None = None
23+
assignee_to: PyObjectId | None = None
24+
# For general user reference (who performed the action)
25+
performed_by: PyObjectId | None = None

todo/repositories/audit_log_repository.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@ def create(cls, audit_log: AuditLogModel) -> AuditLogModel:
1414
insert_result = collection.insert_one(audit_log_dict)
1515
audit_log.id = insert_result.inserted_id
1616
return audit_log
17+
18+
@classmethod
19+
def get_by_team_id(cls, team_id: str) -> list[AuditLogModel]:
20+
collection = cls.get_collection()
21+
logs = collection.find({"team_id": team_id}).sort("timestamp", -1)
22+
return [AuditLogModel(**log) for log in logs]

todo/services/task_assignment_service.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from todo.exceptions.task_exceptions import TaskNotFoundException
1212
from todo.models.task_assignment import TaskAssignmentModel
1313
from todo.dto.task_assignment_dto import TaskAssignmentDTO
14+
from todo.models.audit_log import AuditLogModel
15+
from todo.repositories.audit_log_repository import AuditLogRepository
1416

1517

1618
class TaskAssignmentService:
@@ -39,13 +41,22 @@ def create_task_assignment(cls, dto: CreateTaskAssignmentDTO, user_id: str) -> C
3941
# Check if task already has an active assignment
4042
existing_assignment = TaskAssignmentRepository.get_by_task_id(dto.task_id)
4143
if existing_assignment:
44+
# If previous assignment was to a team, log unassignment
45+
if existing_assignment.user_type == "team":
46+
AuditLogRepository.create(
47+
AuditLogModel(
48+
task_id=existing_assignment.task_id,
49+
team_id=existing_assignment.assignee_id,
50+
action="unassigned_from_team",
51+
performed_by=PyObjectId(user_id),
52+
)
53+
)
4254
# Update existing assignment
4355
updated_assignment = TaskAssignmentRepository.update_assignment(
4456
dto.task_id, dto.assignee_id, dto.user_type, user_id
4557
)
4658
if not updated_assignment:
4759
raise ValueError("Failed to update task assignment")
48-
4960
assignment = updated_assignment
5061
else:
5162
# Create new assignment
@@ -56,9 +67,19 @@ def create_task_assignment(cls, dto: CreateTaskAssignmentDTO, user_id: str) -> C
5667
created_by=PyObjectId(user_id),
5768
updated_by=None,
5869
)
59-
6070
assignment = TaskAssignmentRepository.create(task_assignment)
6171

72+
# If new assignment is to a team, log assignment
73+
if assignment.user_type == "team":
74+
AuditLogRepository.create(
75+
AuditLogModel(
76+
task_id=assignment.task_id,
77+
team_id=assignment.assignee_id,
78+
action="assigned_to_team",
79+
performed_by=PyObjectId(user_id),
80+
)
81+
)
82+
6283
# Also insert into assignee_task_details if this is a team assignment (legacy, can be removed if not needed)
6384
# if dto.user_type == "team":
6485
# TaskAssignmentRepository.create(

todo/services/task_service.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from todo.repositories.user_repository import UserRepository
4343
from todo.repositories.watchlist_repository import WatchlistRepository
4444
import math
45+
from todo.models.audit_log import AuditLogModel
46+
from todo.repositories.audit_log_repository import AuditLogRepository
4547

4648

4749
@dataclass
@@ -303,6 +305,10 @@ def update_task(cls, task_id: str, validated_data: dict, user_id: str) -> TaskDT
303305
if not team_data:
304306
raise ValueError(f"Team not found: {assignee_id}")
305307

308+
# Track status change for audit log
309+
old_status = getattr(current_task, "status", None)
310+
new_status = validated_data.get("status")
311+
306312
update_payload = {}
307313
enum_fields = {"priority": TaskPriority, "status": TaskStatus}
308314

@@ -332,6 +338,18 @@ def update_task(cls, task_id: str, validated_data: dict, user_id: str) -> TaskDT
332338
update_payload["updatedBy"] = user_id
333339
updated_task = TaskRepository.update(task_id, update_payload)
334340

341+
# Audit log for status change
342+
if old_status and new_status and old_status != new_status:
343+
AuditLogRepository.create(
344+
AuditLogModel(
345+
task_id=current_task.id,
346+
action="status_changed",
347+
status_from=old_status,
348+
status_to=new_status,
349+
performed_by=PyObjectId(user_id),
350+
)
351+
)
352+
335353
if not updated_task:
336354
raise TaskNotFoundException(task_id)
337355

todo/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
JoinTeamByInviteCodeView,
1212
AddTeamMembersView,
1313
TeamInviteCodeView,
14+
TeamActivityTimelineView,
1415
)
1516
from todo.views.watchlist import WatchlistListView, WatchlistDetailView, WatchlistCheckView
1617
from todo.views.task_assignment import TaskAssignmentView, TaskAssignmentDetailView
@@ -22,6 +23,7 @@
2223
path("teams/<str:team_id>", TeamDetailView.as_view(), name="team_detail"),
2324
path("teams/<str:team_id>/members", AddTeamMembersView.as_view(), name="add_team_members"),
2425
path("teams/<str:team_id>/invite-code", TeamInviteCodeView.as_view(), name="team_invite_code"),
26+
path("teams/<str:team_id>/activity-timeline", TeamActivityTimelineView.as_view(), name="team_activity_timeline"),
2527
path("tasks", TaskListView.as_view(), name="tasks"),
2628
path("tasks/<str:task_id>", TaskDetailView.as_view(), name="task_detail"),
2729
path("tasks/<str:task_id>/update", TaskUpdateView.as_view(), name="update_task_and_assignee"),

todo/views/team.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
from todo.dto.team_dto import TeamDTO
2020
from todo.services.user_service import UserService
2121
from todo.repositories.team_repository import TeamRepository
22+
from todo.repositories.audit_log_repository import AuditLogRepository
23+
from todo.repositories.user_repository import UserRepository
24+
from todo.repositories.task_repository import TaskRepository
2225

2326

2427
class TeamListView(APIView):
@@ -367,3 +370,85 @@ def get(self, request: Request, team_id: str):
367370
{"detail": "You are not authorized to view the invite code for this team."},
368371
status=status.HTTP_403_FORBIDDEN,
369372
)
373+
374+
375+
class TeamActivityTimelineView(APIView):
376+
@extend_schema(
377+
operation_id="get_team_activity_timeline",
378+
summary="Get team activity timeline",
379+
description="Return a timeline of all activities related to tasks assigned to the team, including assignment, unassignment, executor changes, and status changes. All IDs are replaced with names.",
380+
tags=["teams"],
381+
parameters=[
382+
OpenApiParameter(
383+
name="team_id",
384+
type=OpenApiTypes.STR,
385+
location=OpenApiParameter.PATH,
386+
description="Unique identifier of the team",
387+
required=True,
388+
),
389+
],
390+
responses={
391+
200: OpenApiResponse(
392+
response={
393+
"type": "object",
394+
"properties": {
395+
"timeline": {
396+
"type": "array",
397+
"items": {"type": "object"},
398+
}
399+
},
400+
},
401+
description="Team activity timeline returned successfully",
402+
),
403+
404: OpenApiResponse(description="Team not found"),
404+
},
405+
)
406+
def get(self, request: Request, team_id: str):
407+
team = TeamRepository.get_by_id(team_id)
408+
if not team:
409+
return Response({"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND)
410+
logs = AuditLogRepository.get_by_team_id(team_id)
411+
# Pre-fetch team name
412+
team_name = team.name
413+
# Pre-fetch all user and task names needed
414+
user_ids = set()
415+
task_ids = set()
416+
for log in logs:
417+
if log.performed_by:
418+
user_ids.add(str(log.performed_by))
419+
if log.spoc_id:
420+
user_ids.add(str(log.spoc_id))
421+
if log.previous_executor_id:
422+
user_ids.add(str(log.previous_executor_id))
423+
if log.new_executor_id:
424+
user_ids.add(str(log.new_executor_id))
425+
if log.task_id:
426+
task_ids.add(str(log.task_id))
427+
user_map = {str(u.id): u.name for u in UserRepository.get_by_ids(list(user_ids))}
428+
task_map = {str(t.id): t.title for t in TaskRepository.get_by_ids(list(task_ids))}
429+
timeline = []
430+
for log in logs:
431+
entry = {
432+
"action": log.action,
433+
"timestamp": log.timestamp,
434+
}
435+
if log.task_id:
436+
entry["task_title"] = task_map.get(str(log.task_id), str(log.task_id))
437+
if log.team_id:
438+
entry["team_name"] = team_name
439+
if log.performed_by:
440+
entry["performed_by_name"] = user_map.get(str(log.performed_by), str(log.performed_by))
441+
if log.spoc_id:
442+
entry["spoc_name"] = user_map.get(str(log.spoc_id), str(log.spoc_id))
443+
if log.previous_executor_id:
444+
entry["previous_executor_name"] = user_map.get(
445+
str(log.previous_executor_id), str(log.previous_executor_id)
446+
)
447+
if log.new_executor_id:
448+
entry["new_executor_name"] = user_map.get(str(log.new_executor_id), str(log.new_executor_id))
449+
if log.status_from:
450+
entry["status_from"] = log.status_from
451+
if log.status_to:
452+
entry["status_to"] = log.status_to
453+
timeline.append(entry)
454+
return Response({"timeline": timeline}, status=status.HTTP_200_OK)

0 commit comments

Comments
 (0)