Skip to content

Commit ea637c1

Browse files
authored
Merge pull request #277 from Real-Dev-Squad/develop
Dev to Main Sync
2 parents cc97bf6 + 6f01590 commit ea637c1

File tree

11 files changed

+447
-5
lines changed

11 files changed

+447
-5
lines changed

todo/constants/messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class ApiErrors:
5454
USER_NOT_FOUND_GENERIC = "User not found."
5555
SEARCH_QUERY_EMPTY = "Search query cannot be empty"
5656
TASK_ALREADY_IN_WATCHLIST = "Task is already in the watchlist"
57+
CANNOT_REMOVE_OWNER = "Owner cannot be removed from the team"
58+
CANNOT_REMOVE_POC = "POC cannot be removed from a team. Reassign the POC first."
5759

5860

5961
# Validation error messages

todo/exceptions/team_exceptions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from todo.constants.messages import ApiErrors
2+
3+
4+
class BaseTeamException(Exception):
5+
def __init__(self, message: str):
6+
self.message = message
7+
super().__init__(self.message)
8+
9+
10+
class CannotRemoveOwnerException(BaseTeamException):
11+
def __init__(self, message=ApiErrors.CANNOT_REMOVE_OWNER):
12+
super().__init__(message)
13+
14+
15+
class NotTeamAdminException(BaseTeamException):
16+
def __init__(self, message=ApiErrors.UNAUTHORIZED_TITLE):
17+
super().__init__(message)
18+
19+
20+
class CannotRemoveTeamPOCException(BaseTeamException):
21+
def __init__(self, message=ApiErrors.CANNOT_REMOVE_POC):
22+
super().__init__(message)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.1.5 on 2025-09-09 14:49
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("todo", "0002_rename_postgres_ta_assignee_95ca3b_idx_postgres_ta_assigne_f1c6e7_idx_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AlterUniqueTogether(
13+
name="postgresuserrole",
14+
unique_together=set(),
15+
),
16+
migrations.AddConstraint(
17+
model_name="postgresuserrole",
18+
constraint=models.UniqueConstraint(
19+
condition=models.Q(("is_active", True)),
20+
fields=("user_id", "role_name", "scope", "team_id"),
21+
name="unique_active_user_team_role",
22+
),
23+
),
24+
]

todo/models/postgres/user_role.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ class PostgresUserRole(models.Model):
2727

