Skip to content

Commit 5400bea

Browse files
authored
feat: Add team creation invite code system with admin-only access (#249)
* feat: add team invite code functionality * feat: implement team invite code generation and verification functionality * refactor: clean up code formatting and remove unused imports in team invite code files * chore: add audit logs in team creation invite code * refactor: improve error handling * feat: add endpoint to list team creation invite codes with pagination and user details * chore: remove unused user ID from team creation invite code * refactor: improve code formatting * refactor: enhance DTO descriptions * refactor: update team creation invite code handling and validation logic * fix: patch unit test * refactor: rename method for retrieving team creation invite codes and improve error handling * feat: enhance JWT authentication middleware to verify user existence and include email in request
1 parent f755a88 commit 5400bea

21 files changed

+695
-53
lines changed

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ TODO_BACKEND_BASE_URL='http://localhost:8000'
2727

2828
CORS_ALLOWED_ORIGINS='http://localhost:3000,http://localhost:8000'
2929

30-
SWAGGER_UI_PATH='/api/schema'
30+
SWAGGER_UI_PATH='/api/schema'
31+
32+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from pydantic import BaseModel, Field
2+
3+
4+
class GenerateTeamCreationInviteCodeResponse(BaseModel):
5+
"""Response model for team creation invite code generation endpoint.
6+
7+
Attributes:
8+
code: The generated team creation invite code
9+
description: Optional description for the code
10+
message: Success or status message from the operation
11+
"""
12+
13+
code: str = Field(description="The generated team creation invite code")
14+
description: str | None = Field(None, description="Optional description for the code")
15+
message: str = Field(description="Success message confirming code generation")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pydantic import BaseModel, Field
2+
from typing import List, Optional
3+
from datetime import datetime
4+
5+
6+
class TeamCreationInviteCodeListItemDTO(BaseModel):
7+
"""DTO for a single team creation invite code in the list."""
8+
9+
id: str = Field(description="Unique identifier for the team creation invite code")
10+
code: str = Field(description="The actual invite code")
11+
description: Optional[str] = Field(None, description="Optional description provided when generating the code")
12+
created_by: dict = Field(description="User details of who created this code")
13+
created_at: datetime = Field(description="Timestamp when the code was created")
14+
used_at: Optional[datetime] = Field(None, description="Timestamp when the code was used (null if unused)")
15+
used_by: Optional[dict] = Field(None, description="User details of who used this code (null if unused)")
16+
is_used: bool = Field(description="Whether this code has been used for team creation")
17+
18+
19+
class GetTeamCreationInviteCodesResponse(BaseModel):
20+
"""Response model for listing all team creation invite codes with pagination links."""
21+
22+
codes: List[TeamCreationInviteCodeListItemDTO] = Field(
23+
description="List of team creation invite codes for current page"
24+
)
25+
previous_url: Optional[str] = Field(None, description="URL for previous page (null if no previous page)")
26+
next_url: Optional[str] = Field(None, description="URL for next page (null if no next page)")
27+
message: str = Field(description="Success message")
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from pydantic import BaseModel, Field
2+
from typing import Optional
3+
from datetime import datetime
4+
5+
6+
class GenerateTeamCreationInviteCodeDTO(BaseModel):
7+
"""DTO for generating team creation invite codes.
8+
9+
Allows admins to create invite codes with an optional description for tracking purposes."""
10+
11+
description: Optional[str] = None
12+
13+
14+
class VerifyTeamCreationInviteCodeDTO(BaseModel):
15+
"""DTO for verifying team creation invite codes."""
16+
17+
code: str
18+
19+
20+
class TeamCreationInviteCodeDTO(BaseModel):
21+
"""DTO for team creation invite code data."""
22+
23+
id: str = Field(description="Unique identifier for the team invite code")
24+
code: str = Field(description="The actual invite code")
25+
description: Optional[str] = Field(None, description="Optional description provided when generating the code")
26+
created_by: str = Field(description="User ID of the admin who generated this code")
27+
created_at: datetime = Field(description="Timestamp when the code was created")
28+
used_at: Optional[datetime] = Field(None, description="Timestamp when the code was used (null if unused)")
29+
used_by: Optional[str] = Field(None, description="User ID who used this code (null if unused)")
30+
is_used: bool = Field(description="Whether this code has been used for team creation")

todo/dto/team_dto.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class CreateTeamDTO(BaseModel):
99
description: Optional[str] = None
1010
member_ids: Optional[List[str]] = None
1111
poc_id: Optional[str] = None
12+
team_invite_code: str
1213

1314
@validator("member_ids")
1415
def validate_member_ids(cls, value):

todo/middlewares/jwt_auth.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from todo.constants.messages import AuthErrorMessages, ApiErrors
1616
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail
17+
from todo.repositories.user_repository import UserRepository
1718

1819

1920
class JWTAuthenticationMiddleware:
@@ -110,8 +111,14 @@ def _try_refresh(self, request) -> bool:
110111
return False
111112

112113
def _set_user_data(self, request, payload):
113-
"""Set user data on request"""
114-
request.user_id = payload["user_id"]
114+
"""Set user data on request with database verification"""
115+
user_id = payload["user_id"]
116+
user = UserRepository.get_by_id(user_id)
117+
if not user:
118+
raise TokenInvalidError(AuthErrorMessages.INVALID_TOKEN)
119+
120+
request.user_id = user_id
121+
request.user_email = user.email_id
115122

116123
def _process_response(self, request, response):
117124
"""Process response and set new cookies if token was refreshed"""
@@ -156,6 +163,7 @@ def get_current_user_info(request) -> dict:
156163

157164
user_info = {
158165
"user_id": request.user_id,
166+
"email": request.user_email,
159167
}
160168

161169
return user_info
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from bson import ObjectId
2+
from pydantic import Field, validator
3+
from typing import ClassVar
4+
from datetime import datetime, timezone
5+
6+
from todo.models.common.document import Document
7+
from todo.models.common.pyobjectid import PyObjectId
8+
9+
10+
class TeamCreationInviteCodeModel(Document):
11+
"""
12+
Model for team creation invite codes.
13+
"""
14+
15+
collection_name: ClassVar[str] = "team_creation_invite_codes"
16+
17+
code: str = Field(description="The actual invite code")
18+
description: str | None = None
19+
created_by: PyObjectId
20+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
21+
used_at: datetime | None = None
22+
used_by: PyObjectId | None = None
23+
is_used: bool = False
24+
25+
@validator("created_by", "used_by")
26+
def validate_object_id(cls, v):
27+
if v is None:
28+
return v
29+
if not ObjectId.is_valid(v):
30+
raise ValueError(f"Invalid ObjectId: {v}")
31+
return ObjectId(v)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from typing import Optional, List
2+
from datetime import datetime, timezone
3+
4+
from todo.repositories.common.mongo_repository import MongoRepository
5+
from todo.models.team_creation_invite_code import TeamCreationInviteCodeModel
6+
from todo.repositories.user_repository import UserRepository
7+
8+
9+
class TeamCreationInviteCodeRepository(MongoRepository):
10+
"""Repository for team creation invite code operations."""
11+
12+
collection_name = TeamCreationInviteCodeModel.collection_name
13+
14+
@classmethod
15+
def is_code_valid(cls, code: str) -> Optional[dict]:
16+
"""Check if a code is available for use (unused)."""
17+
collection = cls.get_collection()
18+
try:
19+
code_data = collection.find_one({"code": code, "is_used": False})
20+
return code_data
21+
except Exception as e:
22+
raise Exception(f"Error checking if code is valid: {e}")
23+
24+
@classmethod
25+
def validate_and_consume_code(cls, code: str, used_by: str) -> Optional[dict]:
26+
"""Validate and consume a code in one atomic operation using findOneAndUpdate."""
27+
collection = cls.get_collection()
28+
try:
29+
current_time = datetime.now(timezone.utc)
30+
result = collection.find_one_and_update(
31+
{"code": code, "is_used": False},
32+
{"$set": {"is_used": True, "used_by": used_by, "used_at": current_time.isoformat()}},
33+
return_document=True,
34+
)
35+
return result
36+
except Exception as e:
37+
raise Exception(f"Error validating and consuming code: {e}")
38+
39+
@classmethod
40+
def create(cls, team_invite_code: TeamCreationInviteCodeModel) -> TeamCreationInviteCodeModel:
41+
"""Create a new team invite code."""
42+
collection = cls.get_collection()
43+
team_invite_code.created_at = datetime.now(timezone.utc)
44+
45+
code_dict = team_invite_code.model_dump(mode="json", by_alias=True, exclude_none=True)
46+
insert_result = collection.insert_one(code_dict)
47+
team_invite_code.id = insert_result.inserted_id
48+
return team_invite_code
49+
50+
@classmethod
51+
def get_all_codes(cls, page: int = 1, limit: int = 10) -> tuple[List[dict], int]:
52+
"""Get paginated team creation invite codes with user details for created_by and used_by."""
53+
collection = cls.get_collection()
54+
try:
55+
skip = (page - 1) * limit
56+
57+
total_count = collection.count_documents({})
58+
59+
codes = list(collection.find().sort("created_at", -1).skip(skip).limit(limit))
60+
61+
enhanced_codes = []
62+
for code in codes:
63+
created_by_user = None
64+
used_by_user = None
65+
66+
if code.get("created_by"):
67+
user = UserRepository.get_by_id(str(code["created_by"]))
68+
if user:
69+
created_by_user = {"id": str(user.id), "name": user.name}
70+
71+
if code.get("used_by"):
72+
user = UserRepository.get_by_id(str(code["used_by"]))
73+
if user:
74+
used_by_user = {"id": str(user.id), "name": user.name}
75+
76+
enhanced_code = {
77+
"id": str(code["_id"]),
78+
"code": code["code"],
79+
"description": code.get("description"),
80+
"created_at": code.get("created_at"),
81+
"used_at": code.get("used_at"),
82+
"is_used": code.get("is_used", False),
83+
"created_by": created_by_user or {},
84+
"used_by": used_by_user,
85+
}
86+
enhanced_codes.append(enhanced_code)
87+
88+
return enhanced_codes, total_count
89+
except Exception as e:
90+
raise Exception(f"Error getting all codes with user details: {e}")

todo/serializers/create_team_serializer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class CreateTeamSerializer(serializers.Serializer):
1313
description = serializers.CharField(max_length=500, required=False, allow_blank=True)
1414
member_ids = serializers.ListField(child=serializers.CharField(), required=False, default=list)
1515
poc_id = serializers.CharField(required=False, allow_null=True, allow_blank=True)
16+
team_invite_code = serializers.CharField(max_length=20, min_length=6)
1617

1718
def validate_poc_id(self, value):
1819
if not value or not value.strip():
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+
3+
4+
class GenerateTeamCreationInviteCodeSerializer(serializers.Serializer):
5+
"""Serializer for generating team creation invite codes."""
6+
7+
description = serializers.CharField(
8+
max_length=500,
9+
required=False,
10+
allow_blank=True,
11+
help_text="Optional description for the team creation invite code (e.g., 'Code for marketing team')",
12+
)
13+
14+
15+
class VerifyTeamCreationInviteCodeSerializer(serializers.Serializer):
16+
"""Serializer for verifying team creation invite codes."""
17+
18+
code = serializers.CharField(max_length=100)

0 commit comments

Comments
 (0)