diff --git a/.env.example b/.env.example index 33febe73..0f9b151a 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,4 @@ PUBLIC_KEY="generate keys and paste here" # use if required # ACCESS_LIFETIME="20" -# REFRESH_LIFETIME="30" \ No newline at end of file +# REFRESH_LIFETIME="30" diff --git a/todo/constants/messages.py b/todo/constants/messages.py index 6bf38828..98e3f499 100644 --- a/todo/constants/messages.py +++ b/todo/constants/messages.py @@ -1,6 +1,7 @@ # Application Messages class AppMessages: TASK_CREATED = "Task created successfully" + TEAM_CREATED = "Team created successfully" GOOGLE_LOGIN_SUCCESS = "Successfully logged in with Google" GOOGLE_LOGOUT_SUCCESS = "Successfully logged out" TOKEN_REFRESHED = "Access token refreshed successfully" diff --git a/todo/dto/responses/create_team_response.py b/todo/dto/responses/create_team_response.py new file mode 100644 index 00000000..78c8d4d5 --- /dev/null +++ b/todo/dto/responses/create_team_response.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from todo.dto.team_dto import TeamDTO + + +class CreateTeamResponse(BaseModel): + team: TeamDTO + message: str diff --git a/todo/dto/team_dto.py b/todo/dto/team_dto.py new file mode 100644 index 00000000..f2a73f0b --- /dev/null +++ b/todo/dto/team_dto.py @@ -0,0 +1,49 @@ +from pydantic import BaseModel, validator +from typing import List, Optional +from datetime import datetime +from todo.repositories.user_repository import UserRepository + + +class CreateTeamDTO(BaseModel): + name: str + description: Optional[str] = None + member_ids: Optional[List[str]] = None + poc_id: str + + @validator("member_ids") + def validate_member_ids(cls, value): + """Validate that all member IDs exist in the database.""" + if value is None: + return value + + invalid_ids = [] + for member_id in value: + user = UserRepository.get_by_id(member_id) + if not user: + invalid_ids.append(member_id) + + if invalid_ids: + raise ValueError(f"Invalid member IDs: {invalid_ids}") + return value + + @validator("poc_id") + def validate_poc_id(cls, value): + """Validate that the POC ID exists in the database.""" + if value is None: + return value + + user = UserRepository.get_by_id(value) + if not user: + raise ValueError(f"Invalid POC ID: {value}") + return value + + +class TeamDTO(BaseModel): + id: str + name: str + description: Optional[str] = None + poc_id: str + created_by: str + updated_by: str + created_at: datetime + updated_at: datetime diff --git a/todo/models/team.py b/todo/models/team.py new file mode 100644 index 00000000..499c3a58 --- /dev/null +++ b/todo/models/team.py @@ -0,0 +1,58 @@ +from pydantic import Field, validator +from typing import ClassVar +from datetime import datetime, timezone + +from todo.models.common.document import Document +from todo.models.common.pyobjectid import PyObjectId + + +class TeamModel(Document): + """ + Model for teams. + """ + + collection_name: ClassVar[str] = "teams" + + name: str = Field(..., min_length=1, max_length=100) + description: str | None = None + poc_id: PyObjectId + created_by: PyObjectId + updated_by: PyObjectId + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + is_deleted: bool = False + + @validator("created_by", "updated_by") + def validate_user_id(cls, v): + """Validate that the user ID is a valid ObjectId format.""" + if v is None: + raise ValueError("User ID cannot be None") + if not PyObjectId.is_valid(v): + raise ValueError(f"Invalid user ID format: {v}") + return v + + +class UserTeamDetailsModel(Document): + """ + Model for user-team relationships. + """ + + collection_name: ClassVar[str] = "user_team_details" + + user_id: PyObjectId + team_id: PyObjectId + is_active: bool = True + role_id: str + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_by: PyObjectId + updated_by: PyObjectId + + @validator("user_id", "team_id", "created_by", "updated_by") + def validate_object_ids(cls, v): + """Validate that the ObjectId fields are in valid format.""" + if v is None: + raise ValueError("ObjectId cannot be None") + if not PyObjectId.is_valid(v): + raise ValueError(f"Invalid ObjectId format: {v}") + return v diff --git a/todo/repositories/team_repository.py b/todo/repositories/team_repository.py new file mode 100644 index 00000000..f530d659 --- /dev/null +++ b/todo/repositories/team_repository.py @@ -0,0 +1,79 @@ +from datetime import datetime, timezone +from typing import Optional +from bson import ObjectId + +from todo.models.team import TeamModel, UserTeamDetailsModel +from todo.repositories.common.mongo_repository import MongoRepository + + +class TeamRepository(MongoRepository): + collection_name = TeamModel.collection_name + + @classmethod + def create(cls, team: TeamModel) -> TeamModel: + """ + Creates a new team in the repository. + """ + teams_collection = cls.get_collection() + team.created_at = datetime.now(timezone.utc) + team.updated_at = datetime.now(timezone.utc) + + team_dict = team.model_dump(mode="json", by_alias=True, exclude_none=True) + insert_result = teams_collection.insert_one(team_dict) + team.id = insert_result.inserted_id + return team + + @classmethod + def get_by_id(cls, team_id: str) -> Optional[TeamModel]: + """ + Get a team by its ID. + """ + teams_collection = cls.get_collection() + try: + team_data = teams_collection.find_one({"_id": ObjectId(team_id), "is_deleted": False}) + if team_data: + return TeamModel(**team_data) + return None + except Exception: + return None + + +class UserTeamDetailsRepository(MongoRepository): + collection_name = UserTeamDetailsModel.collection_name + + @classmethod + def create(cls, user_team: UserTeamDetailsModel) -> UserTeamDetailsModel: + """ + Creates a new user-team relationship. + """ + collection = cls.get_collection() + user_team.created_at = datetime.now(timezone.utc) + user_team.updated_at = datetime.now(timezone.utc) + + user_team_dict = user_team.model_dump(mode="json", by_alias=True, exclude_none=True) + insert_result = collection.insert_one(user_team_dict) + user_team.id = insert_result.inserted_id + return user_team + + @classmethod + def create_many(cls, user_teams: list[UserTeamDetailsModel]) -> list[UserTeamDetailsModel]: + """ + Creates multiple user-team relationships. + """ + collection = cls.get_collection() + current_time = datetime.now(timezone.utc) + + for user_team in user_teams: + user_team.created_at = current_time + user_team.updated_at = current_time + + user_teams_dicts = [ + user_team.model_dump(mode="json", by_alias=True, exclude_none=True) for user_team in user_teams + ] + insert_result = collection.insert_many(user_teams_dicts) + + # Set the inserted IDs + for i, user_team in enumerate(user_teams): + user_team.id = insert_result.inserted_ids[i] + + return user_teams diff --git a/todo/serializers/create_team_serializer.py b/todo/serializers/create_team_serializer.py new file mode 100644 index 00000000..74af88d0 --- /dev/null +++ b/todo/serializers/create_team_serializer.py @@ -0,0 +1,8 @@ +from rest_framework import serializers + + +class CreateTeamSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100) + description = serializers.CharField(max_length=500, required=False, allow_blank=True) + member_ids = serializers.ListField(child=serializers.CharField(), required=False, default=list) + poc_id = serializers.CharField(required=True, allow_null=False) diff --git a/todo/services/team_service.py b/todo/services/team_service.py new file mode 100644 index 00000000..2af914b2 --- /dev/null +++ b/todo/services/team_service.py @@ -0,0 +1,86 @@ +from todo.dto.team_dto import CreateTeamDTO, TeamDTO +from todo.dto.responses.create_team_response import CreateTeamResponse +from todo.models.team import TeamModel, UserTeamDetailsModel +from todo.models.common.pyobjectid import PyObjectId +from todo.repositories.team_repository import TeamRepository, UserTeamDetailsRepository +from todo.constants.messages import AppMessages + + +class TeamService: + @classmethod + def create_team(cls, dto: CreateTeamDTO, created_by_user_id: str) -> CreateTeamResponse: + """ + Create a new team with members and POC. + """ + try: + # Member IDs and POC ID validation is handled at DTO level + member_ids = dto.member_ids or [] + + # Create team + team = TeamModel( + name=dto.name, + description=dto.description, + poc_id=PyObjectId(dto.poc_id) if dto.poc_id else None, + created_by=PyObjectId(created_by_user_id), + updated_by=PyObjectId(created_by_user_id), + ) + + created_team = TeamRepository.create(team) + + # Create user-team relationships + user_teams = [] + + # Add members to the team + if member_ids: + for user_id in member_ids: + user_team = UserTeamDetailsModel( + user_id=PyObjectId(user_id), + team_id=created_team.id, + role_id="1", + created_by=PyObjectId(created_by_user_id), + updated_by=PyObjectId(created_by_user_id), + ) + user_teams.append(user_team) + + # Add POC if not already in member_ids + if dto.poc_id and dto.poc_id not in member_ids: + poc_user_team = UserTeamDetailsModel( + user_id=PyObjectId(dto.poc_id), + team_id=created_team.id, + role_id="2", # POC role + created_by=PyObjectId(created_by_user_id), + updated_by=PyObjectId(created_by_user_id), + ) + user_teams.append(poc_user_team) + + # Add creator if not already in member_ids + if created_by_user_id not in member_ids: + creator_user_team = UserTeamDetailsModel( + user_id=PyObjectId(created_by_user_id), + team_id=created_team.id, + role_id="1", + created_by=PyObjectId(created_by_user_id), + updated_by=PyObjectId(created_by_user_id), + ) + user_teams.append(creator_user_team) + + # Create all user-team relationships + if user_teams: + UserTeamDetailsRepository.create_many(user_teams) + + # Convert to DTO + team_dto = TeamDTO( + id=str(created_team.id), + name=created_team.name, + description=created_team.description, + poc_id=str(created_team.poc_id) if created_team.poc_id else None, + created_by=str(created_team.created_by), + updated_by=str(created_team.updated_by), + created_at=created_team.created_at, + updated_at=created_team.updated_at, + ) + + return CreateTeamResponse(team=team_dto, message=AppMessages.TEAM_CREATED) + + except Exception as e: + raise ValueError(str(e)) diff --git a/todo/tests/unit/views/test_auth.py b/todo/tests/unit/views/test_auth.py index cb25e681..7966e967 100644 --- a/todo/tests/unit/views/test_auth.py +++ b/todo/tests/unit/views/test_auth.py @@ -111,6 +111,8 @@ def setUp(self): self.test_user_data = users_db_data[0] + self.test_user_data = users_db_data[0] + def test_get_redirects_for_oauth_error(self): error = "access_denied" response = self.client.get(f"{self.url}?error={error}") @@ -148,7 +150,10 @@ def test_get_redirects_for_valid_code_and_state(self, mock_create_user, mock_han "email": self.test_user_data["email_id"], "name": self.test_user_data["name"], } +<<<<<<< HEAD +======= +>>>>>>> a57ab63 (fix: lint and format) user_id = str(ObjectId()) mock_user = Mock() mock_user.id = ObjectId(user_id) diff --git a/todo/urls.py b/todo/urls.py index b56bf1e5..acfea896 100644 --- a/todo/urls.py +++ b/todo/urls.py @@ -2,6 +2,7 @@ from todo.views.task import TaskListView, TaskDetailView from todo.views.label import LabelListView from todo.views.health import HealthView +from todo.views.team import TeamListView from todo.views.auth import ( GoogleLoginView, GoogleCallbackView, @@ -12,6 +13,7 @@ urlpatterns = [ path("tasks", TaskListView.as_view(), name="tasks"), path("tasks/", TaskDetailView.as_view(), name="task_detail"), + path("teams", TeamListView.as_view(), name="teams"), path("health", HealthView.as_view(), name="health"), path("labels", LabelListView.as_view(), name="labels"), path("auth/google/login/", GoogleLoginView.as_view(), name="google_login"), diff --git a/todo/utils/google_jwt_utils.py b/todo/utils/google_jwt_utils.py index c4aa4375..24c8d16e 100644 --- a/todo/utils/google_jwt_utils.py +++ b/todo/utils/google_jwt_utils.py @@ -1,4 +1,5 @@ import jwt +import logging from datetime import datetime, timedelta, timezone from django.conf import settings @@ -10,6 +11,8 @@ from todo.constants.messages import AuthErrorMessages +logger = logging.getLogger(__name__) + def generate_google_access_token(user_data: dict) -> str: try: @@ -34,7 +37,8 @@ def generate_google_access_token(user_data: dict) -> str: return token except Exception as e: - raise GoogleTokenInvalidError(f"Token generation failed: {str(e)}") + logger.error(f"Token generation failed: {str(e)}") # Log the detailed error internally + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) # Return generic message to client def generate_google_refresh_token(user_data: dict) -> str: @@ -59,7 +63,8 @@ def generate_google_refresh_token(user_data: dict) -> str: return token except Exception as e: - raise GoogleTokenInvalidError(f"Refresh token generation failed: {str(e)}") + logger.error(f"Refresh token generation failed: {str(e)}") # Log the detailed error internally + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) # Return generic message to client def validate_google_access_token(token: str) -> dict: @@ -76,9 +81,11 @@ def validate_google_access_token(token: str) -> dict: except jwt.ExpiredSignatureError: raise GoogleTokenExpiredError() except jwt.InvalidTokenError as e: - raise GoogleTokenInvalidError(f"Invalid token: {str(e)}") + logger.error(f"Invalid token: {str(e)}") # Log the detailed error internally + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) # Return generic message to client except Exception as e: - raise GoogleTokenInvalidError(f"Token validation failed: {str(e)}") + logger.error(f"Token validation failed: {str(e)}") # Log the detailed error internally + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) # Return generic message to client def validate_google_refresh_token(token: str) -> dict: @@ -94,9 +101,11 @@ def validate_google_refresh_token(token: str) -> dict: except jwt.ExpiredSignatureError: raise GoogleRefreshTokenExpiredError() except jwt.InvalidTokenError as e: - raise GoogleTokenInvalidError(f"Invalid refresh token: {str(e)}") + logger.error(f"Invalid refresh token: {str(e)}") # Log the detailed error internally + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) # Return generic message to client except Exception as e: - raise GoogleTokenInvalidError(f"Refresh token validation failed: {str(e)}") + logger.error(f"Refresh token validation failed: {str(e)}") # Log the detailed error internally + raise GoogleTokenInvalidError(AuthErrorMessages.GOOGLE_TOKEN_INVALID) # Return generic message to client def generate_google_token_pair(user_data: dict) -> dict: diff --git a/todo/views/team.py b/todo/views/team.py new file mode 100644 index 00000000..aeb3cfc0 --- /dev/null +++ b/todo/views/team.py @@ -0,0 +1,69 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.request import Request +from django.conf import settings + +from todo.serializers.create_team_serializer import CreateTeamSerializer +from todo.services.team_service import TeamService +from todo.dto.team_dto import CreateTeamDTO +from todo.dto.responses.create_team_response import CreateTeamResponse +from todo.dto.responses.error_response import ApiErrorResponse +from todo.constants.messages import ApiErrors + + +class TeamListView(APIView): + def post(self, request: Request): + """ + Create a new team. + """ + serializer = CreateTeamSerializer(data=request.data) + + if not serializer.is_valid(): + return self._handle_validation_errors(serializer.errors) + + try: + dto = CreateTeamDTO(**serializer.validated_data) + created_by_user_id = request.user_id + response: CreateTeamResponse = TeamService.create_team(dto, created_by_user_id) + + return Response(data=response.model_dump(mode="json"), status=status.HTTP_201_CREATED) + + except ValueError as e: + if isinstance(e.args[0], ApiErrorResponse): + error_response = e.args[0] + return Response(data=error_response.model_dump(mode="json"), status=error_response.statusCode) + + fallback_response = ApiErrorResponse( + statusCode=500, + message=ApiErrors.UNEXPECTED_ERROR_OCCURRED, + errors=[{"detail": str(e) if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR}], + ) + return Response( + data=fallback_response.model_dump(mode="json"), status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + def _handle_validation_errors(self, errors): + from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorSource + + formatted_errors = [] + for field, messages in errors.items(): + if isinstance(messages, list): + for message in messages: + formatted_errors.append( + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: field}, + title=ApiErrors.VALIDATION_ERROR, + detail=str(message), + ) + ) + else: + formatted_errors.append( + ApiErrorDetail( + source={ApiErrorSource.PARAMETER: field}, title=ApiErrors.VALIDATION_ERROR, detail=str(messages) + ) + ) + + error_response = ApiErrorResponse(statusCode=400, message=ApiErrors.VALIDATION_ERROR, errors=formatted_errors) + + return Response(data=error_response.model_dump(mode="json"), status=status.HTTP_400_BAD_REQUEST)