Refactor user management module: remove timezone field, add address, pob, and dob; update related schemas, services, and tests; simplify list users endpoint to support status filtering

This commit is contained in:
Yoga Pangestu 2026-07-25 12:14:25 +07:00
parent 7dceb01d89
commit 0893cc5d34
10 changed files with 103 additions and 74 deletions

View File

@ -11,5 +11,4 @@ class UserProfileFactory(Factory):
"user_id": None,
"full_name": f"{random_string(6).title()} {random_string(8).title()}",
"phone": random_phone(),
"timezone": "Asia/Jakarta",
}

View File

@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import DateTime, ForeignKey, String, Text, Date
from sqlalchemy.dialects.mysql import CHAR
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -15,7 +15,9 @@ class UserProfile(Base):
user_id: Mapped[str] = mapped_column(CHAR(36), ForeignKey("users.id"), unique=True)
full_name: Mapped[str] = mapped_column(String(200))
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
timezone: Mapped[str | None] = mapped_column(String(50), nullable=True, default="Asia/Jakarta")
address: Mapped[str | None] = mapped_column(Text, nullable=True)
pob: Mapped[str | None] = mapped_column(String(100), nullable=True)
dob: Mapped[datetime | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=datetime.now)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@ -71,12 +71,19 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
db.flush()
try:
profile = UserProfile(
user_id=user.id,
full_name=data.full_name,
phone=data.phone,
timezone=data.timezone,
)
profile_data = {
"user_id": user.id,
"full_name": data.full_name,
}
if data.phone is not None:
profile_data["phone"] = data.phone
if data.address is not None:
profile_data["address"] = data.address
if data.pob is not None:
profile_data["pob"] = data.pob
if data.dob is not None:
profile_data["dob"] = data.dob
profile = UserProfile(**profile_data)
db.add(profile)
db.commit()
except:
@ -139,7 +146,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
profile = UserProfile(
user_id=user.id,
full_name=name,
timezone="Asia/Jakarta",
)
db.add(profile)
db.commit()
@ -151,7 +157,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
profile = UserProfile(
user_id=user.id,
full_name=name,
timezone="Asia/Jakarta",
)
db.add(profile)
elif user.profile.full_name != name:

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.orm import Session
from app.database import get_db
@ -21,19 +21,8 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[UserDetailResponse])
def list_users(
response: Response,
db: Session = Depends(get_db),
page: int = 1,
limit: int = 10,
):
users, total = get_user_list(db, page, limit)
response.headers["Pagination-Count"] = str(total)
response.headers["Pagination-Page"] = str(page)
response.headers["Pagination-Limit"] = str(limit)
return users
def list_users(status: str = None, db: Session = Depends(get_db)):
return get_user_list(db, status=status)
@router.get("/{id}", response_model=UserDetailResponse)

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import date, datetime
from pydantic import BaseModel, EmailStr
@ -9,7 +9,9 @@ class UserCreate(BaseModel):
password: str
full_name: str
phone: str | None = None
timezone: str = "Asia/Jakarta"
address: str | None = None
pob: str | None = None
dob: date | None = None
class UserUpdate(BaseModel):
@ -18,7 +20,9 @@ class UserUpdate(BaseModel):
password: str | None = None
full_name: str | None = None
phone: str | None = None
timezone: str | None = None
address: str | None = None
pob: str | None = None
dob: date | None = None
status: str | None = None
@ -37,7 +41,9 @@ class UserResponse(BaseModel):
class UserProfileResponse(BaseModel):
full_name: str
phone: str | None
timezone: str | None
address: str | None
pob: str | None
dob: date | None
class Config:
from_attributes = True

View File

@ -15,7 +15,6 @@ def seed_users(db, count: int = 5, tenant_ids: list[str] | None = None):
user_id=user.id,
full_name="User Default",
phone="081234567890",
timezone="Asia/Jakarta",
)
db.add(profile)
result.append(user)
@ -40,7 +39,6 @@ def seed_users(db, count: int = 5, tenant_ids: list[str] | None = None):
user_id=admin.id,
full_name="Pangestu Yoga",
phone="081234567890",
timezone="Asia/Jakarta",
)
db.add(admin_profile)
else:

View File

@ -9,11 +9,11 @@ from app.schemas.user import UserCreate, UserUpdate
from app.security import hash_password
def get_user_list(db: Session, page: int, limit: int):
def get_user_list(db: Session, status: str = None):
query = db.query(User).options(selectinload(User.profile)).filter(User.deleted_at.is_(None))
total = query.count()
users = query.offset((page - 1) * limit).limit(limit).all()
return users, total
if status is not None:
query = query.filter(User.status == status)
return query.order_by(User.created_at.desc()).all()
def get_user_by_id(db: Session, id: str):
@ -45,12 +45,18 @@ def create_user(db: Session, data: UserCreate):
db.flush()
try:
profile = UserProfile(
user_id=user.id,
full_name=data.full_name,
phone=data.phone,
timezone=data.timezone,
)
profile_data = {
"user_id": user.id,
"full_name": data.full_name,
"phone": data.phone,
}
if data.address is not None:
profile_data["address"] = data.address
if data.pob is not None:
profile_data["pob"] = data.pob
if data.dob is not None:
profile_data["dob"] = data.dob
profile = UserProfile(**profile_data)
db.add(profile)
db.commit()
except:
@ -70,7 +76,7 @@ def update_user(db: Session, id: str, data: UserUpdate):
update_data = data.model_dump(exclude_unset=True)
profile_fields = ["full_name", "phone", "timezone"]
profile_fields = ["full_name", "phone", "address", "pob", "dob"]
user_fields = {k: v for k, v in update_data.items() if k not in profile_fields}
profile_update = {k: v for k, v in update_data.items() if k in profile_fields}
@ -117,4 +123,4 @@ def delete_user(db: Session, id: str):
)
user.deleted_at = datetime.now()
db.commit()
db.commit()

