diff --git a/apps/blog/serializers.py b/apps/blog/serializers.py index 43e82d29..d0c41c54 100644 --- a/apps/blog/serializers.py +++ b/apps/blog/serializers.py @@ -24,3 +24,29 @@ class CategoryListSerializer(serializers.Serializer): def get_parent_name(self, obj: Category) -> str | None: return obj.parent.name if obj.parent else None + + +class BlogIngestImageUploadSerializer(serializers.Serializer): + """블로그 ingest용 이미지/파일 업로드 요청 검증. 파일 1~10개.""" + + files = serializers.ListField( + child=serializers.FileField(), + min_length=1, + max_length=10, + ) + + +class BlogIngestImageUploadResultSerializer(serializers.Serializer): + """이미지/파일 업로드 결과 응답 아이템. 파일 단위로 성공/실패를 구분한다.""" + + filename = serializers.CharField() + success = serializers.BooleanField() + url = serializers.CharField(allow_blank=True) + markdown = serializers.CharField(allow_blank=True) + error_message = serializers.CharField(allow_blank=True) + + +class BlogIngestImageUploadResponseSerializer(serializers.Serializer): + """이미지/파일 업로드 API의 응답 스키마 문서화용. 실제 응답 본문({"results": [...]})과 구조를 맞추기 위해 존재하며 런타임에는 인스턴스화하지 않는다.""" + + results = BlogIngestImageUploadResultSerializer(many=True) diff --git a/apps/blog/tests/test_serializers.py b/apps/blog/tests/test_serializers.py index f41822ad..8db1da73 100644 --- a/apps/blog/tests/test_serializers.py +++ b/apps/blog/tests/test_serializers.py @@ -1,4 +1,6 @@ -from apps.blog.serializers import BlogIngestSerializer +from django.core.files.uploadedfile import SimpleUploadedFile + +from apps.blog.serializers import BlogIngestImageUploadSerializer, BlogIngestSerializer def test_필수_필드만_있어도_유효하다() -> None: @@ -58,3 +60,43 @@ def test_모든_필드를_채우면_그대로_반영된다() -> None: assert serializer.is_valid(), serializer.errors assert serializer.validated_data['tags'] == ['django', 'api-design'] assert serializer.validated_data['repo_url'] == 'https://github.com/example/waitee-app' + + +def test_이미지_업로드_시리얼라이저는_파일_1개면_유효하다() -> None: + upload = SimpleUploadedFile('photo.png', b'fake-image-bytes', content_type='image/png') + + serializer = BlogIngestImageUploadSerializer(data={'files': [upload]}) + + assert serializer.is_valid(), serializer.errors + assert len(serializer.validated_data['files']) == 1 + + +def test_이미지_업로드_시리얼라이저는_파일이_없으면_무효하다() -> None: + serializer = BlogIngestImageUploadSerializer(data={}) + + assert not serializer.is_valid() + assert 'files' in serializer.errors + + +def test_이미지_업로드_시리얼라이저는_10개_초과면_무효하다() -> None: + uploads = [ + SimpleUploadedFile(f'photo{i}.png', b'fake-image-bytes', content_type='image/png') + for i in range(11) + ] + + serializer = BlogIngestImageUploadSerializer(data={'files': uploads}) + + assert not serializer.is_valid() + assert 'files' in serializer.errors + + +def test_이미지_업로드_시리얼라이저는_10개면_유효하다() -> None: + uploads = [ + SimpleUploadedFile(f'photo{i}.png', b'fake-image-bytes', content_type='image/png') + for i in range(10) + ] + + serializer = BlogIngestImageUploadSerializer(data={'files': uploads}) + + assert serializer.is_valid(), serializer.errors + assert len(serializer.validated_data['files']) == 10 diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index a9068fe5..bf26b068 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -4,6 +4,11 @@ from apps.blog.models import Category, Post, Tag +import io + +from django.core.files.uploadedfile import SimpleUploadedFile +from PIL import Image + @pytest.mark.django_db def test_올바른_키로_요청하면_초안이_생성된다(client: Client, settings) -> None: @@ -176,3 +181,106 @@ def test_ingest는_대소문자만_다른_태그를_기존_태그로_재사용 ) assert Tag.objects.count() == 1 + + +def _make_image_upload(name: str = 'photo.png') -> SimpleUploadedFile: + buffer = io.BytesIO() + Image.new('RGB', (10, 10), color='red').save(buffer, format='PNG') + buffer.seek(0) + return SimpleUploadedFile(name, buffer.read(), content_type='image/png') + + +@pytest.mark.django_db +def test_이미지_업로드는_1개면_200과_webp_url을_반환한다(client: Client, settings, tmp_path) -> None: + settings.BLOG_INGEST_API_KEY = 'secret-key' + settings.MEDIA_ROOT = tmp_path + url = reverse('blog-ingest-image-upload') + + response = client.post( + url, + data={'files': [_make_image_upload()]}, + HTTP_X_BLOG_INGEST_KEY='secret-key', + ) + + assert response.status_code == 200 + results = response.json()['results'] + assert len(results) == 1 + assert results[0]['success'] is True + assert results[0]['url'].endswith('.webp') + assert results[0]['markdown'] == f"![업로드 이미지]({results[0]['url']})" + + +@pytest.mark.django_db +def test_이미지_업로드는_여러_개면_순서대로_결과가_대응된다(client: Client, settings, tmp_path) -> None: + settings.BLOG_INGEST_API_KEY = 'secret-key' + settings.MEDIA_ROOT = tmp_path + url = reverse('blog-ingest-image-upload') + + response = client.post( + url, + data={'files': [_make_image_upload('a.png'), _make_image_upload('b.png')]}, + HTTP_X_BLOG_INGEST_KEY='secret-key', + ) + + assert response.status_code == 200 + results = response.json()['results'] + assert len(results) == 2 + assert all(r['success'] is True for r in results) + assert results[0]['filename'] == 'a.png' + assert results[1]['filename'] == 'b.png' + + +@pytest.mark.django_db +def test_이미지_업로드는_일부_파일이_지원하지_않는_형식이면_부분_성공을_반환한다(client: Client, settings, tmp_path) -> None: + settings.BLOG_INGEST_API_KEY = 'secret-key' + settings.MEDIA_ROOT = tmp_path + url = reverse('blog-ingest-image-upload') + bad_file = SimpleUploadedFile('notes.txt', b'plain text', content_type='text/plain') + + response = client.post( + url, + data={'files': [_make_image_upload('good.png'), bad_file]}, + HTTP_X_BLOG_INGEST_KEY='secret-key', + ) + + assert response.status_code == 200 + results = response.json()['results'] + assert len(results) == 2 + assert results[0]['success'] is True + assert results[1]['success'] is False + assert '.txt' in results[1]['error_message'] + + +@pytest.mark.django_db +def test_이미지_업로드는_파일이_없으면_400이다(client: Client, settings) -> None: + settings.BLOG_INGEST_API_KEY = 'secret-key' + url = reverse('blog-ingest-image-upload') + + response = client.post(url, data={}, HTTP_X_BLOG_INGEST_KEY='secret-key') + + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_이미지_업로드는_10개_초과면_400이다(client: Client, settings) -> None: + settings.BLOG_INGEST_API_KEY = 'secret-key' + url = reverse('blog-ingest-image-upload') + uploads = [_make_image_upload(f'{i}.png') for i in range(11)] + + response = client.post(url, data={'files': uploads}, HTTP_X_BLOG_INGEST_KEY='secret-key') + + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_이미지_업로드는_키가_틀리면_403이다(client: Client, settings) -> None: + settings.BLOG_INGEST_API_KEY = 'secret-key' + url = reverse('blog-ingest-image-upload') + + response = client.post( + url, + data={'files': [_make_image_upload()]}, + HTTP_X_BLOG_INGEST_KEY='wrong-key', + ) + + assert response.status_code == 403 diff --git a/apps/blog/urls.py b/apps/blog/urls.py index e96105c1..6b819204 100644 --- a/apps/blog/urls.py +++ b/apps/blog/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from apps.blog.views import BlogCategoryListView, BlogIngestView +from apps.blog.views import BlogCategoryListView, BlogIngestImageUploadView, BlogIngestView urlpatterns = [ path('ingest/', BlogIngestView.as_view(), name='blog-ingest'), + path('ingest/images/', BlogIngestImageUploadView.as_view(), name='blog-ingest-image-upload'), path('categories/', BlogCategoryListView.as_view(), name='blog-category-list'), ] diff --git a/apps/blog/views.py b/apps/blog/views.py index 4f863817..8973b8dd 100644 --- a/apps/blog/views.py +++ b/apps/blog/views.py @@ -1,8 +1,10 @@ from typing import ClassVar +from django.core.files.uploadedfile import UploadedFile from django.urls import reverse from django.utils import timezone from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle @@ -10,8 +12,14 @@ from apps.blog.models import Category, Post from apps.blog.permissions import HasBlogIngestKey -from apps.blog.serializers import BlogIngestSerializer, CategoryListSerializer +from apps.blog.serializers import ( + BlogIngestImageUploadResponseSerializer, + BlogIngestImageUploadSerializer, + BlogIngestSerializer, + CategoryListSerializer, +) from apps.blog.services.category import CategoryNotFoundError, get_category_by_name +from apps.blog.services.media_storage import MediaUploadResult, save_uploaded_media from apps.blog.services.slug import generate_unique_slug from apps.blog.services.tags import get_or_create_tags @@ -98,3 +106,52 @@ def get(self, request: Request) -> Response: categories = Category.objects.select_related('parent').all() serializer = CategoryListSerializer(categories, many=True) return Response(serializer.data) + + +class BlogIngestImageUploadView(APIView): + """블로그 ingest용 이미지/파일 업로드 엔드포인트. 기존 admin 업로드와 동일한 저장 로직(save_uploaded_media)을 재사용한다.""" + + authentication_classes: ClassVar[list] = [] + permission_classes: ClassVar[list] = [HasBlogIngestKey] + parser_classes: ClassVar[list] = [MultiPartParser, FormParser] + throttle_classes: ClassVar[list] = [ScopedRateThrottle] + throttle_scope = 'blog_ingest_upload' + + @extend_schema( + summary='블로그 글 이미지/파일 업로드 (Ingest)', + description=( + '외부에서 블로그 글 본문(`content`)에 삽입할 이미지·동영상·PDF를 업로드한다. ' + '`X-Blog-Ingest-Key` 헤더의 전용 API 키로만 인증하며(JWT 미사용), 한 요청에 파일 1~10개를 함께 보낼 수 있다. ' + '이미지는 webp로 변환되어 저장되고(애니메이션 GIF는 원본 유지), 동영상(mp4/mov/webm)·PDF는 원본 그대로 저장된다. ' + '파일당 최대 50MB이며, 일부 파일이 형식·크기 문제로 실패해도 나머지는 정상 저장된다(부분 성공 허용). ' + '요청 형식이 유효하면(파일 1~10개) 개별 파일 성공/실패와 무관하게 항상 200을 반환하며, ' + '`results[].markdown` 필드를 그대로 `content`에 이어붙이면 된다.' + ), + request=BlogIngestImageUploadSerializer, + responses={ + 200: BlogIngestImageUploadResponseSerializer, + 400: OpenApiResponse(description='파일 누락 또는 10개 초과'), + 403: OpenApiResponse(description='API 키 누락 또는 불일치'), + }, + tags=['blog'], + ) + def post(self, request: Request) -> Response: + serializer = BlogIngestImageUploadSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + results = [ + _to_upload_result(uploaded_file, save_uploaded_media(uploaded_file)) + for uploaded_file in serializer.validated_data['files'] + ] + return Response({'results': results}, status=200) + + +def _to_upload_result(uploaded_file: UploadedFile, result: MediaUploadResult) -> dict[str, bool | str]: + return { + 'filename': uploaded_file.name, + 'success': result.success, + 'url': result.url, + 'markdown': result.markdown, + 'error_message': result.error_message, + } diff --git a/config/settings/base.py b/config/settings/base.py index ab8cb18b..31fad6be 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -132,6 +132,7 @@ 'PAGE_SIZE': 20, 'DEFAULT_THROTTLE_RATES': { 'blog_ingest': '30/day', + 'blog_ingest_upload': '100/day', }, }