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:
2026-07-17 20:19:35 +09:00
parent d0dd530e26
commit 55918d56d1
11 changed files with 167 additions and 3 deletions
+2
View File
@@ -18,6 +18,7 @@ class HabitType(str, enum.Enum):
class HabitStatus(str, enum.Enum): class HabitStatus(str, enum.Enum):
ACTIVE = "active" ACTIVE = "active"
COMPLETED = "completed" COMPLETED = "completed"
ABANDONED = "abandoned"
class Habit(Base): class Habit(Base):
@@ -38,6 +39,7 @@ class Habit(Base):
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True) sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(nullable=True) completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
abandoned_at: Mapped[datetime | None] = mapped_column(nullable=True)
logs: Mapped[list["HabitLog"]] = relationship( logs: Mapped[list["HabitLog"]] = relationship(
back_populates="habit", cascade="all, delete-orphan", passive_deletes=True back_populates="habit", cascade="all, delete-orphan", passive_deletes=True
+6
View File
@@ -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) 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) @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)): 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) habit = _get_habit_or_404(db, habit_id, current_user.id)
+30 -3
View File
@@ -118,6 +118,8 @@ def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_
if tab == "completed": if tab == "completed":
habits = habit_service.list_habits(db, current.id, status=HabitStatus.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: else:
if tab not in ("build", "quit"): if tab not in ("build", "quit"):
tab = "build" 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") @router.post("/habits/new")
def create_habit_page( def create_habit_page(
request: Request, request: Request,
@@ -208,7 +218,7 @@ def edit_habit_page(
return HTMLResponse("입력값을 확인해주세요") return HTMLResponse("입력값을 확인해주세요")
habit_service.update_habit(db, habit, data) 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 = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}" response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response return response
@@ -230,6 +240,22 @@ def complete_habit_page(request: Request, habit_id: int, db: Session = Depends(g
return response 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") @router.post("/habits/{habit_id}/reactivate")
def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, 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) habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None: if habit is None:
return Response(status_code=404) return Response(status_code=404)
tab = _tab_for_habit(habit)
habit_service.reactivate_habit(db, habit) habit_service.reactivate_habit(db, habit)
response = Response(status_code=200) response = Response(status_code=200)
response.headers["HX-Redirect"] = "/habits?tab=completed" response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response 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) habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None: if habit is None:
return Response(status_code=404) 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) habit_service.delete_habit(db, habit)
response = Response(status_code=200) response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}" response.headers["HX-Redirect"] = f"/habits?tab={tab}"
+1
View File
@@ -56,6 +56,7 @@ class HabitOut(BaseModel):
reminder_time: time | None reminder_time: time | None
created_at: datetime created_at: datetime
completed_at: datetime | None completed_at: datetime | None
abandoned_at: datetime | None
class HabitReorderRequest(BaseModel): class HabitReorderRequest(BaseModel):
+9
View File
@@ -79,9 +79,18 @@ def complete_habit(db: Session, habit: Habit) -> Habit:
return 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: def reactivate_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.ACTIVE habit.status = HabitStatus.ACTIVE
habit.completed_at = None habit.completed_at = None
habit.abandoned_at = None
db.commit() db.commit()
db.refresh(habit) db.refresh(habit)
return habit return habit
+2
View File
@@ -8,6 +8,7 @@
<a href="/habits?tab=build" class="{{ 'active' if tab == 'build' }}">만들고 싶은 습관</a> <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=quit" class="{{ 'active' if tab == 'quit' }}">멈추고 싶은 습관</a>
<a href="/habits?tab=completed" class="{{ 'active' if tab == 'completed' }}">완료된 습관</a> <a href="/habits?tab=completed" class="{{ 'active' if tab == 'completed' }}">완료된 습관</a>
<a href="/habits?tab=abandoned" class="{{ 'active' if tab == 'abandoned' }}">포기한 습관</a>
</div> </div>
{% if tab in ("build", "quit") %} {% if tab in ("build", "quit") %}
@@ -25,6 +26,7 @@
<div class="empty-state"> <div class="empty-state">
{% if tab == "build" %}만들고 싶은 습관이 아직 없어요. 위에서 추가해보세요. {% if tab == "build" %}만들고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
{% elif tab == "quit" %}끊고 싶은 습관이 아직 없어요. 위에서 추가해보세요. {% elif tab == "quit" %}끊고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
{% elif tab == "abandoned" %}아직 포기한 습관이 없어요.
{% else %}아직 완료된 습관이 없어요.{% endif %} {% else %}아직 완료된 습관이 없어요.{% endif %}
</div> </div>
{% endif %} {% endif %}
+8
View File
@@ -41,6 +41,14 @@
hx-swap="none" hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?" hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?"
>완료 처리</button> >완료 처리</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 %} {% else %}
<button <button
type="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")
+12
View File
@@ -70,6 +70,18 @@ def test_complete_and_reactivate_habit(auth_client, db_session, test_user):
assert reactivate_res.json()["status"] == "active" 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): def test_reorder_endpoint(auth_client, db_session, test_user):
a = _make_habit(db_session, test_user.id, name="A") a = _make_habit(db_session, test_user.id, name="A")
b = _make_habit(db_session, test_user.id, name="B") b = _make_habit(db_session, test_user.id, name="B")
+24
View File
@@ -86,6 +86,30 @@ def test_complete_and_reactivate_habit(db_session, test_user):
assert reactivated.completed_at is None 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): def test_reorder_habits_applies_given_order(db_session, test_user):
a = _make_habit(db_session, test_user.id, name="A") a = _make_habit(db_session, test_user.id, name="A")
b = _make_habit(db_session, test_user.id, name="B") b = _make_habit(db_session, test_user.id, name="B")
+45
View File
@@ -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