기능
프로필
휴지통
추가 기능
휴지통 비우기
- 30일 보관
- 삭제후에 30일이 경과되면 주기적으로 영구삭제 진행
휴지통 일괄 삭제
service
def get_trashed_posts(*, user: User) -> QuerySet[Post]:
"""사용자의 삭제된 게시글(휴지통) 목록을 조회합니다."""
return Post.objects.filter(
user=user,
deleted_at__isnull=False
).order_by("-deleted_at")
def get_trashed_post_detail(*, post_id: int, user: User) -> Post:
"""휴지통 내 특정 게시글의 상세 내용을 조회합니다."""
post = Post.objects.filter(
id=post_id,
user=user,
deleted_at__isnull=False
).first()
if not post:
raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)
return post
def restore_trashed_post(*, post_id: int, user: User) -> Post:
"""휴지통에 있는 게시글을 원래 상태로 복구합니다."""
post = get_trashed_post_detail(post_id=post_id, user=user)
post.deleted_at = None
post.save(update_fields=["deleted_at"])
return post
def hard_delete_post(*, post_id: int, user: User) -> None:
"""휴지통에 있는 게시글을 DB에서 영구 삭제합니다."""
post = get_trashed_post_detail(post_id=post_id, user=user)
post.delete()
view
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from drf_spectacular.utils import extend_schema
from typing import cast
from apps.post.services.trash.post_trash_service import get_trashed_posts, get_trashed_post_detail, \
restore_trashed_post, hard_delete_post
from apps.user.models import User
from apps.post.serializers.post_list import PostListSerializer
from apps.post.serializers.post_detail import PostDetailSerializer
from apps.core.pagination import PostPageNumberPagination
class TrashListAPIView(APIView):
"""휴지통 목록 조회를 담당합니다."""
permission_classes = [IsAuthenticated]
pagination_class = PostPageNumberPagination
@extend_schema(tags=["휴지통"], summary="휴지통 목록 조회")
def get(self, request: Request):
user = cast(User, request.user)
posts = get_trashed_posts(user=user)
paginator = self.pagination_class()
page = paginator.paginate_queryset(posts, request, view=self)
if page is not None:
return paginator.get_paginated_response(PostListSerializer(page, many=True).data)
return Response(PostListSerializer(posts, many=True).data)
class TrashDetailAPIView(APIView):
"""휴지통 내 게시글 상세 확인, 복구, 영구 삭제를 담당합니다."""
permission_classes = [IsAuthenticated]
@extend_schema(tags=["휴지통"], summary="휴지통 게시글 상세 조회")
def get(self, request: Request, post_id: int):
post = get_trashed_post_detail(post_id=post_id, user=cast(User, request.user))
return Response(PostDetailSerializer(post, context={"request": request}).data)
@extend_schema(tags=["휴지통"], summary="휴지통 게시글 복구")
def patch(self, request: Request, post_id: int):
restore_trashed_post(post_id=post_id, user=cast(User, request.user)
return Response({"message": "게시글이 성공적으로 복구되었습니다."}, status=status.HTTP_200_OK)
@extend_schema(tags=["휴지통"], summary="휴지통 게시글 영구 삭제")
def delete(self, request: Request, post_id: int):
hard_delete_post(post_id=post_id, user=cast(User, request.user))
return Response(status=status.HTTP_204_NO_CONTENT)





휴지통에 있는 포스트 내용 미리보기
제목을 클릭 가능한 링크로 변경하기
fetchTrashedPosts 함수 안에서 container.innerHTML = posts.map(...) 부분을 찾아
- 제목이 들어가는
<td> 태그 안의 코드를 아래처럼 <a> 태그로 수정해
// 기존 코드: <div class="fw-bold text-dark">${post.title}</div>
// 👇 아래 코드로 변경하세요!
<td class="ps-4">
<a href="#" onclick="event.preventDefault(); showTrashDetail(${post.id})" class="fw-bold text-dark text-decoration-none" style="cursor: pointer;">
${post.title} <i class="bi bi-box-arrow-up-right small text-muted ms-1"></i>
</a>
</td>
화면에 모달(미리보기 창) 뼈대 추가하기
- {% block content %} 내부의 제일 하단
- (즉, {% endblock %} 바로 위)에 아래의 부트스트랩 모달 HTML을 추가
<div class="modal fade" id="trashDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-bottom-0 pb-0 pt-4 px-4">
<h5 class="modal-title fw-bold text-success" id="modalTitle">불러오는 중...</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div id="modalDate" class="text-muted small mb-4 pb-2 border-bottom"></div>
<div id="modalContent" class="text-dark" style="white-space: pre-wrap; line-height: 1.8;">내용을 불러오는 중입니다...</div>
</div>
<div class="modal-footer border-top-0 pt-0 pb-4 px-4">
<button type="button" class="btn btn-secondary rounded-pill px-4" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
클릭 시 모달을 띄우는 자바스크립트 함수 추가하기
- {% block extra_js %} 안쪽(기존 함수들 아래)에
- 모달을 띄우고 데이터를 불러오는 showTrashDetail 함수를 추가
/**
* 휴지통 게시글 제목을 클릭했을 때 모달을 띄워 상세 내용을 보여주는 함수입니다.
*/
async function showTrashDetail(postId) {
// 1. 부트스트랩 모달 객체를 가져와서 화면에 띄웁니다.
const modalElement = document.getElementById('trashDetailModal');
const modal = new bootstrap.Modal(modalElement);
// 2. 데이터를 불러오기 전 로딩 상태로 텍스트를 초기화합니다.
document.getElementById('modalTitle').innerText = '불러오는 중...';
document.getElementById('modalDate').innerText = '';
document.getElementById('modalContent').innerHTML = '<div class="text-center py-5"><div class="spinner-border text-success" role="status"></div></div>';
modal.show();
try {
const token = localStorage.getItem('access_token');
// 3. 백엔드 휴지통 상세 조회 API를 호출합니다. (이미 잘 만들어두신 TrashManageAPIView의 get 활용)
const response = await fetch(`/api/v1/post/my/trash/${postId}/`, {
headers: { 'Authorization': 'Bearer ' + token }
});
if (response.ok) {
const data = await response.json();
// 4. 받아온 데이터를 모달 창에 채워 넣습니다.
document.getElementById('modalTitle').innerText = data.title;
document.getElementById('modalDate').innerText = `삭제일: ${new Date(data.created_at).toLocaleDateString()}`;
// 본문 내용 삽입 (휴지통이므로 에디터 없이 마크다운 원문을 텍스트로 보여줍니다)
document.getElementById('modalContent').innerText = data.content || "내용이 없습니다.";
} else {
document.getElementById('modalContent').innerHTML = '<span class="text-danger">내용을 불러오지 못했습니다.</span>';
}
} catch (error) {
console.error('Error:', error);
document.getElementById('modalContent').innerHTML = '<span class="text-danger">서버 통신 오류가 발생했습니다.</span>';
}
}
태그 필터링
- GET /posts/?tag=태그이름 형식의 Query Parameter를 받아 처리
service
def get_global_posts(series_id: int | None = None) -> QuerySet[Post]<:
"""
모든 사용자의 공개된 포스트 목록을 가져옵니다. (전체 피드 및 시리즈 목차용)
"""
qs = Post.objects.filter(
is_temp=False,
visibility=Post.Visibility.PUBLIC,
deleted_at__isnull=True,
)
if series_id:
qs = qs.filter(series_id=series_id)
return (
qs.select_related("user")
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_global_posts(series_id: int | None = None, tag_name: str | None = None) -> QuerySet[Post]:
"""전체 피드 및 시리즈 목차, 태그 필터링용 포스트 목록을 가져옵니다."""
qs = Post.objects.filter(
is_temp=False,
visibility=Post.Visibility.PUBLIC,
deleted_at__isnull=True,
)
if series_id:
qs = qs.filter(series_id=series_id)
if tag_name:
qs = qs.filter(tags__name=tag_name)
return (
qs.select_related("user")
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
def get_my_published_posts(
*, user: User, series_id: int | None = None
) -> QuerySet[Post]:
"""
내가 작성한 글 중 공개된(발행된) 글만 가져옵니다. (내 블로그용)
"""
qs = Post.objects.filter(
user=user,
is_temp=False,
deleted_at__isnull=True,
)
if series_id:
qs = qs.filter(series_id=series_id)
return (
qs.select_related("user")
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_my_published_posts(
*, user: User, series_id: int | None = None, tag_name: str | None = None
) -> QuerySet[Post]:
"""내가 작성한 발행 글 중 조건에 맞는 글만 가져옵니다."""
qs = Post.objects.filter(
user=user,
is_temp=False,
deleted_at__isnull=True,
)
if series_id:
qs = qs.filter(series_id=series_id)
if tag_name:
qs = qs.filter(tags__name=tag_name)
return (
qs.select_related("user")
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
view
- 클라이언트가 전달한 URL 파라미터(?tag=...)를 읽어서 Service 레이어에 넘겨줌
def get(self, request: Request):
series_id_str = request.query_params.get("series")
series_id = (
int(series_id_str) if series_id_str and series_id_str.isdigit() else None
)
posts = get_global_posts(series_id=series_id)
paginator = self.pagination_class()
page = paginator.paginate_queryset(posts, request, view=self)
if page is not None:
serializer = PostListSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
return Response(PostListSerializer(posts, many=True).data)
——————————————————————————————————————[비교]—————————————————————————————————————————
def get(self, request: Request):
series_id_str = request.query_params.get("series")
series_id = int(series_id_str) if series_id_str and series_id_str.isdigit() else None
tag_name = request.query_params.get("tag")
posts = get_global_posts(series_id=series_id, tag_name=tag_name)
paginator = self.pagination_class()
page = paginator.paginate_queryset(posts, request, view=self)
if page is not None:
serializer = PostListSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
return Response(PostListSerializer(posts, many=True).data)
def get(self, request: Request):
user = cast(User, request.user)
series_id_str = request.query_params.get("series")
series_id = (
int(series_id_str) if series_id_str and series_id_str.isdigit() else None
)
posts = get_my_published_posts(user=user, series_id=series_id)
paginator = self.pagination_class()
page = paginator.paginate_queryset(posts, request, view=self)
if page is not None:
return paginator.get_paginated_response(
PostListSerializer(page, many=True).data
)
return Response(PostListSerializer(posts, many=True).data)
——————————————————————————————————————[비교]—————————————————————————————————————————
def get(self, request: Request):
user = cast(User, request.user)
series_id_str = request.query_params.get("series")
series_id = int(series_id_str) if series_id_str and series_id_str.isdigit() else None
tag_name = request.query_params.get("tag")
posts = get_my_published_posts(user=user, series_id=series_id, tag_name=tag_name)
paginator = self.pagination_class()
page = paginator.paginate_queryset(posts, request, view=self)
if page is not None:
return paginator.get_paginated_response(
PostListSerializer(page, many=True).data
)
return Response(PostListSerializer(posts, many=True).data)
홈페이지 태그
태그 개수 카운트
- 현재 전체 블로그 글을 기준으로 태그개수를 카운트함
- 로그인한 유저가 작성한 글을 기준으로 태그 개수 파악
service
def get_tags_with_post_counts() -> QuerySet[Tag]:
"""태그 목록과 각 태그별 유효한 게시글 수를 반환하는 서비스 로직입니다."""
valid_post_count = Count(
"posts",
filter=Q(
posts__is_temp=False,
posts__deleted_at__isnull=True,
posts__visibility=Post.Visibility.PUBLIC,
),
distinct=True,
)
return (
Tag.objects.annotate(
post_count=valid_post_count
)
.filter(post_count__gt=0)
.order_by(
"-post_count", "name"
)
)
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_tags_with_post_counts(user=None) -> QuerySet[Tag]:
"""태그 목록과 각 태그별 유효한 게시글 수를 반환하는 서비스 로직입니다."""
filter_conditions = Q(
posts__is_temp=False,
posts__deleted_at__isnull=True,
posts__visibility=Post.Visibility.PUBLIC
)
if user and user.is_authenticated:
filter_conditions &= Q(posts__author=user)
valid_post_count = Count(
"posts",
filter=filter_conditions,
distinct=True,
)
return (
Tag.objects.annotate(
post_count=valid_post_count
)
.filter(post_count__gt=0)
.order_by(
"-post_count", "name"
)
)
view
class TagListAPIView(APIView):
"""홈 화면 등에 쓰일 전체 태그 목록 및 통계를 제공합니다."""
permission_classes = [AllowAny]
@extend_schema(tags=["태그"], summary="전체 태그 및 게시글 갯수 통계 조회")
def get(self, request):
tags = get_tags_with_post_counts()
serializer = TagStatSerializer(tags, many=True)
return Response(serializer.data)
——————————————————————————————————————[비교]—————————————————————————————————————————
class TagListAPIView(APIView):
permission_classes = [AllowAny]
@extend_schema(tags=["태그"], summary="전체 태그 및 게시글 갯수 통계 조회")
def get(self, request):
tags = get_tags_with_post_counts()
serializer = TagStatSerializer(tags, many=True)
return Response(serializer.data)
class MyTagListAPIView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(tags=["태그"], summary="내가 작성한 글의 태그 통계 조회")
def get(self, request):
tags = get_tags_with_post_counts(user=request.user)
serializer = TagStatSerializer(tags, many=True)
return Response(serializer.data)