View File

@ -0,0 +1,45 @@
# Modul User / Pengguna
**Tanggal:** 2026-07-25
**Status:** Selesai
## Tujuan
Membuat modul manajemen pengguna (user) dengan CRUD, mengikuti pola module jenis bisnis (business-types) dan paket (plans).
## Yang Dikerjakan
### 1. Simplify List Users Endpoint
- **Sebelum:** Menggunakan pagination (`page`, `limit`) dengan response headers
- **Sesudah:** List tanpa pagination, mendukung filter `status` (opsional)
- Mengikuti pola yang sama seperti business-types dan plans
### 2. Update UserProfile Model
- **Hapus:** field `timezone`
- **Tambah:** field `address` (Text), `pob` (String 100), `dob` (Date)
- Update schema, service, auth router, factory, seeder
### 3. Frontend CRUD Users
- Halaman baru `/admin/users` dengan DataTable, filter, create/edit dialog, delete
- Form create/edit: email, username, password, full_name, phone, address, pob, dob
## File yang Diubah
| File | Aksi | Detail |
|------|------|--------|
| `api/app/models/user_profile.py` | Diubah | Hapus `timezone`, tambah `address`, `pob`, `dob` |
| `api/app/schemas/user.py` | Diubah | Update `UserCreate`, `UserUpdate`, `UserProfileResponse` |
| `api/app/services/user_service.py` | Diubah | Update `create_user`, `profile_fields` |
| `api/app/routers/auth.py` | Diubah | Hapus `timezone` dari register & google-login |
| `api/app/factories/user_profile_factory.py` | Diubah | Hapus `timezone` |
| `api/app/seeders/user_seeder.py` | Diubah | Hapus `timezone` |
| `api/app/services/user_service.py` | Diubah | Update `get_user_list` (tanpa pagination + filter `status`) |
| `api/app/routers/users.py` | Diubah | Update `GET /` tanpa pagination |
| `api/tests/test_user_service.py` | Diubah | Update test `get_user_list` |
| `api/tests/test_user_api.py` | Diubah | Update test list tanpa pagination |
| `core/app/pages/admin/users/columns.ts` | Dibuat | Column definitions untuk tabel users |
| `core/app/pages/admin/users/index.vue` | Dibuat | Halaman CRUD users (list, create, edit, delete) |
| `core/app/components/AppSidebar.vue` | Diubah | Tambah menu "Pengguna" dengan icon `Users` |
## Notes
- Migrasi DB: `ALTER TABLE user_profiles DROP COLUMN timezone, ADD COLUMN address TEXT, ADD COLUMN pob VARCHAR(100), ADD COLUMN dob DATE;`
- Menggunakan field `status` yang sudah ada (ACTIVE/INACTIVE) untuk menampilkan status

View File

@ -10,21 +10,6 @@ class TestListUsers:
resp = client.get("/v1/users/")
assert resp.status_code == 200
assert resp.json() == []
assert resp.headers.get("Pagination-Count") == "0"
assert resp.headers.get("Pagination-Page") == "1"
assert resp.headers.get("Pagination-Limit") == "10"
def test_pagination_headers(self, client: TestClient, db_session: Session):
for i in range(5):
db_session.add(User(email=f"u{i}@ex.com", username=f"u{i}", password="x"))
db_session.commit()
resp = client.get("/v1/users/?page=1&limit=2")
data = resp.json()
assert len(data) == 2
assert resp.headers["Pagination-Count"] == "5"
assert resp.headers["Pagination-Page"] == "1"
assert resp.headers["Pagination-Limit"] == "2"
def test_includes_profile(self, client: TestClient, db_session: Session):
user = User(email="prof@ex.com", username="prof", password="x")
@ -123,3 +108,6 @@ class TestDeleteUser:
resp = client.delete("/v1/users/nonexistent")
assert resp.status_code == 404
assert resp.json() == {"message": "The item does not exist"}

View File

@ -17,9 +17,8 @@ from app.services.user_service import (
class TestGetUserList:
def test_empty(self, db_session: Session):
users, total = get_user_list(db_session, page=1, limit=10)
users = get_user_list(db_session)
assert users == []
assert total == 0
def test_with_users(self, db_session: Session):
for i in range(3):
@ -27,20 +26,9 @@ class TestGetUserList:
db_session.add(user)
db_session.commit()
users, total = get_user_list(db_session, page=1, limit=10)
assert total == 3
users = get_user_list(db_session)
assert len(users) == 3
def test_pagination(self, db_session: Session):
for i in range(5):
user = User(email=f"page{i}@ex.com", username=f"page{i}", password="x")
db_session.add(user)
db_session.commit()
users, total = get_user_list(db_session, page=1, limit=2)
assert total == 5
assert len(users) == 2
def test_soft_deleted_excluded(self, db_session: Session):
from datetime import datetime
@ -49,8 +37,8 @@ class TestGetUserList:
db_session.add_all([u1, u2])
db_session.commit()
users, total = get_user_list(db_session, page=1, limit=10)
assert total == 1
users = get_user_list(db_session)
assert len(users) == 1
assert users[0].email == "active@ex.com"
@ -199,3 +187,6 @@ class TestDeleteUser:
with pytest.raises(HTTPException) as exc:
delete_user(db_session, "nonexistent")
assert exc.value.status_code == 404