2828
class Meta:
2929
db_table = "postgres_user_roles"
30-
unique_together = ["user_id", "role_name", "scope", "team_id"]
30+
constraints = [
31+
models.UniqueConstraint(
32+
fields=["user_id", "role_name", "scope", "team_id"],
33+
condition=models.Q(is_active=True),
34+
name="unique_active_user_team_role",
35+
)
36+
]
3137
indexes = [
3238
models.Index(fields=["mongo_id"]),
3339
models.Index(fields=["user_id"]),

todo/repositories/task_assignment_repository.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from todo.models.task_assignment import TaskAssignmentModel
77
from todo.repositories.common.mongo_repository import MongoRepository
88
from todo.models.common.pyobjectid import PyObjectId
9+
from todo.constants.task import TaskStatus
910
from todo.services.enhanced_dual_write_service import EnhancedDualWriteService
11+
from todo.repositories.audit_log_repository import AuditLogRepository, AuditLogModel
1012

1113

1214
class TaskAssignmentRepository(MongoRepository):
@@ -355,3 +357,167 @@ def deactivate_by_task_id(cls, task_id: str, user_id: str) -> bool:
355357
return result.modified_count > 0
356358
except Exception:
357359
return False
360+
361+
@classmethod
362+
def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_by_user_id: str) -> bool:
363+
"""
364+
Reassign all tasks of user to team
365+
"""
366+
collection = cls.get_collection()
367+
client = cls.get_client()
368+
with client.start_session() as session:
369+
try:
370+
with session.start_transaction():
371+
now = datetime.now(timezone.utc)
372+
user_task_assignments = list(
373+
collection.find(
374+
{
375+
"$and": [
376+
{"is_active": True},
377+
{
378+
"$or": [{"assignee_id": user_id}, {"assignee_id": ObjectId(user_id)}],
379+
},
380+
{"$or": [{"team_id": team_id}, {"team_id": ObjectId(team_id)}]},
381+
]
382+
},
383+
session=session,
384+
)
385+
)
386+
if not user_task_assignments:
387+
return 0
388+
active_user_task_assignments_ids = [
389+
ObjectId(assignment["task_id"]) for assignment in user_task_assignments
390+
]
391+
392+
from todo.repositories.task_repository import TaskRepository
393+
394+
tasks_collection = TaskRepository.get_collection()
395+
active_tasks = list(
396+
tasks_collection.find(
397+
{
398+
"_id": {"$in": active_user_task_assignments_ids},
399+
"status": {"$ne": TaskStatus.DONE.value},
400+
},
401+
session=session,
402+
)
403+
)
404+
not_done_tasks_ids = [str(tasks["_id"]) for tasks in active_tasks]
405+
tasks_to_reset_status_ids = []
406+
tasks_to_clear_deferred_ids = []
407+
for tasks in active_tasks:
408+
if tasks["status"] == TaskStatus.IN_PROGRESS.value:
409+
tasks_to_reset_status_ids.append(tasks["_id"])
410+
elif tasks.get("deferredDetails") is not None:
411+
tasks_to_clear_deferred_ids.append(tasks["_id"])
412+
413+
collection.update_many(
414+
{
415+
"task_id": {"$in": not_done_tasks_ids},
416+
},
417+
{
418+
"$set": {
419+
"assignee_id": team_id,
420+
"user_type": "team",
421+
"updated_at": now,
422+
"updated_by": ObjectId(performed_by_user_id),
423+
}
424+
},
425+
session=session,
426+
)
427+
428+
for assignment in user_task_assignments:
429+
AuditLogRepository.create(
430+
AuditLogModel(
431+
task_id=PyObjectId(assignment["task_id"]),
432+
team_id=PyObjectId(team_id),
433+
action="assigned_to_team",
434+
performed_by=PyObjectId(performed_by_user_id),
435+
)
436+
)
437+
438+
tasks_collection.update_many(
439+
{"_id": {"$in": tasks_to_reset_status_ids}},
440+
{
441+
"$set": {
442+
"status": TaskStatus.TODO.value,
443+
"updated_at": now,
444+
"updated_by": ObjectId(performed_by_user_id),
445+
}
446+
},
447+
session=session,
448+
)
449+
tasks_collection.update_many(
450+
{"_id": {"$in": tasks_to_clear_deferred_ids}},
451+
{
452+
"$set": {
453+
"status": TaskStatus.TODO.value,
454+
"deferredDetails": None,
455+
"updated_at": now,
456+
"updated_by": ObjectId(performed_by_user_id),
457+
}
458+
},
459+
session=session,
460+
)
461+
462+
tasks_by_id = {task["_id"]: task for task in active_tasks}
463+
operations = []
464+
dual_write_service = EnhancedDualWriteService()
465+
for assignment in user_task_assignments:
466+
operations.append(
467+
{
468+
"collection_name": "task_assignments",
469+
"operation": "update",
470+
"mongo_id": assignment["_id"],
471+
"data": {
472+
"task_mongo_id": str(assignment["task_id"]),
473+
"assignee_id": str(assignment["team_id"]),
474+
"user_type": "team",
475+
"team_id": str(assignment["team_id"]),
476+
"is_active": True,
477+
"created_at": assignment["created_at"],
478+
"created_by": str(assignment["created_by"]),
479+
"updated_at": datetime.now(timezone.utc),
480+
"updated_by": str(performed_by_user_id),
481+
},
482+
}
483+
)
484+
if (
485+
assignment["task_id"] in tasks_to_clear_deferred_ids
486+
or assignment["task_id"] in tasks_to_reset_status_ids
487+
):
488+
task = tasks_by_id[assignment["task_id"]]
489+
operations.append(
490+
{
491+
"collection_name": "tasks",
492+
"operation": "update",
493+
"mongo_id": assignment["task_id"],
494+
"data": {
495+
"title": task.get("title"),
496+
"description": task.get("description"),
497+
"priority": task.get("priority"),
498+
"status": TaskStatus.TODO.value,
499+
"displayId": task.get("displayId"),
500+
"deferredDetails": None,
501+
"isAcknowledged": task.get("isAcknowledged", False),
502+
"isDeleted": task.get("isDeleted", False),
503+
"startedAt": task.get("startedAt"),
504+
"dueAt": task.get("dueAt"),
505+
"createdAt": task.get("createdAt"),
506+
"createdBy": str(task.get("createdBy")),
507+
"updatedAt": datetime.now(timezone.utc),
508+
"updated_by": str(performed_by_user_id),
509+
},
510+
}
511+
)
512+
513+
dual_write_success = dual_write_service.batch_operations(operations)
514+
if not dual_write_success:
515+
import logging
516+
517+
logger = logging.getLogger(__name__)
518+
logger.warning("Failed to sync task reassignments to Postgres")
519+
520+
return False
521+
return True
522+
except Exception:
523+
return False
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from rest_framework import serializers
2+
from bson import ObjectId
3+
from todo.constants.messages import ValidationErrors
4+
5+
6+
class RemoveFromTeamSerializer(serializers.Serializer):
7+
team_id = serializers.CharField()
8+
user_id = serializers.CharField()
9+
10+
def validate_team_id(self, value):
11+
if not ObjectId.is_valid(value):
12+
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(value))
13+
return value
14+
15+
def validate_user_id(self, value):
16+
if not ObjectId.is_valid(value):
17+
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(value))
18+
return value

