Source code for api.views

"""Module containing Rewards Suite API views."""

from adrf.views import APIView
from asgiref.sync import sync_to_async
from django.db import transaction
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.permissions import BasePermission
from rest_framework.response import Response

from api.serializers import (
    AggregatedCycleSerializer,
    ContributionSerializer,
    CycleSerializer,
    HumanizedContributionSerializer,
)
from core.models import (
    Contribution,
    Contributor,
    Cycle,
    Reward,
    RewardType,
    SocialPlatform,
)
from utils.constants.core import CONTRIBUTIONS_TAIL_SIZE
from utils.helpers import humanize_contributions


[docs] class IsLocalhostPermission(BasePermission): """Allow access only to requests from localhost."""
[docs] def has_permission(self, request, view): """Allow only localhost to access API endpoint. :param request: HTTP request object :type request: :class:`rest_framework.request.Request` :return: Boolean """ remote_addr = request.META.get("REMOTE_ADDR") # # You might also want to check HTTP_X_FORWARDED_FOR if behind proxy # x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') # if x_forwarded_for: # # X-Forwarded-For can contain multiple IPs, the first is original client # remote_addr = x_forwarded_for.split(',')[0].strip() return remote_addr in ["127.0.0.1", "localhost", "::1"]
# # HELPERS
[docs] async def aggregated_cycle_response(cycle: Cycle): """Generate aggregated cycle response with contributor rewards data. :param cycle: Cycle instance to aggregate data for :type cycle: :class:`core.models.Cycle` :return: DRF Response with aggregated cycle data :rtype: :class:`rest_framework.response.Response` """ if not cycle: return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) contributor_rewards = await sync_to_async(lambda: cycle.contributor_rewards)() total_rewards = await sync_to_async(lambda: cycle.total_rewards)() data = { "id": cycle.id, "start": cycle.start, "end": cycle.end, "contributor_rewards": contributor_rewards, "total_rewards": total_rewards or 0, } serializer = AggregatedCycleSerializer(data=data) serializer.is_valid() return Response(serializer.data)
[docs] async def contributions_response(contributions): """Fetch, humanize, serialize, and return contributions. :param contributions: QuerySet of Contribution objects :type contributions: :class:`django.db.models.QuerySet` :return: DRF Response with humanized contributions data :rtype: :class:`rest_framework.response.Response` """ # Run DB-dependent humanization on a thread pool data = await sync_to_async(lambda: humanize_contributions(contributions))() serializer = HumanizedContributionSerializer(data=data, many=True) serializer.is_valid() return Response(serializer.data)
[docs] class LocalhostAPIView(APIView): """Base APIView that restricts access to localhost by default.""" permission_classes = [IsLocalhostPermission]
[docs] class CycleAggregatedView(LocalhostAPIView): """API view to retrieve aggregated data for a specific cycle. :var cycle_id: URL parameter specifying the cycle identifier :type cycle_id: int """
[docs] async def get(self, request, cycle_id): """Handle GET request for specific cycle aggregated data. :param request: HTTP request object :type request: :class:`rest_framework.request.Request` :param cycle_id: cycle identifier from URL :type cycle_id: int :return: aggregated cycle data response :rtype: :class:`rest_framework.response.Response` """ cycle = await sync_to_async(lambda: Cycle.objects.filter(id=cycle_id).first())() return await aggregated_cycle_response(cycle)
[docs] class CurrentCycleAggregatedView(LocalhostAPIView): """API view to retrieve aggregated data for the current cycle."""
[docs] async def get(self, request): """Handle GET request for current cycle aggregated data. :param request: HTTP request object :type request: :class:`rest_framework.request.Request` :return: aggregated current cycle data response :rtype: :class:`rest_framework.response.Response` """ cycle = await sync_to_async(lambda: Cycle.objects.latest("start"))() return await aggregated_cycle_response(cycle)
[docs] class CyclePlainView(LocalhostAPIView): """API view to retrieve plain cycle data for a specific cycle. :var cycle_id: URL parameter specifying the cycle identifier :type cycle_id: int """
[docs] async def get(self, request, cycle_id): """Handle GET request for specific cycle plain data. :param request: HTTP request object :type request: :class:`rest_framework.request.Request` :param cycle_id: cycle identifier from URL :type cycle_id: int :return: plain cycle data response :rtype: :class:`rest_framework.response.Response` """ cycle = await sync_to_async(lambda: Cycle.objects.filter(id=cycle_id).first())() if not cycle: return Response( {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND ) serializer = CycleSerializer(cycle) return Response(serializer.data)
[docs] class CurrentCyclePlainView(LocalhostAPIView): """API view to retrieve plain cycle data for the current cycle."""
[docs] async def get(self, request): """Handle GET request for current cycle plain data. :param request: HTTP request object :type request: :class:`rest_framework.request.Request` :return: plain current cycle data response :rtype: :class:`rest_framework.response.Response` """ # Async database query cycle = await sync_to_async(lambda: Cycle.objects.latest("start"))() serializer = CycleSerializer(cycle) return Response(serializer.data)
[docs] class ContributionsView(LocalhostAPIView): """API view to retrieve contributions with optional contributor filtering."""
[docs] async def get(self, request): """Handle GET request for contributions data. :param request: HTTP request object with optional 'name' query parameter :type request: :class:`rest_framework.request.Request` :var username: contributor's username :type username: str :var contributor: contributor's model instance :type contributor: :class:`core.models.Contributor` :var queryset: QuerySet of Contribution objects :type queryset: :class:`django.db.models.QuerySet` :return: contributions data response :rtype: :class:`rest_framework.response.Response` """ username = request.GET.get("name") if username: contributor = await sync_to_async( lambda: Contributor.objects.from_handle(username) )() queryset = Contribution.objects.filter(contributor=contributor) else: queryset = Contribution.objects.order_by("-id")[ : CONTRIBUTIONS_TAIL_SIZE * 2 ] return await contributions_response(queryset)
[docs] class ContributionsTailView(LocalhostAPIView): """API view to retrieve the most recent contributions (tail)."""
[docs] async def get(self, request): """Handle GET request for recent contributions tail. :param request: HTTP request object :type request: :class:`rest_framework.request.Request` :return: recent contributions data response :rtype: :class:`rest_framework.response.Response` """ queryset = Contribution.objects.order_by("-id")[:CONTRIBUTIONS_TAIL_SIZE] return await contributions_response(queryset)
[docs] class AddContributionView(LocalhostAPIView): """API view to add new contributions."""
[docs] async def post(self, request): """Handle POST request to create a new contribution. :param request: HTTP request object with contribution data :type request: :class:`rest_framework.request.Request` :var username: contributor username :type username: str :var platform: social platform name :type platform: str :var type: reward type in format "[label] name" :type type: str :var level: contribution level :type level: int :var url: contribution URL :type url: str :var comment: optional comment :type comment: str :return: created contribution data or validation errors :rtype: :class:`rest_framework.response.Response` """ @sync_to_async def process_contribution(raw_data): """Process contribution data synchronously in thread pool. :param raw_data: raw contribution data from request :type raw_data: dict :return: tuple of (serialized_data, errors) :rtype: tuple """ contributor = Contributor.objects.from_full_handle(raw_data.get("username")) cycle = Cycle.objects.latest("start") platform = SocialPlatform.objects.get(name=raw_data.get("platform")) label, name = ( raw_data.get("type").split(" ", 1)[0].strip("[]"), raw_data.get("type").split(" ", 1)[1].strip(), ) reward_type = get_object_or_404(RewardType, label=label, name=name) rewards = Reward.objects.filter( type=reward_type, level=int(raw_data.get("level", 1)), active=True ) data = { "contributor": contributor.id, "cycle": cycle.id, "platform": platform.id, "reward": rewards[0].id, "percentage": 1, "url": raw_data.get("url"), "comment": raw_data.get("comment"), "confirmed": False, } serializer = ContributionSerializer(data=data) if serializer.is_valid(): with transaction.atomic(): serializer.save() return serializer.data, None return None, serializer.errors data, errors = await process_contribution(request.data) if data: return Response(data, status=status.HTTP_201_CREATED) return Response(errors, status=status.HTTP_400_BAD_REQUEST)