MukeshKapoor25 commited on
Commit
aebf005
Β·
1 Parent(s): 4221dc4

feat(system_users): implement user status enum and migrate from boolean flag

Browse files

- Add UserStatus enum with states: ACTIVE, INACTIVE, SUSPENDED, LOCKED, PENDING_ACTIVATION
- Replace is_active boolean field with status enum field in SystemUserModel
- Update CreateUserRequest and UpdateUserRequest schemas to use status field
- Update UserInfoResponse schema to reflect status field instead of is_active
- Migrate EmployeeService to use UserStatus.ACTIVE when creating system users
- Migrate MerchantService to use UserStatus.ACTIVE and remove duplicate status field
- Update system_users router documentation to reference status_filter parameter
- Rename is_active filter parameter to status_filter in list_users endpoint
- Add migration utility scripts for status field migration
- Provides better semantic clarity and extensibility for user account states

app/employees/services/service.py CHANGED
@@ -20,6 +20,7 @@ from app.employees.models.model import EmployeeModel
20
  from app.employees.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
21
  from app.system_users.services.service import SystemUserService
22
  from app.system_users.schemas.schema import CreateUserRequest
 
23
 
24
  logger = get_logger(__name__)
25
 
@@ -742,7 +743,7 @@ class EmployeeService:
742
  password=temp_password,
743
  full_name=f"{employee_payload.first_name} {employee_payload.last_name or ''}".strip(),
744
  role_id=role_id,