todo/services/task_assignment_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,10 @@ def delete_task_assignment(cls, task_id: str, user_id: str) -> bool:
150150
Delete task assignment by task ID.
151151
"""
152152
return TaskAssignmentRepository.delete_assignment(task_id, user_id)
153+
154+
@classmethod
155+
def reassign_tasks_from_user_to_team(cls, user_id: str, team_id: str, performed_by_user_id: str):
156+
"""
157+
Reassign all tasks of user to team
158+
"""
159+
return TaskAssignmentRepository.reassign_tasks_from_user_to_team(user_id, team_id, performed_by_user_id)

todo/services/team_service.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
from todo.models.audit_log import AuditLogModel
1414
from todo.repositories.audit_log_repository import AuditLogRepository
1515
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail
16+
from todo.constants.role import RoleScope
17+
from todo.services.user_role_service import UserRoleService
18+
from todo.services.task_assignment_service import TaskAssignmentService
19+
from todo.services.user_service import UserService
20+
from todo.exceptions.team_exceptions import (
21+
CannotRemoveOwnerException,
22+
NotTeamAdminException,
23+
CannotRemoveTeamPOCException,
24+
)
1625

1726
DEFAULT_ROLE_ID = "1"
1827

@@ -483,7 +492,25 @@ class TeamOrUserNotFound(Exception):
483492
pass
484493

485494
@classmethod
486-
def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str = None):
495+
def _validate_remove_member_permissions(cls, user_id: str, team_id: str, removed_by_user_id: str):
496+
team = TeamService.get_team_by_id(team_id)
497+
team_members = UserService.get_users_by_team_id(team_id)
498+
team_member_ids = [user.id for user in team_members]
499+
500+
if user_id not in team_member_ids:
501+
raise cls.TeamOrUserNotFound
502+
if user_id == team.created_by:
503+
raise CannotRemoveOwnerException()
504+
if user_id == team.poc_id:
505+
raise CannotRemoveTeamPOCException()
506+
if user_id != removed_by_user_id:
507+
if not UserRoleService.has_role(removed_by_user_id, RoleName.ADMIN.value, RoleScope.TEAM.value, team_id):
508+
raise NotTeamAdminException()
509+
510+
@classmethod
511+
def remove_member_from_team(cls, user_id: str, team_id: str, removed_by_user_id: str):
512+
cls._validate_remove_member_permissions(user_id, team_id, removed_by_user_id)
513+
487514
from todo.repositories.user_team_details_repository import UserTeamDetailsRepository
488515

489516
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:
494521
AuditLogRepository.create(
495522
AuditLogModel(
496523
team_id=PyObjectId(team_id),
497-
action="member_removed_from_team",
524+
action="member_removed_from_team" if user_id != removed_by_user_id else "member_left_team",
498525
performed_by=PyObjectId(removed_by_user_id) if removed_by_user_id else PyObjectId(user_id),
499526
)
500527
)
501528

529+
UserRoleService.remove_all_user_roles_for_team(user_id, team_id)
530+
TaskAssignmentService.reassign_tasks_from_user_to_team(user_id, team_id, removed_by_user_id)
531+
502532
return True

todo/services/user_role_service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,16 @@ def get_team_users_with_roles(cls, team_id: str) -> List[Dict[str, Any]]:
129129
except Exception as e:
130130
logger.error(f"Failed to get team users with roles: {str(e)}")
131131
return []
132+
133+
@classmethod
134+
def remove_all_user_roles_for_team(cls, user_id: str, team_id: str) -> bool:
135+
"""Remove all roles for a user within a specific team."""
136+
try:
137+
user_roles = cls.get_user_roles(user_id, RoleScope.TEAM.value, team_id)
138+
user_role_ids = [roles["role_id"] for roles in user_roles]
139+
for role_id in user_role_ids:
140+
cls.remove_role_by_id(user_id, role_id, RoleScope.TEAM.value, team_id)
141+
return True
142+
except Exception as e:
143+
logger.error(f"Failed to remove roles of user: {str(e)}")
144+
return False

0 commit comments

Comments
 (0)