Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "apps/backend/**"
- "packages/drf-std-response/**"
- "docs/contributors/**"
- "docs/alienmark/**"
- "docs/users/**"
Expand Down Expand Up @@ -46,6 +47,7 @@ jobs:
filters: |
backend:
- 'apps/backend/**'
- 'packages/drf-std-response/**'
- 'pyproject.toml'
- 'uv.lock'
docs-contributors:
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ COPY ./apps/backend/pyproject.toml ./apps/backend/pyproject.toml
COPY ./docs/alienmark/pyproject.toml ./docs/alienmark/pyproject.toml
COPY ./docs/contributors/pyproject.toml ./docs/contributors/pyproject.toml
COPY ./docs/users/pyproject.toml ./docs/users/pyproject.toml
RUN uv sync --locked --no-dev --no-install-workspace --package aliencommons-backend
COPY ./packages/drf-std-response/pyproject.toml ./packages/drf-std-response/pyproject.toml
COPY ./packages/drf-std-response/src ./packages/drf-std-response/src
RUN uv sync --locked --no-dev --no-install-project --package aliencommons-backend

# Copy Project
COPY . .
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/articles/serializers/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from core.validators import (
FileTypeValidator, FileSizeValidator
)
from core.exceptions import ServiceError
from drf_std_response import ServiceError
from ..models import (
Article,
ArticleSource,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/articles/services/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
ArticleSnapshot,
ArticleEvent,
)
from core.exceptions import ServiceError
from drf_std_response import ServiceError
from core.utils.alienmark import render_md_to_html
from logs.logging import get_logger
from notifications.services import notify_subscribed_author_posted
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/articles/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
unpublish,
withdraw,
)
from core.exceptions import ServiceError
from drf_std_response import ServiceError
from core.tests.factories import (
create_article_snapshot,
create_moderator,
Expand Down
11 changes: 6 additions & 5 deletions apps/backend/articles/views/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet

from core.utils.permissions import is_moderator
from core.views.viewsets import MyModelViewSet, MyReadOnlyModelViewSet
from drf_std_response import EnvelopeMixin
from ..filters import ArticleFilter
from ..models import Article, ArticleEvent, ArticleSnapshot, ArticlePublication
from ..permissions import (
Expand All @@ -27,7 +28,7 @@
)


class ArticleViewSet(MyModelViewSet):
class ArticleViewSet(EnvelopeMixin, ModelViewSet):
queryset = Article.objects.select_related("author", "source")
filter_backends = [filters.DjangoFilterBackend]
filterset_class = ArticleFilter
Expand Down Expand Up @@ -229,7 +230,7 @@ def trash(self, request, pk=None):
)


class ArticlePublicationViewSet(MyReadOnlyModelViewSet):
class ArticlePublicationViewSet(EnvelopeMixin, ReadOnlyModelViewSet):
queryset = ArticlePublication.objects.select_related("article").prefetch_related("versions")
serializer_class = ArticlePublicationSerializer
permission_classes = [IsAuthenticated]
Expand All @@ -248,7 +249,7 @@ def get_queryset(self):
return with_article_publication_comment_count(queryset)


class ArticleSnapshotViewSet(MyReadOnlyModelViewSet):
class ArticleSnapshotViewSet(EnvelopeMixin, ReadOnlyModelViewSet):
queryset = ArticleSnapshot.objects.all()
serializer_class = ArticleSnapshotSerializer
permission_classes = [ModeratorOnly]
Expand All @@ -269,7 +270,7 @@ def pending_ones(self, request):
)


class ArticleEventReadViewset(MyReadOnlyModelViewSet):
class ArticleEventReadViewset(EnvelopeMixin, ReadOnlyModelViewSet):
queryset = ArticleEvent.objects.all()
permission_classes = (ArticleEventPermission,)
serializer_class = ArticleEventSerializer
Expand Down
7 changes: 4 additions & 3 deletions apps/backend/articles/views/collections.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.db.models import Count, Max
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.viewsets import ModelViewSet

