Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
13e97ad
feat(team): restrict member removal to admins and reassign tasks
Hariom01010 Aug 23, 2025
ad26f3a
feat(remove-member): resolve comments by bot
Hariom01010 Aug 24, 2025
f25bc28
feat(remove-member): instantiate the exception raised
Hariom01010 Aug 24, 2025
8ef56b8
Merge branch 'develop' of https://github.com/Hariom01010/todo-backend…
Hariom01010 Aug 29, 2025
3f0bd93
Merge branch 'develop' of https://github.com/Hariom01010/todo-backend…
Hariom01010 Aug 31, 2025
7877f29
feat(remove-from-team): sync changes for task assignment to postgres
Hariom01010 Sep 2, 2025
f075980
feat(remove-member): update cannot remove POC exception message
Hariom01010 Sep 2, 2025
13485ff
Merge branch 'develop' of https://github.com/Hariom01010/todo-backend…
Hariom01010 Sep 3, 2025
6bb20aa
feat(remove-member-from-team): handle mixed ObjectId and string types…
Hariom01010 Sep 3, 2025
7713420
feat(remove-member-from-team): move UserTeamDetailsRepository to the
Hariom01010 Sep 3, 2025
b0e48d3
feat(remove-from-team): set updated_by to None in the ops for task
Hariom01010 Sep 3, 2025
f96e4c4
feat(remove-member): move task_reassignment function in transaction
Hariom01010 Sep 4, 2025
42c9485
feat(remove-member): fix formatting issues
Hariom01010 Sep 4, 2025
3b50a66
feat(remove-from-team): include is_active in unique constraint to pre…
Hariom01010 Sep 5, 2025
4b4ae55
feat(remove-from-team): remove unecessary comments
Hariom01010 Sep 9, 2025
bd88687
feat(remove-from-member): add serializer for path params validation
Hariom01010 Sep 9, 2025
3d20bfb
Merge branch 'develop' of https://github.com/Hariom01010/todo-backend…
Hariom01010 Sep 12, 2025
d567b3a
feat(remove-from-team): removed aggregation pipeline and use normal db
Hariom01010 Sep 16, 2025
01e59b8
feat(remove-from-todo): remove unecessary migration files
Hariom01010 Sep 16, 2025
bbc2132
feat(remove-from-team): remove task_count field from audit log model
Hariom01010 Sep 16, 2025
3bf3c2a
feat(remove-from-team): fix bug where reassigned todos not showing on
Hariom01010 Sep 17, 2025
3c6f245
feat(remove-from-team): move remove role and validation to individual
Hariom01010 Sep 17, 2025
8bcea47
feat(remove-from-team): fix formatting issues
Hariom01010 Sep 17, 2025
2059402
feat(remove-from-team): use bool return value instead of count for ta…
Hariom01010 Sep 17, 2025
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
2 changes: 2 additions & 0 deletions todo/constants/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class ApiErrors:
USER_NOT_FOUND_GENERIC = "User not found."
SEARCH_QUERY_EMPTY = "Search query cannot be empty"
TASK_ALREADY_IN_WATCHLIST = "Task is already in the watchlist"
CANNOT_REMOVE_OWNER = "Owner cannot be removed from the team"
CANNOT_REMOVE_POC = "POC cannot be removed from a team. Reassign the POC first."


# Validation error messages
Expand Down
22 changes: 22 additions & 0 deletions todo/exceptions/team_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from todo.constants.messages import ApiErrors


class BaseTeamException(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(self.message)


class CannotRemoveOwnerException(BaseTeamException):
def __init__(self, message=ApiErrors.CANNOT_REMOVE_OWNER):
super().__init__(message)


class NotTeamAdminException(BaseTeamException):
def __init__(self, message=ApiErrors.UNAUTHORIZED_TITLE):
super().__init__(message)


class CannotRemoveTeamPOCException(BaseTeamException):
def __init__(self, message=ApiErrors.CANNOT_REMOVE_POC):
super().__init__(message)
17 changes: 17 additions & 0 deletions todo/migrations/0003_postgresauditlog_task_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-09-02 15:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("todo", "0002_rename_postgres_ta_assignee_95ca3b_idx_postgres_ta_assigne_f1c6e7_idx_and_more"),
]

