diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 9f5ec965..4f80b52e 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -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 diff --git a/todo/exceptions/team_exceptions.py b/todo/exceptions/team_exceptions.py new file mode 100644 index 00000000..a1543a91 --- /dev/null +++ b/todo/exceptions/team_exceptions.py @@ -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) diff --git a/todo/migrations/0003_alter_postgresuserrole_unique_together_and_more.py b/todo/migrations/0003_alter_postgresuserrole_unique_together_and_more.py new file mode 100644 index 00000000..87620757 --- /dev/null +++ b/todo/migrations/0003_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", "0002_rename_postgres_ta_assignee_95ca3b_idx_postgres_ta_assigne_f1c6e7_idx_and_more"), + ] + + 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 9358e1f8..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"] + 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 773d9328..efcb3947 100644 --- a/todo/repositories/task_assignment_repository.py +++ b/todo/repositories/task_assignment_repository.py @@ -6,7 +6,9 @@ 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 +from todo.repositories.audit_log_repository import AuditLogRepository, AuditLogModel class TaskAssignmentRepository(MongoRepository): @@ -355,3 +357,167 @@ 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) -> bool: + """ + Reassign all tasks of user to team + """ + collection = cls.get_collection() + client = cls.get_client() + with client.start_session() as session: + try: + with session.start_transaction(): + now = datetime.now(timezone.utc) + 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}, + }, + { + "$set": { + "assignee_id": 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}}, + { + "$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}}, + { + "$set": { + "status": TaskStatus.TODO.value, + "deferredDetails": None, + "updated_at": now, + "updated_by": ObjectId(performed_by_user_id), + } + }, + session=session, + ) + + tasks_by_id = {task["_id"]: task for task in active_tasks} + operations = [] + dual_write_service = EnhancedDualWriteService() + for assignment in user_task_assignments: + operations.append( + { + "collection_name": "task_assignments", + "operation": "update", + "mongo_id": assignment["_id"], + "data": { + "task_mongo_id": str(assignment["task_id"]), + "assignee_id": str(assignment["team_id"]), + "user_type": "team", + "team_id": str(assignment["team_id"]), + "is_active": True, + "created_at": assignment["created_at"], + "created_by": str(assignment["created_by"]), + "updated_at": datetime.now(timezone.utc), + "updated_by": str(performed_by_user_id), + }, + } + ) + 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.value, + "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), + }, + } + ) + + 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 False + return True + except Exception: + return False 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/services/task_assignment_service.py b/todo/services/task_assignment_service.py index 5b530f6b..7dbbbb9a 100644 --- a/todo/services/task_assignment_service.py +++ b/todo/services/task_assignment_service.py @@ -150,3 +150,10 @@ 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 + """ + return TaskAssignmentRepository.reassign_tasks_from_user_to_team(user_id, team_id, performed_by_user_id) diff --git a/todo/services/team_service.py b/todo/services/team_service.py index 81e5ee3c..a8a3c736 100644 --- a/todo/services/team_service.py +++ b/todo/services/team_service.py @@ -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.services.user_service import UserService +from todo.exceptions.team_exceptions import ( + CannotRemoveOwnerException, + NotTeamAdminException, + CannotRemoveTeamPOCException, +) DEFAULT_ROLE_ID = "1" @@ -483,7 +492,25 @@ class TeamOrUserNotFound(Exception): pass @classmethod - def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str = None): + 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] + + 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: + 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() + + @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) @@ -494,9 +521,12 @@ 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), ) ) + 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..1b02a539 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 diff --git a/todo/views/team.py b/todo/views/team.py index 03e2079d..caffed3f 100644 --- a/todo/views/team.py +++ b/todo/views/team.py @@ -22,6 +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.serializers.remove_from_team_serializer import RemoveFromTeamSerializer class TeamListView(APIView): @@ -469,15 +475,48 @@ 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) 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)