Features: 1) Introduced extend_schema for multiple viewsets to improve OpenAPI documentation; 2) Added detailed schema definitions for blog and payments viewsets using drf-spectacular; 3) Transitioned download_digital_asset functionality to class-based DownloadDigitalAssetView for better modularity.

Fixes: 1) Standardized error responses in `DownloadDigitalAssetView`.

Extra: Improved maintainability by refactoring serializers and schema definitions into modular components; updated API URLs to use new class-based view.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-10-26 18:44:19 +03:00
parent c9fd4b4f98
commit c0fcde4bb4
8 changed files with 323 additions and 65 deletions

17
blog/docs/drf/viewsets.py Normal file
View file

@ -0,0 +1,17 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema
from rest_framework import status
from core.docs.drf import BASE_ERRORS
from blog.serializers import PostSerializer
POST_SCHEMA = {
"list": extend_schema(
summary=_("list all posts (read-only)"),
responses={status.HTTP_200_OK: PostSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single post (read-only)"),
responses={status.HTTP_200_OK: PostSerializer(), **BASE_ERRORS},
),
}

View file

@ -1,28 +1,30 @@
from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema_view
from rest_framework.viewsets import ReadOnlyModelViewSet
from blog.filters import PostFilter
from blog.models import Post
from blog.serializers import PostSerializer
from blog.docs.drf.viewsets import POST_SCHEMA
from core.permissions import EvibesPermission
@extend_schema_view(**POST_SCHEMA)
class PostViewSet(ReadOnlyModelViewSet): # type: ignore [type-arg]
"""
Encapsulates operations for managing and retrieving Post entities in a read-only model view set.
This class is tailored to handle Post objects that are active and allows filtration based on defined
filters. It integrates with Django's backend filtering system and ensures operations align with the
defined permissions. The view set also includes an additional "retrieve" permission configuration.
Attributes:
serializer_class: Specifies the serializer to be used for Post objects.
permission_classes: Defines the permissions required to interact with this view set.
queryset: Determines the initial queryset, filtered to include only active Post objects.
filter_backends: Lists the backends to be used for filtering querysets.
filterset_class: Defines the set of filters used for filtering Post objects.
additional: Contains additional configuration, such as specific action permissions.
"""
__doc__ = _( # type: ignore [assignment]
"Encapsulates operations for managing and retrieving Post entities in a read-only model view set.\n\n"
"This class is tailored to handle Post objects that are active and allows filtration based on defined "
"filters. It integrates with Django's backend filtering system and ensures operations align with the "
"defined permissions. The view set also includes an additional 'retrieve' permission configuration.\n\n"
"Attributes:\n"
" serializer_class: Specifies the serializer to be used for Post objects.\n"
" permission_classes: Defines the permissions required to interact with this view set.\n"
" queryset: Determines the initial queryset, filtered to include only active Post objects.\n"
" filter_backends: Lists the backends to be used for filtering querysets.\n"
" filterset_class: Defines the set of filters used for filtering Post objects.\n"
" additional: Contains additional configuration, such as specific action permissions."
)
serializer_class = PostSerializer
permission_classes = (EvibesPermission,)

View file

@ -5,11 +5,11 @@ from core.sitemaps import BrandSitemap, CategorySitemap, ProductSitemap, StaticP
from core.views import (
CacheOperatorView,
ContactUsView,
DownloadDigitalAssetView,
GlobalSearchView,
RequestCursedURLView,
SupportedLanguagesView,
WebsiteParametersView,
download_digital_asset_view,
sitemap_detail,
sitemap_index,
)
@ -78,7 +78,7 @@ urlpatterns = [
{"sitemaps": sitemaps},
name="sitemap-detail",
),
path("download/<str:order_product_uuid>/", download_digital_asset_view, name="download_digital_asset"),
path("download/<str:order_product_uuid>/", DownloadDigitalAssetView.as_view(), name="download_digital_asset"),
path("search/", GlobalSearchView.as_view(), name="global_search"),
path("app/cache/", CacheOperatorView.as_view(), name="cache_operator"),
path("app/languages/", SupportedLanguagesView.as_view(), name="supported_languages"),

View file

@ -14,6 +14,8 @@ from core.serializers import (
AttributeSimpleSerializer,
AttributeValueDetailSerializer,
AttributeValueSimpleSerializer,
BrandDetailSerializer,
BrandSimpleSerializer,
BulkAddOrderProductsSerializer,
BulkAddWishlistProductSerializer,
BulkRemoveOrderProductsSerializer,
@ -29,9 +31,21 @@ from core.serializers import (
OrderProductSimpleSerializer,
OrderSimpleSerializer,
ProductDetailSerializer,
ProductImageDetailSerializer,
ProductImageSimpleSerializer,
ProductSimpleSerializer,
ProductTagDetailSerializer,
ProductTagSimpleSerializer,
PromoCodeDetailSerializer,
PromoCodeSimpleSerializer,
PromotionDetailSerializer,
PromotionSimpleSerializer,
RemoveOrderProductSerializer,
RemoveWishlistProductSerializer,
StockDetailSerializer,
StockSimpleSerializer,
VendorDetailSerializer,
VendorSimpleSerializer,
WishlistDetailSerializer,
WishlistSimpleSerializer,
)
@ -652,3 +666,196 @@ ORDER_PRODUCT_SCHEMA = {
},
),
}
BRAND_SCHEMA = {
"list": extend_schema(
summary=_("list all brands (simple view)"),
responses={status.HTTP_200_OK: BrandSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single brand (detailed view)"),
responses={status.HTTP_200_OK: BrandDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a brand"),
responses={status.HTTP_201_CREATED: BrandDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a brand"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing brand saving non-editables"),
responses={status.HTTP_200_OK: BrandDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing brand saving non-editables"),
responses={status.HTTP_200_OK: BrandDetailSerializer(), **BASE_ERRORS},
),
"seo_meta": extend_schema(
summary=_("SEO Meta snapshot for brand"),
responses={status.HTTP_200_OK: SeoSnapshotSerializer(), **BASE_ERRORS},
),
}
VENDOR_SCHEMA = {
"list": extend_schema(
summary=_("list all vendors (simple view)"),
responses={status.HTTP_200_OK: VendorSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single vendor (detailed view)"),
responses={status.HTTP_200_OK: VendorDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a vendor"),
responses={status.HTTP_201_CREATED: VendorDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a vendor"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing vendor saving non-editables"),
responses={status.HTTP_200_OK: VendorDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing vendor saving non-editables"),
responses={status.HTTP_200_OK: VendorDetailSerializer(), **BASE_ERRORS},
),
}
PRODUCT_IMAGE_SCHEMA = {
"list": extend_schema(
summary=_("list all product images (simple view)"),
responses={status.HTTP_200_OK: ProductImageSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single product image (detailed view)"),
responses={status.HTTP_200_OK: ProductImageDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a product image"),
responses={status.HTTP_201_CREATED: ProductImageDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a product image"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing product image saving non-editables"),
responses={status.HTTP_200_OK: ProductImageDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing product image saving non-editables"),
responses={status.HTTP_200_OK: ProductImageDetailSerializer(), **BASE_ERRORS},
),
}
PROMOCODE_SCHEMA = {
"list": extend_schema(
summary=_("list all promo codes (simple view)"),
responses={status.HTTP_200_OK: PromoCodeSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single promo code (detailed view)"),
responses={status.HTTP_200_OK: PromoCodeDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a promo code"),
responses={status.HTTP_201_CREATED: PromoCodeDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a promo code"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing promo code saving non-editables"),
responses={status.HTTP_200_OK: PromoCodeDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing promo code saving non-editables"),
responses={status.HTTP_200_OK: PromoCodeDetailSerializer(), **BASE_ERRORS},
),
}
PROMOTION_SCHEMA = {
"list": extend_schema(
summary=_("list all promotions (simple view)"),
responses={status.HTTP_200_OK: PromotionSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single promotion (detailed view)"),
responses={status.HTTP_200_OK: PromotionDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a promotion"),
responses={status.HTTP_201_CREATED: PromotionDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a promotion"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing promotion saving non-editables"),
responses={status.HTTP_200_OK: PromotionDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing promotion saving non-editables"),
responses={status.HTTP_200_OK: PromotionDetailSerializer(), **BASE_ERRORS},
),
}
STOCK_SCHEMA = {
"list": extend_schema(
summary=_("list all stocks (simple view)"),
responses={status.HTTP_200_OK: StockSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single stock (detailed view)"),
responses={status.HTTP_200_OK: StockDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a stock record"),
responses={status.HTTP_201_CREATED: StockDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a stock record"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing stock record saving non-editables"),
responses={status.HTTP_200_OK: StockDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing stock record saving non-editables"),
responses={status.HTTP_200_OK: StockDetailSerializer(), **BASE_ERRORS},
),
}
PRODUCT_TAG_SCHEMA = {
"list": extend_schema(
summary=_("list all product tags (simple view)"),
responses={status.HTTP_200_OK: ProductTagSimpleSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single product tag (detailed view)"),
responses={status.HTTP_200_OK: ProductTagDetailSerializer(), **BASE_ERRORS},
),
"create": extend_schema(
summary=_("create a product tag"),
responses={status.HTTP_201_CREATED: ProductTagDetailSerializer(), **BASE_ERRORS},
),
"destroy": extend_schema(
summary=_("delete a product tag"),
responses={status.HTTP_204_NO_CONTENT: {}, **BASE_ERRORS},
),
"update": extend_schema(
summary=_("rewrite an existing product tag saving non-editables"),
responses={status.HTTP_200_OK: ProductTagDetailSerializer(), **BASE_ERRORS},
),
"partial_update": extend_schema(
summary=_("rewrite some fields of an existing product tag saving non-editables"),
responses={status.HTTP_200_OK: ProductTagDetailSerializer(), **BASE_ERRORS},
),
}

View file

@ -301,66 +301,64 @@ class BuyAsBusinessView(APIView):
)
def download_digital_asset_view(request: HttpRequest, *args, **kwargs) -> FileResponse | JsonResponse:
try:
logger.debug(f"download_digital_asset_view: {kwargs}")
op_uuid = str(kwargs.get("order_product_uuid"))
if not op_uuid:
raise BadRequest(_("order_product_uuid is required"))
uuid = urlsafe_base64_decode(op_uuid).decode("utf-8")
class DownloadDigitalAssetView(APIView):
__doc__ = _( # type: ignore [assignment]
"Handles the downloading of a digital asset associated with an order.\n"
"This function attempts to serve the digital asset file located in the "
"storage directory of the project. If the file is not found, an HTTP 404 "
"error is raised to indicate the resource is unavailable."
)
download = DigitalAssetDownload.objects.get(order_product__uuid=uuid)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse | FileResponse:
try:
op_uuid = str(kwargs.get("order_product_uuid"))
if not op_uuid:
raise BadRequest(_("order_product_uuid is required"))
uuid = urlsafe_base64_decode(op_uuid).decode("utf-8")
if download.num_downloads >= 1:
raise BadRequest(_("you can only download the digital asset once"))
download = DigitalAssetDownload.objects.get(order_product__uuid=uuid)
if download.order_product.status != "FINISHED":
raise BadRequest(_("the order must be paid before downloading the digital asset"))
if download.num_downloads >= 1:
raise BadRequest(_("you can only download the digital asset once"))
download.num_downloads += 1
download.save()
if download.order_product.status != "FINISHED":
raise BadRequest(_("the order must be paid before downloading the digital asset"))
if not download.order_product.product:
raise BadRequest(_("the order product does not have a product"))
download.num_downloads += 1
download.save()
file_path = download.order_product.product.stocks.first().digital_asset.path # type: ignore [union-attr]
if not download.order_product.product:
raise BadRequest(_("the order product does not have a product"))
content_type, encoding = mimetypes.guess_type(file_path)
if not content_type:
content_type = "application/octet-stream"
file_path = download.order_product.product.stocks.first().digital_asset.path # type: ignore [union-attr]
response = FileResponse(open(file_path, "rb"), content_type=content_type)
filename = os.path.basename(file_path)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
content_type, encoding = mimetypes.guess_type(file_path)
if not content_type:
content_type = "application/octet-stream"
except BadRequest as e:
return JsonResponse(camelize({"error": str(e)}), status=400)
response = FileResponse(open(file_path, "rb"), content_type=content_type)
filename = os.path.basename(file_path)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except DigitalAssetDownload.DoesNotExist:
return JsonResponse(camelize({"error": "Digital asset not found"}), status=404)
except BadRequest as e:
return Response(data=camelize({"error": str(e)}), status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return JsonResponse(
camelize(
{
"error": "An error occurred while trying to download the digital asset",
"traceback": traceback.format_exc() if settings.DEBUG else None,
"received": {"order_product_uuid": kwargs.get("order_product_uuid", "")},
}
),
status=500,
)
except DigitalAssetDownload.DoesNotExist:
return Response(data=camelize({"error": "Digital asset not found"}), status=status.HTTP_404_NOT_FOUND)
# noinspection PyTypeChecker
download_digital_asset_view.__doc__ = _( # type: ignore [assignment]
"Handles the downloading of a digital asset associated with an order.\n"
"This function attempts to serve the digital asset file located in the "
"storage directory of the project. If the file is not found, an HTTP 404 "
"error is raised to indicate the resource is unavailable."
)
except Exception as e:
capture_exception(e)
return Response(
data=camelize(
{
"error": "An error occurred while trying to download the digital asset",
"traceback": traceback.format_exc() if settings.DEBUG else None,
"received": {"order_product_uuid": kwargs.get("order_product_uuid", "")},
}
),
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
def favicon_view(request: HttpRequest, *args, **kwargs) -> HttpResponse | FileResponse:

View file

@ -31,11 +31,18 @@ from core.docs.drf.viewsets import (
ATTRIBUTE_GROUP_SCHEMA,
ATTRIBUTE_SCHEMA,
ATTRIBUTE_VALUE_SCHEMA,
BRAND_SCHEMA,
CATEGORY_SCHEMA,
FEEDBACK_SCHEMA,
ORDER_PRODUCT_SCHEMA,
ORDER_SCHEMA,
PRODUCT_IMAGE_SCHEMA,
PRODUCT_SCHEMA,
PRODUCT_TAG_SCHEMA,
PROMOCODE_SCHEMA,
PROMOTION_SCHEMA,
STOCK_SCHEMA,
VENDOR_SCHEMA,
WISHLIST_SCHEMA,
)
from core.filters import AddressFilter, BrandFilter, CategoryFilter, FeedbackFilter, OrderFilter, ProductFilter
@ -313,6 +320,7 @@ class CategoryViewSet(EvibesViewSet):
return Response(SeoSnapshotSerializer(payload).data)
@extend_schema_view(**BRAND_SCHEMA)
class BrandViewSet(EvibesViewSet):
__doc__ = _(
"Represents a viewset for managing Brand instances. "
@ -554,6 +562,7 @@ class ProductViewSet(EvibesViewSet):
return Response(SeoSnapshotSerializer(payload).data)
@extend_schema_view(**VENDOR_SCHEMA)
class VendorViewSet(EvibesViewSet):
__doc__ = _(
"Represents a viewset for managing Vendor objects. "
@ -853,6 +862,7 @@ class OrderProductViewSet(EvibesViewSet):
return Response(status=status.HTTP_404_NOT_FOUND)
@extend_schema_view(**PRODUCT_IMAGE_SCHEMA)
class ProductImageViewSet(EvibesViewSet):
__doc__ = _("Manages operations related to Product images in the application. ")
@ -865,6 +875,7 @@ class ProductImageViewSet(EvibesViewSet):
}
@extend_schema_view(**PROMOCODE_SCHEMA)
class PromoCodeViewSet(EvibesViewSet):
__doc__ = _("Manages the retrieval and handling of PromoCode instances through various API actions.")
@ -886,6 +897,7 @@ class PromoCodeViewSet(EvibesViewSet):
return qs.filter(user=user)
@extend_schema_view(**PROMOTION_SCHEMA)
class PromotionViewSet(EvibesViewSet):
__doc__ = _("Represents a view set for managing promotions. ")
@ -898,6 +910,7 @@ class PromotionViewSet(EvibesViewSet):
}
@extend_schema_view(**STOCK_SCHEMA)
class StockViewSet(EvibesViewSet):
__doc__ = _("Handles operations related to Stock data in the system.")
@ -1100,6 +1113,7 @@ class AddressViewSet(EvibesViewSet):
)
@extend_schema_view(**PRODUCT_TAG_SCHEMA)
class ProductTagViewSet(EvibesViewSet):
__doc__ = _(
"Handles operations related to Product Tags within the application. "

View file

@ -0,0 +1,17 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema
from rest_framework import status
from core.docs.drf import BASE_ERRORS
from payments.serializers import TransactionSerializer
TRANSACTION_SCHEMA = {
"list": extend_schema(
summary=_("list all transactions (read-only)"),
responses={status.HTTP_200_OK: TransactionSerializer(many=True), **BASE_ERRORS},
),
"retrieve": extend_schema(
summary=_("retrieve a single transaction (read-only)"),
responses={status.HTTP_200_OK: TransactionSerializer(), **BASE_ERRORS},
),
}

View file

@ -1,10 +1,13 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema_view
from rest_framework.viewsets import ReadOnlyModelViewSet
from core.permissions import EvibesPermission, IsOwner
from payments.serializers import TransactionSerializer
from payments.docs.drf.viewsets import TRANSACTION_SCHEMA
@extend_schema_view(**TRANSACTION_SCHEMA)
class TransactionViewSet(ReadOnlyModelViewSet): # type: ignore
__doc__ = _( # type: ignore [assignment]
"ViewSet for handling read-only operations on the Transaction model. "