from core.views.viewsets import MyModelViewSet
from drf_std_response import EnvelopeMixin
from ..models import Collection, CollectionItem
from ..permissions import CollectionItemPermission, CollectionPermission
from ..serializers import (
Expand All @@ -13,7 +14,7 @@
)


class CollectionViewSet(MyModelViewSet):
class CollectionViewSet(EnvelopeMixin, ModelViewSet):
queryset = Collection.objects.select_related("author")
permission_classes = [CollectionPermission]
default_serializer_class = CollectionReadSerializer
Expand Down Expand Up @@ -82,7 +83,7 @@ def partial_update(self, request, *args, **kwargs):
return self.update(request, *args, **kwargs)


class CollectionItemViewSet(MyModelViewSet):
class CollectionItemViewSet(EnvelopeMixin, ModelViewSet):
queryset = CollectionItem.objects.select_related(
"collection",
"collection__author",
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@
"DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination",
"PAGE_SIZE": 20,

"EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler",
"EXCEPTION_HANDLER": "drf_std_response.exception_handlers.exception_handler",
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z",
"DATE_FORMAT": "%Y-%m-%d",
}
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/backend/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
"EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler",
"EXCEPTION_HANDLER": "drf_std_response.exception_handlers.exception_handler",
"DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination",
"PAGE_SIZE": 20,
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z",
Expand Down
7 changes: 4 additions & 3 deletions apps/backend/bookmarks/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.db.models import Count
from rest_framework import status
from rest_framework.viewsets import ModelViewSet

from core.views.viewsets import MyModelViewSet
from drf_std_response import EnvelopeMixin
from .models import Bookmark, BookmarkFolder
from .permissions import BookmarkOwnerOnly
from .serializers import (
Expand All @@ -12,7 +13,7 @@
)


class BookmarkFolderViewSet(MyModelViewSet):
class BookmarkFolderViewSet(EnvelopeMixin, ModelViewSet):
queryset = BookmarkFolder.objects.select_related("user")
permission_classes = [BookmarkOwnerOnly]
default_serializer_class = BookmarkFolderReadSerializer
Expand Down Expand Up @@ -83,7 +84,7 @@ def partial_update(self, request, *args, **kwargs):
return self.update(request, *args, **kwargs)


