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:
parent
7dceb01d89
commit
0893cc5d34
@ -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",
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
45
docs/history/2026-07-25-modul-user-pengguna.md
Normal file
45
docs/history/2026-07-25-modul-user-pengguna.md
Normal 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
|
||||
@ -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"}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user