할 일, 완료 상태로 변경
할 일, 완료 상태로 변경
(1) TodoService에 할 일 완료 처리 API 구현
할 일이 완료됐는지 여부는 Todo 모델의 completed_at 필드 값이 None인지 일시정보인지로 구분해요. 그러니 TodoService에 완료일시를 반영하는 API를 구현할게요.
챙겨볼 부분이 세 개 있어요.
먼저 권한 검사하는 부분을 볼게요. 할 일이 누구의 것인지는 할 일 자체에 없는 정보이고, 할 일이 속한 할 일 그룹에 있어요. 이르 감안해 할 일의 소유 여부를 비교하고, 자신의 할 일이 아니면 권한이 없다는 예외 오류를 일으켜요. 이 예외는 새로 추가한 거예요.
두 번째는 완료일시 값이 있는 경우, 할 일을 취소 처리하는 일시를 빼는 부분이예요. 이 부분은 기획 정책 차원에 따라 달라지는 구현인데, 저는 할 일을 완료한 상태와 취소한 상태가 양립하지 않는다고 보고 이렇게 구현했어요.
세 번째는 self.session.refresh() 부분이예요. 이 부분은 SQLAlchemy의 특성을 이해해야 하죠. 먼저 Todo 모델을 보죠.
created_at과 updated_at 모델 필드의 값은 Python 영역이 아니라 서버, 즉 데이터베이스 영역에서 관리돼요. 데이터가 입력되거나(insert) 갱신되면(update) 서버 기본값으로, 즉 데이터베이스에서 일시정보를 넣도록 되어 있죠. 그런데 self.session.commit()은 데이터베이스에 반영하는 작업이잖아요. 그래서 updated_at 값이 데이터베이스 상에서는 갱신돼요.
하지만 Python 영역에서는 어떤 값이 있는지 알 지 못해요. 실제 갱신(update) SQL 질의를 보내는 경우에도 질의 여부만 알 수 있지, 질의를 수행한 데이터(레코드)가 어떤 상태인지는 알 수 없어요. 가장 최신 상태를 알려면 데이터베이스에서 데이터를 가져와야 하죠. 그렇지 않은 상태에서 Todo 모델의 updated_at 모델 필드의 값에 접근하려 하면 다음과 같은 오류가 발생해요.
문제는 오류 내용만으로는 문제 원인을 바로 파악하기 어렵다는 점에 있어요. 오류 내용은 단순하게 말하면 비동기(async) IO 관련된 내용이거든요. 이는 updated_at 정보를 가져오려 하는데, SQLAlchemy 세션이 관리하는 객체 상태에 따르면 데이터가 변경되었다는 걸 알고 있고 따라서 데이터베이스에서 최신 정보를 가져오려 하죠. 하지만 Todo 모델(SQLAlchemy ORM 모델)에 연결되어 있는 세션은 비동기 세션(AsyncSession)이고, 이를 await으로 가져오지 않고 그냥 가져오려 하기 때문에 이러한 문제가 발생했어요.
근데 updated_at 값을 어디에서 접근하려 하냐고요? 바로 템플릿이예요.
todo-list.jinja2 템플릿 파일에서 각 할 일의 정보를 이와 같이 출력하도록 하고 있거든요.
이 문제를 피하는 가장 단순한 방법은 직접 Todo 데이터를 최신 상태로 갱신하는 거예요. 그래서 refresh()를 한 거죠.
테스트 코드 작성
TodoService.set_completed_at()에 대해 두 가지 테스트를 할게요. 먼저 정상 동작하는 테스트예요.
완료일시가 없는, 즉 완료처리 되지 않은 상태로 바꾸는 것과 완료 일시정보를 사용해 완료 상태로 바꾸는 것을 테스트 합니다.
todo.completed_at is None 또는 todo.completed_at is not None 보다는 todo.is_completed가 더 가독성이 좋아서 is_completed 프로퍼티를 추가했어요.
두 번째 테스트는 남의 할 일을 완료처리 하지 못하는지 확인하는 거예요.
남의 할 일을 조작하려 하면 PermissionDeniedError 예외 오류가 발생하니 Pytest에서 이를 검출하는 걸로 동작을 확인합니다.
여기까지 진행한 코드 커밋 : a44f606
개별 할 일 템플릿 분리
현재는 개별 할 일을 todo-list.jinja2 템플릿에서 직접 출력하는데요. 이를 별도 템플릿으로 분리할게요. 이유는 뒤에서 설명해드릴게요. 🙂
pudding_todo/apps/todo/templates 디렉터리에 components 디렉터리를 만들고, 이 디렉터리 안에 todo-detail.jinja2 템플릿을 작성합니다.
todo-list.jinja2 템플릿 안에 있던 내용을 옮겼는데, {% macro render(todo, url_for) %}와 {% endmacro %}이 추가됐어요. 이는 Jinja2의 Macro 기능인데요. 템플릿 안에서 사용하는 함수라고 할 수 있어요.
Jinja2의 매크로 사용할 때 주의할 점은 매크로를 호출하는 템플릿에 있는 템플릿 컨텍스트(Context)가 매크로 내부로 공유되지 않는다는 점이예요. 그래서 인자로 사용할 템플릿 컨텍스트를 전달해야 하죠. Jinja2의 include 는 include 되는 템플릿에 include하는 템플릿의 컨텍스트가 공유되는 것과 다르죠. 번거로울지도 모르지만, 템플릿 안에 사용되는 템플릿 컨텍스트가 명시되기 때문에 템플릿 상속 구조가 복잡한 경우에는 디버깅하기 더 좋아요.
매크로 인자로 넘겨받은 url_for는 Request 객체에 있는 url_for인데, Jinja2 템플릿 컨텍스트로 자동 전달되어 우리는 템플릿 안에서 url_for를 사용하지만, 매크로 안에서는 템플릿 컨텍스트를 공유받지 않기 때문에 직접 인자로 전달해야 해요.
이제 todo-list.jinja2 템플릿에 components/todo-detail.jinja2 템플릿을 반영할게요.
여기까지 진행한 코드 커밋 : 22571e8
완료 일시 설정하는 API 구현
할 일을 완료 처리하는 HTTP API를 구현할 차례예요. 코드부터 볼까요?
지정한 할 일을 완료처리하는 종단점 함수는 크게 4단계 동작을 수행해요.
-
지정한 할 일을 가져온다.
-
완료 처리한 현재 일시정보 객체를 만든다.
-
지정한 할 일의 완료일시를 설정한다.
-
변경한 할 일을 서버측에서 렌더링하여 HTML로 반환한다.
구현 자체는 그동안 해온 것에서 특별히 다를 게 없어요. count_todo_group_todos() 종단점 함수와 거의 비슷하죠.
이 HTTP API를 사용하도록 템플릿에 코드를 작성할게요. pudding_todo/apps/todo/templates/components/todo-detail.jinja2 템플릿을 수정합니다.
개별 할 일을 출력하는 내용 전체를 감싸는 HTML 태그에 id 속성을 추가했어요(<div id="todo-detail-{{ todo.id }}">). 이건 htmx 처리에 사용하는데요. 완료하기 버튼을 누르면 이 HTML 태그 영역을 서버로부터 응답 받은 HTML 전체를 이 div 태그에 치환해 넣어요. 치환하는 방식을 hx-swap 속성의 값으로 innerHTML로 지정하고, 치환할 대상은 hx-target 속성의 값으로 #todo-detail-{{ todo.id }}를 지정한 거죠. 완료하기 버튼을 누르면 호출할 HTTP API는 hx-post 속성으로 지정했는데, 이는 HTTP POST 방식으로 호출하는 거예요.
완료 일시 해제하는 API 구현
완료를 취소하는, 즉 완료일시를 해제하는 HTTP API를 구현하는 건 간단해요. TodoService의 set_completed_at() API를 호출할 때 일시정보를 전달하지 않는다는 점, 이 종단점 함수는 HTTP DELETE 방식으로 후찰한다는 점만 다르죠.
이 HTTP API를 호출하는 htmx 코드를 components/todo-detail.jinja2에 추가합니다.
완료 상태로 바꾸는 부분과 거의 동일하죠?
마지막으로 partial/todo-detail.jinja2 템플릿을 작성하면 돼요. 이 템플릿은 완료일시 설정 또는 해제하는 HTTP API 종단점 함수가 사용하는데요. components/todo-detail.jinja2 템플릿은 Jinja2 매크로이지 템플릿 그 자체를 출력할 수 없어요. 그래서 이 매크로를 호출해줄 템플릿이 필요해서 partial/todo-detail.jinja2를 만드는 거죠.
할 일, 완료 설정/해제 HTTP API 테스트 작성
할 일을 완료 처리하는 HTTP API에 대한 테스트를 먼저 작성할게요.
여태껏 작성해온 테스트 코드와 그리 다르지 않은데, 처음보는 코드가 한 줄 추가됐어요.
htmx 요청은 HTTP 헤더에 HX-Request 키, 그리고 "true" 값이 있는지 여부로 구분해요. 우리가 사용하고 있는 FastHX 패키지도 이 헤더 정보로 htmx 요청인지를 판정하죠.
set_todo_to_completed과 unset_todo_to_completed 종단점 함수에 @tpl.hx("partial/todo-detail.jinja2", no_data=True)와 같이 no_data 인자를 설정했기 때문에 htmx 요청에 대해서만 응답하죠. htmx 요청이 아니면 HTTP Status 400 응답을 하고요.
할 일의 완료 해제에 대한 테스트도 작성할게요. 거의 비슷한 코드지요.
여기까지 진행한 코드 커밋 : b5df701