"""Module containing Rewards Suite API views."""
import logging
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,
IssueSerializer,
)
from core.models import (
Contribution,
Contributor,
Cycle,
IssueStatus,
Reward,
RewardType,
SocialPlatform,
)
from utils.constants.core import CONTRIBUTIONS_TAIL_SIZE
from utils.helpers import humanize_contributions
logger = logging.getLogger(__name__)
[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`
:var xff_address: forwarded address
:type xff_address: str
:var remote_addr: address that called the endpoint
:type remote_addr: str
:return: Boolean
"""
xff_address = request.META.get("HTTP_X_FORWARDED_FOR")
# xff_address could be: "127.0.0.1, 10.0.0.1"
remote_addr = (
xff_address.split(",")[0].strip()
if xff_address
else request.META.get("REMOTE_ADDR")
)
return remote_addr in ["127.0.0.1", "::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]
@sync_to_async
def process_contribution(raw_data, confirmed=False):
"""Process contribution data synchronously in thread pool.
:param raw_data: raw contribution data from request
:type raw_data: dict
:param confirmed: should contribution be created as confirmed or not
:type confirmed: Boolean
:var contributor: contributor instance
:type contributor: :class:`core.models.Contributor`
:var cycle: rewards cycle instance
:type cycle: :class:`core.models.Cycle`
:var platform: social platform instance
:type platform: :class:`core.models.SocialPlatform`
:var label: reward name
:type label: str
:var name: reward label
:type name: int
:var reward_type: reward type instance
:type reward_type: :class:`core.models.RewardType`
:var rewards: queryset of Reward objects
:type rewards: :class:`django.db.models.QuerySet`
:var data: prepared contribution data
:type data: dict
:var serializer: contribution serializer instance
:type serializer: :class:`api.serializers.ContributionSerializer`
:return: tuple of (serialized_data, errors)
:rtype: two-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", "")[:255],
"confirmed": confirmed,
}
logger.info(f"Contribution received: {raw_data.get('url')}")
serializer = ContributionSerializer(data=data)
if serializer.is_valid():
with transaction.atomic():
serializer.save()
logger.info(f"Contribution saved: {serializer.data.get('id')}")
return serializer.data, None
logger.error(f"Errors: {serializer.errors}")
return None, serializer.errors
[docs]
@sync_to_async
def process_issue(raw_data):
"""Process issue data synchronously in thread pool.
:param raw_data: raw issue data from request
:type raw_data: dict
:var data: prepared issue data
:type data: dict
:var serializer: issue serializer instance
:type serializer: :class:`api.serializers.IssueSerializer`
:return: tuple of (serialized_data, errors)
:rtype: tuple
"""
data = {"number": raw_data.get("issue_number"), "status": IssueStatus.CREATED}
logger.info(f"Issue received: {raw_data.get('issue_number')}")
serializer = IssueSerializer(data=data)
if serializer.is_valid():
with transaction.atomic():
serializer.save()
logger.info(f"Issue saved: {serializer.data.get('id')}")
return serializer.data, None
logger.error(f"Errors: {serializer.errors}")
return None, serializer.errors
[docs]
class AddContributionView(LocalhostAPIView):
"""API view to add new contribution."""
[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 data: prepared contribution data
:type data: dict
:var errors: collection of error messages
:type errors: dict
:return: created contribution data or validation errors
:rtype: :class:`rest_framework.response.Response`
"""
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)
[docs]
class AddIssueView(LocalhostAPIView):
"""API view to add new issue and related contribution."""
[docs]
async def post(self, request):
"""Handle POST request to create a new issue.
:param request: HTTP request object with issue data
:type request: :class:`rest_framework.request.Request`
:var contribution_data: prepared contribution data
:type contribution_data: dict
:var data: prepared issue data
:type data: dict
:var errors: collection of error messages
:type errors: dictomment
:return: created issue data or validation errors
:rtype: :class:`rest_framework.response.Response`
"""
contribution_data, errors = await process_contribution(
request.data, confirmed=True
)
if contribution_data:
data, errors = await process_issue(request.data)
if data:
await sync_to_async(
lambda: Contribution.objects.assign_issue(
data.get("id"), contribution_data.get("id")
)
)()
return Response(data, status=status.HTTP_201_CREATED)
return Response(errors, status=status.HTTP_400_BAD_REQUEST)