From 13e97ad177dfb823249dbb0441837248ad0434ea Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Sat, 23 Aug 2025 21:55:33 +0530 Subject: [PATCH 01/20] feat(team): restrict member removal to admins and reassign tasks - enforce auth so only admins can remove another member - automatically remove user roles on removal - reassign all non-DONE tasks of the removed user to the team - add audit log entry for task reassignment --- todo/constants/messages.py | 1 + todo/exceptions/team_exceptions.py | 18 ++++++ todo/models/audit_log.py | 2 + .../task_assignment_repository.py | 61 ++++++++++++++++++- todo/services/task_assignment_service.py | 16 ++++- todo/services/team_service.py | 26 +++++++- todo/views/team.py | 2 + 7 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 todo/exceptions/team_exceptions.py diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 9f5ec965..0c6fa1e2 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -54,6 +54,7 @@ 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_POC = "POC cannot be removed from a team" # Validation error messages diff --git a/todo/exceptions/team_exceptions.py b/todo/exceptions/team_exceptions.py new file mode 100644 index 00000000..c84e8c54 --- /dev/null +++ b/todo/exceptions/team_exceptions.py @@ -0,0 +1,18 @@ +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.UNAUTHORIZED_TITLE): + super().__init__(message) + +class NotTeamAdminException(BaseTeamException): + def __init__(self, message = ApiErrors.UNAUTHORIZED_TITLE): + super().__init__(message) + +class CannotRemoveTeamPOC(BaseTeamException): + def __init__(self, message = ApiErrors.CANNOT_REMOVE_POC): + super().__init__(message) \ No newline at end of file diff --git a/todo/models/audit_log.py b/todo/models/audit_log.py index aa17809c..c699f05e 100644 --- a/todo/models/audit_log.py +++ b/todo/models/audit_log.py @@ -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: str | None = None diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index fdbf0b1c..807ec418 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -6,8 +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 class TaskAssignmentRepository(MongoRepository): collection_name = TaskAssignmentModel.collection_name @@ -224,3 +223,61 @@ 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, removed_by_user_id: str): + """ + Reassign all tasks of user to team + """ + collection = cls.get_collection() + now = datetime.now(timezone.utc) + pipeline = [ + { + "$match": { + "team_id": team_id, + "assignee_id": user_id, + "user_type": "user", + "is_active": True + } + }, + {"$addFields": {"task_id_obj": { "$toObjectId": "$task_id" }}}, + {"$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": ObjectId(user_id), "team_id": ObjectId(team_id), "is_active": True }, + {"$set": {"is_active": False, "updated_by": ObjectId(removed_by_user_id), "updated_at": now}}) + + collection.update_many( + {"assignee_id": user_id, "team_id": team_id, "is_active": True}, + {"$set": {"is_active": False, "updated_by": ObjectId(removed_by_user_id), "updated_at": now}} + ) + + new_assignments = [] + for task in user_task_assignments: + new_assignments.append(TaskAssignmentModel( + _id=PyObjectId(), + task_id=task["task_id_obj"], + assignee_id=PyObjectId(team_id), + user_type="team", + created_by=PyObjectId(removed_by_user_id), + updated_by=None, + team_id=PyObjectId(team_id) + ).model_dump(mode="json", by_alias=True, exclude_none=True)) + if new_assignments: + collection.insert_many(new_assignments) + return len(new_assignments) + + + \ No newline at end of file diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index 592c5579..61db941a 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -14,7 +14,6 @@ from todo.models.audit_log import AuditLogModel from todo.repositories.audit_log_repository import AuditLogRepository - class TaskAssignmentService: @classmethod def create_task_assignment(cls, dto: CreateTaskAssignmentDTO, user_id: str) -> CreateTaskAssignmentResponse: @@ -149,3 +148,18 @@ 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, user_type: str, removed_by_user_id: str): + """ + Reassign all tasks of user to team + """ + assignment = TaskAssignmentRepository.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) + if assignment != 0: + AuditLogRepository.create(AuditLogModel( + team_id=PyObjectId(team_id), + performed_by = PyObjectId(removed_by_user_id), + task_count = str(assignment), + action="tasks_reassigned_to_team" + )) + \ No newline at end of file diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 134ece79..fdabab18 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -12,7 +12,10 @@ 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.services.user_role_service import UserRoleService +from todo.constants.role import RoleScope, RoleName +from todo.services.task_assignment_service import TaskAssignmentService +from todo.exceptions.team_exceptions import (CannotRemoveOwnerException, NotTeamAdminException, CannotRemoveTeamPOC) DEFAULT_ROLE_ID = "1" @@ -479,7 +482,24 @@ class TeamOrUserNotFound(Exception): @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 - + user_roles = UserRoleService.get_user_roles(user_id, RoleScope.TEAM.value, team_id) + user_role_ids = [roles["role_id"] for roles in user_roles] + team = TeamService.get_team_by_id(team_id) + # Authentication Checks + 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 + elif user_id == team.poc_id: + raise CannotRemoveTeamPOC + if user_id == team.created_by: + raise CannotRemoveOwnerException + if user_id == team.poc_id: + cls.update_team(team_id, UpdateTeamDTO(poc_id=team.created_by), removed_by_user_id) + # # Remove 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, 'team', removed_by_user_id) success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() @@ -488,7 +508,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), ) ) diff --git a/todo/views/team.py b/todo/views/team.py index 03e2079d..2116e376 100644 --- a/todo/views/team.py +++ b/todo/views/team.py @@ -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) From ad26f3a2bcd05b46239d108dcd01fffaa9af8421 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Sun, 24 Aug 2025 19:06:36 +0530 Subject: [PATCH 02/20] feat(remove-member): resolve comments by bot --- todo/constants/messages.py | 1 + todo/exceptions/team_exceptions.py | 14 +++-- todo/models/audit_log.py | 2 +- .../task_assignment_repository.py | 63 ++++++++++--------- todo/services/task_assignment_service.py | 25 +++++--- todo/services/team_service.py | 41 +++++++----- 6 files changed, 85 insertions(+), 61 deletions(-) diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 0c6fa1e2..79b40ad6 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -54,6 +54,7 @@ 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" diff --git a/todo/exceptions/team_exceptions.py b/todo/exceptions/team_exceptions.py index c84e8c54..343e5d01 100644 --- a/todo/exceptions/team_exceptions.py +++ b/todo/exceptions/team_exceptions.py @@ -1,18 +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.UNAUTHORIZED_TITLE): + def __init__(self, message=ApiErrors.UNAUTHORIZED_TITLE): super().__init__(message) + class NotTeamAdminException(BaseTeamException): - def __init__(self, message = ApiErrors.UNAUTHORIZED_TITLE): + def __init__(self, message=ApiErrors.CANNOT_REMOVE_OWNER): super().__init__(message) -class CannotRemoveTeamPOC(BaseTeamException): - def __init__(self, message = ApiErrors.CANNOT_REMOVE_POC): - super().__init__(message) \ No newline at end of file + +class CannotRemoveTeamPOCException(BaseTeamException): + def __init__(self, message=ApiErrors.CANNOT_REMOVE_POC): + super().__init__(message) diff --git a/todo/models/audit_log.py b/todo/models/audit_log.py index c699f05e..6b861fc7 100644 --- a/todo/models/audit_log.py +++ b/todo/models/audit_log.py @@ -24,4 +24,4 @@ class AuditLogModel(Document): # For general user reference (who performed the action) performed_by: PyObjectId | None = None # For multiple task reassignments - task_count: str | None = None + task_count: int | None = None diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 807ec418..ba66f53e 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -7,6 +7,8 @@ from todo.repositories.common.mongo_repository import MongoRepository from todo.models.common.pyobjectid import PyObjectId from todo.constants.task import TaskStatus + + class TaskAssignmentRepository(MongoRepository): collection_name = TaskAssignmentModel.collection_name @@ -225,7 +227,7 @@ def deactivate_by_task_id(cls, task_id: str, user_id: str) -> bool: return False @classmethod - def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, removed_by_user_id: str): + 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 """ @@ -234,50 +236,51 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, removed_by pipeline = [ { "$match": { - "team_id": team_id, - "assignee_id": user_id, + "team_id": {"$in": [team_id, ObjectId(team_id)]}, + "assignee_id": {"$in": [user_id, ObjectId(user_id)]}, "user_type": "user", - "is_active": True + "is_active": True, + } + }, + { + "$addFields": { + "task_id_obj": { + "$convert": {"input": "$task_id", "to": "objectId", "onError": "$task_id", "onNull": None} } + } }, - {"$addFields": {"task_id_obj": { "$toObjectId": "$task_id" }}}, - {"$lookup": { - "from": "tasks", - "localField": "task_id_obj", - "foreignField": "_id", - "as": "task_info" - }}, - { "$unwind": "$task_info" }, - { "$match": { "task_info.status": { "$ne": TaskStatus.DONE.value } } } + {"$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": ObjectId(user_id), "team_id": ObjectId(team_id), "is_active": True }, - {"$set": {"is_active": False, "updated_by": ObjectId(removed_by_user_id), "updated_at": now}}) - + {"assignee_id": ObjectId(user_id), "team_id": ObjectId(team_id), "is_active": True}, + {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, + ) + collection.update_many( {"assignee_id": user_id, "team_id": team_id, "is_active": True}, - {"$set": {"is_active": False, "updated_by": ObjectId(removed_by_user_id), "updated_at": now}} + {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, ) new_assignments = [] for task in user_task_assignments: - new_assignments.append(TaskAssignmentModel( - _id=PyObjectId(), - task_id=task["task_id_obj"], - assignee_id=PyObjectId(team_id), - user_type="team", - created_by=PyObjectId(removed_by_user_id), - updated_by=None, - team_id=PyObjectId(team_id) - ).model_dump(mode="json", by_alias=True, exclude_none=True)) + new_assignments.append( + TaskAssignmentModel( + _id=PyObjectId(), + 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) + ) if new_assignments: collection.insert_many(new_assignments) return len(new_assignments) - - - \ No newline at end of file diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index 61db941a..02df89b0 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -14,6 +14,7 @@ from todo.models.audit_log import AuditLogModel from todo.repositories.audit_log_repository import AuditLogRepository + class TaskAssignmentService: @classmethod def create_task_assignment(cls, dto: CreateTaskAssignmentDTO, user_id: str) -> CreateTaskAssignmentResponse: @@ -150,16 +151,20 @@ def delete_task_assignment(cls, task_id: str, user_id: str) -> bool: return TaskAssignmentRepository.delete_assignment(task_id, user_id) @classmethod - def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, user_type: str, removed_by_user_id: str): + 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 """ - assignment = TaskAssignmentRepository.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) - if assignment != 0: - AuditLogRepository.create(AuditLogModel( - team_id=PyObjectId(team_id), - performed_by = PyObjectId(removed_by_user_id), - task_count = str(assignment), - action="tasks_reassigned_to_team" - )) - \ No newline at end of file + 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 diff --git a/todo/services/team_service.py b/todo/services/team_service.py index fdabab18..efdeb64e 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -12,10 +12,14 @@ 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.services.user_role_service import UserRoleService from todo.constants.role import RoleScope, RoleName -from todo.services.task_assignment_service import TaskAssignmentService -from todo.exceptions.team_exceptions import (CannotRemoveOwnerException, NotTeamAdminException, CannotRemoveTeamPOC) + +from todo.exceptions.team_exceptions import ( + CannotRemoveOwnerException, + NotTeamAdminException, + CannotRemoveTeamPOCException, +) + DEFAULT_ROLE_ID = "1" @@ -480,26 +484,33 @@ 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 - user_roles = UserRoleService.get_user_roles(user_id, RoleScope.TEAM.value, team_id) - user_role_ids = [roles["role_id"] for roles in user_roles] + 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) + from todo.services.user_role_service import UserRoleService + # Authentication Checks - 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 - elif user_id == team.poc_id: - raise CannotRemoveTeamPOC if user_id == team.created_by: raise CannotRemoveOwnerException if user_id == team.poc_id: - cls.update_team(team_id, UpdateTeamDTO(poc_id=team.created_by), removed_by_user_id) - # # Remove User Roles + 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, 'team', removed_by_user_id) + from todo.services.task_assignment_service import TaskAssignmentService + + TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) + + # Remove User + from todo.repositories.user_team_details_repository import UserTeamDetailsRepository + success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() From f25bc289e4e39c20c675e14a7bc26178048581df Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Sun, 24 Aug 2025 19:36:53 +0530 Subject: [PATCH 03/20] feat(remove-member): instantiate the exception raised - fix cannot remove error being shown for correct exception --- todo/exceptions/team_exceptions.py | 4 ++-- todo/services/team_service.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/todo/exceptions/team_exceptions.py b/todo/exceptions/team_exceptions.py index 343e5d01..a1543a91 100644 --- a/todo/exceptions/team_exceptions.py +++ b/todo/exceptions/team_exceptions.py @@ -8,12 +8,12 @@ def __init__(self, message: str): class CannotRemoveOwnerException(BaseTeamException): - def __init__(self, message=ApiErrors.UNAUTHORIZED_TITLE): + def __init__(self, message=ApiErrors.CANNOT_REMOVE_OWNER): super().__init__(message) class NotTeamAdminException(BaseTeamException): - def __init__(self, message=ApiErrors.CANNOT_REMOVE_OWNER): + def __init__(self, message=ApiErrors.UNAUTHORIZED_TITLE): super().__init__(message) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index efdeb64e..8816bf98 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -490,12 +490,12 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: # Authentication Checks if user_id == team.created_by: - raise CannotRemoveOwnerException + raise CannotRemoveOwnerException() if user_id == team.poc_id: - raise CannotRemoveTeamPOCException + 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 + raise NotTeamAdminException() # Remove User Roles user_roles = UserRoleService.get_user_roles(user_id, RoleScope.TEAM.value, team_id) From 7877f294092d6362c22f3492eeabbcd527eeba79 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Tue, 2 Sep 2025 20:45:22 +0530 Subject: [PATCH 04/20] feat(remove-from-team): sync changes for task assignment to postgres - update postgres model for audit log to include task_count --- .../0003_postgresauditlog_task_count.py | 18 +++ todo/models/postgres/audit_log.py | 1 + todo/repositories/audit_log_repository.py | 1 + .../task_assignment_repository.py | 144 ++++++++++++------ 4 files changed, 114 insertions(+), 50 deletions(-) create mode 100644 todo/migrations/0003_postgresauditlog_task_count.py diff --git a/todo/migrations/0003_postgresauditlog_task_count.py b/todo/migrations/0003_postgresauditlog_task_count.py new file mode 100644 index 00000000..cc70a019 --- /dev/null +++ b/todo/migrations/0003_postgresauditlog_task_count.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/todo/models/postgres/audit_log.py b/todo/models/postgres/audit_log.py index c57b281c..7cffe001 100644 --- a/todo/models/postgres/audit_log.py +++ b/todo/models/postgres/audit_log.py @@ -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( diff --git a/todo/repositories/audit_log_repository.py b/todo/repositories/audit_log_repository.py index 8f94ed1b..7bb8fe28 100644 --- a/todo/repositories/audit_log_repository.py +++ b/todo/repositories/audit_log_repository.py @@ -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( diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 1c8f1f77..93df88ac 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -362,56 +362,100 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ """ Reassign all tasks of user to team """ - 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} + 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, } - } - }, - {"$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": ObjectId(user_id), "team_id": ObjectId(team_id), "is_active": True}, - {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, - ) - - collection.update_many( - {"assignee_id": user_id, "team_id": team_id, "is_active": True}, - {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, - ) + }, + { + "$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": ObjectId(user_id), "team_id": ObjectId(team_id), "is_active": True}, + {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, + ) - new_assignments = [] - for task in user_task_assignments: - new_assignments.append( - TaskAssignmentModel( - _id=PyObjectId(), - 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) + collection.update_many( + {"assignee_id": user_id, "team_id": team_id, "is_active": True}, + {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, ) - if new_assignments: - collection.insert_many(new_assignments) - return len(new_assignments) + + 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 From f07598035190824499d59424d8a670757f6c68f2 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Tue, 2 Sep 2025 20:48:39 +0530 Subject: [PATCH 05/20] feat(remove-member): update cannot remove POC exception message - fix formatting --- todo/constants/messages.py | 2 +- .../0003_postgresauditlog_task_count.py | 7 +- .../task_assignment_repository.py | 68 ++++++++++--------- 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 79b40ad6..4f80b52e 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -55,7 +55,7 @@ class ApiErrors: 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" + CANNOT_REMOVE_POC = "POC cannot be removed from a team. Reassign the POC first." # Validation error messages diff --git a/todo/migrations/0003_postgresauditlog_task_count.py b/todo/migrations/0003_postgresauditlog_task_count.py index cc70a019..bd452c3c 100644 --- a/todo/migrations/0003_postgresauditlog_task_count.py +++ b/todo/migrations/0003_postgresauditlog_task_count.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('todo', '0002_rename_postgres_ta_assignee_95ca3b_idx_postgres_ta_assigne_f1c6e7_idx_and_more'), + ("todo", "0002_rename_postgres_ta_assignee_95ca3b_idx_postgres_ta_assigne_f1c6e7_idx_and_more"), ] operations = [ migrations.AddField( - model_name='postgresauditlog', - name='task_count', + model_name="postgresauditlog", + name="task_count", field=models.IntegerField(blank=True, null=True), ), ] diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 93df88ac..34deef43 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -399,27 +399,29 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ {"assignee_id": user_id, "team_id": 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) - } - }) + 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( @@ -432,22 +434,24 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ 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) + 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) From 6bb20aadccd66f00db39b310330e3b3379528163 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 3 Sep 2025 07:04:58 +0530 Subject: [PATCH 06/20] feat(remove-member-from-team): handle mixed ObjectId and string types in task reassignment - Moves service imports to the top level in `team_service.py` for better code organization. - Modifies the `update_many` call in `task_assignment_repository.py` to use the `$in` operator. This ensures that `assignee_id` and `team_id` can be matched regardless of whether they are stored as ObjectIds or strings, preventing potential issues when deactivating task assignments during member removal. --- todo/repositories/task_assignment_repository.py | 11 +++++------ todo/services/team_service.py | 9 +++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 34deef43..642d5f20 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -391,12 +391,11 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ # Deactivate current assignment (try using both ObjectId and string) collection.update_many( - {"assignee_id": ObjectId(user_id), "team_id": ObjectId(team_id), "is_active": True}, - {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, - ) - - collection.update_many( - {"assignee_id": user_id, "team_id": team_id, "is_active": True}, + { + "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}}, ) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 0672cc56..c0c82bc3 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -13,7 +13,9 @@ 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, RoleName +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, @@ -492,7 +494,6 @@ class TeamOrUserNotFound(Exception): @classmethod 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) - from todo.services.user_role_service import UserRoleService # Authentication Checks if user_id == team.created_by: @@ -510,13 +511,9 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: UserRoleService.remove_role_by_id(user_id, role_id, RoleScope.TEAM.value, team_id) # Reassign Tasks: - from todo.services.task_assignment_service import TaskAssignmentService - TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) # Remove User - from todo.repositories.user_team_details_repository import UserTeamDetailsRepository - success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() From 77134208294b65cc339652c50a571e701c512e59 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 3 Sep 2025 15:36:53 +0530 Subject: [PATCH 07/20] feat(remove-member-from-team): move UserTeamDetailsRepository to the function --- todo/services/team_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index c0c82bc3..88c25ee3 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -514,6 +514,7 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) # Remove User + from todo.repositories.user_team_details_repository import UserTeamDetailsRepository success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() From b0e48d325e984eed819056bec7c0f8c62f52b505 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 3 Sep 2025 16:29:00 +0530 Subject: [PATCH 08/20] feat(remove-from-team): set updated_by to None in the ops for task assignment creation in reassign_tasks_from_user_to_team - fix formatting issues --- todo/repositories/task_assignment_repository.py | 2 +- todo/services/team_service.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 642d5f20..5ac290bc 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -447,7 +447,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ "created_at": datetime.now(timezone.utc), "updated_at": None, "created_by": str(performed_by_user_id), - "updated_by": str(performed_by_user_id), + "updated_by": None, }, } ) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 88c25ee3..3964ac22 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -515,6 +515,7 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: # Remove User from todo.repositories.user_team_details_repository import UserTeamDetailsRepository + success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() From f96e4c4b89c20ece0a26a2037f9c606adc911477 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Fri, 5 Sep 2025 00:58:51 +0530 Subject: [PATCH 09/20] feat(remove-member): move task_reassignment function in transaction - add team exception in views - add assigned_from and assigned_to to audit log --- .../task_assignment_repository.py | 200 +++++++++--------- todo/services/task_assignment_service.py | 2 + todo/services/team_service.py | 10 +- todo/views/team.py | 8 +- 4 files changed, 119 insertions(+), 101 deletions(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 5ac290bc..28bb6dcd 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -362,103 +362,109 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ """ 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), + client = cls.get_client() + collection = cls.get_collection() + + with client.start_session() as session: + try: + with session.start_transaction(): + 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, + } }, - } - ) - 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), + { + "$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, - "created_at": datetime.now(timezone.utc), - "updated_at": None, - "created_by": str(performed_by_user_id), - "updated_by": None, }, - } - ) - 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 + {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, + session=session + ) + + 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": None, + }, + } + ) + if new_assignments: + collection.insert_many(new_assignments, session=session) + 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 diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index 20e00654..f20ca002 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -166,6 +166,8 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ performed_by=PyObjectId(performed_by_user_id), task_count=reassigned_tasks_count, action="tasks_reassigned_to_team", + assignee_from=PyObjectId(user_id), + assignee_to=PyObjectId(team_id) ) ) return reassigned_tasks_count diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 3964ac22..d7d328a6 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -6,7 +6,7 @@ from todo.models.common.pyobjectid import PyObjectId from todo.repositories.team_creation_invite_code_repository import TeamCreationInviteCodeRepository from todo.repositories.team_repository import TeamRepository, UserTeamDetailsRepository -from todo.constants.messages import AppMessages +from todo.constants.messages import ApiErrors, AppMessages from todo.constants.role import RoleName from todo.utils.invite_code_utils import generate_invite_code from typing import List @@ -16,7 +16,7 @@ from todo.constants.role import RoleScope from todo.services.user_role_service import UserRoleService from todo.services.task_assignment_service import TaskAssignmentService - +from todo.services.user_service import UserService from todo.exceptions.team_exceptions import ( CannotRemoveOwnerException, NotTeamAdminException, @@ -494,8 +494,12 @@ class TeamOrUserNotFound(Exception): @classmethod 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) - + team_members = UserService.get_users_by_team_id(team_id) + team_member_ids = [user.id for user in team_members] + # Authentication Checks + if user_id not in team_member_ids: + raise cls.TeamOrUserNotFound if user_id == team.created_by: raise CannotRemoveOwnerException() if user_id == team.poc_id: diff --git a/todo/views/team.py b/todo/views/team.py index 2116e376..23cccb28 100644 --- a/todo/views/team.py +++ b/todo/views/team.py @@ -22,7 +22,7 @@ from todo.repositories.audit_log_repository import AuditLogRepository from todo.repositories.user_repository import UserRepository from todo.repositories.task_repository import TaskRepository - +from todo.exceptions.team_exceptions import NotTeamAdminException, CannotRemoveOwnerException, CannotRemoveTeamPOCException class TeamListView(APIView): def get(self, request: Request): @@ -481,5 +481,11 @@ def delete(self, request, team_id, user_id): return Response(status=status.HTTP_204_NO_CONTENT) except TeamService.TeamOrUserNotFound: return Response({"detail": "Team or user not found."}, status=status.HTTP_404_NOT_FOUND) + except NotTeamAdminException as e: + return Response({"detail": e.message}, status=status.HTTP_403_FORBIDDEN) + except CannotRemoveTeamPOCException as e: + return Response({"detail": e.message}, status=status.HTTP_403_FORBIDDEN) + except CannotRemoveOwnerException as e: + return Response({"detail": e.message}, status=status.HTTP_403_FORBIDDEN) except Exception as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) From 42c9485c0a43883a3b63cb1e28c3ff00908a94ad Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Fri, 5 Sep 2025 01:01:17 +0530 Subject: [PATCH 10/20] feat(remove-member): fix formatting issues --- .../task_assignment_repository.py | 22 ++++++++++++++----- todo/services/task_assignment_service.py | 2 +- todo/services/team_service.py | 4 ++-- todo/views/team.py | 7 +++++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 28bb6dcd..b98fb942 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -364,7 +364,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ """ client = cls.get_client() collection = cls.get_collection() - + with client.start_session() as session: try: with session.start_transaction(): @@ -381,15 +381,27 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ { "$addFields": { "task_id_obj": { - "$convert": {"input": "$task_id", "to": "objectId", "onError": "$task_id", "onNull": None} + "$convert": { + "input": "$task_id", + "to": "objectId", + "onError": "$task_id", + "onNull": None, + } } } }, - {"$lookup": {"from": "tasks", "localField": "task_id_obj", "foreignField": "_id", "as": "task_info"}}, + { + "$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 @@ -402,7 +414,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ "is_active": True, }, {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, - session=session + session=session, ) new_assignments = [] diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index f20ca002..cee27346 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -167,7 +167,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ task_count=reassigned_tasks_count, action="tasks_reassigned_to_team", assignee_from=PyObjectId(user_id), - assignee_to=PyObjectId(team_id) + assignee_to=PyObjectId(team_id), ) ) return reassigned_tasks_count diff --git a/todo/services/team_service.py b/todo/services/team_service.py index d7d328a6..bc0a8146 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -6,7 +6,7 @@ from todo.models.common.pyobjectid import PyObjectId from todo.repositories.team_creation_invite_code_repository import TeamCreationInviteCodeRepository from todo.repositories.team_repository import TeamRepository, UserTeamDetailsRepository -from todo.constants.messages import ApiErrors, AppMessages +from todo.constants.messages import AppMessages from todo.constants.role import RoleName from todo.utils.invite_code_utils import generate_invite_code from typing import List @@ -496,7 +496,7 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: team = TeamService.get_team_by_id(team_id) team_members = UserService.get_users_by_team_id(team_id) team_member_ids = [user.id for user in team_members] - + # Authentication Checks if user_id not in team_member_ids: raise cls.TeamOrUserNotFound diff --git a/todo/views/team.py b/todo/views/team.py index 23cccb28..dcb77e3e 100644 --- a/todo/views/team.py +++ b/todo/views/team.py @@ -22,7 +22,12 @@ from todo.repositories.audit_log_repository import AuditLogRepository from todo.repositories.user_repository import UserRepository from todo.repositories.task_repository import TaskRepository -from todo.exceptions.team_exceptions import NotTeamAdminException, CannotRemoveOwnerException, CannotRemoveTeamPOCException +from todo.exceptions.team_exceptions import ( + NotTeamAdminException, + CannotRemoveOwnerException, + CannotRemoveTeamPOCException, +) + class TeamListView(APIView): def get(self, request: Request): From 3b50a662c809c0d787b5bd22e72fff5444d34830 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Fri, 5 Sep 2025 21:09:46 +0530 Subject: [PATCH 11/20] feat(remove-from-team): include is_active in unique constraint to prevent sync failures --- ...004_alter_postgresuserrole_unique_together.py | 16 ++++++++++++++++ todo/models/postgres/user_role.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 todo/migrations/0004_alter_postgresuserrole_unique_together.py diff --git a/todo/migrations/0004_alter_postgresuserrole_unique_together.py b/todo/migrations/0004_alter_postgresuserrole_unique_together.py new file mode 100644 index 00000000..d46d82a2 --- /dev/null +++ b/todo/migrations/0004_alter_postgresuserrole_unique_together.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.5 on 2025-09-05 15:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("todo", "0003_postgresauditlog_task_count"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="postgresuserrole", + unique_together={("user_id", "role_name", "scope", "team_id", "is_active")}, + ), + ] diff --git a/todo/models/postgres/user_role.py b/todo/models/postgres/user_role.py index 9358e1f8..14ffd09c 100644 --- a/todo/models/postgres/user_role.py +++ b/todo/models/postgres/user_role.py @@ -27,7 +27,7 @@ class PostgresUserRole(models.Model): class Meta: db_table = "postgres_user_roles" - unique_together = ["user_id", "role_name", "scope", "team_id"] + unique_together = ["user_id", "role_name", "scope", "team_id", "is_active"] indexes = [ models.Index(fields=["mongo_id"]), models.Index(fields=["user_id"]), From 4b4ae55010ada533f6d9829cf2b0d49a732f314c Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 10 Sep 2025 00:22:49 +0530 Subject: [PATCH 12/20] feat(remove-from-team): remove unecessary comments - fix bug where done tasks were also being reassigned - fix bug where in postgres_user_role where data sync failed if a user was removed and added more than once - updated reassigned tasks status and add sync to postgres db --- ...stgresuserrole_unique_together_and_more.py | 24 +++++++ todo/models/postgres/user_role.py | 8 ++- .../task_assignment_repository.py | 72 ++++++++++++++----- todo/services/team_service.py | 4 -- 4 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py diff --git a/todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py b/todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py new file mode 100644 index 00000000..850f494f --- /dev/null +++ b/todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.5 on 2025-09-09 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("todo", "0004_alter_postgresuserrole_unique_together"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="postgresuserrole", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="postgresuserrole", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True)), + fields=("user_id", "role_name", "scope", "team_id"), + name="unique_active_user_team_role", + ), + ), + ] diff --git a/todo/models/postgres/user_role.py b/todo/models/postgres/user_role.py index 14ffd09c..3f9d0fe5 100644 --- a/todo/models/postgres/user_role.py +++ b/todo/models/postgres/user_role.py @@ -27,7 +27,13 @@ class PostgresUserRole(models.Model): class Meta: db_table = "postgres_user_roles" - unique_together = ["user_id", "role_name", "scope", "team_id", "is_active"] + constraints = [ + models.UniqueConstraint( + fields=["user_id", "role_name", "scope", "team_id"], + condition=models.Q(is_active=True), + name="unique_active_user_team_role", + ) + ] indexes = [ models.Index(fields=["mongo_id"]), models.Index(fields=["user_id"]), diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index b98fb942..a7b96fcd 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -401,40 +401,36 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ {"$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) + not_done_task_assignment_ids = [assignment["_id"] for assignment in user_task_assignments] + not_done_task_ids = [assignment["task_id_obj"] for assignment in user_task_assignments] collection.update_many( - { - "assignee_id": {"$in": [user_id, ObjectId(user_id)]}, - "team_id": {"$in": [team_id, ObjectId(team_id)]}, - "is_active": True, - }, + {"_id": {"$in": not_done_task_assignment_ids}}, {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, session=session, ) new_assignments = [] + operations = [] dual_write_service = EnhancedDualWriteService() - for task in user_task_assignments: + for assignment in user_task_assignments: operations.append( { "collection_name": "task_assignments", "operation": "update", - "mongo_id": task["_id"], + "mongo_id": assignment["_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"]), + "task_mongo_id": str(assignment["task_id"]), + "assignee_id": str(assignment["assignee_id"]), + "user_type": assignment["user_type"], + "team_id": str(assignment["team_id"]), "is_active": False, - "created_at": task["created_at"], + "created_at": assignment["created_at"], "updated_at": datetime.now(timezone.utc), - "created_by": str(task["created_by"]), + "created_by": str(assignment["created_by"]), "updated_by": str(performed_by_user_id), }, } @@ -443,7 +439,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ new_assignments.append( TaskAssignmentModel( _id=new_assignment_id, - task_id=task["task_id_obj"], + task_id=assignment["task_id_obj"], assignee_id=PyObjectId(team_id), user_type="team", created_by=PyObjectId(performed_by_user_id), @@ -457,7 +453,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ "operation": "create", "mongo_id": new_assignment_id, "data": { - "task_mongo_id": str(task["task_id"]), + "task_mongo_id": str(assignment["task_id"]), "assignee_id": str(team_id), "user_type": "team", "team_id": str(team_id), @@ -469,9 +465,49 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ }, } ) + task_details = assignment["task_info"] + operations.append( + { + "collection_name": "tasks", + "operation": "update", + "mongo_id": assignment["task_id"], + "data": { + "title": task_details.get("title"), + "description": task_details.get("description"), + "priority": task_details.get("priority"), + "status": TaskStatus.TODO, + "displayId": task_details.get("displayId"), + "deferredDetails": None, + "isAcknowledged": task_details.get("isAcknowledged", False), + "isDeleted": task_details.get("isDeleted", False), + "startedAt": task_details.get("startedAt"), + "dueAt": task_details.get("dueAt"), + "createdAt": task_details.get("createdAt"), + "updatedAt": datetime.now(timezone.utc), + "createdBy": str(task_details.get("createdBy")), + "updated_by": str(performed_by_user_id), + }, + } + ) + if new_assignments: collection.insert_many(new_assignments, session=session) + from todo.repositories.task_repository import TaskRepository + + tasks_collection = TaskRepository.get_collection() + tasks_collection.update_many( + {"_id": {"$in": not_done_task_ids}}, + { + "$set": { + "status": TaskStatus.TODO.value, + "deferredDetails": None, + "updatedAt": datetime.now(timezone.utc), + } + }, + session=session, + ) dual_write_success = dual_write_service.batch_operations(operations) + if not dual_write_success: import logging diff --git a/todo/services/team_service.py b/todo/services/team_service.py index bc0a8146..509df2f1 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -497,7 +497,6 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: team_members = UserService.get_users_by_team_id(team_id) team_member_ids = [user.id for user in team_members] - # Authentication Checks if user_id not in team_member_ids: raise cls.TeamOrUserNotFound if user_id == team.created_by: @@ -508,16 +507,13 @@ def remove_member_from_team(cls, user_id: str, team_id: str, 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 from todo.repositories.user_team_details_repository import UserTeamDetailsRepository success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) From bd88687bd375712eae3079b36dbf099e30d86ad3 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 10 Sep 2025 04:00:55 +0530 Subject: [PATCH 13/20] feat(remove-from-member): add serializer for path params validation --- .../remove_from_team_serializer.py | 18 ++++++++++++ todo/views/team.py | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 todo/serializers/remove_from_team_serializer.py diff --git a/todo/serializers/remove_from_team_serializer.py b/todo/serializers/remove_from_team_serializer.py new file mode 100644 index 00000000..73e1aa61 --- /dev/null +++ b/todo/serializers/remove_from_team_serializer.py @@ -0,0 +1,18 @@ +from rest_framework import serializers +from bson import ObjectId +from todo.constants.messages import ValidationErrors + + +class RemoveFromTeamSerializer(serializers.Serializer): + team_id = serializers.CharField() + user_id = serializers.CharField() + + def validate_team_id(self, value): + if not ObjectId.is_valid(value): + raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(value)) + return value + + def validate_user_id(self, value): + if not ObjectId.is_valid(value): + raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(value)) + return value diff --git a/todo/views/team.py b/todo/views/team.py index dcb77e3e..97fb7773 100644 --- a/todo/views/team.py +++ b/todo/views/team.py @@ -27,6 +27,7 @@ CannotRemoveOwnerException, CannotRemoveTeamPOCException, ) +from todo.serializers.remove_from_team_serializer import RemoveFromTeamSerializer class TeamListView(APIView): @@ -476,10 +477,37 @@ class RemoveTeamMemberView(APIView): }, tags=["teams"], ) + def _handle_validation_errors(self, errors): + """Handle validation errors and return appropriate response.""" + formatted = [] + for field, msgs in errors.items(): + for msg in msgs: + formatted.append( + { + "source": field, + "title": "Invalid value", + "detail": str(msg), + } + ) + return Response( + { + "statusCode": 400, + "message": "Validation Error", + "errors": formatted, + "authenticated": getattr(self.request, "user", None) is not None, + }, + status=400, + ) + def delete(self, request, team_id, user_id): print(f"DEBUG: RemoveTeamMemberView.delete called with team_id={team_id}, user_id={user_id}") from todo.services.team_service import TeamService + serializer = RemoveFromTeamSerializer(data={"team_id": team_id, "user_id": user_id}) + + if not serializer.is_valid(): + return self._handle_validation_errors(serializer.errors) + try: # Pass the user performing the removal (request.user_id) and the user being removed (user_id) TeamService.remove_member_from_team(user_id=user_id, team_id=team_id, removed_by_user_id=request.user_id) From d567b3a1ef8acab0790a1cde4182362c7a5ea36d Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 00:16:26 +0530 Subject: [PATCH 14/20] feat(remove-from-team): removed aggregation pipeline and use normal db queries --- .../task_assignment_repository.py | 235 +++++++++--------- todo/services/task_assignment_service.py | 11 - todo/services/team_service.py | 14 +- 3 files changed, 127 insertions(+), 133 deletions(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index a7b96fcd..5e2bc318 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -8,6 +8,7 @@ from todo.models.common.pyobjectid import PyObjectId from todo.constants.task import TaskStatus from todo.services.enhanced_dual_write_service import EnhancedDualWriteService +from todo.repositories.audit_log_repository import AuditLogRepository, AuditLogModel class TaskAssignmentRepository(MongoRepository): @@ -362,58 +363,103 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ """ Reassign all tasks of user to team """ - client = cls.get_client() collection = cls.get_collection() - + client = cls.get_client() with client.start_session() as session: try: with session.start_transaction(): now = datetime.now(timezone.utc) - pipeline = [ + user_task_assignments = list( + collection.find( + { + "$and": [ + {"is_active": True}, + { + "$or": [{"assignee_id": user_id}, {"assignee_id": ObjectId(user_id)}], + }, + {"$or": [{"team_id": team_id}, {"team_id": ObjectId(team_id)}]}, + ] + }, + session=session, + ) + ) + if not user_task_assignments: + return 0 + active_user_task_assignments_ids = [ + ObjectId(assignment["task_id"]) for assignment in user_task_assignments + ] + + from todo.repositories.task_repository import TaskRepository + + tasks_collection = TaskRepository.get_collection() + active_tasks = list( + tasks_collection.find( + { + "_id": {"$in": active_user_task_assignments_ids}, + "status": {"$ne": TaskStatus.DONE.value}, + }, + session=session, + ) + ) + not_done_tasks_ids = [str(tasks["_id"]) for tasks in active_tasks] + tasks_to_reset_status_ids = [] + tasks_to_clear_deferred_ids = [] + for tasks in active_tasks: + if tasks["status"] == TaskStatus.IN_PROGRESS.value: + tasks_to_reset_status_ids.append(tasks["_id"]) + elif tasks.get("deferredDetails") is not None: + tasks_to_clear_deferred_ids.append(tasks["_id"]) + + collection.update_many( + { + "task_id": {"$in": not_done_tasks_ids}, + }, { - "$match": { - "team_id": {"$in": [team_id, ObjectId(team_id)]}, - "assignee_id": {"$in": [user_id, ObjectId(user_id)]}, - "user_type": "user", - "is_active": True, + "$set": { + "assignee_id": ObjectId(team_id), + "user_type": "team", + "updated_at": now, + "updated_by": ObjectId(performed_by_user_id), } }, + session=session, + ) + + for assignment in user_task_assignments: + AuditLogRepository.create( + AuditLogModel( + task_id=PyObjectId(assignment["task_id"]), + team_id=PyObjectId(team_id), + action="assigned_to_team", + performed_by=PyObjectId(performed_by_user_id), + ) + ) + + tasks_collection.update_many( + {"_id": {"$in": tasks_to_reset_status_ids}}, { - "$addFields": { - "task_id_obj": { - "$convert": { - "input": "$task_id", - "to": "objectId", - "onError": "$task_id", - "onNull": None, - } - } + "$set": { + "status": TaskStatus.TODO.value, + "updated_at": now, + "updated_by": ObjectId(performed_by_user_id), } }, + session=session, + ) + tasks_collection.update_many( + {"_id": {"$in": tasks_to_clear_deferred_ids}}, { - "$lookup": { - "from": "tasks", - "localField": "task_id_obj", - "foreignField": "_id", - "as": "task_info", + "$set": { + "status": TaskStatus.TODO.value, + "deferredDetails": None, + "updated_at": now, + "updated_by": ObjectId(performed_by_user_id), } }, - {"$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 - not_done_task_assignment_ids = [assignment["_id"] for assignment in user_task_assignments] - not_done_task_ids = [assignment["task_id_obj"] for assignment in user_task_assignments] - collection.update_many( - {"_id": {"$in": not_done_task_assignment_ids}}, - {"$set": {"is_active": False, "updated_by": ObjectId(performed_by_user_id), "updated_at": now}}, session=session, ) - new_assignments = [] - + tasks_by_id = {task["_id"]: task for task in active_tasks} operations = [] dual_write_service = EnhancedDualWriteService() for assignment in user_task_assignments: @@ -424,95 +470,54 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ "mongo_id": assignment["_id"], "data": { "task_mongo_id": str(assignment["task_id"]), - "assignee_id": str(assignment["assignee_id"]), - "user_type": assignment["user_type"], + "assignee_id": str(assignment["team_id"]), + "user_type": "team", "team_id": str(assignment["team_id"]), - "is_active": False, + "is_active": True, "created_at": assignment["created_at"], - "updated_at": datetime.now(timezone.utc), "created_by": str(assignment["created_by"]), + "updated_at": datetime.now(timezone.utc), "updated_by": str(performed_by_user_id), }, } ) - new_assignment_id = PyObjectId() - new_assignments.append( - TaskAssignmentModel( - _id=new_assignment_id, - task_id=assignment["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(assignment["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": None, - }, - } - ) - task_details = assignment["task_info"] - operations.append( - { - "collection_name": "tasks", - "operation": "update", - "mongo_id": assignment["task_id"], - "data": { - "title": task_details.get("title"), - "description": task_details.get("description"), - "priority": task_details.get("priority"), - "status": TaskStatus.TODO, - "displayId": task_details.get("displayId"), - "deferredDetails": None, - "isAcknowledged": task_details.get("isAcknowledged", False), - "isDeleted": task_details.get("isDeleted", False), - "startedAt": task_details.get("startedAt"), - "dueAt": task_details.get("dueAt"), - "createdAt": task_details.get("createdAt"), - "updatedAt": datetime.now(timezone.utc), - "createdBy": str(task_details.get("createdBy")), - "updated_by": str(performed_by_user_id), - }, - } - ) - - if new_assignments: - collection.insert_many(new_assignments, session=session) - from todo.repositories.task_repository import TaskRepository - - tasks_collection = TaskRepository.get_collection() - tasks_collection.update_many( - {"_id": {"$in": not_done_task_ids}}, - { - "$set": { - "status": TaskStatus.TODO.value, - "deferredDetails": None, - "updatedAt": datetime.now(timezone.utc), + if ( + assignment["task_id"] in tasks_to_clear_deferred_ids + or assignment["task_id"] in tasks_to_reset_status_ids + ): + task = tasks_by_id[assignment["task_id"]] + operations.append( + { + "collection_name": "tasks", + "operation": "update", + "mongo_id": assignment["task_id"], + "data": { + "title": task.get("title"), + "description": task.get("description"), + "priority": task.get("priority"), + "status": TaskStatus.TODO, + "displayId": task.get("displayId"), + "deferredDetails": None, + "isAcknowledged": task.get("isAcknowledged", False), + "isDeleted": task.get("isDeleted", False), + "startedAt": task.get("startedAt"), + "dueAt": task.get("dueAt"), + "createdAt": task.get("createdAt"), + "createdBy": str(task.get("createdBy")), + "updatedAt": datetime.now(timezone.utc), + "updated_by": str(performed_by_user_id), + }, } - }, - session=session, - ) - dual_write_success = dual_write_service.batch_operations(operations) + ) + + dual_write_success = dual_write_service.batch_operations(operations) + if not dual_write_success: + import logging - if not dual_write_success: - import logging + logger = logging.getLogger(__name__) + logger.warning("Failed to sync task reassignments to Postgres") - logger = logging.getLogger(__name__) - logger.warning("Failed to sync task reassignments to Postgres") - return len(new_assignments) + return len(user_task_assignments) + return len(user_task_assignments) except Exception: return 0 diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index cee27346..c1cc63db 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -159,15 +159,4 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ 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", - assignee_from=PyObjectId(user_id), - assignee_to=PyObjectId(team_id), - ) - ) return reassigned_tasks_count diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 509df2f1..4e4831a8 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -507,13 +507,6 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: if not UserRoleService.has_role(removed_by_user_id, RoleName.ADMIN.value, RoleScope.TEAM.value, team_id): raise NotTeamAdminException() - 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) - - TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) - from todo.repositories.user_team_details_repository import UserTeamDetailsRepository success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) @@ -529,4 +522,11 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: ) ) + 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) + + TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) + return True From 01e59b8b6af7e884975d0e43f5afcd859cfe6cb3 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 00:24:34 +0530 Subject: [PATCH 15/20] feat(remove-from-todo): remove unecessary migration files --- ...ostgresuserrole_unique_together_and_more.py} | 2 +- .../0003_postgresauditlog_task_count.py | 17 ----------------- ...04_alter_postgresuserrole_unique_together.py | 16 ---------------- todo/models/postgres/audit_log.py | 1 - todo/repositories/audit_log_repository.py | 1 - todo/views/team.py | 2 -- 6 files changed, 1 insertion(+), 38 deletions(-) rename todo/migrations/{0005_alter_postgresuserrole_unique_together_and_more.py => 0003_alter_postgresuserrole_unique_together_and_more.py} (85%) delete mode 100644 todo/migrations/0003_postgresauditlog_task_count.py delete mode 100644 todo/migrations/0004_alter_postgresuserrole_unique_together.py diff --git a/todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py b/todo/migrations/0003_alter_postgresuserrole_unique_together_and_more.py similarity index 85% rename from todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py rename to todo/migrations/0003_alter_postgresuserrole_unique_together_and_more.py index 850f494f..87620757 100644 --- a/todo/migrations/0005_alter_postgresuserrole_unique_together_and_more.py +++ b/todo/migrations/0003_alter_postgresuserrole_unique_together_and_more.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ("todo", "0004_alter_postgresuserrole_unique_together"), + ("todo", "0002_rename_postgres_ta_assignee_95ca3b_idx_postgres_ta_assigne_f1c6e7_idx_and_more"), ] operations = [ diff --git a/todo/migrations/0003_postgresauditlog_task_count.py b/todo/migrations/0003_postgresauditlog_task_count.py deleted file mode 100644 index bd452c3c..00000000 --- a/todo/migrations/0003_postgresauditlog_task_count.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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), - ), - ] diff --git a/todo/migrations/0004_alter_postgresuserrole_unique_together.py b/todo/migrations/0004_alter_postgresuserrole_unique_together.py deleted file mode 100644 index d46d82a2..00000000 --- a/todo/migrations/0004_alter_postgresuserrole_unique_together.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.5 on 2025-09-05 15:27 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("todo", "0003_postgresauditlog_task_count"), - ] - - operations = [ - migrations.AlterUniqueTogether( - name="postgresuserrole", - unique_together={("user_id", "role_name", "scope", "team_id", "is_active")}, - ), - ] diff --git a/todo/models/postgres/audit_log.py b/todo/models/postgres/audit_log.py index 7cffe001..c57b281c 100644 --- a/todo/models/postgres/audit_log.py +++ b/todo/models/postgres/audit_log.py @@ -17,7 +17,6 @@ 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( diff --git a/todo/repositories/audit_log_repository.py b/todo/repositories/audit_log_repository.py index 7bb8fe28..8f94ed1b 100644 --- a/todo/repositories/audit_log_repository.py +++ b/todo/repositories/audit_log_repository.py @@ -29,7 +29,6 @@ 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( diff --git a/todo/views/team.py b/todo/views/team.py index 97fb7773..caffed3f 100644 --- a/todo/views/team.py +++ b/todo/views/team.py @@ -454,8 +454,6 @@ 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) From bbc213253c5ac37f46bf1f2f3bef10821af1485b Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 00:33:41 +0530 Subject: [PATCH 16/20] feat(remove-from-team): remove task_count field from audit log model --- todo/models/audit_log.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/todo/models/audit_log.py b/todo/models/audit_log.py index 6b861fc7..aa17809c 100644 --- a/todo/models/audit_log.py +++ b/todo/models/audit_log.py @@ -23,5 +23,3 @@ 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 From 3bf3c2a3785bb9d3d34bc56e92db9078cd34baae Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 20:37:58 +0530 Subject: [PATCH 17/20] feat(remove-from-team): fix bug where reassigned todos not showing on poc dashboard --- todo/repositories/task_assignment_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 5e2bc318..41ccdf5e 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -416,7 +416,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ }, { "$set": { - "assignee_id": ObjectId(team_id), + "assignee_id": team_id, "user_type": "team", "updated_at": now, "updated_by": ObjectId(performed_by_user_id), From 3c6f245f47345bdf76d2be0a54185b7aabc4089a Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 21:07:31 +0530 Subject: [PATCH 18/20] feat(remove-from-team): move remove role and validation to individual function --- todo/services/team_service.py | 13 ++++++------- todo/services/user_role_service.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 4e4831a8..4ba695cf 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -492,7 +492,7 @@ class TeamOrUserNotFound(Exception): pass @classmethod - def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str): + def _validate_remove_member_permissions(cls, user_id: str, team_id: str, removed_by_user_id: str): team = TeamService.get_team_by_id(team_id) team_members = UserService.get_users_by_team_id(team_id) team_member_ids = [user.id for user in team_members] @@ -507,8 +507,11 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: if not UserRoleService.has_role(removed_by_user_id, RoleName.ADMIN.value, RoleScope.TEAM.value, team_id): raise NotTeamAdminException() + @classmethod + def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str): + cls._validate_remove_member_permissions(user_id, team_id, removed_by_user_id) + from todo.repositories.user_team_details_repository import UserTeamDetailsRepository - success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() @@ -522,11 +525,7 @@ def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: ) ) - 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) - + UserRoleService.remove_all_user_roles_for_team(user_id, team_id) TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id) return True diff --git a/todo/services/user_role_service.py b/todo/services/user_role_service.py index 5cb4bc97..b4efe6f7 100644 --- a/todo/services/user_role_service.py +++ b/todo/services/user_role_service.py @@ -129,3 +129,16 @@ def get_team_users_with_roles(cls, team_id: str) -> List[Dict[str, Any]]: except Exception as e: logger.error(f"Failed to get team users with roles: {str(e)}") return [] + + @classmethod + def remove_all_user_roles_for_team(cls, user_id: str, team_id: str) -> bool: + """Remove all roles for a user within a specific team.""" + try: + user_roles = cls.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: + cls.remove_role_by_id(user_id, role_id, RoleScope.TEAM.value, team_id) + return True + except Exception as e: + logger.error(f"Failed to remove roles of user: {str(e)}") + return False From 8bcea471b58fde1812828c1b574a0da86060c2f3 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 21:10:36 +0530 Subject: [PATCH 19/20] feat(remove-from-team): fix formatting issues --- todo/services/team_service.py | 3 ++- todo/services/user_role_service.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 4ba695cf..a8a3c736 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -510,8 +510,9 @@ def _validate_remove_member_permissions(cls, user_id: str, team_id: str, removed @classmethod def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str): cls._validate_remove_member_permissions(user_id, team_id, removed_by_user_id) - + from todo.repositories.user_team_details_repository import UserTeamDetailsRepository + success = UserTeamDetailsRepository.remove_member_from_team(user_id=user_id, team_id=team_id) if not success: raise cls.TeamOrUserNotFound() diff --git a/todo/services/user_role_service.py b/todo/services/user_role_service.py index b4efe6f7..1b02a539 100644 --- a/todo/services/user_role_service.py +++ b/todo/services/user_role_service.py @@ -133,7 +133,7 @@ def get_team_users_with_roles(cls, team_id: str) -> List[Dict[str, Any]]: @classmethod def remove_all_user_roles_for_team(cls, user_id: str, team_id: str) -> bool: """Remove all roles for a user within a specific team.""" - try: + try: user_roles = cls.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: From 20594026b113008438894d417eb5afce539db8c6 Mon Sep 17 00:00:00 2001 From: Hariom Vashista Date: Wed, 17 Sep 2025 21:37:08 +0530 Subject: [PATCH 20/20] feat(remove-from-team): use bool return value instead of count for task reassignment function --- todo/repositories/task_assignment_repository.py | 10 +++++----- todo/services/task_assignment_service.py | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/todo/repositories/task_assignment_repository.py b/todo/repositories/task_assignment_repository.py index 41ccdf5e..efcb3947 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -359,7 +359,7 @@ def deactivate_by_task_id(cls, task_id: str, user_id: str) -> bool: return False @classmethod - def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_by_user_id: str): + def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_by_user_id: str) -> bool: """ Reassign all tasks of user to team """ @@ -495,7 +495,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ "title": task.get("title"), "description": task.get("description"), "priority": task.get("priority"), - "status": TaskStatus.TODO, + "status": TaskStatus.TODO.value, "displayId": task.get("displayId"), "deferredDetails": None, "isAcknowledged": task.get("isAcknowledged", False), @@ -517,7 +517,7 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ logger = logging.getLogger(__name__) logger.warning("Failed to sync task reassignments to Postgres") - return len(user_task_assignments) - return len(user_task_assignments) + return False + return True except Exception: - return 0 + return False diff --git a/todo/services/task_assignment_service.py b/todo/services/task_assignment_service.py index c1cc63db..7dbbbb9a 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -156,7 +156,4 @@ def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_ """ 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 - ) - return reassigned_tasks_count + return TaskAssignmentRepository.reassign_tasks_from_user_to_team(user_id, team_id, performed_by_user_id)