745
- is_active=True,
746
  metadata={
747
  "employee_user_id": employee_user_id,
748
  "employee_code": employee_payload.employee_code,
 
20
  from app.employees.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
21
  from app.system_users.services.service import SystemUserService
22
  from app.system_users.schemas.schema import CreateUserRequest
23
+ from app.system_users.models.model import UserStatus
24
 
25
  logger = get_logger(__name__)
26
 
 
743
  password=temp_password,
744
  full_name=f"{employee_payload.first_name} {employee_payload.last_name or ''}".strip(),
745
  role_id=role_id,
746
+ status=UserStatus.ACTIVE,
747
  metadata={
748
  "employee_user_id": employee_user_id,
749
  "employee_code": employee_payload.employee_code,
app/merchants/services/service.py CHANGED
@@ -15,6 +15,7 @@ from app.merchants.models.model import MerchantModel
15
  from app.merchants.schemas.schema import MerchantCreate, MerchantUpdate, MerchantResponse
16
  from app.system_users.services.service import SystemUserService
17
  from app.system_users.schemas.schema import CreateUserRequest
 
18
  from pymongo.errors import DuplicateKeyError
19
 
20
  logger = get_logger(__name__)
@@ -1143,8 +1144,7 @@ class MerchantService:
1143
  full_name=merchant_payload.merchant_name,
1144
  role_id=role_id,
1145
  merchant_id=merchant_id,
1146
- is_active=True,
1147
- status="active",
1148
  metadata={
1149
  "created_for_merchant": merchant_id,
1150
  "merchant_type": merchant_payload.merchant_type.value,
 
15
  from app.merchants.schemas.schema import MerchantCreate, MerchantUpdate, MerchantResponse
16
  from app.system_users.services.service import SystemUserService
17
  from app.system_users.schemas.schema import CreateUserRequest
18
+ from app.system_users.models.model import UserStatus
19
  from pymongo.errors import DuplicateKeyError
20
 
21
  logger = get_logger(__name__)
 
1144
  full_name=merchant_payload.merchant_name,
1145
  role_id=role_id,
1146
  merchant_id=merchant_id,
1147
+ status=UserStatus.ACTIVE,
 
1148
  metadata={
1149
  "created_for_merchant": merchant_id,
1150
  "merchant_type": merchant_payload.merchant_type.value,
app/system_users/controllers/router.py CHANGED
@@ -36,12 +36,12 @@ async def list_users(
36
  user_service: SystemUserService = Depends(get_system_user_service)
37
  ):
38
  """
39
- List users with pagination and optional field projection. Requires admin privileges.
40
 
41
  **Request Body:**
42
  - `page`: Page number (default: 1)
43
  - `page_size`: Page size (default: 20, max: 100)
44
- - `is_active`: Filter by active status
45
  - `projection_list`: Optional list of fields to include in response
46
  """
47
  try:
@@ -50,11 +50,11 @@ async def list_users(
50
  users, total_count = await user_service.list_users(
51
  payload.page,
52
  page_size,
53
- payload.is_active,
54
  payload.projection_list
55
  )
56
 
57
- # If projection is used, return raw data
58
  if payload.projection_list:
59
  return {
60
  "users": users,
 
36
  user_service: SystemUserService = Depends(get_system_user_service)
37
  ):
38
  """
39
+ List users with pagination and optional field projection following API standards.
40
 
41
  **Request Body:**
42
  - `page`: Page number (default: 1)
43
  - `page_size`: Page size (default: 20, max: 100)
44
+ - `status_filter`: Filter by user status (active, inactive, suspended, etc.)
45
  - `projection_list`: Optional list of fields to include in response
46
  """
47
  try:
 
50
  users, total_count = await user_service.list_users(
51
  payload.page,
52
  page_size,
53
+ payload.status_filter,
54
  payload.projection_list
55
  )
56
 
57
+ # If projection is used, return raw data (API standard)
58
  if payload.projection_list:
59
  return {
60
  "users": users,
app/system_users/models/model.py CHANGED
@@ -2,6 +2,16 @@
2
  from datetime import datetime
3
  from typing import Optional, Dict, Any
4
  from pydantic import BaseModel, Field, EmailStr
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  class SystemUserModel(BaseModel):
@@ -15,7 +25,7 @@ class SystemUserModel(BaseModel):
15
  role_id: Optional[str] = Field(None, description="Role identifier e.g. role_distributor_manager")
16
  merchant_id: Optional[str] = Field(None, description="Owning merchant id")
17
  merchant_type: Optional[str] = Field(None, description="Merchant type (national_cnf, cnf, distributor, salon)")
18
- is_active: bool = Field(default=True, description="Active flag")
19
  last_login: Optional[datetime] = Field(None, description="Timestamp of last successful login")
20
  created_by: str = Field(..., description="User id who created this record")
21
  created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
@@ -33,7 +43,7 @@ class SystemUserModel(BaseModel):
33
  "full_name": "Vikram Singh",
34
  "role_id": "role_distributor_manager",
35
  "merchant_id": "dist_delhi_premium",
36
- "is_active": True,
37
  "last_login": None,
38
  "created_by": "user_company_admin",
39
  "created_at": "2025-11-30T16:52:33.902Z"
 
2
  from datetime import datetime
3
  from typing import Optional, Dict, Any
4
  from pydantic import BaseModel, Field, EmailStr
5
+ from enum import Enum
6
+
7
+
8
+ class UserStatus(str, Enum):
9
+ """User account status options."""
10
+ ACTIVE = "active"
11
+ INACTIVE = "inactive"
12
+ SUSPENDED = "suspended"
13
+ LOCKED = "locked"
14
+ PENDING_ACTIVATION = "pending_activation"
15
 
16
 
17
  class SystemUserModel(BaseModel):
 
25
  role_id: Optional[str] = Field(None, description="Role identifier e.g. role_distributor_manager")
26
  merchant_id: Optional[str] = Field(None, description="Owning merchant id")
27
  merchant_type: Optional[str] = Field(None, description="Merchant type (national_cnf, cnf, distributor, salon)")
28
+ status: UserStatus = Field(default=UserStatus.ACTIVE, description="Account status")
29
  last_login: Optional[datetime] = Field(None, description="Timestamp of last successful login")
30
  created_by: str = Field(..., description="User id who created this record")
31
  created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
 
43
  "full_name": "Vikram Singh",
44
  "role_id": "role_distributor_manager",
45
  "merchant_id": "dist_delhi_premium",
46
+ "status": "active",
47
  "last_login": None,
48
  "created_by": "user_company_admin",
49
  "created_at": "2025-11-30T16:52:33.902Z"
app/system_users/schemas/schema.py CHANGED
@@ -2,6 +2,7 @@
2
  from datetime import datetime
3
  from typing import Optional, List, Dict
4
  from pydantic import BaseModel, Field, EmailStr, validator
 
5
 
6
 
7
  class LoginRequest(BaseModel):
@@ -31,7 +32,7 @@ class UserInfoResponse(BaseModel):
31
  role_id: Optional[str] = Field(None, description="Role identifier")
32
  merchant_id: Optional[str] = Field(None, description="Merchant identifier")
33
  merchant_type: Optional[str] = Field(None, description="Merchant type")
34
- is_active: bool = Field(..., description="Account active flag")
35
  last_login: Optional[datetime] = Field(None, description="Last login timestamp")
36
  created_by: str = Field(..., description="Creator user id")
37
  created_at: datetime = Field(..., description="Creation timestamp")
@@ -47,7 +48,7 @@ class CreateUserRequest(BaseModel):
47
  password: str = Field(..., description="Password", min_length=8, max_length=100)
48
  full_name: str = Field(..., description="Full name", min_length=1, max_length=100)
49
  role_id: str = Field(..., description="Role identifier")
50
- is_active: bool = Field(default=True, description="Active flag")
51
  metadata: Optional[Dict[str, str]] = Field(None, description="Optional metadata")
52
 
53
  @validator("username")
@@ -78,7 +79,7 @@ class UpdateUserRequest(BaseModel):
78
  role_id: Optional[str] = Field(None, description="Role identifier")
79
  merchant_id: Optional[str] = Field(None, description="Merchant identifier")
80
  merchant_type: Optional[str] = Field(None, description="Merchant type (national_cnf, cnf, distributor, salon)")
81
- is_active: Optional[bool] = Field(None, description="Active flag")
82
  metadata: Optional[Dict[str, str]] = Field(None, description="Optional metadata")
83
 
84
  @validator("username")
@@ -148,19 +149,19 @@ LoginResponse.model_rebuild()
148
 
149
 
150
  class SystemUserListRequest(BaseModel):
151
- """Schema for listing system users with filters (POST request body)."""
152
  page: int = Field(1, ge=1, description="Page number (default: 1)")
153
  page_size: int = Field(20, ge=1, le=100, description="Page size (default: 20, max: 100)")
154
- is_active: Optional[bool] = Field(None, description="Filter by active status")
155
- projection_list: Optional[List[str]] = Field(None, description="List of fields to include in response (e.g., ['user_id', 'username', 'email'])")
156
 
157
  class Config:
158
  json_schema_extra = {
159
  "example": {
160
  "page": 1,
161
  "page_size": 20,
162
- "is_active": True,
163
- "projection_list": ["user_id", "username", "email", "full_name", "role_id"]
164
  }
165
  }
166
 
 
2
  from datetime import datetime
3
  from typing import Optional, List, Dict
4
  from pydantic import BaseModel, Field, EmailStr, validator
5
+ from app.system_users.models.model import UserStatus
6
 
7
 
8
  class LoginRequest(BaseModel):
 
32
  role_id: Optional[str] = Field(None, description="Role identifier")
33
  merchant_id: Optional[str] = Field(None, description="Merchant identifier")
34
  merchant_type: Optional[str] = Field(None, description="Merchant type")
35
+ status: UserStatus = Field(..., description="Account status")
36
  last_login: Optional[datetime] = Field(None, description="Last login timestamp")
37
  created_by: str = Field(..., description="Creator user id")
38
  created_at: datetime = Field(..., description="Creation timestamp")
 
48
  password: str = Field(..., description="Password", min_length=8, max_length=100)
49
  full_name: str = Field(..., description="Full name", min_length=1, max_length=100)
50
  role_id: str = Field(..., description="Role identifier")
51
+ status: UserStatus = Field(default=UserStatus.ACTIVE, description="Account status")
52
  metadata: Optional[Dict[str, str]] = Field(None, description="Optional metadata")
53
 
54
  @validator("username")
 
79
  role_id: Optional[str] = Field(None, description="Role identifier")
80
  merchant_id: Optional[str] = Field(None, description="Merchant identifier")
81
  merchant_type: Optional[str] = Field(None, description="Merchant type (national_cnf, cnf, distributor, salon)")
82
+ status: Optional[UserStatus] = Field(None, description="Account status")
83
  metadata: Optional[Dict[str, str]] = Field(None, description="Optional metadata")
84
 
85
  @validator("username")
 
149
 
150
 
151
  class SystemUserListRequest(BaseModel):
152
+ """Schema for listing system users with filters (POST request body) following API standards."""
153
  page: int = Field(1, ge=1, description="Page number (default: 1)")
154
  page_size: int = Field(20, ge=1, le=100, description="Page size (default: 20, max: 100)")
155
+ status_filter: Optional[UserStatus] = Field(None, description="Filter by user status")
156
+ projection_list: Optional[List[str]] = Field(None, description="List of fields to include in response")
157
 
158
  class Config:
159
  json_schema_extra = {
160
  "example": {
161
  "page": 1,
162
  "page_size": 20,
163
+ "status_filter": "active",
164
+ "projection_list": ["user_id", "username", "email", "full_name", "role_id", "status"]
165
  }
166
  }
167
 
app/system_users/services/service.py CHANGED
@@ -10,7 +10,7 @@ from jose import JWTError, jwt
10
  from fastapi import HTTPException, status
11
  from insightfy_utils.logging import get_logger
12
 
13
- from app.system_users.models.model import SystemUserModel
14
  from app.system_users.schemas.schema import (
15
  CreateUserRequest,
16
  UpdateUserRequest,
@@ -149,7 +149,7 @@ class SystemUserService:
149
  role_id=user_data.role_id,
150
  merchant_id=user_data.merchant_id,
151
  merchant_type=user_data.merchant_type,
152
- is_active=user_data.is_active,
153
  last_login=None,
154
  created_by=created_by,
155
  created_at=datetime.utcnow(),
@@ -181,8 +181,9 @@ class SystemUserService:
181
  logger.warning(f"Login attempt with non-existent identifier: {identifier}")
182
  return None, "Invalid email or username"
183
 
184
- if not user.is_active:
185
- return None, "Account is inactive"
 
186
 
187
  if not self.verify_password(password, user.password_hash):
188
  return None, "Invalid username or password"
@@ -261,18 +262,18 @@ class SystemUserService:
261
  logger.error(f"Error changing password for user {user_id}: {e}")
262
  return False
263
 
264
- async def list_users(self, page: int = 1, page_size: int = 20, is_active: Optional[bool] = None, projection_list: Optional[List[str]] = None):
265
- """List users with pagination, optional active filter, and field projection."""
266
  try:
267
  skip = (page - 1) * page_size
268
 
269
  # Build query filter
270
  query_filter = {}
271
- if is_active is not None:
272
- query_filter["is_active"] = is_active
273
 
274
  # Debug logging
275
- logger.info(f"list_users called: page={page}, page_size={page_size}, is_active={is_active}, projection_list={projection_list}")
276
  logger.info(f"Database: {self.db.name}, Collection: {self.collection.name}")
277
  logger.info(f"Query filter: {query_filter}")
278
 
@@ -280,24 +281,21 @@ class SystemUserService:
280
  total_count = await self.collection.count_documents(query_filter)
281
  logger.info(f"Total count: {total_count}")
282
 
283
- # Build projection dict for MongoDB
284
  projection_dict = None
285
  if projection_list:
286
- # Create projection with specified fields
287
  projection_dict = {field: 1 for field in projection_list}
288
- # Only exclude _id if it's not in the projection_list
289
- if "_id" not in projection_list:
290
- projection_dict["_id"] = 0
291
 
292
- # Get users with sorting applied before skip/limit for better performance
293
  cursor = self.collection.find(query_filter, projection_dict).sort("created_at", -1).skip(skip).limit(page_size)
294
- users = []
295
- async for user_doc in cursor:
296
- # If projection is used, return raw dict; otherwise return SystemUserModel
297
- if projection_list:
298
- users.append(user_doc)
299
- else:
300
- users.append(SystemUserModel(**user_doc))
301
 
302
  logger.info(f"Returning {len(users)} users")
303
  return users, total_count
@@ -312,7 +310,7 @@ class SystemUserService:
312
  result = await self.collection.update_one(
313
  {"user_id": user_id},
314
  {"$set": {
315
- "is_active": False,
316
  "updated_at": datetime.utcnow(),
317
  "updated_by": deactivated_by
318
  }}
@@ -333,7 +331,7 @@ class SystemUserService:
333
  result = await self.collection.update_one(
334
  {"user_id": user_id},
335
  {"$set": {
336
- "is_active": False,
337
  "suspension_reason": reason,
338
  "suspended_at": datetime.utcnow(),
339
  "suspended_by": suspended_by,
@@ -397,7 +395,7 @@ class SystemUserService:
397
  {"user_id": user_id},
398
  {
399
  "$set": {
400
- "is_active": True,
401
  "updated_at": datetime.utcnow(),
402
  "updated_by": unlocked_by
403
  },
@@ -430,7 +428,7 @@ class SystemUserService:
430
  role_id=user.role_id,
431
  merchant_id=user.merchant_id,
432
  merchant_type=user.merchant_type,
433
- is_active=user.is_active,
434
  last_login=user.last_login,
435
  created_by=user.created_by,
436
  created_at=user.created_at,
 
10
  from fastapi import HTTPException, status
11
  from insightfy_utils.logging import get_logger
12
 
13
+ from app.system_users.models.model import SystemUserModel, UserStatus
14
  from app.system_users.schemas.schema import (
15
  CreateUserRequest,
16
  UpdateUserRequest,
 
149
  role_id=user_data.role_id,
150
  merchant_id=user_data.merchant_id,
151
  merchant_type=user_data.merchant_type,
152
+ status=UserStatus.ACTIVE, # Set as active by default
153
  last_login=None,
154
  created_by=created_by,
155
  created_at=datetime.utcnow(),
 
181
  logger.warning(f"Login attempt with non-existent identifier: {identifier}")
182
  return None, "Invalid email or username"
183
 
184
+ # Check account status
185
+ if user.status not in [UserStatus.ACTIVE]:
186
+ return None, f"Account is {user.status.value}"
187
 
188
  if not self.verify_password(password, user.password_hash):
189
  return None, "Invalid username or password"
 
262
  logger.error(f"Error changing password for user {user_id}: {e}")
263
  return False
264
 
265
+ async def list_users(self, page: int = 1, page_size: int = 20, status_filter: Optional[UserStatus] = None, projection_list: Optional[List[str]] = None):
266
+ """List users with pagination, optional status filter, and field projection following API standards."""
267
  try:
268
  skip = (page - 1) * page_size
269
 
270
  # Build query filter
271
  query_filter = {}
272
+ if status_filter is not None:
273
+ query_filter["status"] = status_filter.value
274
 
275
  # Debug logging
276
+ logger.info(f"list_users called: page={page}, page_size={page_size}, status_filter={status_filter}, projection_list={projection_list}")
277
  logger.info(f"Database: {self.db.name}, Collection: {self.collection.name}")
278
  logger.info(f"Query filter: {query_filter}")
279
 
 
281
  total_count = await self.collection.count_documents(query_filter)
282
  logger.info(f"Total count: {total_count}")
283
 
284
+ # Build MongoDB projection following API standards
285
  projection_dict = None
286
  if projection_list:
 
287
  projection_dict = {field: 1 for field in projection_list}
288
+ projection_dict["_id"] = 0 # Always exclude _id for projection
 
 
289
 
290
+ # Query with projection
291
  cursor = self.collection.find(query_filter, projection_dict).sort("created_at", -1).skip(skip).limit(page_size)
292
+ docs = await cursor.to_list(length=page_size)
293
+
294
+ # Return raw dict if projection, model otherwise (API standard)
295
+ if projection_list:
296
+ users = docs
297
+ else:
298
+ users = [SystemUserModel(**doc) for doc in docs]
299
 
300
  logger.info(f"Returning {len(users)} users")
301
  return users, total_count
 
310
  result = await self.collection.update_one(
311
  {"user_id": user_id},
312
  {"$set": {
313
+ "status": UserStatus.INACTIVE.value,
314
  "updated_at": datetime.utcnow(),
315
  "updated_by": deactivated_by
316
  }}
 
331
  result = await self.collection.update_one(
332
  {"user_id": user_id},
333
  {"$set": {
334
+ "status": UserStatus.SUSPENDED.value,
335
  "suspension_reason": reason,
336
  "suspended_at": datetime.utcnow(),
337
  "suspended_by": suspended_by,
 
395
  {"user_id": user_id},
396
  {
397
  "$set": {
398
+ "status": UserStatus.ACTIVE.value,
399
  "updated_at": datetime.utcnow(),
400
  "updated_by": unlocked_by
401
  },
 
428
  role_id=user.role_id,
429
  merchant_id=user.merchant_id,
430
  merchant_type=user.merchant_type,
431
+ status=user.status,
432
  last_login=user.last_login,
433
  created_by=user.created_by,
434
  created_at=user.created_at,
migration_status_field.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Migration script to convert is_active field to status field in system users.
4
+
5
+ This script:
6
+ 1. Connects to MongoDB
7
+ 2. Finds all documents with is_active field
8
+ 3. Converts is_active boolean to status enum
9
+ 4. Updates documents with new status field
10
+ 5. Optionally removes is_active field after migration
11
+
12
+ Usage:
13
+ python migration_status_field.py --dry-run # Preview changes
14
+ python migration_status_field.py --execute # Execute migration
15
+ python migration_status_field.py --cleanup # Remove is_active field after migration
16
+ """
17
+
18
+ import asyncio
19
+ import argparse
20
+ import logging
21
+ from datetime import datetime
22
+ from motor.motor_asyncio import AsyncIOMotorClient
23
+ from app.core.config import settings
24
+ from app.system_users.constants import SCM_SYSTEM_USERS_COLLECTION
25
+
26
+ # Configure logging
27
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class StatusFieldMigration:
32
+ """Migration class for converting is_active to status field."""
33
+
34
+ def __init__(self):
35
+ self.client = None
36
+ self.db = None
37
+ self.collection = None
38
+
39
+ async def connect(self):
40
+ """Connect to MongoDB."""
41
+ try:
42
+ self.client = AsyncIOMotorClient(settings.MONGODB_URI)
43
+ self.db = self.client[settings.MONGODB_DB_NAME]
44
+ self.collection = self.db[SCM_SYSTEM_USERS_COLLECTION]
45
+
46
+ # Test connection
47
+ await self.client.admin.command('ping')
48
+ logger.info("βœ… Connected to MongoDB successfully")
49
+
50
+ except Exception as e:
51
+ logger.error(f"❌ Failed to connect to MongoDB: {e}")
52
+ raise
53
+
54
+ async def disconnect(self):
55
+ """Disconnect from MongoDB."""
56
+ if self.client:
57
+ self.client.close()
58
+ logger.info("πŸ”Œ Disconnected from MongoDB")
59
+
60
+ async def analyze_data(self):
61
+ """Analyze current data state."""
62
+ logger.info("πŸ“Š Analyzing current data state...")
63
+
64
+ # Count documents with is_active field
65
+ has_is_active = await self.collection.count_documents({"is_active": {"$exists": True}})
66
+
67
+ # Count documents with status field
68
+ has_status = await self.collection.count_documents({"status": {"$exists": True}})
69
+
70
+ # Count active/inactive users
71
+ active_users = await self.collection.count_documents({"is_active": True})
72
+ inactive_users = await self.collection.count_documents({"is_active": False})
73
+
74
+ # Count by status values
75
+ status_counts = {}
76
+ async for doc in self.collection.aggregate([
77
+ {"$match": {"status": {"$exists": True}}},
78
+ {"$group": {"_id": "$status", "count": {"$sum": 1}}}
79
+ ]):
80
+ status_counts[doc["_id"]] = doc["count"]
81
+
82
+ logger.info(f"πŸ“ˆ Analysis Results:")
83
+ logger.info(f" Documents with is_active field: {has_is_active}")
84
+ logger.info(f" Documents with status field: {has_status}")
85
+ logger.info(f" Active users (is_active=true): {active_users}")
86
+ logger.info(f" Inactive users (is_active=false): {inactive_users}")
87
+
88
+ if status_counts:
89
+ logger.info(f" Status field distribution:")
90
+ for status, count in status_counts.items():
91
+ logger.info(f" {status}: {count}")
92
+
93
+ return {
94
+ "has_is_active": has_is_active,
95
+ "has_status": has_status,
96
+ "active_users": active_users,
97
+ "inactive_users": inactive_users,
98
+ "status_counts": status_counts
99
+ }
100
+
101
+ async def preview_migration(self):
102
+ """Preview what the migration would do."""
103
+ logger.info("πŸ‘€ Previewing migration changes...")
104
+
105
+ # Find documents that need migration
106
+ cursor = self.collection.find(
107
+ {"is_active": {"$exists": True}, "status": {"$exists": False}},
108
+ {"user_id": 1, "username": 1, "is_active": 1}
109
+ ).limit(10)
110
+
111
+ preview_count = 0
112
+ async for doc in cursor:
113
+ new_status = "active" if doc.get("is_active", True) else "inactive"
114
+ logger.info(f" {doc.get('user_id', 'N/A')} ({doc.get('username', 'N/A')}): is_active={doc.get('is_active')} β†’ status='{new_status}'")
115
+ preview_count += 1
116
+
117
+ total_to_migrate = await self.collection.count_documents(
118
+ {"is_active": {"$exists": True}, "status": {"$exists": False}}
119
+ )
120
+
121
+ if preview_count < total_to_migrate:
122
+ logger.info(f" ... and {total_to_migrate - preview_count} more documents")
123
+
124
+ logger.info(f"πŸ“ Total documents to migrate: {total_to_migrate}")
125
+ return total_to_migrate
126
+
127
+ async def execute_migration(self):
128
+ """Execute the migration."""
129
+ logger.info("πŸš€ Executing migration...")
130
+
131
+ # Migration logic: is_active=true β†’ status="active", is_active=false β†’ status="inactive"
132
+ migration_time = datetime.utcnow()
133
+
134
+ # Update active users
135
+ result_active = await self.collection.update_many(
136
+ {"is_active": True, "status": {"$exists": False}},
137
+ {
138
+ "$set": {
139
+ "status": "active",
140
+ "migrated_at": migration_time,
141
+ "migration_note": "Migrated from is_active=true"
142
+ }
143
+ }
144
+ )
145
+
146
+ # Update inactive users
147
+ result_inactive = await self.collection.update_many(
148
+ {"is_active": False, "status": {"$exists": False}},
149
+ {
150
+ "$set": {
151
+ "status": "inactive",
152
+ "migrated_at": migration_time,
153
+ "migration_note": "Migrated from is_active=false"
154
+ }
155
+ }
156
+ )
157
+
158
+ logger.info(f"βœ… Migration completed:")
159
+ logger.info(f" Active users migrated: {result_active.modified_count}")
160
+ logger.info(f" Inactive users migrated: {result_inactive.modified_count}")
161
+ logger.info(f" Total documents updated: {result_active.modified_count + result_inactive.modified_count}")
162
+
163
+ return result_active.modified_count + result_inactive.modified_count
164
+
165
+ async def cleanup_is_active_field(self):
166
+ """Remove is_active field after successful migration."""
167
+ logger.info("🧹 Cleaning up is_active field...")
168
+
169
+ # Only remove is_active if status field exists (safety check)
170
+ result = await self.collection.update_many(
171
+ {"is_active": {"$exists": True}, "status": {"$exists": True}},
172
+ {"$unset": {"is_active": ""}}
173
+ )
174
+
175
+ logger.info(f"βœ… Cleanup completed: {result.modified_count} documents updated")
176
+ return result.modified_count
177
+
178
+ async def verify_migration(self):
179
+ """Verify migration was successful."""
180
+ logger.info("πŸ” Verifying migration...")
181
+
182
+ analysis = await self.analyze_data()
183
+
184
+ # Check if any documents still have is_active but no status
185
+ orphaned = await self.collection.count_documents(
186
+ {"is_active": {"$exists": True}, "status": {"$exists": False}}
187
+ )
188
+
189
+ if orphaned > 0:
190
+ logger.warning(f"⚠️ Found {orphaned} documents with is_active but no status field")
191
+ return False
192
+
193
+ logger.info("βœ… Migration verification passed")
194
+ return True
195
+
196
+
197
+ async def main():
198
+ """Main migration function."""
199
+ parser = argparse.ArgumentParser(description="Migrate is_active field to status field")
200
+ parser.add_argument("--dry-run", action="store_true", help="Preview changes without executing")
201
+ parser.add_argument("--execute", action="store_true", help="Execute the migration")
202
+ parser.add_argument("--cleanup", action="store_true", help="Remove is_active field after migration")
203
+ parser.add_argument("--analyze", action="store_true", help="Analyze current data state")
204
+
205
+ args = parser.parse_args()
206
+
207
+ if not any([args.dry_run, args.execute, args.cleanup, args.analyze]):
208
+ parser.print_help()
209
+ return
210
+
211
+ migration = StatusFieldMigration()
212
+
213
+ try:
214
+ await migration.connect()
215
+
216
+ if args.analyze or args.dry_run:
217
+ await migration.analyze_data()
218
+
219
+ if args.dry_run:
220
+ await migration.preview_migration()
221
+ logger.info("πŸ” Dry run completed. Use --execute to perform the migration.")
222
+
223
+ if args.execute:
224
+ # Analyze first
225
+ await migration.analyze_data()
226
+
227
+ # Preview
228
+ total_to_migrate = await migration.preview_migration()
229
+
230
+ if total_to_migrate == 0:
231
+ logger.info("ℹ️ No documents need migration.")
232
+ return
233
+
234
+ # Confirm execution
235
+ print(f"\n⚠️ About to migrate {total_to_migrate} documents.")
236
+ confirm = input("Continue? (yes/no): ").lower().strip()
237
+
238
+ if confirm != "yes":
239
+ logger.info("❌ Migration cancelled by user")
240
+ return
241
+
242
+ # Execute migration
243
+ migrated_count = await migration.execute_migration()
244
+
245
+ # Verify
246
+ if await migration.verify_migration():
247
+ logger.info("πŸŽ‰ Migration completed successfully!")
248
+ else:
249
+ logger.error("❌ Migration verification failed!")
250
+
251
+ if args.cleanup:
252
+ # Verify migration first
253
+ if await migration.verify_migration():
254
+ cleanup_count = await migration.cleanup_is_active_field()
255
+ logger.info(f"πŸŽ‰ Cleanup completed! Removed is_active field from {cleanup_count} documents.")
256
+ else:
257
+ logger.error("❌ Cannot cleanup: Migration verification failed!")
258
+
259
+ except Exception as e:
260
+ logger.error(f"❌ Migration failed: {e}")
261
+ raise
262
+
263
+ finally:
264
+ await migration.disconnect()
265
+
266
+
267
+ if __name__ == "__main__":
268
+ asyncio.run(main())
test_status_migration.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test script to verify status field migration works correctly.
4
+ Tests the new UserStatus enum and service methods.
5
+ """
6
+
7
+ import asyncio
8
+ import sys
9
+ from datetime import datetime
10
+ from app.system_users.models.model import SystemUserModel, UserStatus
11
+ from app.system_users.schemas.schema import CreateUserRequest, SystemUserListRequest
12
+
13
+
14
+ def test_user_status_enum():
15
+ """Test UserStatus enum values."""
16
+ print("πŸ§ͺ Testing UserStatus enum...")
17
+
18
+ # Test enum values
19
+ assert UserStatus.ACTIVE == "active"
20
+ assert UserStatus.INACTIVE == "inactive"
21
+ assert UserStatus.SUSPENDED == "suspended"
22
+ assert UserStatus.LOCKED == "locked"
23
+ assert UserStatus.PENDING_ACTIVATION == "pending_activation"
24
+
25
+ print("βœ… UserStatus enum test passed")
26
+
27
+
28
+ def test_system_user_model():
29
+ """Test SystemUserModel with status field."""
30
+ print("πŸ§ͺ Testing SystemUserModel with status field...")
31
+
32
+ # Test model creation with status
33
+ user_data = {
34
+ "user_id": "test_user_123",
35
+ "username": "testuser",
36
+ "email": "test@example.com",
37
+ "password_hash": "hashed_password",
38
+ "full_name": "Test User",
39
+ "role_id": "role_test",
40
+ "merchant_id": "merchant_123",
41
+ "merchant_type": "salon",
42
+ "status": UserStatus.ACTIVE,
43
+ "created_by": "admin",
44
+ "created_at": datetime.utcnow()
45
+ }
46
+
47
+ user = SystemUserModel(**user_data)
48
+
49
+ # Verify status field
50
+ assert user.status == UserStatus.ACTIVE
51
+ assert user.status.value == "active"
52
+
53
+ # Test different status values
54
+ user_data["status"] = UserStatus.SUSPENDED
55
+ user_suspended = SystemUserModel(**user_data)
56
+ assert user_suspended.status == UserStatus.SUSPENDED
57
+
58
+ print("βœ… SystemUserModel test passed")
59
+
60
+
61
+ def test_create_user_request():
62
+ """Test CreateUserRequest schema with status field."""
63
+ print("πŸ§ͺ Testing CreateUserRequest schema...")
64
+
65
+ # Test with default status
66
+ request_data = {
67
+ "username": "newuser",
68
+ "email": "newuser@example.com",
69
+ "merchant_id": "merchant_456",
70
+ "password": "SecurePass123",
71
+ "full_name": "New User",
72
+ "role_id": "role_user"
73
+ }
74
+
75
+ request = CreateUserRequest(**request_data)
76
+ assert request.status == UserStatus.ACTIVE # Default value
77
+
78
+ # Test with explicit status
79
+ request_data["status"] = UserStatus.PENDING_ACTIVATION
80
+ request_pending = CreateUserRequest(**request_data)
81
+ assert request_pending.status == UserStatus.PENDING_ACTIVATION
82
+
83
+ print("βœ… CreateUserRequest test passed")
84
+
85
+
86
+ def test_system_user_list_request():
87
+ """Test SystemUserListRequest with status_filter."""
88
+ print("πŸ§ͺ Testing SystemUserListRequest schema...")
89
+
90
+ # Test with status filter
91
+ list_request_data = {
92
+ "page": 1,
93
+ "page_size": 20,
94
+ "status_filter": UserStatus.ACTIVE,
95
+ "projection_list": ["user_id", "username", "email", "status"]
96
+ }
97
+
98
+ list_request = SystemUserListRequest(**list_request_data)
99
+ assert list_request.status_filter == UserStatus.ACTIVE
100
+ assert list_request.projection_list == ["user_id", "username", "email", "status"]
101
+
102
+ # Test without status filter
103
+ minimal_request = SystemUserListRequest(page=1, page_size=10)
104
+ assert minimal_request.status_filter is None
105
+ assert minimal_request.projection_list is None
106
+
107
+ print("βœ… SystemUserListRequest test passed")
108
+
109
+
110
+ def test_authentication_logic():
111
+ """Test authentication logic with status field."""
112
+ print("πŸ§ͺ Testing authentication logic...")
113
+
114
+ # Simulate authentication check
115
+ def check_user_status(status: UserStatus) -> bool:
116
+ """Simulate the authentication status check."""
117
+ return status in [UserStatus.ACTIVE]
118
+
119
+ # Test active user
120
+ assert check_user_status(UserStatus.ACTIVE) == True
121
+
122
+ # Test inactive statuses
123
+ assert check_user_status(UserStatus.INACTIVE) == False
124
+ assert check_user_status(UserStatus.SUSPENDED) == False
125
+ assert check_user_status(UserStatus.LOCKED) == False
126
+ assert check_user_status(UserStatus.PENDING_ACTIVATION) == False
127
+
128
+ print("βœ… Authentication logic test passed")
129
+
130
+
131
+ def test_projection_list_example():
132
+ """Test projection list functionality example."""
133
+ print("πŸ§ͺ Testing projection list example...")
134
+
135
+ # Simulate MongoDB projection logic
136
+ def build_projection(projection_list):
137
+ """Simulate the projection building logic."""
138
+ if not projection_list:
139
+ return None
140
+
141
+ projection_dict = {field: 1 for field in projection_list}
142
+ projection_dict["_id"] = 0 # Always exclude _id
143
+ return projection_dict
144
+
145
+ # Test with projection list
146
+ fields = ["user_id", "username", "email", "status"]
147
+ projection = build_projection(fields)
148
+ expected = {"user_id": 1, "username": 1, "email": 1, "status": 1, "_id": 0}
149
+ assert projection == expected
150
+
151
+ # Test without projection list
152
+ no_projection = build_projection(None)
153
+ assert no_projection is None
154
+
155
+ print("βœ… Projection list test passed")
156
+
157
+
158
+ def main():
159
+ """Run all tests."""
160
+ print("πŸš€ Starting status field migration tests...\n")
161
+
162
+ try:
163
+ test_user_status_enum()
164
+ test_system_user_model()
165
+ test_create_user_request()
166
+ test_system_user_list_request()
167
+ test_authentication_logic()
168
+ test_projection_list_example()
169
+
170
+ print("\nπŸŽ‰ All tests passed! Status field migration is working correctly.")
171
+ print("\nπŸ“‹ Migration Summary:")
172
+ print(" βœ… UserStatus enum working")
173
+ print(" βœ… SystemUserModel using status field")
174
+ print(" βœ… CreateUserRequest with status support")
175
+ print(" βœ… SystemUserListRequest with status_filter")
176
+ print(" βœ… Authentication logic updated")
177
+ print(" βœ… Projection list support implemented")
178
+
179
+ return 0
180
+
181
+ except Exception as e:
182
+ print(f"\n❌ Test failed: {e}")
183
+ import traceback
184
+ traceback.print_exc()
185
+ return 1
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())