operations = [
migrations.AddField(
model_name="postgresauditlog",
name="task_count",
field=models.IntegerField(blank=True, null=True),
),
]
2 changes: 2 additions & 0 deletions todo/models/audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ class AuditLogModel(Document):
assignee_to: PyObjectId | None = None
# For general user reference (who performed the action)
performed_by: PyObjectId | None = None
# For multiple task reassignments
task_count: int | None = None
1 change: 1 addition & 0 deletions todo/models/postgres/audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class PostgresAuditLog(models.Model):
assignee_from = models.CharField(max_length=24, null=True, blank=True)
assignee_to = models.CharField(max_length=24, null=True, blank=True)
performed_by = models.CharField(max_length=24, null=True, blank=True)
task_count = models.IntegerField(null=True, blank=True)

last_sync_at = models.DateTimeField(auto_now=True)
sync_status = models.CharField(
Expand Down
1 change: 1 addition & 0 deletions todo/repositories/audit_log_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def create(cls, audit_log: AuditLogModel) -> AuditLogModel:
"assignee_from": str(audit_log.assignee_from) if audit_log.assignee_from else None,
"assignee_to": str(audit_log.assignee_to) if audit_log.assignee_to else None,
"performed_by": str(audit_log.performed_by) if audit_log.performed_by else None,
"task_count": int(audit_log.task_count) if audit_log.task_count else None,
}

dual_write_success = dual_write_service.create_document(
Expand Down
107 changes: 107 additions & 0 deletions todo/repositories/task_assignment_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from todo.models.task_assignment import TaskAssignmentModel
from todo.repositories.common.mongo_repository import MongoRepository
from todo.models.common.pyobjectid import PyObjectId
from todo.constants.task import TaskStatus
from todo.services.enhanced_dual_write_service import EnhancedDualWriteService


Expand Down Expand Up @@ -355,3 +356,109 @@ def deactivate_by_task_id(cls, task_id: str, user_id: str) -> bool:
return result.modified_count > 0
except Exception:
return False

@classmethod
def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_by_user_id: str):
"""
Reassign all tasks of user to team
"""
try:
collection = cls.get_collection()
now = datetime.now(timezone.utc)
pipeline = [
{
"$match": {
"team_id": {"$in": [team_id, ObjectId(team_id)]},
"assignee_id": {"$in": [user_id, ObjectId(user_id)]},
"user_type": "user",
"is_active": True,
}
},
{
"$addFields": {
"task_id_obj": {
"$convert": {"input": "$task_id", "to": "objectId", "onError": "$task_id", "onNull": None}
}
}
},
{"$lookup": {"from": "tasks", "localField": "task_id_obj", "foreignField": "_id", "as": "task_info"}},
{"$unwind": "$task_info"},
{"$match": {"task_info.status": {"$ne": TaskStatus.DONE.value}}},
]
user_task_assignments = list(collection.aggregate(pipeline))
if not user_task_assignments:
return 0