class BookmarkViewSet(MyModelViewSet):
class BookmarkViewSet(EnvelopeMixin, ModelViewSet):
queryset = Bookmark.objects.select_related(
"user",
"folder",
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/comments/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from rest_framework import serializers

from articles.models import ArticlePublication
from core.exceptions import ServiceError
from drf_std_response import ServiceError
from core.models import ContentTarget
from core.utils.markdown import (
render_markdown_mentions,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/comments/services.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.db import transaction

from articles.models import ArticlePublication
from core.exceptions import ServiceError
from drf_std_response import ServiceError
from core.models import ContentTarget
from core.services.content_targets import (
get_or_create_comment_target,
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/comments/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.db.models import Count, Q
from rest_framework import status
from rest_framework.viewsets import ModelViewSet

from core.views.viewsets import MyModelViewSet
from drf_std_response import EnvelopeMixin
from .models import Comment
from .permissions import CommentPermission
from .serializers import CommentReadSerializer, CommentWriteSerializer
Expand All @@ -14,7 +15,7 @@
)


class CommentViewSet(MyModelViewSet):
class CommentViewSet(EnvelopeMixin, ModelViewSet):
queryset = Comment.objects.select_related(
"author",
"target",
Expand Down
17 changes: 10 additions & 7 deletions apps/backend/core/pagination.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response


class StandardPagination(PageNumberPagination):
def get_paginated_response(self, data):
return {
"count": self.page.paginator.count,
"total_pages": self.page.paginator.num_pages,
"current_page": self.page.number,
"page_size": self.get_page_size(self.request),
"results": data
}
return Response(
{
"count": self.page.paginator.count,
"total_pages": self.page.paginator.num_pages,
"current_page": self.page.number,
"page_size": self.get_page_size(self.request),
"results": data,
}
)
27 changes: 0 additions & 27 deletions apps/backend/core/responses.py

This file was deleted.

69 changes: 58 additions & 11 deletions apps/backend/core/tests/test_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from rest_framework import status
from rest_framework.exceptions import NotAuthenticated, ValidationError

from core.exceptions import ServiceError
from core.tests.testcases import BaseTestCase
from core.views.exception_handler import custom_exception_handler
from drf_std_response import ServiceError
from drf_std_response.exception_handlers import exception_handler


class CustomExceptionHandlerTests(BaseTestCase):
Expand All @@ -16,7 +16,7 @@ def setUp(self):
self.context = {"request": self.request}

def test_service_error_is_wrapped_with_standard_payload(self):
response = custom_exception_handler(
response = exception_handler(
ServiceError(detail="Bad state", code="bad_state"),
self.context,
)
Expand All @@ -25,10 +25,14 @@ def test_service_error_is_wrapped_with_standard_payload(self):
self.assertFalse(response.data["success"])
self.assertEqual(response.data["message"], "Bad state")
self.assertEqual(response.data["code"], "bad_state")
self.assertEqual(
response.data["errors"],
[{"code": "bad_state", "message": "Bad state", "field": None}],
)
self.assertEqual(response.data["meta"]["request_id"], "req-1")

def test_validation_error_is_wrapped_with_validation_payload(self):
response = custom_exception_handler(
response = exception_handler(
ValidationError({"title": ["required"]}),
self.context,
)
Expand All @@ -37,34 +41,77 @@ def test_validation_error_is_wrapped_with_validation_payload(self):
self.assertFalse(response.data["success"])
self.assertEqual(response.data["message"], "Validation failed")
self.assertEqual(response.data["code"], "validation_error")
self.assertEqual(response.data["errors"], {"title": ["required"]})
self.assertEqual(
response.data["errors"],
[{"code": "invalid", "message": "required", "field": "title"}],
)

def test_nested_validation_errors_are_flattened_with_field_paths(self):
response = exception_handler(
ValidationError(
{
"shipping_address": {
"non_field_errors": ["Unsupported address"],
},
"recipients": [
{"name": ["This field is required."]},
{"email": ["Enter a valid email address."]},
],
}
),
self.context,
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data["errors"],
[
{
"code": "invalid",
"message": "Unsupported address",
"field": "shipping_address.non_field_errors",
},
{
"code": "invalid",
"message": "This field is required.",
"field": "recipients.0.name",
},
{
"code": "invalid",
"message": "Enter a valid email address.",
"field": "recipients.1.email",
},
],
)

def test_builtin_api_exception_is_wrapped(self):
response = custom_exception_handler(NotAuthenticated(), self.context)
response = exception_handler(NotAuthenticated(), self.context)

self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertFalse(response.data["success"])
self.assertEqual(response.data["message"], "Request failed")
self.assertEqual(response.data["code"], "not_authenticated")
self.assertEqual(response.data["errors"][0]["field"], None)

def test_django_permission_denied_is_wrapped(self):
response = custom_exception_handler(PermissionDenied(), self.context)
response = exception_handler(PermissionDenied(), self.context)

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(response.data["success"])
self.assertEqual(response.data["code"], "error")
self.assertEqual(response.data["code"], "permission_denied")

def test_unknown_error_becomes_internal_server_error(self):
response = custom_exception_handler(RuntimeError("boom"), self.context)
response = exception_handler(RuntimeError("boom"), self.context)

self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
self.assertFalse(response.data["success"])
self.assertEqual(response.data["message"], "Internal server error")
self.assertEqual(response.data["code"], "internal_server_error")
self.assertEqual(response.data["errors"][0]["field"], None)

def test_http404_is_wrapped(self):
response = custom_exception_handler(Http404(), self.context)
response = exception_handler(Http404(), self.context)

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertFalse(response.data["success"])
self.assertEqual(response.data["code"], "error")
self.assertEqual(response.data["code"], "not_found")
2 changes: 1 addition & 1 deletion apps/backend/core/tests/test_markdown.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from core.exceptions import ServiceError
from drf_std_response import ServiceError
from core.tests.factories import create_user
from core.tests.testcases import BaseTestCase
from core.utils.markdown import (
Expand Down
Loading