diff --git a/src/activity_gallery/admin.py b/src/activity_gallery/admin.py index 5575b24..6a06f8e 100644 --- a/src/activity_gallery/admin.py +++ b/src/activity_gallery/admin.py @@ -3,7 +3,7 @@ from public.tag_models import ActivityTag, Tag -from .models import ActionPhoto, ActivityAction +from .models import ActionPhoto, ActivityAction, ActivityHistory class ActivityTagInline(admin.TabularInline): @@ -63,3 +63,20 @@ def delete_queryset(self, request, queryset): # Admin에서 여러 객체 삭제 시 호출됨 for obj in queryset: obj.delete() + + +@admin.register(ActivityHistory) +class ActivityHistoryAdmin(admin.ModelAdmin): + """활동 히스토리 관리자 페이지""" + + list_display = ("title", "activity_date") + search_fields = ("title", "content", "activity_date") + + def content_preview(self, obj): + """내용이 길 경우 일부만 미리보기""" + return obj.content[:50] + "..." if len(obj.content) > 50 else obj.content + + def delete_queryset(self, request, queryset): + # Admin에서 여러 객체 삭제 시 호출됨 + for obj in queryset: + obj.delete() diff --git a/src/activity_gallery/migrations/0008_activityhistory.py b/src/activity_gallery/migrations/0008_activityhistory.py new file mode 100644 index 0000000..f17dd2b --- /dev/null +++ b/src/activity_gallery/migrations/0008_activityhistory.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.5 on 2025-06-26 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity_gallery", "0007_alter_actionphoto_image_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ActivityHistory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("is_deleted", models.BooleanField(default=False, verbose_name="숨김 여부")), + ("title", models.CharField(max_length=255, verbose_name="활동명")), + ("content", models.TextField(verbose_name="내용")), + ( + "thumbnail", + models.ImageField( + blank=True, + null=True, + upload_to="activity/history/thumbnail/", + verbose_name="활동 썸네일 이미지", + ), + ), + ("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="수정일")), + ], + options={ + "verbose_name": "활동 히스토리", + "verbose_name_plural": "활동 히스토리", + "ordering": ("activity_date",), + }, + ), + ] diff --git a/src/activity_gallery/models.py b/src/activity_gallery/models.py index 50b3fc3..ee6feca 100644 --- a/src/activity_gallery/models.py +++ b/src/activity_gallery/models.py @@ -1,5 +1,6 @@ from django.db import models +from core.mixins.models import SoftDeleteModel from public.mixin.img_models import MultiImageFieldMixin from public.tag_models import ActivityTag, Tag @@ -39,3 +40,22 @@ class ActionPhoto(MultiImageFieldMixin): def __str__(self): return f"Photo for {self.activity_action.title if self.activity_action else 'No Activity'}" + + +class ActivityHistory(SoftDeleteModel): + title = models.CharField(max_length=255, verbose_name="활동명") + content = models.TextField(verbose_name="내용") + thumbnail = models.ImageField( + "활동 썸네일 이미지", 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="수정일") + + class Meta: + verbose_name = "활동 히스토리" + verbose_name_plural = "활동 히스토리" + ordering = ("activity_date",) + + def __str__(self): + return f"History for {self.title} at {self.activity_date}" diff --git a/src/activity_gallery/serializers.py b/src/activity_gallery/serializers.py index 6f331d6..1385171 100644 --- a/src/activity_gallery/serializers.py +++ b/src/activity_gallery/serializers.py @@ -3,7 +3,7 @@ from public.serializers import TagSerializer from public.tag_models import ActivityTag -from .models import ActionPhoto, ActivityAction +from .models import ActionPhoto, ActivityAction, ActivityHistory # 액션과 연결된 사진 정보를 직렬화하는 Serializer @@ -44,3 +44,14 @@ class Meta: "tags", "photos", ] # 상세 조회 시 모든 정보 포함 + + +class ActivityHistorySerializer(serializers.ModelSerializer): + class Meta: + model = ActivityHistory + fields = [ + "id", + "title", + "content", + "activity_date", + ] diff --git a/src/activity_gallery/swagger.py b/src/activity_gallery/swagger.py index 65cfbcf..354421f 100644 --- a/src/activity_gallery/swagger.py +++ b/src/activity_gallery/swagger.py @@ -102,3 +102,58 @@ def retrieve(cls): description="특정 커뮤니티 활동 상세 조회", responses=responses, ) + + +class ActivityHistoryAPIDocs(SwaggerSchema): + """활동 히스토리 API 문서""" + + sample_activity_list = [ + { + "id": 1, + "title": "활동 제목 1", + "content": "월드 와이드 파이콘", + "activity_date": "2024-01-01", + }, + { + "id": 2, + "title": "활동 제목 2", + "content": "월드 와이드 파이콘2", + "activity_date": "2024-06-01", + }, + ] + + @classmethod + def list(cls): + """활동 히스토리 목록 조회 문서""" + responses = { + "성공": OpenApiResponse( + response=ListSuccessResponseSerializer, + description="활동 히스토리 목록 조회 성공", + examples=[ + OpenApiExample( + name="활동 히스토리 목록 조회", + value={ + "status": "SUCCESS", + "data": cls.sample_activity_list, + "error": None, + "pagination": {"count": 2, "next": None, "previous": None}, + }, + ), + OpenApiExample( + name="활동 히스토리 목록 조회 (데이터 없음)", + value={ + "status": "SUCCESS", + "data": [], + "error": None, + "pagination": {"count": 0, "next": None, "previous": None}, + }, + ), + ], + ), + "에러": OpenApiResponse(response=ErrorResponseSerializer, description="응답 에러"), + } + return cls.generate_schema( + operation_id="activity_action_list", + description="모든 활동 히스토리 목록 조회", + responses=responses, + ) diff --git a/src/activity_gallery/tests.py b/src/activity_gallery/tests.py deleted file mode 100644 index a39b155..0000000 --- a/src/activity_gallery/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/activity_gallery/tests/test_activity_history_model.py b/src/activity_gallery/tests/test_activity_history_model.py new file mode 100644 index 0000000..ebad14a --- /dev/null +++ b/src/activity_gallery/tests/test_activity_history_model.py @@ -0,0 +1,174 @@ +import pytest +from django.db.utils import DatabaseError + +from activity_gallery.models import ActivityHistory + + +@pytest.mark.django_db +@pytest.mark.feature +@pytest.mark.parametrize( + "title, content, activity_date", + [ + ("Activity 1", "Content 1", "2024-01-01"), + ("Activity 2", "Content 2", "2024-06-01"), + ("Activity 3", "Content 3", "2023-12-31"), + ], +) +def test_create_activity_history_success_given_valid_data(title, content, activity_date): + # Given + data = { + "title": title, + "content": content, + "activity_date": activity_date, + } + # When + history = ActivityHistory.objects.create(**data) + # Then + assert history.title == title + assert history.content == content + assert str(history.activity_date) == activity_date + + +@pytest.mark.django_db +@pytest.mark.feature +@pytest.mark.parametrize( + "title, content, activity_date", + [ + (None, "Content", "2024-01-01"), + ("Title", None, "2024-01-01"), + ("Title", "Content", None), + (None, None, None), + ], +) +def test_create_activity_history_fail_given_invalid_data(title, content, activity_date): + # Given + data = { + "title": title, + "content": content, + "activity_date": activity_date, + } + # Then + with pytest.raises((DatabaseError, ValueError, TypeError)): + # When + ActivityHistory.objects.create(**data) + + +@pytest.mark.django_db +@pytest.mark.feature +@pytest.mark.parametrize( + "title, content, activity_date", + [ + ("Activity 1", "Content 1", "2024-01-01"), + ("Activity 2", "Content 2", "2024-06-01"), + ], +) +def test_read_activity_history_given_exist_id(title, content, activity_date): + # Given + history = ActivityHistory.objects.create( + title=title, content=content, activity_date=activity_date + ) + # When + fetched = ActivityHistory.objects.get(id=history.id) + # Then + assert fetched.title == title + assert fetched.content == content + assert str(fetched.activity_date) == activity_date + + +@pytest.mark.django_db +@pytest.mark.feature +@pytest.mark.parametrize( + "history_id", + [-1, 0], +) +def test_read_activity_history_given_non_exist_id(history_id): + # Given + # When / Then + with pytest.raises(ActivityHistory.DoesNotExist): + ActivityHistory.objects.get(id=history_id) + + +@pytest.mark.django_db +@pytest.mark.feature +@pytest.mark.parametrize( + "title, content, activity_date, new_title, new_content, new_activity_date", + [ + ("Activity 1", "Content 1", "2024-01-01", "Updated 1", "Updated Content 1", "2024-02-01"), + ("Activity 2", "Content 2", "2024-06-01", "Updated 2", "Updated Content 2", "2024-07-01"), + ], +) +def test_update_activity_history_success_given_valid_data( + title, content, activity_date, new_title, new_content, new_activity_date +): + # Given + history = ActivityHistory.objects.create( + title=title, content=content, activity_date=activity_date + ) + # When + history.title = new_title + history.content = new_content + history.activity_date = new_activity_date + history.save() + updated = ActivityHistory.objects.get(id=history.id) + # Then + assert updated.title == new_title + assert updated.content == new_content + assert str(updated.activity_date) == new_activity_date + + +@pytest.mark.django_db +@pytest.mark.feature +@pytest.mark.parametrize( + "title, content, activity_date, new_title, new_content, new_activity_date", + [ + ("Activity 1", "Content 1", "2024-01-01", None, "Updated Content", "2024-02-01"), + ("Activity 2", "Content 2", "2024-06-01", "Updated", None, "2024-07-01"), + ("Activity 3", "Content 3", "2024-06-01", "Updated", "Updated Content", None), + ], +) +def test_update_activity_history_fail_given_invalid_data( + title, content, activity_date, new_title, new_content, new_activity_date +): + # Given + history = ActivityHistory.objects.create( + title=title, content=content, activity_date=activity_date + ) + # When + history.title = new_title + history.content = new_content + history.activity_date = new_activity_date + # Then + with pytest.raises((DatabaseError, ValueError, TypeError)): + history.save() + + +@pytest.mark.django_db +@pytest.mark.feature +def test_delete_activity_history_hard_delete(): + # Given + history = ActivityHistory.objects.create( + title="To Delete", content="Delete me", activity_date="2024-01-01" + ) + history_id = history.id + # When + # If using soft delete, use hard delete method; otherwise, keep as is + history.delete(force_delete=True) + # Then + with pytest.raises(ActivityHistory.DoesNotExist): + ActivityHistory.objects.get(id=history_id) + + +@pytest.mark.django_db +@pytest.mark.feature +def test_delete_activity_history_when_soft_delete_operation() -> None: + # Given + history = ActivityHistory.objects.create( + title="To Delete", content="Delete me", activity_date="2024-01-01" + ) + + # When + history.delete() + + # Then + deleted = ActivityHistory.objects.get(id=history.id) + assert deleted.is_deleted is True diff --git a/src/activity_gallery/tests/test_activity_history_view.py b/src/activity_gallery/tests/test_activity_history_view.py new file mode 100644 index 0000000..ac7ff50 --- /dev/null +++ b/src/activity_gallery/tests/test_activity_history_view.py @@ -0,0 +1,38 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from activity_gallery.models import ActivityHistory + + +class ActivityHistoryViewSetTest(APITestCase): + def setUp(self): + self.history1 = ActivityHistory.objects.create( + title="History 1", + content="Content 1", + activity_date="2024-01-01", + is_deleted=False, + ) + self.history2 = ActivityHistory.objects.create( + title="History 2", + content="Content 2", + activity_date="2024-02-01", + is_deleted=False, + ) + self.deleted_history = ActivityHistory.objects.create( + title="Deleted History", + content="Should not show up.", + activity_date="2024-03-01", + is_deleted=True, + ) + + def test_list_activity_history(self): + url = reverse("activity-history-list") + response = self.client.get(url, query_params={"page": 1}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertIn("data", response.data) + returned_ids = [item["id"] for item in response.data["data"]] + self.assertIn(self.history1.id, returned_ids) + self.assertIn(self.history2.id, returned_ids) + self.assertNotIn(self.deleted_history.id, returned_ids) diff --git a/src/activity_gallery/urls.py b/src/activity_gallery/urls.py index 7e69354..703a372 100644 --- a/src/activity_gallery/urls.py +++ b/src/activity_gallery/urls.py @@ -1,8 +1,9 @@ from rest_framework.routers import DefaultRouter -from .views import ActivityActionViewSet +from .views import ActivityActionViewSet, ActivityHistoryViewSet router = DefaultRouter() +router.register("histories", ActivityHistoryViewSet, basename="activity-history") router.register("", ActivityActionViewSet, basename="activity-action") urlpatterns = router.urls diff --git a/src/activity_gallery/views.py b/src/activity_gallery/views.py index 32ed697..205eb4a 100644 --- a/src/activity_gallery/views.py +++ b/src/activity_gallery/views.py @@ -4,12 +4,13 @@ from core.responses.base import BaseResponse -from .models import ActivityAction +from .models import ActivityAction, ActivityHistory from .serializers import ( ActivityActionDetailSerializer, ActivityActionListSerializer, + ActivityHistorySerializer, ) -from .swagger import ActivityActionAPIDocs +from .swagger import ActivityActionAPIDocs, ActivityHistoryAPIDocs @extend_schema_view( @@ -42,3 +43,18 @@ def retrieve(self, request, *args, **kwargs) -> BaseResponse: instance = self.get_object() serializer = self.get_serializer(instance) return BaseResponse(serializer.data) + + +@extend_schema_view(list=ActivityHistoryAPIDocs.list()) +class ActivityHistoryViewSet(GenericViewSet, ListModelMixin): + """활동 히스토리 API 뷰""" + + queryset = ActivityHistory.objects.filter(is_deleted=False).all() + serializer_class = ActivityHistorySerializer + + def list(self, request, *args, **kwargs) -> BaseResponse: + """활동 히스토리 목록 조회 API""" + queryset = self.get_queryset() + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page or queryset, many=True) + return self.get_paginated_response(serializer.data)