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, "user_id": None,
"full_name": f"{random_string(6).title()} {random_string(8).title()}", "full_name": f"{random_string(6).title()} {random_string(8).title()}",
"phone": random_phone(), "phone": random_phone(),
"timezone": "Asia/Jakarta",
} }

View File

@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime 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.dialects.mysql import CHAR
from sqlalchemy.orm import Mapped, mapped_column, relationship 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) user_id: Mapped[str] = mapped_column(CHAR(36), ForeignKey("users.id"), unique=True)
full_name: Mapped[str] = mapped_column(String(200)) full_name: Mapped[str] = mapped_column(String(200))
phone: Mapped[str | None] = mapped_column(String(20), nullable=True) 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=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) 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() db.flush()
try: try:
profile = UserProfile( profile_data = {
user_id=user.id, "user_id": user.id,
full_name=data.full_name, "full_name": data.full_name,
phone=data.phone, }
timezone=data.timezone, 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.add(profile)
db.commit() db.commit()
except: except:
@ -139,7 +146,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
profile = UserProfile( profile = UserProfile(
user_id=user.id, user_id=user.id,
full_name=name, full_name=name,
timezone="Asia/Jakarta",
) )
db.add(profile) db.add(profile)
db.commit() db.commit()
@ -151,7 +157,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
profile = UserProfile( profile = UserProfile(
user_id=user.id, user_id=user.id,
full_name=name, full_name=name,
timezone="Asia/Jakarta",
) )
db.add(profile) db.add(profile)
elif user.profile.full_name != name: 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 sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
@ -21,19 +21,8 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[UserDetailResponse]) @router.get("/", response_model=list[UserDetailResponse])
def list_users( def list_users(status: str = None, db: Session = Depends(get_db)):
response: Response, return get_user_list(db, status=status)
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
@router.get("/{id}", response_model=UserDetailResponse) @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 from pydantic import BaseModel, EmailStr
@ -9,7 +9,9 @@ class UserCreate(BaseModel):
password: str password: str
full_name: str full_name: str
phone: str | None = None phone: str | None = None
timezone: str = "Asia/Jakarta" address: str | None = None
pob: str | None = None
dob: date | None = None
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@ -18,7 +20,9 @@ class UserUpdate(BaseModel):
password: str | None = None password: str | None = None
full_name: str | None = None full_name: str | None = None
phone: 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 status: str | None = None
@ -37,7 +41,9 @@ class UserResponse(BaseModel):
class UserProfileResponse(BaseModel): class UserProfileResponse(BaseModel):
full_name: str full_name: str
phone: str | None phone: str | None
timezone: str | None address: str | None
pob: str | None
dob: date | None
class Config: class Config:
from_attributes = True 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, user_id=user.id,
full_name="User Default", full_name="User Default",
phone="081234567890", phone="081234567890",
timezone="Asia/Jakarta",
) )
db.add(profile) db.add(profile)
result.append(user) result.append(user)
@ -40,7 +39,6 @@ def seed_users(db, count: int = 5, tenant_ids: list[str] | None = None):
user_id=admin.id, user_id=admin.id,
full_name="Pangestu Yoga", full_name="Pangestu Yoga",
phone="081234567890", phone="081234567890",
timezone="Asia/Jakarta",
) )
db.add(admin_profile) db.add(admin_profile)
else: else:

View File

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

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/") resp = client.get("/v1/users/")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == [] 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): def test_includes_profile(self, client: TestClient, db_session: Session):
user = User(email="prof@ex.com", username="prof", password="x") user = User(email="prof@ex.com", username="prof", password="x")
@ -123,3 +108,6 @@ class TestDeleteUser:
resp = client.delete("/v1/users/nonexistent") resp = client.delete("/v1/users/nonexistent")
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.json() == {"message": "The item does not exist"} assert resp.json() == {"message": "The item does not exist"}

View File

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