From e52a5dc0d833c91a38a158520f0bdfba0ac7dfc3 Mon Sep 17 00:00:00 2001 From: jungmir Date: Wed, 9 Jul 2025 01:03:28 +0900 Subject: [PATCH 1/2] Feat: Add visibility and deletion flags to models and update admin interfaces --- src/activity_gallery/admin.py | 8 ++- ...eted_activityaction_is_visible_and_more.py | 67 +++++++++++++++++++ src/activity_gallery/models.py | 20 +++--- src/activity_gallery/views.py | 8 ++- src/core/mixins/models.py | 19 +++++- src/faq/admin.py | 4 +- .../0008_alter_faq_options_faq_is_visible.py | 25 +++++++ src/faq/models.py | 7 +- src/merchandise/admin.py | 5 +- ...ndise_is_deleted_merchandise_is_visible.py | 22 ++++++ src/merchandise/models.py | 4 +- src/notice/admin.py | 7 +- .../migrations/0007_notice_is_visible.py | 17 +++++ src/notice/models.py | 1 + 14 files changed, 191 insertions(+), 23 deletions(-) create mode 100644 src/activity_gallery/migrations/0009_activityaction_is_deleted_activityaction_is_visible_and_more.py create mode 100644 src/faq/migrations/0008_alter_faq_options_faq_is_visible.py create mode 100644 src/merchandise/migrations/0006_merchandise_is_deleted_merchandise_is_visible.py create mode 100644 src/notice/migrations/0007_notice_is_visible.py diff --git a/src/activity_gallery/admin.py b/src/activity_gallery/admin.py index 6a06f8e..f673811 100644 --- a/src/activity_gallery/admin.py +++ b/src/activity_gallery/admin.py @@ -49,9 +49,11 @@ def image_preview(self, obj): class ActivityActionAdmin(admin.ModelAdmin): """커뮤니티 활동 관리자 페이지""" - list_display = ("id", "title", "content_preview") + list_display = ("id", "title", "content_preview", "is_visible") + list_editable = ("is_visible",) search_fields = ("title", "content") inlines = [ActivityTagInline, ActionPhotoInline] # 태그 & 사진 추가 가능 + exclude = ("is_deleted",) def content_preview(self, obj): """내용이 길 경우 일부만 미리보기""" @@ -69,8 +71,10 @@ def delete_queryset(self, request, queryset): class ActivityHistoryAdmin(admin.ModelAdmin): """활동 히스토리 관리자 페이지""" - list_display = ("title", "activity_date") + list_display = ("title", "activity_date", "is_visible") + list_editable = ("is_visible",) search_fields = ("title", "content", "activity_date") + exclude = ("is_deleted",) def content_preview(self, obj): """내용이 길 경우 일부만 미리보기""" diff --git a/src/activity_gallery/migrations/0009_activityaction_is_deleted_activityaction_is_visible_and_more.py b/src/activity_gallery/migrations/0009_activityaction_is_deleted_activityaction_is_visible_and_more.py new file mode 100644 index 0000000..adb7c48 --- /dev/null +++ b/src/activity_gallery/migrations/0009_activityaction_is_deleted_activityaction_is_visible_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.3 on 2025-07-08 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity_gallery", "0008_activityhistory"), + ] + + operations = [ + migrations.AddField( + model_name="activityaction", + name="is_deleted", + field=models.BooleanField(default=False, verbose_name="숨김 여부"), + ), + migrations.AddField( + model_name="activityaction", + name="is_visible", + field=models.BooleanField(default=True, verbose_name="활동 공개 여부"), + ), + migrations.AddField( + model_name="activityhistory", + name="is_visible", + field=models.BooleanField(default=True, verbose_name="히스토리 공개 여부"), + ), + migrations.AlterField( + model_name="actionphoto", + name="image", + field=models.ImageField(upload_to="activity/action-photo/", verbose_name="활동 사진"), + ), + migrations.AlterField( + model_name="activityaction", + name="content", + field=models.TextField(verbose_name="커뮤니티 활동 내용"), + ), + migrations.AlterField( + model_name="activityhistory", + name="content", + field=models.TextField(verbose_name="히스토리 내용"), + ), + migrations.AlterField( + model_name="activityhistory", + name="created_at", + field=models.DateTimeField(auto_now_add=True, verbose_name="생성일시"), + ), + migrations.AlterField( + model_name="activityhistory", + name="thumbnail", + field=models.ImageField( + blank=True, + null=True, + upload_to="activity/history/thumbnail/", + verbose_name="히스토리 썸네일", + ), + ), + migrations.AlterField( + model_name="activityhistory", + name="title", + field=models.CharField(max_length=255, verbose_name="히스토리 이름"), + ), + migrations.AlterField( + model_name="activityhistory", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="수정일시"), + ), + ] diff --git a/src/activity_gallery/models.py b/src/activity_gallery/models.py index ee6feca..e6ddbf2 100644 --- a/src/activity_gallery/models.py +++ b/src/activity_gallery/models.py @@ -5,15 +5,16 @@ from public.tag_models import ActivityTag, Tag -class ActivityAction(MultiImageFieldMixin): +class ActivityAction(MultiImageFieldMixin, SoftDeleteModel): """ ### 커뮤니티 활동 모델 """ title = models.CharField(max_length=20, verbose_name="활동명") thumbnail = models.ImageField("썸네일 이미지", upload_to="activity/thumbnail/") - content = models.TextField(verbose_name="내용") + content = models.TextField("커뮤니티 활동 내용") tags = models.ManyToManyField(Tag, through=ActivityTag, related_name="actions") + is_visible = models.BooleanField("활동 공개 여부", default=True) # MultiImageFieldMixin에서 clean, delete를 위해 필요한 정보 작성 image_field_names = ["thumbnail"] @@ -33,7 +34,7 @@ class ActionPhoto(MultiImageFieldMixin): activity_action = models.ForeignKey( ActivityAction, on_delete=models.CASCADE, related_name="photos", null=True, blank=True ) - image = models.ImageField(upload_to="activity/action-photo/") + image = models.ImageField("활동 사진", upload_to="activity/action-photo/") # MultiImageFieldMixin에서 clean, delete를 위해 필요한 정보 작성 image_field_names = ["image"] @@ -43,14 +44,15 @@ def __str__(self): class ActivityHistory(SoftDeleteModel): - title = models.CharField(max_length=255, verbose_name="활동명") - content = models.TextField(verbose_name="내용") + title = models.CharField("히스토리 이름", max_length=255) + content = models.TextField("히스토리 내용") thumbnail = models.ImageField( - "활동 썸네일 이미지", upload_to="activity/history/thumbnail/", null=True, blank=True + "히스토리 썸네일", upload_to="activity/history/thumbnail/", null=True, blank=True ) - activity_date = models.DateField(verbose_name="활동 날짜") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일") - updated_at = models.DateTimeField(auto_now=True, verbose_name="수정일") + activity_date = models.DateField("활동 날짜") + is_visible = models.BooleanField("히스토리 공개 여부", default=True) + created_at = models.DateTimeField("생성일시", auto_now_add=True) + updated_at = models.DateTimeField("수정일시", auto_now=True) class Meta: verbose_name = "활동 히스토리" diff --git a/src/activity_gallery/views.py b/src/activity_gallery/views.py index 205eb4a..5777810 100644 --- a/src/activity_gallery/views.py +++ b/src/activity_gallery/views.py @@ -22,7 +22,9 @@ class ActivityActionViewSet(GenericViewSet, ListModelMixin, RetrieveModelMixin): def get_queryset(self): """공통 쿼리셋""" - return ActivityAction.objects.prefetch_related("photos", "activity_tags__tag").all() + return ActivityAction.objects.prefetch_related("photos", "activity_tags__tag").filter( + is_visible=True + ) def get_serializer_class(self): """요청 방식에 따라 적절한 Serializer 반환""" @@ -49,9 +51,11 @@ def retrieve(self, request, *args, **kwargs) -> BaseResponse: class ActivityHistoryViewSet(GenericViewSet, ListModelMixin): """활동 히스토리 API 뷰""" - queryset = ActivityHistory.objects.filter(is_deleted=False).all() serializer_class = ActivityHistorySerializer + def get_queryset(self): + return ActivityHistory.objects.filter(is_visible=True).all() + def list(self, request, *args, **kwargs) -> BaseResponse: """활동 히스토리 목록 조회 API""" queryset = self.get_queryset() diff --git a/src/core/mixins/models.py b/src/core/mixins/models.py index fbb9485..f8fb5aa 100644 --- a/src/core/mixins/models.py +++ b/src/core/mixins/models.py @@ -3,13 +3,30 @@ from django.db import models +class SoftDeleteObjectManager(models.Manager): + """ + Custom manager to filter out soft-deleted objects by default. + """ + + def get_queryset(self): + return super().get_queryset().filter(is_deleted=False) + + def all_with_deleted(self): + return super().get_queryset() # Returns all objects, including soft-deleted ones + + class SoftDeleteModel(models.Model): is_deleted = models.BooleanField("숨김 여부", default=False) + objects = SoftDeleteObjectManager() + all_objects = models.Manager() # Manager to access all objects, including soft-deleted + class Meta: abstract = True - def delete(self, force_delete: bool = False, using: Any = None, keep_parents: bool = False): + def delete( + self, using: Any = None, keep_parents: bool = False, force_delete: bool = False + ) -> None: if force_delete: return super().delete(using=using, keep_parents=keep_parents) self.is_deleted = True diff --git a/src/faq/admin.py b/src/faq/admin.py index d31b193..5ca3019 100644 --- a/src/faq/admin.py +++ b/src/faq/admin.py @@ -7,4 +7,6 @@ @admin.register(FAQ) class FAQAdmin(admin.ModelAdmin): - list_display = ("question", "answer") + list_display = ("question", "answer", "is_visible") + list_editable = ("is_visible",) + exclude = ("is_deleted",) diff --git a/src/faq/migrations/0008_alter_faq_options_faq_is_visible.py b/src/faq/migrations/0008_alter_faq_options_faq_is_visible.py new file mode 100644 index 0000000..3cc4bc0 --- /dev/null +++ b/src/faq/migrations/0008_alter_faq_options_faq_is_visible.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-07-08 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("faq", "0007_alter_faq_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="faq", + options={ + "ordering": ["-created_at"], + "verbose_name": "FAQ", + "verbose_name_plural": "FAQs", + }, + ), + migrations.AddField( + model_name="faq", + name="is_visible", + field=models.BooleanField(default=True, verbose_name="공개 여부"), + ), + ] diff --git a/src/faq/models.py b/src/faq/models.py index 261e00e..192b1e4 100644 --- a/src/faq/models.py +++ b/src/faq/models.py @@ -8,14 +8,15 @@ class FAQ(SoftDeleteModel): ### FAQ 필드 정의 """ - question = models.CharField(max_length=255, verbose_name="질문") - answer = models.TextField(verbose_name="답변") + question = models.CharField("질문", max_length=255) + answer = models.TextField("답변") + is_visible = models.BooleanField("공개 여부", default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "FAQ" - verbose_name_plural = "자주하는 질문" + verbose_name_plural = "FAQs" ordering = ["-created_at"] def __str__(self): diff --git a/src/merchandise/admin.py b/src/merchandise/admin.py index c7e1c94..db5bdd7 100644 --- a/src/merchandise/admin.py +++ b/src/merchandise/admin.py @@ -7,7 +7,10 @@ @admin.register(Merchandise) class MerchandiseAdmin(admin.ModelAdmin): - list_display = ("name", "image") + list_display = ("name", "image", "is_visible") + list_editable = ("is_visible",) + search_fields = ("name", "description") + exclude = ("is_deleted",) def delete_queryset(self, request, queryset): # Admin에서 여러 객체 삭제 시 호출됨 diff --git a/src/merchandise/migrations/0006_merchandise_is_deleted_merchandise_is_visible.py b/src/merchandise/migrations/0006_merchandise_is_deleted_merchandise_is_visible.py new file mode 100644 index 0000000..b96d2bc --- /dev/null +++ b/src/merchandise/migrations/0006_merchandise_is_deleted_merchandise_is_visible.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.3 on 2025-07-08 15:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("merchandise", "0005_alter_merchandise_image"), + ] + + operations = [ + migrations.AddField( + model_name="merchandise", + name="is_deleted", + field=models.BooleanField(default=False, verbose_name="숨김 여부"), + ), + migrations.AddField( + model_name="merchandise", + name="is_visible", + field=models.BooleanField(default=True, verbose_name="상품 공개 여부"), + ), + ] diff --git a/src/merchandise/models.py b/src/merchandise/models.py index 1563d0d..427affe 100644 --- a/src/merchandise/models.py +++ b/src/merchandise/models.py @@ -1,15 +1,17 @@ from django.db import models +from core.mixins.models import SoftDeleteModel from public.mixin.img_models import MultiImageFieldMixin -class Merchandise(MultiImageFieldMixin): +class Merchandise(MultiImageFieldMixin, SoftDeleteModel): """ ### MD 모델 """ name = models.CharField("상품 이름", max_length=255) description = models.TextField("상품 설명", null=True, blank=True) + is_visible = models.BooleanField("상품 공개 여부", default=True) image = models.ImageField("썸네일 이미지", upload_to="merchandise/image/") # MultiImageFieldMixin에서 clean, delete를 위해 필요한 정보 작성 diff --git a/src/notice/admin.py b/src/notice/admin.py index 09964cd..18905ff 100644 --- a/src/notice/admin.py +++ b/src/notice/admin.py @@ -27,12 +27,13 @@ def save_new_instance(self, obj, *args, **kwargs): @admin.register(Notice) class NoticeAdmin(admin.ModelAdmin): - list_display = ("id", "title", "is_pinned", "is_deleted") - list_filter = ("is_pinned",) + list_display = ("id", "title", "is_pinned", "is_visible") + list_filter = ("is_pinned", "is_visible") + exclude = ("is_deleted",) + list_editable = ("is_visible", "is_pinned") search_fields = ("title", "content") ordering = ("-created_at",) - fieldsets = (("수정 가능 필드", {"fields": ("title", "content", "is_pinned")}),) inlines = [NoticeTagInline] def delete_queryset(self, request, queryset): diff --git a/src/notice/migrations/0007_notice_is_visible.py b/src/notice/migrations/0007_notice_is_visible.py new file mode 100644 index 0000000..b650004 --- /dev/null +++ b/src/notice/migrations/0007_notice_is_visible.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-07-08 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("notice", "0006_rename_tag_notice_tags"), + ] + + operations = [ + migrations.AddField( + model_name="notice", + name="is_visible", + field=models.BooleanField(default=True, verbose_name="공지사항 공개 여부"), + ), + ] diff --git a/src/notice/models.py b/src/notice/models.py index d3bda40..61a87e0 100644 --- a/src/notice/models.py +++ b/src/notice/models.py @@ -14,6 +14,7 @@ class Notice(SoftDeleteModel): title = models.CharField("제목", max_length=255) content = CKEditor5Field("본문", config_name="extends", null=False, blank=False) is_pinned = models.BooleanField("상단 고정", default=False) + is_visible = models.BooleanField("공지사항 공개 여부", default=True) created_at = models.DateTimeField("등록 일시", auto_now_add=True) updated_at = models.DateTimeField("수정 일시", auto_now=True) tags = models.ManyToManyField(Tag, through=NoticeTag, related_name="notice") From c1e4939d5363e1eff8d486531faea01db8e5c2a2 Mon Sep 17 00:00:00 2001 From: jungmir Date: Wed, 9 Jul 2025 01:10:11 +0900 Subject: [PATCH 2/2] Fix: Update soft delete tests to use all_objects for retrieval --- src/activity_gallery/tests/test_activity_history_model.py | 2 +- src/faq/tests/test_faq_model.py | 2 +- src/faq/tests/test_faq_serializer.py | 2 +- src/notice/tests/test_notice_model.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/activity_gallery/tests/test_activity_history_model.py b/src/activity_gallery/tests/test_activity_history_model.py index ebad14a..2954954 100644 --- a/src/activity_gallery/tests/test_activity_history_model.py +++ b/src/activity_gallery/tests/test_activity_history_model.py @@ -170,5 +170,5 @@ def test_delete_activity_history_when_soft_delete_operation() -> None: history.delete() # Then - deleted = ActivityHistory.objects.get(id=history.id) + deleted = ActivityHistory.all_objects.get(id=history.id) assert deleted.is_deleted is True diff --git a/src/faq/tests/test_faq_model.py b/src/faq/tests/test_faq_model.py index 20a4890..0028cd0 100644 --- a/src/faq/tests/test_faq_model.py +++ b/src/faq/tests/test_faq_model.py @@ -197,7 +197,7 @@ def test_delete_faq_when_soft_delete_operation( faq.delete() # Then - deleted = FAQ.objects.get(id=faq_id) + deleted = FAQ.all_objects.get(id=faq_id) assert deleted.is_deleted is True diff --git a/src/faq/tests/test_faq_serializer.py b/src/faq/tests/test_faq_serializer.py index d600281..5a5733a 100644 --- a/src/faq/tests/test_faq_serializer.py +++ b/src/faq/tests/test_faq_serializer.py @@ -86,7 +86,7 @@ def test_faq_serializer_is_valid_false_given_invalid_data( ( "What is the best way to learn Python?", "The best way to learn Python is to practice coding every day.", - True, + False, ), ], ) diff --git a/src/notice/tests/test_notice_model.py b/src/notice/tests/test_notice_model.py index 9717b04..939a55f 100644 --- a/src/notice/tests/test_notice_model.py +++ b/src/notice/tests/test_notice_model.py @@ -285,7 +285,7 @@ def test_delete_notice_when_soft_delete_operation( notice.delete() # Then - deleted = Notice.objects.get(id=notice_id) + deleted = Notice.all_objects.get(id=notice_id) assert deleted.is_deleted is True