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,
|
"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",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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}
|
||||||
|
|
||||||
|
|||||||
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/")
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user