Skip to content

Commit af8b098

Browse files
authored
Implement user-role management system with predefined roles (#218)
* feat: implement user-role management system with predefined roles and user-role associations * refactor: remove RoleAlreadyExistsException handling from global exception handler * refactor: enhance user role handling in repository and service * refactor: streamline role management and user role retrieval * fix(migrations): update role existence check to be case-insensitive using regex * feat: add role removal by ID functionality and update related endpoints * refactor: streamline user role assignment in team creation process * fix(user_role): improve role validation to handle attribute access for role values
1 parent 02ba88d commit af8b098

File tree

18 files changed

+529
-424
lines changed

18 files changed

+529
-424
lines changed

todo/constants/role.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,24 @@ class RoleScope(Enum):
66
TEAM = "TEAM"
77

88

9+
class RoleName(Enum):
10+
MODERATOR = "moderator"
11+
OWNER = "owner"
12+
ADMIN = "admin"
13+
MEMBER = "member"
14+
15+
16+
GLOBAL_ROLES = [RoleName.MODERATOR.value]
17+
TEAM_ROLES = [RoleName.OWNER.value, RoleName.ADMIN.value, RoleName.MEMBER.value]
18+
19+
DEFAULT_TEAM_ROLE = RoleName.MEMBER.value
20+
921
ROLE_SCOPE_CHOICES = [
1022
(RoleScope.GLOBAL.value, "Global"),
1123
(RoleScope.TEAM.value, "Team"),
1224
]
1325

14-
GLOBAL_ROLE_NAMES = [
15-
"moderator",
16-
]
17-
18-
TEAM_ROLE_NAMES = [
19-
"owner",
20-
"admin",
21-
]
22-
2326
VALID_ROLE_NAMES_BY_SCOPE = {
24-
RoleScope.GLOBAL.value: GLOBAL_ROLE_NAMES,
25-
RoleScope.TEAM.value: TEAM_ROLE_NAMES,
27+
RoleScope.GLOBAL.value: GLOBAL_ROLES,
28+
RoleScope.TEAM.value: TEAM_ROLES,
2629
}

todo/exceptions/global_exception_handler.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from todo.exceptions.role_exceptions import (
99
RoleNotFoundException,
10-
RoleAlreadyExistsException,
1110
RoleOperationException,
1211
)
1312

@@ -26,9 +25,6 @@ def wrapper(*args, **kwargs):
2625
except RoleNotFoundException as e:
2726
logger.error(f"RoleNotFoundException: {e}")
2827
return Response({"error": str(e)}, status=status.HTTP_404_NOT_FOUND)
29-
except RoleAlreadyExistsException as e:
30-
logger.error(f"RoleAlreadyExistsException: {e}")
31-
return Response({"error": str(e)}, status=status.HTTP_409_CONFLICT)
3228
except RoleOperationException as e:
3329
logger.error(f"RoleOperationException: {e}")
3430
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -52,12 +48,6 @@ def handle_role_not_found(exc: RoleNotFoundException) -> Dict[str, Any]:
5248
logger.error(f"Role not found: {exc}")
5349
return {"error": str(exc), "status_code": status.HTTP_404_NOT_FOUND}
5450

55-
@staticmethod
56-
def handle_role_already_exists(exc: RoleAlreadyExistsException) -> Dict[str, Any]:
57-
"""Handle RoleAlreadyExistsException"""
58-
logger.error(f"Role already exists: {exc}")
59-
return {"error": str(exc), "status_code": status.HTTP_409_CONFLICT}
60-
6151
@staticmethod
6252
def handle_role_operation_error(exc: RoleOperationException) -> Dict[str, Any]:
6353
"""Handle RoleOperationException"""

todo/exceptions/role_exceptions.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,6 @@ def __init__(self, role_id: str | None = None, role_name: str | None = None):
1414
self.role_name = role_name
1515

1616

17-
class RoleAlreadyExistsException(Exception):
18-
"""Exception raised when attempting to create a role that already exists."""
19-
20-
def __init__(self, role_name: str, existing_role_id: str | None = None):
21-
message = f"Role with name '{role_name}' already exists"
22-
if existing_role_id:
23-
message += f" (ID: {existing_role_id})"
24-
25-
super().__init__(message)
26-
self.role_name = role_name
27-
self.existing_role_id = existing_role_id
28-
29-
3017
class RoleOperationException(Exception):
3118
"""Exception raised when a role operation fails."""
3219

@@ -42,20 +29,3 @@ def __init__(self, message: str, operation: str | None = None, role_id: str | No
4229
self.operation = operation
4330
self.role_id = role_id
4431
self.original_message = message
45-
46-
47-
class RoleValidationException(Exception):
48-
"""Exception raised when role data validation fails."""
49-
50-
def __init__(self, message: str, field: str | None = None, value: str | None = None):
51-
if field and value:
52-
full_message = f"Validation failed for field '{field}' with value '{value}': {message}"
53-
elif field:
54-
full_message = f"Validation failed for field '{field}': {message}"
55-
else:
56-
full_message = f"Role validation failed: {message}"
57-
58-
super().__init__(full_message)
59-
self.field = field
60-
self.value = value
61-
self.original_message = message
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.core.management.base import BaseCommand
2+
from todo_project.db.migrations import run_all_migrations
3+
4+
5+
class Command(BaseCommand):
6+
help = "Run database migrations including predefined roles"
7+
8+
def handle(self, *args, **options):
9+
self.stdout.write("Starting database migrations...")
10+
11+
success = run_all_migrations()
12+
13+
if success:
14+
self.stdout.write("All database migrations completed successfully!")
15+
else:
16+
self.stdout.write("Some database migrations failed!")

todo/models/role.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import ClassVar
33
from datetime import datetime
44

5-
from todo.constants.role import RoleScope
5+
from todo.constants.role import RoleScope, RoleName
66
from todo.models.common.document import Document
77
from todo.models.common.pyobjectid import PyObjectId
88

@@ -11,7 +11,7 @@ class RoleModel(Document):
1111
collection_name: ClassVar[str] = "roles"
1212

1313
id: PyObjectId | None = Field(None, alias="_id")
14-
name: str
14+
name: RoleName
1515
description: str | None = None
1616
scope: RoleScope = RoleScope.GLOBAL
1717
is_active: bool = True

todo/models/team.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ class UserTeamDetailsModel(Document, ObjectIdValidatorMixin):
5353
user_id: PyObjectId
5454
team_id: PyObjectId
5555
is_active: bool = True
56-
role_id: str
5756
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
5857
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
5958
created_by: PyObjectId

todo/models/user_role.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pydantic import Field, validator, ConfigDict
2+
from typing import ClassVar
3+
from datetime import datetime, timezone
4+
5+
from todo.models.common.document import Document
6+
from todo.models.common.pyobjectid import PyObjectId
7+
from todo.constants.role import RoleScope, RoleName, VALID_ROLE_NAMES_BY_SCOPE
8+
9+
10+
class UserRoleModel(Document):
11+
"""User-role relationship model"""
12+
13+
collection_name: ClassVar[str] = "user_roles"
14+
15+
id: PyObjectId | None = Field(None, alias="_id")
16+
user_id: str
17+
role_name: RoleName
18+
scope: RoleScope
19+
team_id: str | None = None
20+
is_active: bool = True
21+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
22+
created_by: str = "system"
23+
24+
model_config = ConfigDict(ser_enum="value", from_attributes=True, populate_by_name=True, use_enum_values=True)
25+
26+
@validator("role_name")
27+
def validate_role_name(cls, v, values):
28+
"""Validate role_name is valid for the given scope."""
29+
scope = values.get("scope")
30+
if scope and scope.value in VALID_ROLE_NAMES_BY_SCOPE:
31+
valid_roles = VALID_ROLE_NAMES_BY_SCOPE[scope.value]
32+
role_value = v.value if hasattr(v, "value") else v
33+
if role_value not in valid_roles:
34+
raise ValueError(f"Invalid role '{role_value}' for scope '{scope.value}'. Valid roles: {valid_roles}")
35+
return v
36+
37+
@validator("team_id")
38+
def validate_team_id(cls, v, values):
39+
"""Validate team_id requirements based on scope."""
40+
scope = values.get("scope")
41+
if scope == RoleScope.TEAM and not v:
42+
raise ValueError("team_id is required for TEAM scope roles")
43+
if scope == RoleScope.GLOBAL and v:
44+
raise ValueError("team_id should not be provided for GLOBAL scope roles")
45+
return v

todo/repositories/role_repository.py

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
from bson.errors import InvalidId
2-
from datetime import datetime, timezone
31
from typing import List, Dict, Any, Optional
42
from bson import ObjectId
5-
from pymongo import ReturnDocument
63
import logging
74

85
from todo.models.role import RoleModel
96
from todo.repositories.common.mongo_repository import MongoRepository
107
from todo.constants.role import RoleScope
11-
from todo.exceptions.role_exceptions import RoleAlreadyExistsException
128

139
logger = logging.getLogger(__name__)
1410

@@ -50,24 +46,6 @@ def _document_to_model(cls, role_doc: dict) -> RoleModel:
5046

5147
return RoleModel(**role_doc)
5248

53-
@classmethod
54-
def create(cls, role: RoleModel) -> RoleModel:
55-
roles_collection = cls.get_collection()
56-
57-
scope_value = role.scope.value if isinstance(role.scope, RoleScope) else role.scope
58-
existing_role = roles_collection.find_one({"name": role.name, "scope": scope_value})
59-
if existing_role:
60-
raise RoleAlreadyExistsException(role.name)
61-
62-
role.created_at = datetime.now(timezone.utc)
63-
role.updated_at = None
64-
65-
role_dict = role.model_dump(mode="json", by_alias=True, exclude_none=True)
66-
insert_result = roles_collection.insert_one(role_dict)
67-
68-
role.id = insert_result.inserted_id
69-
return role
70-
7149
@classmethod
7250
def get_by_id(cls, role_id: str) -> Optional[RoleModel]:
7351
roles_collection = cls.get_collection()
@@ -76,50 +54,6 @@ def get_by_id(cls, role_id: str) -> Optional[RoleModel]:
7654
return cls._document_to_model(role_data)
7755
return None
7856

79-
@classmethod
80-
def update(cls, role_id: str, update_data: dict) -> Optional[RoleModel]:
81-
try:
82-
obj_id = ObjectId(role_id)
83-
except InvalidId:
84-
return None
85-
86-
if "name" in update_data:
87-
scope_value = update_data.get("scope", "GLOBAL")
88-
if isinstance(scope_value, RoleScope):
89-
scope_value = scope_value.value
90-
91-
existing_role = cls.get_by_name_and_scope(update_data["name"], scope_value)
92-
if existing_role and str(existing_role.id) != role_id:
93-
raise RoleAlreadyExistsException(update_data["name"])
94-
95-
if "scope" in update_data and isinstance(update_data["scope"], RoleScope):
96-
update_data["scope"] = update_data["scope"].value
97-
98-
update_data["updated_at"] = datetime.now(timezone.utc)
99-
100-
update_data.pop("_id", None)
101-
update_data.pop("id", None)
102-
103-
roles_collection = cls.get_collection()
104-
updated_role_doc = roles_collection.find_one_and_update(
105-
{"_id": obj_id}, {"$set": update_data}, return_document=ReturnDocument.AFTER
106-
)
107-
108-
if updated_role_doc:
109-
return cls._document_to_model(updated_role_doc)
110-
return None
111-
112-
@classmethod
113-
def delete_by_id(cls, role_id: str) -> bool:
114-
try:
115-
obj_id = ObjectId(role_id)
116-
except Exception:
117-
return False
118-
119-
roles_collection = cls.get_collection()
120-
result = roles_collection.delete_one({"_id": obj_id})
121-
return result.deleted_count > 0
122-
12357
@classmethod
12458
def get_by_name(cls, name: str) -> Optional[RoleModel]:
12559
roles_collection = cls.get_collection()
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from datetime import datetime, timezone
2+
from typing import List, Optional
3+
import logging
4+
from bson import ObjectId
5+
6+
from todo.models.user_role import UserRoleModel
7+
from todo.repositories.common.mongo_repository import MongoRepository
8+
from todo.constants.role import RoleScope, RoleName
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class UserRoleRepository(MongoRepository):
14+
collection_name = UserRoleModel.collection_name
15+
16+
@classmethod
17+
def create(cls, user_role: UserRoleModel) -> UserRoleModel:
18+
collection = cls.get_collection()
19+
20+
role_name_value = user_role.role_name.value if hasattr(user_role.role_name, "value") else user_role.role_name
21+
scope_value = user_role.scope.value if hasattr(user_role.scope, "value") else user_role.scope
22+
23+
# Check if already exists and is active
24+
existing = collection.find_one(
25+
{
26+
"user_id": user_role.user_id,
27+
"role_name": role_name_value,
28+
"scope": scope_value,
29+
"team_id": user_role.team_id,
30+
"is_active": True,
31+
}
32+
)
33+
34+
if existing:
35+
return UserRoleModel(**existing)
36+
37+
user_role.created_at = datetime.now(timezone.utc)
38+
user_role_dict = user_role.model_dump(mode="json", by_alias=True, exclude_none=True)
39+
result = collection.insert_one(user_role_dict)
40+
user_role.id = result.inserted_id
41+
return user_role
42+
43+
@classmethod
44+
def get_user_roles(
45+
cls, user_id: Optional[str] = None, scope: Optional["RoleScope"] = None, team_id: Optional[str] = None
46+
) -> List[UserRoleModel]:
47+
collection = cls.get_collection()
48+
49+
query = {"is_active": True}
50+
51+
if user_id:
52+
query["user_id"] = user_id
53+
54+
if scope:
55+
scope_value = scope.value if hasattr(scope, "value") else scope
56+
query["scope"] = scope_value
57+
58+
if team_id:
59+
query["team_id"] = team_id
60+
elif scope and (scope.value if hasattr(scope, "value") else scope) == "GLOBAL":
61+
query["team_id"] = None
62+
63+
roles = []
64+
for doc in collection.find(query):
65+
roles.append(UserRoleModel(**doc))
66+
return roles
67+
68+
@classmethod
69+
def assign_role(
70+
cls, user_id: str, role_name: "RoleName", scope: "RoleScope", team_id: Optional[str] = None
71+
) -> UserRoleModel:
72+
"""Assign a role to a user - simple and clean."""
73+
user_role = UserRoleModel(user_id=user_id, role_name=role_name, scope=scope, team_id=team_id, is_active=True)
74+
return cls.create(user_role)
75+
76+
@classmethod
77+
def remove_role_by_id(cls, user_id: str, role_id: str, scope: str, team_id: Optional[str] = None) -> bool:
78+
"""Remove a role from a user by role_id - simple deactivation."""
79+
collection = cls.get_collection()
80+
81+
try:
82+
object_id = ObjectId(role_id)
83+
except Exception:
84+
return False
85+
86+
query = {"_id": object_id, "user_id": user_id, "scope": scope, "is_active": True}
87+
88+
if scope == "TEAM" and team_id:
89+
query["team_id"] = team_id
90+
elif scope == "GLOBAL":
91+
query["team_id"] = None
92+
93+
result = collection.update_one(query, {"$set": {"is_active": False}})
94+
95+
return result.modified_count > 0

0 commit comments

Comments
 (0)