add abandoned status to distinguish given-up habits from completed ones
Completed and abandoned habits were both lumped under a single terminal state, making it impossible to tell "finished successfully" apart from "gave up" when reviewing habit history.
This commit is contained in:
@@ -18,6 +18,7 @@ class HabitType(str, enum.Enum):
|
||||
class HabitStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
ABANDONED = "abandoned"
|
||||
|
||||
|
||||
class Habit(Base):
|
||||
@@ -38,6 +39,7 @@ class Habit(Base):
|
||||
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
abandoned_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
logs: Mapped[list["HabitLog"]] = relationship(
|
||||
back_populates="habit", cascade="all, delete-orphan", passive_deletes=True
|
||||
|
||||
@@ -69,6 +69,12 @@ def complete_habit(habit_id: int, db: Session = Depends(get_db), current_user: U
|
||||
return habit_service.complete_habit(db, habit)
|
||||
|
||||
|
||||
@router.post("/{habit_id}/abandon", response_model=HabitOut)
|
||||
def abandon_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
return habit_service.abandon_habit(db, habit)
|
||||
|
||||
|
||||
@router.post("/{habit_id}/reactivate", response_model=HabitOut)
|
||||
def reactivate_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
|
||||
+30
-3
@@ -118,6 +118,8 @@ def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_
|
||||
|
||||
if tab == "completed":
|
||||
habits = habit_service.list_habits(db, current.id, status=HabitStatus.COMPLETED)
|
||||
elif tab == "abandoned":
|
||||
habits = habit_service.list_habits(db, current.id, status=HabitStatus.ABANDONED)
|
||||
else:
|
||||
if tab not in ("build", "quit"):
|
||||
tab = "build"
|
||||
@@ -139,6 +141,14 @@ def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_
|
||||
)
|
||||
|
||||
|
||||
def _tab_for_habit(habit) -> str:
|
||||
if habit.status == HabitStatus.COMPLETED:
|
||||
return "completed"
|
||||
if habit.status == HabitStatus.ABANDONED:
|
||||
return "abandoned"
|
||||
return habit.habit_type.value
|
||||
|
||||
|
||||
@router.post("/habits/new")
|
||||
def create_habit_page(
|
||||
request: Request,
|
||||
@@ -208,7 +218,7 @@ def edit_habit_page(
|
||||
return HTMLResponse("입력값을 확인해주세요")
|
||||
|
||||
habit_service.update_habit(db, habit, data)
|
||||
tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
|
||||
tab = _tab_for_habit(habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
return response
|
||||
@@ -230,6 +240,22 @@ def complete_habit_page(request: Request, habit_id: int, db: Session = Depends(g
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/abandon")
|
||||
def abandon_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
tab = habit.habit_type.value
|
||||
habit_service.abandon_habit(db, habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/reactivate")
|
||||
def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
@@ -239,9 +265,10 @@ def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
tab = _tab_for_habit(habit)
|
||||
habit_service.reactivate_habit(db, habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = "/habits?tab=completed"
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
return response
|
||||
|
||||
|
||||
@@ -254,7 +281,7 @@ def delete_habit_page(request: Request, habit_id: int, db: Session = Depends(get
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
|
||||
tab = _tab_for_habit(habit)
|
||||
habit_service.delete_habit(db, habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
|
||||
@@ -56,6 +56,7 @@ class HabitOut(BaseModel):
|
||||
reminder_time: time | None
|
||||
created_at: datetime
|
||||
completed_at: datetime | None
|
||||
abandoned_at: datetime | None
|
||||
|
||||
|
||||
class HabitReorderRequest(BaseModel):
|
||||
|
||||
@@ -79,9 +79,18 @@ def complete_habit(db: Session, habit: Habit) -> Habit:
|
||||
return habit
|
||||
|
||||
|
||||
def abandon_habit(db: Session, habit: Habit) -> Habit:
|
||||
habit.status = HabitStatus.ABANDONED
|
||||
habit.abandoned_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(habit)
|
||||
return habit
|
||||
|
||||
|
||||
def reactivate_habit(db: Session, habit: Habit) -> Habit:
|
||||
habit.status = HabitStatus.ACTIVE
|
||||
habit.completed_at = None
|
||||
habit.abandoned_at = None
|
||||
db.commit()
|
||||
db.refresh(habit)
|
||||
return habit
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<a href="/habits?tab=build" class="{{ 'active' if tab == 'build' }}">만들고 싶은 습관</a>
|
||||
<a href="/habits?tab=quit" class="{{ 'active' if tab == 'quit' }}">멈추고 싶은 습관</a>
|
||||
<a href="/habits?tab=completed" class="{{ 'active' if tab == 'completed' }}">완료된 습관</a>
|
||||
<a href="/habits?tab=abandoned" class="{{ 'active' if tab == 'abandoned' }}">포기한 습관</a>
|
||||
</div>
|
||||
|
||||
{% if tab in ("build", "quit") %}
|
||||
@@ -25,6 +26,7 @@
|
||||
<div class="empty-state">
|
||||
{% if tab == "build" %}만들고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
|
||||
{% elif tab == "quit" %}끊고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
|
||||
{% elif tab == "abandoned" %}아직 포기한 습관이 없어요.
|
||||
{% else %}아직 완료된 습관이 없어요.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
hx-swap="none"
|
||||
hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?"
|
||||
>완료 처리</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
hx-post="/habits/{{ habit.id }}/abandon"
|
||||
hx-target="body"
|
||||
hx-swap="none"
|
||||
hx-confirm="'{{ habit.name }}' 습관을 포기할까요?"
|
||||
>포기</button>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add abandoned status to habit
|
||||
|
||||
Revision ID: 0006_add_habit_abandoned
|
||||
Revises: 0005_add_summary_notif_log
|
||||
Create Date: 2026-07-17
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_add_habit_abandoned"
|
||||
down_revision: Union[str, None] = "0005_add_summary_notif_log"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("habit", sa.Column("abandoned_at", sa.DateTime(), nullable=True))
|
||||
op.drop_constraint("ck_habit_status", "habit", type_="check")
|
||||
op.create_check_constraint("ck_habit_status", "habit", "status in ('active','completed','abandoned')")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_habit_status", "habit", type_="check")
|
||||
op.create_check_constraint("ck_habit_status", "habit", "status in ('active','completed')")
|
||||
op.drop_column("habit", "abandoned_at")
|
||||
@@ -70,6 +70,18 @@ def test_complete_and_reactivate_habit(auth_client, db_session, test_user):
|
||||
assert reactivate_res.json()["status"] == "active"
|
||||
|
||||
|
||||
def test_abandon_and_reactivate_habit(auth_client, db_session, test_user):
|
||||
habit = _make_habit(db_session, test_user.id)
|
||||
|
||||
abandon_res = auth_client.post(f"/api/habits/{habit.id}/abandon")
|
||||
assert abandon_res.status_code == 200
|
||||
assert abandon_res.json()["status"] == "abandoned"
|
||||
|
||||
reactivate_res = auth_client.post(f"/api/habits/{habit.id}/reactivate")
|
||||
assert reactivate_res.status_code == 200
|
||||
assert reactivate_res.json()["status"] == "active"
|
||||
|
||||
|
||||
def test_reorder_endpoint(auth_client, db_session, test_user):
|
||||
a = _make_habit(db_session, test_user.id, name="A")
|
||||
b = _make_habit(db_session, test_user.id, name="B")
|
||||
|
||||
@@ -86,6 +86,30 @@ def test_complete_and_reactivate_habit(db_session, test_user):
|
||||
assert reactivated.completed_at is None
|
||||
|
||||
|
||||
def test_abandon_and_reactivate_habit(db_session, test_user):
|
||||
habit = _make_habit(db_session, test_user.id)
|
||||
|
||||
abandoned = habit_service.abandon_habit(db_session, habit)
|
||||
assert abandoned.status == HabitStatus.ABANDONED
|
||||
assert abandoned.abandoned_at is not None
|
||||
|
||||
reactivated = habit_service.reactivate_habit(db_session, habit)
|
||||
assert reactivated.status == HabitStatus.ACTIVE
|
||||
assert reactivated.abandoned_at is None
|
||||
|
||||
|
||||
def test_list_habits_filters_by_abandoned_status(db_session, test_user):
|
||||
build = _make_habit(db_session, test_user.id, name="빌드")
|
||||
quit_ = _make_habit(db_session, test_user.id, name="퀴트", habit_type=HabitType.QUIT)
|
||||
habit_service.abandon_habit(db_session, quit_)
|
||||
|
||||
active = habit_service.list_habits(db_session, test_user.id, status=HabitStatus.ACTIVE)
|
||||
assert [h.id for h in active] == [build.id]
|
||||
|
||||
abandoned = habit_service.list_habits(db_session, test_user.id, status=HabitStatus.ABANDONED)
|
||||
assert [h.id for h in abandoned] == [quit_.id]
|
||||
|
||||
|
||||
def test_reorder_habits_applies_given_order(db_session, test_user):
|
||||
a = _make_habit(db_session, test_user.id, name="A")
|
||||
b = _make_habit(db_session, test_user.id, name="B")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
|
||||
from app.schemas.habit import HabitCreate
|
||||
from app.services import habit_service
|
||||
|
||||
|
||||
def _make_habit(db_session, user_id, name="테스트 습관", **overrides):
|
||||
data = HabitCreate(
|
||||
name=name,
|
||||
habit_type=overrides.pop("habit_type", HabitType.BUILD),
|
||||
weekdays_mask=overrides.pop("weekdays_mask", ALL_WEEKDAYS_MASK),
|
||||
)
|
||||
return habit_service.create_habit(db_session, user_id, data)
|
||||
|
||||
|
||||
def test_abandon_habit_page_moves_habit_to_abandoned_tab(auth_client, db_session, test_user):
|
||||
habit = _make_habit(db_session, test_user.id, name="포기할 습관")
|
||||
|
||||
response = auth_client.post(f"/habits/{habit.id}/abandon")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["hx-redirect"] == "/habits?tab=build"
|
||||
|
||||
db_session.refresh(habit)
|
||||
assert habit.status == HabitStatus.ABANDONED
|
||||
|
||||
abandoned_tab = auth_client.get("/habits?tab=abandoned")
|
||||
assert "포기할 습관" in abandoned_tab.text
|
||||
|
||||
|
||||
def test_abandon_other_users_habit_returns_404(auth_client, db_session, other_user):
|
||||
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
|
||||
|
||||
response = auth_client.post(f"/habits/{others_habit.id}/abandon")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_reactivate_habit_page_redirects_to_abandoned_tab(auth_client, db_session, test_user):
|
||||
habit = _make_habit(db_session, test_user.id, name="다시 시작할 습관")
|
||||
habit_service.abandon_habit(db_session, habit)
|
||||
|
||||
response = auth_client.post(f"/habits/{habit.id}/reactivate")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["hx-redirect"] == "/habits?tab=abandoned"
|
||||
|
||||
db_session.refresh(habit)
|
||||
assert habit.status == HabitStatus.ACTIVE
|
||||
Reference in New Issue
Block a user