# Deactivate current assignment (try using both ObjectId and string)
collection.update_many(
{
"assignee_id": {"$in": [user_id, ObjectId(user_id)]},
"team_id": {"$in": [team_id, ObjectId(team_id)]},
"is_active": True,
},
{"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}},
)

new_assignments = []
operations = []
dual_write_service = EnhancedDualWriteService()
for task in user_task_assignments:
operations.append(
{
"collection_name": "task_assignments",
"operation": "update",
"mongo_id": task["_id"],
"data": {
"task_mongo_id": str(task["task_id"]),
"assignee_id": str(task["assignee_id"]),
"user_type": task["user_type"],
"team_id": str(task["team_id"]),
"is_active": False,
"created_at": task["created_at"],
"updated_at": datetime.now(timezone.utc),
"created_by": str(task["created_by"]),
"updated_by": str(performed_by_user_id),
},
}
)
new_assignment_id = PyObjectId()
new_assignments.append(
TaskAssignmentModel(
_id=new_assignment_id,
task_id=task["task_id_obj"],
assignee_id=PyObjectId(team_id),
user_type="team",
created_by=PyObjectId(performed_by_user_id),
updated_by=None,
team_id=PyObjectId(team_id),
).model_dump(mode="json", by_alias=True, exclude_none=True)
)
operations.append(
{
"collection_name": "task_assignments",
"operation": "create",
"mongo_id": new_assignment_id,
"data": {
"task_mongo_id": str(task["task_id"]),
"assignee_id": str(team_id),
"user_type": "team",
"team_id": str(team_id),
"is_active": True,
"created_at": datetime.now(timezone.utc),
"updated_at": None,
"created_by": str(performed_by_user_id),
"updated_by": str(performed_by_user_id),
},
}
)
if new_assignments:
collection.insert_many(new_assignments)
dual_write_success = dual_write_service.batch_operations(operations)
if not dual_write_success:
import logging

logger = logging.getLogger(__name__)
logger.warning("Failed to sync task reassignments to Postgres")
return len(new_assignments)
except Exception:
return 0
19 changes: 19 additions & 0 deletions todo/services/task_assignment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,22 @@ def delete_task_assignment(cls, task_id: str, user_id: str) -> bool:
Delete task assignment by task ID.
"""
return TaskAssignmentRepository.delete_assignment(task_id, user_id)

@classmethod
def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_by_user_id: str):
"""
Reassign all tasks of user to team
"""
reassigned_tasks_count = TaskAssignmentRepository.reassign_tasks_from_user_to_team(
user_id, team_id, performed_by_user_id
)
if reassigned_tasks_count > 0:
AuditLogRepository.create(
AuditLogModel(
team_id=PyObjectId(team_id),
performed_by=PyObjectId(performed_by_user_id),
task_count=reassigned_tasks_count,
action="tasks_reassigned_to_team",
)
)
return reassigned_tasks_count
36 changes: 32 additions & 4 deletions todo/services/team_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
from todo.models.audit_log import AuditLogModel
from todo.repositories.audit_log_repository import AuditLogRepository
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail
from todo.constants.role import RoleScope
from todo.services.user_role_service import UserRoleService
from todo.services.task_assignment_service import TaskAssignmentService

from todo.exceptions.team_exceptions import (
CannotRemoveOwnerException,
NotTeamAdminException,
CannotRemoveTeamPOCException,
)

DEFAULT_ROLE_ID = "1"

Expand Down Expand Up @@ -483,9 +492,28 @@ class TeamOrUserNotFound(Exception):
pass

@classmethod
def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str = None):
from todo.repositories.user_team_details_repository import UserTeamDetailsRepository

def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str):
team = TeamService.get_team_by_id(team_id)

# Authentication Checks
if user_id == team.created_by:
raise CannotRemoveOwnerException()
if user_id == team.poc_id:
raise CannotRemoveTeamPOCException()
if user_id != removed_by_user_id:
if not UserRoleService.has_role(removed_by_user_id, RoleName.ADMIN.value, RoleScope.TEAM.value, team_id):
raise NotTeamAdminException()

# Remove User Roles
user_roles = UserRoleService.get_user_roles(user_id, RoleScope.TEAM.value, team_id)
user_role_ids = [roles["role_id"] for roles in user_roles]
for role_id in user_role_ids:
UserRoleService.remove_role_by_id(user_id, role_id, RoleScope.TEAM.value, team_id)

# Reassign Tasks:
TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id)

# Remove User
success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id)
if not success:
raise cls.TeamOrUserNotFound()
Expand All @@ -494,7 +522,7 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id:
AuditLogRepository.create(
AuditLogModel(
team_id=PyObjectId(team_id),
action="member_removed_from_team",
action="member_removed_from_team" if user_id != removed_by_user_id else "member_left_team",
performed_by=PyObjectId(removed_by_user_id) if removed_by_user_id else PyObjectId(user_id),
)
)
Expand Down
2 changes: 2 additions & 0 deletions todo/views/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ def get(self, request: Request, team_id: str):
entry["status_from"] = log.status_from
if log.status_to:
entry["status_to"] = log.status_to
if log.task_count:
entry["task_count"] = log.task_count
timeline.append(entry)
return Response({"timeline": timeline}, status=status.HTTP_200_OK)

Expand Down