From f972e91deeecbf807f083ed6dd0bc9f74139d26f Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Wed, 12 Feb 2025 22:48:49 +1100 Subject: [PATCH] feat: working data_api app with views --- backend/apps/account/admin.py | 74 +-- .../migrations/0026_delete_dataapikey.py | 16 + backend/apps/account/models.py | 41 -- backend/apps/account/urls.py | 6 - backend/apps/account/views.py | 35 -- backend/apps/data_api/__init__.py | 0 backend/apps/data_api/admin.py | 162 ++++++ backend/apps/data_api/apps.py | 8 + backend/apps/data_api/decorators.py | 53 ++ .../apps/data_api/migrations/0001_initial.py | 277 ++++++++++ ..._key_account_alter_key_balance_and_more.py | 97 ++++ ...003_endpoint_slug_endpointcategory_slug.py | 23 + .../0004_alter_credit_currency_and_more.py | 34 ++ backend/apps/data_api/migrations/__init__.py | 0 backend/apps/data_api/models.py | 245 +++++++++ backend/apps/data_api/translation.py | 22 + backend/apps/data_api/urls.py | 44 ++ backend/apps/data_api/views.py | 471 ++++++++++++++++++ backend/settings/base.py | 5 + backend/urls.py | 1 + 20 files changed, 1461 insertions(+), 153 deletions(-) create mode 100644 backend/apps/account/migrations/0026_delete_dataapikey.py create mode 100644 backend/apps/data_api/__init__.py create mode 100644 backend/apps/data_api/admin.py create mode 100644 backend/apps/data_api/apps.py create mode 100644 backend/apps/data_api/decorators.py create mode 100644 backend/apps/data_api/migrations/0001_initial.py create mode 100644 backend/apps/data_api/migrations/0002_endpointcategory_alter_key_account_alter_key_balance_and_more.py create mode 100644 backend/apps/data_api/migrations/0003_endpoint_slug_endpointcategory_slug.py create mode 100644 backend/apps/data_api/migrations/0004_alter_credit_currency_and_more.py create mode 100644 backend/apps/data_api/migrations/__init__.py create mode 100644 backend/apps/data_api/models.py create mode 100644 backend/apps/data_api/translation.py create mode 100644 backend/apps/data_api/urls.py create mode 100644 backend/apps/data_api/views.py diff --git a/backend/apps/account/admin.py b/backend/apps/account/admin.py index ebe47162..0716b766 100644 --- a/backend/apps/account/admin.py +++ b/backend/apps/account/admin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django import forms -from django.contrib import admin, messages +from django.contrib import admin from django.contrib.admin import ModelAdmin from django.contrib.auth.admin import UserAdmin as BaseAccountAdmin from django.contrib.auth.forms import ReadOnlyPasswordHashField @@ -15,12 +15,12 @@ BDGroup, BDRole, Career, - DataAPIKey, Role, Subscription, Team, ) from backend.apps.account.tasks import sync_subscription_task +from backend.apps.data_api.admin import KeyInline def sync_subscription(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet): @@ -201,30 +201,6 @@ def queryset(self, request, queryset): return queryset.filter(subscription__status=self.value()) -class DataAPIKeyInline(admin.TabularInline): - model = DataAPIKey - extra = 0 - readonly_fields = ( - "id", - "name", - "prefix", - "is_active", - "expires_at", - "last_used_at", - "created_at", - "updated_at", - ) - fields = readonly_fields - can_delete = False - show_change_link = True - - def has_add_permission(self, request, obj=None): - return False - - def has_change_permission(self, request, obj=None): - return False - - class AccountAdmin(BaseAccountAdmin): form = AccountChangeForm add_form = AccountCreationForm @@ -324,7 +300,7 @@ class AccountAdmin(BaseAccountAdmin): ) search_fields = ("email", "full_name") ordering = ["-created_at"] - inlines = (CareerInline, SubscriptionInline, DataAPIKeyInline) + inlines = (CareerInline, SubscriptionInline, KeyInline) filter_horizontal = () def is_subscriber(self, instance): @@ -411,54 +387,10 @@ def has_delete_permission(self, request, obj=None): return False -class DataAPIKeyAdmin(admin.ModelAdmin): - list_display = ( - "name", - "account", - "prefix", - "is_active", - "expires_at", - "last_used_at", - "created_at", - ) - list_filter = ("is_active",) - search_fields = ("name", "prefix", "account__email", "account__full_name") - readonly_fields = ("id", "prefix", "hash", "last_used_at", "created_at", "updated_at") - fieldsets = ( - ( - None, - { - "fields": ( - "account", - "name", - "is_active", - "expires_at", - ) - }, - ), - ) - ordering = ["-created_at"] - - def has_add_permission(self, request): - return True - - def save_model(self, request, obj, form, change): - if not change: # Only when creating new object - obj, key = DataAPIKey.create_key(**form.cleaned_data) - messages.success( - request, - f"API Key generated successfully. " - f"Please copy this key now as it won't be shown again: {key}", - ) - else: - super().save_model(request, obj, form, change) - - admin.site.register(Account, AccountAdmin) admin.site.register(Career, CareerAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(Subscription, SubscriptionAdmin) admin.site.register(Team, TeamAdmin) -admin.site.register(DataAPIKey, DataAPIKeyAdmin) admin.site.register(BDGroup) admin.site.register(BDRole) diff --git a/backend/apps/account/migrations/0026_delete_dataapikey.py b/backend/apps/account/migrations/0026_delete_dataapikey.py new file mode 100644 index 00000000..6d356fea --- /dev/null +++ b/backend/apps/account/migrations/0026_delete_dataapikey.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.18 on 2025-02-12 07:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0025_rename_hashed_key_hash"), + ] + + operations = [ + migrations.DeleteModel( + name="DataAPIKey", + ), + ] diff --git a/backend/apps/account/models.py b/backend/apps/account/models.py index ac59fd1b..a2be8cab 100644 --- a/backend/apps/account/models.py +++ b/backend/apps/account/models.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from hashlib import sha256 from typing import Tuple from uuid import uuid4 @@ -610,46 +609,6 @@ def next_billing_cycle(self): return None -class DataAPIKey(BaseModel): - id = models.UUIDField(primary_key=True, default=uuid4) - account = models.ForeignKey(Account, on_delete=models.DO_NOTHING, related_name="data_api_keys") - name = models.CharField( - max_length=100, null=True, blank=True, help_text="A friendly name to identify this API key" - ) - hash = models.CharField( - max_length=64, unique=True, null=True, blank=True, help_text="The hashed API key" - ) - prefix = models.CharField( - max_length=8, - unique=True, - null=True, - blank=True, - help_text="First 8 characters of the API key", - ) - is_active = models.BooleanField(default=True) - expires_at = models.DateTimeField(null=True, blank=True, help_text="Optional expiration date") - last_used_at = models.DateTimeField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - verbose_name = "Data API Key" - verbose_name_plural = "Data API Keys" - ordering = ["created_at"] - - def __str__(self): - return f"{self.name} ({self.prefix}...)" - - @classmethod - def create_key(cls, **kwargs): - key = str(uuid4()) - obj = cls(**kwargs) - obj.prefix = key[:8] - obj.hash = sha256(key.encode()).hexdigest() - obj.save() - return obj, key - - def split_password(password: str) -> Tuple[str, str, str, str]: """Split a password into four parts: algorithm, iterations, salt, and hash""" algorithm, iterations, salt, hash = password.split("$", 3) diff --git a/backend/apps/account/urls.py b/backend/apps/account/urls.py index 3e133e19..afa101df 100644 --- a/backend/apps/account/urls.py +++ b/backend/apps/account/urls.py @@ -4,7 +4,6 @@ from backend.apps.account.views import ( AccountActivateConfirmView, AccountActivateView, - DataAPIKeyValidateView, PasswordResetConfirmView, PasswordResetView, ) @@ -30,9 +29,4 @@ PasswordResetConfirmView.as_view(), name="password_reset_confirm", ), - path( - "account/validate_data_api_key", - DataAPIKeyValidateView.as_view(), - name="validate_data_api_key", - ), ] diff --git a/backend/apps/account/views.py b/backend/apps/account/views.py index 27d0ddb3..21ebd018 100644 --- a/backend/apps/account/views.py +++ b/backend/apps/account/views.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from hashlib import sha256 from json import loads from typing import Any @@ -11,7 +10,6 @@ from django.http import JsonResponse from django.template.loader import render_to_string from django.urls import reverse_lazy as r -from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.encoding import force_bytes, force_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode @@ -23,8 +21,6 @@ from backend.apps.account.token import token_generator from backend.custom.environment import get_frontend_url -from .models import DataAPIKey - class AccountActivateView(View): @method_decorator(csrf_exempt, name="dispatch") @@ -143,34 +139,3 @@ def dispatch(self, request, uidb64, token): return JsonResponse({}, status=200) else: return JsonResponse({}, status=422) - - -class DataAPIKeyValidateView(View): - def get(self, request): - api_key = request.GET.get("id") - if not api_key: - return JsonResponse({"error": "API key not provided", "success": False}, status=400) - - # Hash the API key - hashed_key = sha256(api_key.encode()).hexdigest() - - try: - key = DataAPIKey.objects.get(hash=hashed_key) - - # Check if key is expired - is_expired = False - if key.expires_at and key.expires_at < timezone.now(): - is_expired = True - - return JsonResponse( - { - "success": True, - "resource": { - "isActive": key.is_active and not is_expired, - "createdAt": key.created_at, - "expiresAt": key.expires_at, - }, - } - ) - except DataAPIKey.DoesNotExist: - return JsonResponse({"error": "API key not found", "success": False}, status=404) diff --git a/backend/apps/data_api/__init__.py b/backend/apps/data_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/data_api/admin.py b/backend/apps/data_api/admin.py new file mode 100644 index 00000000..86002090 --- /dev/null +++ b/backend/apps/data_api/admin.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin, messages + +from .models import ( + Credit, + Endpoint, + EndpointCategory, + EndpointParameter, + EndpointPricingTier, + Key, + Request, +) + + +class KeyInline(admin.TabularInline): + model = Key + extra = 0 + readonly_fields = ( + "id", + "name", + "prefix", + "is_active", + "expires_at", + "created_at", + "updated_at", + ) + fields = readonly_fields + can_delete = False + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + +class EndpointParameterInline(admin.TabularInline): + model = EndpointParameter + extra = 0 + readonly_fields = ("id", "created_at", "updated_at") + + +class EndpointPricingTierInline(admin.TabularInline): + model = EndpointPricingTier + extra = 0 + readonly_fields = ("id", "created_at", "updated_at") + + +class KeyAdmin(admin.ModelAdmin): + list_display = ( + "name", + "account", + "prefix", + "balance", + "is_active", + "expires_at", + "created_at", + ) + list_filter = ("is_active",) + search_fields = ("name", "prefix", "account__email", "account__full_name") + readonly_fields = ("id", "prefix", "hash", "balance", "created_at", "updated_at") + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "account", + "prefix", + "balance", + "is_active", + "expires_at", + ) + }, + ), + ) + ordering = ["-created_at"] + + def has_add_permission(self, request): + return True + + def save_model(self, request, obj, form, change): + if not change: # Only when creating new object + obj, key = Key.create_key(**form.cleaned_data) + messages.success( + request, + f"API Key generated successfully. " + f"Please copy this key now as it won't be shown again: {key}", + ) + else: + super().save_model(request, obj, form, change) + + +class EndpointCategoryAdmin(admin.ModelAdmin): + list_display = ("slug", "name", "description") + list_filter = ("slug", "name", "description") + search_fields = ("slug", "name", "description") + readonly_fields = ("id", "created_at", "updated_at") + + +class EndpointParameterAdmin(admin.ModelAdmin): + list_display = ("name", "description", "endpoint") + list_filter = ("name", "description", "endpoint") + search_fields = ("name", "description", "endpoint__name") + readonly_fields = ("id", "created_at", "updated_at") + + +class EndpointPricingTierAdmin(admin.ModelAdmin): + list_display = ("endpoint", "min_requests", "max_requests", "price_per_request") + list_filter = ("endpoint", "min_requests", "max_requests", "price_per_request") + search_fields = ("endpoint__name", "min_requests", "max_requests", "price_per_request") + readonly_fields = ("id", "created_at", "updated_at") + + +class EndpointAdmin(admin.ModelAdmin): + list_display = ("slug", "name", "description", "category") + list_filter = ("slug", "name", "description", "category") + search_fields = ("slug", "name", "description", "category__name") + readonly_fields = ("id", "created_at", "updated_at", "full_slug", "full_name") + inlines = [EndpointParameterInline, EndpointPricingTierInline] + + +class CreditAdmin(admin.ModelAdmin): + list_display = ("key", "amount", "currency", "created_at") + list_filter = ("key", "currency") + search_fields = ("key__name", "currency__name") + readonly_fields = ("id", "created_at", "updated_at") + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +class RequestAdmin(admin.ModelAdmin): + list_display = ("key", "endpoint", "created_at") + list_filter = ("key", "endpoint") + search_fields = ("key__name", "endpoint__name") + readonly_fields = ("id", "created_at", "updated_at") + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +admin.site.register(Key, KeyAdmin) +admin.site.register(Endpoint, EndpointAdmin) +admin.site.register(EndpointCategory, EndpointCategoryAdmin) +admin.site.register(EndpointParameter, EndpointParameterAdmin) +admin.site.register(EndpointPricingTier, EndpointPricingTierAdmin) +admin.site.register(Credit, CreditAdmin) +admin.site.register(Request, RequestAdmin) diff --git a/backend/apps/data_api/apps.py b/backend/apps/data_api/apps.py new file mode 100644 index 00000000..c2f98894 --- /dev/null +++ b/backend/apps/data_api/apps.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig + + +class DataAPIConfig(AppConfig): + name = "backend.apps.data_api" + verbose_name = "Data API" + default_auto_field = "django.db.models.BigAutoField" diff --git a/backend/apps/data_api/decorators.py b/backend/apps/data_api/decorators.py new file mode 100644 index 00000000..29e84dec --- /dev/null +++ b/backend/apps/data_api/decorators.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +import os +from functools import wraps + +import stripe +from django.http import JsonResponse + + +def cloud_function_only(view_func): + @wraps(view_func) + def wrapped_view(view_instance, request, *args, **kwargs): + # Get the Cloud Function's secret key from environment variables + cloud_function_key = os.getenv("CLOUD_FUNCTION_KEY") + + # Get the authorization header + auth_header = request.headers.get("X-Cloud-Function-Key") + + if not auth_header or auth_header != cloud_function_key: + return JsonResponse({"error": "Unauthorized access", "success": False}, status=403) + + return view_func(view_instance, request, *args, **kwargs) + + return wrapped_view + + +def stripe_webhook_only(view_func): + @wraps(view_func) + def wrapped_view(view_instance, request, *args, **kwargs): + # Get the Stripe webhook secret from environment variables + stripe_webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET") + + # Get the Stripe signature header + stripe_signature = request.headers.get("Stripe-Signature") + + if not stripe_signature: + return JsonResponse({"error": "Missing Stripe signature", "success": False}, status=403) + + try: + # Verify the event using the signature and webhook secret + event = stripe.Webhook.construct_event( + request.body, stripe_signature, stripe_webhook_secret + ) + except stripe.error.SignatureVerificationError: + return JsonResponse({"error": "Invalid Stripe signature", "success": False}, status=403) + except Exception: + return JsonResponse({"error": "Invalid webhook request", "success": False}, status=400) + + # Add the verified Stripe event to the request + request.stripe_event = event + + return view_func(view_instance, request, *args, **kwargs) + + return wrapped_view diff --git a/backend/apps/data_api/migrations/0001_initial.py b/backend/apps/data_api/migrations/0001_initial.py new file mode 100644 index 00000000..c3a4fd11 --- /dev/null +++ b/backend/apps/data_api/migrations/0001_initial.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.18 on 2025-02-12 07:04 + +import uuid + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + operations = [ + migrations.CreateModel( + name="Endpoint", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("name", models.CharField(max_length=100)), + ("name_pt", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("name_es", models.CharField(max_length=100, null=True)), + ("description", models.TextField(blank=True)), + ("description_pt", models.TextField(blank=True, null=True)), + ("description_en", models.TextField(blank=True, null=True)), + ("description_es", models.TextField(blank=True, null=True)), + ("is_active", models.BooleanField(default=True)), + ("is_deprecated", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Endpoint", + "verbose_name_plural": "Endpoints", + "ordering": ["created_at"], + }, + ), + migrations.CreateModel( + name="Key", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ( + "name", + models.CharField( + blank=True, + help_text="A friendly name to identify this API key", + max_length=100, + null=True, + ), + ), + ( + "hash", + models.CharField( + blank=True, + help_text="The hashed API key", + max_length=64, + null=True, + unique=True, + ), + ), + ( + "prefix", + models.CharField( + blank=True, + help_text="First 8 characters of the API key", + max_length=8, + null=True, + unique=True, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "balance", + models.DecimalField( + decimal_places=2, + default=0, + help_text="The balance of the API key in BRL", + max_digits=12, + ), + ), + ( + "expires_at", + models.DateTimeField( + blank=True, help_text="Optional expiration date", null=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="data_keys", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Key", + "verbose_name_plural": "Keys", + "ordering": ["created_at"], + }, + ), + migrations.CreateModel( + name="Request", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("parameters", models.JSONField(default=dict)), + ("error_message", models.TextField(blank=True)), + ("response_time", models.FloatField(default=0)), + ("bytes_processed", models.BigIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="requests", + to="data_api.endpoint", + ), + ), + ( + "key", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="requests", + to="data_api.key", + ), + ), + ], + options={ + "verbose_name": "Request", + "verbose_name_plural": "Requests", + "ordering": ["created_at"], + }, + ), + migrations.CreateModel( + name="EndpointPricingTier", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ( + "min_requests", + models.PositiveIntegerField( + help_text="Minimum number of requests for this tier" + ), + ), + ( + "max_requests", + models.PositiveIntegerField( + blank=True, help_text="Maximum number of requests for this tier", null=True + ), + ), + ( + "price_per_request", + models.DecimalField( + decimal_places=4, + help_text="Price per request", + max_digits=10, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "currency", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="endpoint_pricing_tiers", + to="v1.measurementunit", + ), + ), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pricing_tiers", + to="data_api.endpoint", + ), + ), + ], + options={ + "verbose_name": "Endpoint Pricing Tier", + "verbose_name_plural": "Endpoint Pricing Tiers", + "ordering": ["min_requests"], + }, + ), + migrations.CreateModel( + name="EndpointParameter", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("name", models.CharField(max_length=100)), + ("name_pt", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("name_es", models.CharField(max_length=100, null=True)), + ("description", models.TextField(blank=True)), + ("description_pt", models.TextField(blank=True, null=True)), + ("description_en", models.TextField(blank=True, null=True)), + ("description_es", models.TextField(blank=True, null=True)), + ("required", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="parameters", + to="data_api.endpoint", + ), + ), + ( + "type", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="parameters", + to="v1.bigquerytype", + ), + ), + ], + options={ + "verbose_name": "Endpoint Parameter", + "verbose_name_plural": "Endpoint Parameters", + }, + ), + migrations.CreateModel( + name="Credit", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ( + "amount", + models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[ + django.core.validators.MinValueValidator( + 0.01, message="Amount must be greater than zero" + ) + ], + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "currency", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="credits", + to="v1.measurementunit", + ), + ), + ( + "key", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="credits", + to="data_api.key", + ), + ), + ], + options={ + "verbose_name": "Credit", + "verbose_name_plural": "Credits", + "ordering": ["created_at"], + }, + ), + migrations.AddConstraint( + model_name="endpointpricingtier", + constraint=models.CheckConstraint( + check=models.Q( + ("max_requests__gt", models.F("min_requests")), + ("max_requests__isnull", True), + _connector="OR", + ), + name="max_requests_greater_than_min", + ), + ), + ] diff --git a/backend/apps/data_api/migrations/0002_endpointcategory_alter_key_account_alter_key_balance_and_more.py b/backend/apps/data_api/migrations/0002_endpointcategory_alter_key_account_alter_key_balance_and_more.py new file mode 100644 index 00000000..c636172f --- /dev/null +++ b/backend/apps/data_api/migrations/0002_endpointcategory_alter_key_account_alter_key_balance_and_more.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.18 on 2025-02-12 07:26 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("data_api", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="EndpointCategory", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("name", models.CharField(max_length=100)), + ("name_pt", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("name_es", models.CharField(max_length=100, null=True)), + ("description", models.TextField(blank=True)), + ("description_pt", models.TextField(blank=True, null=True)), + ("description_en", models.TextField(blank=True, null=True)), + ("description_es", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Endpoint Category", + "verbose_name_plural": "Endpoint Categories", + "ordering": ["created_at"], + }, + ), + migrations.AlterField( + model_name="key", + name="account", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="keys", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="key", + name="balance", + field=models.DecimalField( + decimal_places=2, + default=0, + help_text="The balance of the key in BRL", + max_digits=12, + ), + ), + migrations.AlterField( + model_name="key", + name="hash", + field=models.CharField( + blank=True, help_text="The hashed key", max_length=64, null=True, unique=True + ), + ), + migrations.AlterField( + model_name="key", + name="name", + field=models.CharField( + blank=True, + help_text="A friendly name to identify this key", + max_length=100, + null=True, + ), + ), + migrations.AlterField( + model_name="key", + name="prefix", + field=models.CharField( + blank=True, + help_text="First 8 characters of the key", + max_length=8, + null=True, + unique=True, + ), + ), + migrations.AddField( + model_name="endpoint", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="endpoints", + to="data_api.endpointcategory", + ), + ), + ] diff --git a/backend/apps/data_api/migrations/0003_endpoint_slug_endpointcategory_slug.py b/backend/apps/data_api/migrations/0003_endpoint_slug_endpointcategory_slug.py new file mode 100644 index 00000000..08d511cf --- /dev/null +++ b/backend/apps/data_api/migrations/0003_endpoint_slug_endpointcategory_slug.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.18 on 2025-02-12 08:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("data_api", "0002_endpointcategory_alter_key_account_alter_key_balance_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="endpoint", + name="slug", + field=models.SlugField(max_length=100), + ), + migrations.AddField( + model_name="endpointcategory", + name="slug", + field=models.SlugField(max_length=100), + ), + ] diff --git a/backend/apps/data_api/migrations/0004_alter_credit_currency_and_more.py b/backend/apps/data_api/migrations/0004_alter_credit_currency_and_more.py new file mode 100644 index 00000000..1781a92a --- /dev/null +++ b/backend/apps/data_api/migrations/0004_alter_credit_currency_and_more.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.18 on 2025-02-12 10:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("data_api", "0003_endpoint_slug_endpointcategory_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="credit", + name="currency", + field=models.ForeignKey( + limit_choices_to={"category__slug": "currency"}, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="credits", + to="v1.measurementunit", + ), + ), + migrations.AlterField( + model_name="endpointpricingtier", + name="currency", + field=models.ForeignKey( + limit_choices_to={"category__slug": "currency"}, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="endpoint_pricing_tiers", + to="v1.measurementunit", + ), + ), + ] diff --git a/backend/apps/data_api/migrations/__init__.py b/backend/apps/data_api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/data_api/models.py b/backend/apps/data_api/models.py new file mode 100644 index 00000000..1b3766d1 --- /dev/null +++ b/backend/apps/data_api/models.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +from hashlib import sha256 +from uuid import uuid4 + +from django.core.validators import MinValueValidator +from django.db import models + +from backend.apps.account.models import Account +from backend.apps.api.v1.models import BigQueryType, MeasurementUnit +from backend.custom.model import BaseModel + + +class Key(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + account = models.ForeignKey(Account, on_delete=models.DO_NOTHING, related_name="keys") + name = models.CharField( + max_length=100, null=True, blank=True, help_text="A friendly name to identify this key" + ) + hash = models.CharField( + max_length=64, unique=True, null=True, blank=True, help_text="The hashed key" + ) + prefix = models.CharField( + max_length=8, + unique=True, + null=True, + blank=True, + help_text="First 8 characters of the key", + ) + is_active = models.BooleanField(default=True) + balance = models.DecimalField( + max_digits=12, + decimal_places=2, + default=0, + help_text="The balance of the key in BRL", + ) + expires_at = models.DateTimeField(null=True, blank=True, help_text="Optional expiration date") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Key" + verbose_name_plural = "Keys" + ordering = ["created_at"] + + def __str__(self): + return f"{self.name} ({self.prefix}...)" + + @classmethod + def create_key(cls, **kwargs): + key = str(uuid4()) + obj = cls(**kwargs) + obj.prefix = key[:8] + obj.hash = sha256(key.encode()).hexdigest() + obj.save() + return obj, key + + +class EndpointCategory(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + slug = models.SlugField(max_length=100) + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Endpoint Category" + verbose_name_plural = "Endpoint Categories" + ordering = ["created_at"] + + def __str__(self): + return self.name + + +class Endpoint(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + slug = models.SlugField(max_length=100) + name = models.CharField(max_length=100) + category = models.ForeignKey( + EndpointCategory, + on_delete=models.DO_NOTHING, + related_name="endpoints", + null=True, + blank=True, + ) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + is_deprecated = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Endpoint" + verbose_name_plural = "Endpoints" + ordering = ["created_at"] + + def __str__(self): + return self.name + + @property + def full_name(self): + return f"{self.category.name}.{self.name}" + + @property + def full_slug(self): + return f"{self.category.slug}.{self.slug}" + + @property + def parameters(self): + return self.parameters.all() + + def clean(self): + super().clean() + # TODO: Add validation for pricing tiers to ensure: + # 1. No overlapping tiers + # 2. No gaps between tiers + # 3. Only one unlimited tier + # Note: Consider implementing this at the form/admin level to allow individual tier edits + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def get_pricing_tier(self, request_count: int): + """ + Get the pricing tier for a given number of requests. + + Args: + request_count (int): Number of requests made in the current period + + Returns: + EndpointPricingTier: The pricing tier object that matches the request count, + or None if no matching tier is found + """ + return self.pricing_tiers.filter( + models.Q(min_requests__lte=request_count) + & (models.Q(max_requests__gte=request_count) | models.Q(max_requests__isnull=True)) + ).first() + + +class EndpointParameter(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + endpoint = models.ForeignKey(Endpoint, on_delete=models.DO_NOTHING, related_name="parameters") + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + type = models.ForeignKey(BigQueryType, on_delete=models.DO_NOTHING, related_name="parameters") + required = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Endpoint Parameter" + verbose_name_plural = "Endpoint Parameters" + + def __str__(self): + return f"{self.endpoint.name} - {self.name}" + + +class Request(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + key = models.ForeignKey(Key, on_delete=models.DO_NOTHING, related_name="requests") + endpoint = models.ForeignKey(Endpoint, on_delete=models.DO_NOTHING, related_name="requests") + parameters = models.JSONField(default=dict) + error_message = models.TextField(blank=True) + response_time = models.FloatField(default=0) + bytes_processed = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Request" + verbose_name_plural = "Requests" + ordering = ["created_at"] + + def __str__(self): + return f"{self.key.name} - {self.endpoint.name}" + + +class Credit(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + key = models.ForeignKey(Key, on_delete=models.DO_NOTHING, related_name="credits") + amount = models.DecimalField( + max_digits=12, + decimal_places=2, + validators=[MinValueValidator(0.01, message="Amount must be greater than zero")], + ) + currency = models.ForeignKey( + MeasurementUnit, + on_delete=models.DO_NOTHING, + related_name="credits", + limit_choices_to={"category__slug": "currency"}, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Credit" + verbose_name_plural = "Credits" + ordering = ["created_at"] + + def __str__(self): + return f"{self.key.name} - {self.amount} {self.currency.name}" + + +class EndpointPricingTier(BaseModel): + id = models.UUIDField(primary_key=True, default=uuid4) + endpoint = models.ForeignKey(Endpoint, on_delete=models.CASCADE, related_name="pricing_tiers") + min_requests = models.PositiveIntegerField(help_text="Minimum number of requests for this tier") + max_requests = models.PositiveIntegerField( + help_text="Maximum number of requests for this tier", null=True, blank=True + ) + price_per_request = models.DecimalField( + max_digits=10, + decimal_places=4, + help_text="Price per request", + validators=[MinValueValidator(0)], + ) + currency = models.ForeignKey( + MeasurementUnit, + on_delete=models.DO_NOTHING, + related_name="endpoint_pricing_tiers", + limit_choices_to={"category__slug": "currency"}, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Endpoint Pricing Tier" + verbose_name_plural = "Endpoint Pricing Tiers" + ordering = ["min_requests"] + constraints = [ + models.CheckConstraint( + check=models.Q(max_requests__gt=models.F("min_requests")) + | models.Q(max_requests__isnull=True), + name="max_requests_greater_than_min", + ) + ] + + def __str__(self): + if self.max_requests: + return ( + f"{self.endpoint.name}: {self.min_requests}-{self.max_requests} " + f"requests @ R${self.price_per_request}" + ) + return f"{self.endpoint.name}: {self.min_requests}+ requests @ R${self.price_per_request}" diff --git a/backend/apps/data_api/translation.py b/backend/apps/data_api/translation.py new file mode 100644 index 00000000..9df33b58 --- /dev/null +++ b/backend/apps/data_api/translation.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +from modeltranslation.translator import TranslationOptions, translator + +from .models import Endpoint, EndpointCategory, EndpointParameter + + +class EndpointTranslationOptions(TranslationOptions): + fields = ("name", "description") + + +class EndpointParameterTranslationOptions(TranslationOptions): + fields = ("name", "description") + + +class EndpointCategoryTranslationOptions(TranslationOptions): + fields = ("name", "description") + + +translator.register(Endpoint, EndpointTranslationOptions) +translator.register(EndpointParameter, EndpointParameterTranslationOptions) +translator.register(EndpointCategory, EndpointCategoryTranslationOptions) diff --git a/backend/apps/data_api/urls.py b/backend/apps/data_api/urls.py new file mode 100644 index 00000000..be279702 --- /dev/null +++ b/backend/apps/data_api/urls.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from django.urls import path + +from .views import ( + DataAPICreditAddView, + DataAPICreditDeductView, + DataAPICurrentTierView, + DataAPIEndpointValidateView, + DataAPIKeyValidateView, + DataAPIRequestRegisterView, +) + +urlpatterns = [ + path( + "data_api/keys/validate", + DataAPIKeyValidateView.as_view(), + name="validate_api_key", + ), + path( + "data_api/credits/add", + DataAPICreditAddView.as_view(), + name="add_credit", + ), + path( + "data_api/credits/deduct", + DataAPICreditDeductView.as_view(), + name="deduct_credit", + ), + path( + "data_api/endpoints/validate", + DataAPIEndpointValidateView.as_view(), + name="validate_endpoint", + ), + path( + "data_api/requests/current_tier", + DataAPICurrentTierView.as_view(), + name="current_tier", + ), + path( + "data_api/requests/register", + DataAPIRequestRegisterView.as_view(), + name="register_request", + ), +] diff --git a/backend/apps/data_api/views.py b/backend/apps/data_api/views.py new file mode 100644 index 00000000..576707df --- /dev/null +++ b/backend/apps/data_api/views.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +import json +from decimal import Decimal +from hashlib import sha256 + +from django.http import JsonResponse +from django.utils import timezone +from django.views import View + +from backend.apps.api.v1.models import MeasurementUnit + +from .models import Credit, Endpoint, Key, Request + + +class DataAPIKeyValidateView(View): + def get(self, request): + key = request.GET.get("key") + if not key: + return JsonResponse({"error": "API key not provided", "success": False}, status=400) + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + key = Key.objects.get(hash=hashed_key) + + # Check if key is expired + is_expired = False + if key.expires_at and key.expires_at < timezone.now(): + is_expired = True + + return JsonResponse( + { + "success": True, + "resource": { + "isActive": key.is_active and not is_expired, + "createdAt": key.created_at, + "expiresAt": key.expires_at, + "balance": float(key.balance), + }, + } + ) + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + + +class DataAPICurrentTierView(View): + def get(self, request): + key = request.GET.get("key") + category_slug = request.GET.get("category") + endpoint_slug = request.GET.get("endpoint") + + if not all([key, category_slug, endpoint_slug]): + return JsonResponse( + {"error": "Missing required parameters", "success": False}, status=400 + ) + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + key = Key.objects.get(hash=hashed_key) + endpoint = Endpoint.objects.get(category__slug=category_slug, slug=endpoint_slug) + + # Get the first day of current month + today = timezone.now() + first_day = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # Count requests for current month + monthly_requests = Request.objects.filter( + key=key, endpoint=endpoint, created_at__gte=first_day + ).count() + + # Get current pricing tier + current_tier = endpoint.get_pricing_tier(monthly_requests) + + if not current_tier: + return JsonResponse( + {"error": "No pricing tier found for this request volume", "success": False}, + status=404, + ) + + return JsonResponse( + { + "success": True, + "resource": { + "monthly_requests": monthly_requests, + "current_tier": { + "min_requests": current_tier.min_requests, + "max_requests": current_tier.max_requests, + "price_per_request": float(current_tier.price_per_request), + }, + }, + } + ) + + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except Endpoint.DoesNotExist: + return JsonResponse({"error": "Endpoint not found", "success": False}, status=404) + + +class DataAPICreditAddView(View): + # TODO: remove GET method when in production + def get(self, request): + key = request.GET.get("key") + amount = request.GET.get("amount") + currency = request.GET.get("currency") + + if not all([key, amount, currency]): + return JsonResponse( + {"error": "Missing required parameters", "success": False}, status=400 + ) + + # Validate currency is BRL + if currency != "BRL": + return JsonResponse( + {"error": "Only BRL currency is supported", "success": False}, status=400 + ) + + try: + amount = float(amount) + except ValueError: + return JsonResponse({"error": "Invalid amount format", "success": False}, status=400) + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + amount = Decimal(str(amount)) + key = Key.objects.get(hash=hashed_key) + currency_obj = MeasurementUnit.objects.get(slug=currency.lower()) + + # Create credit record + Credit.objects.create(key=key, amount=amount, currency=currency_obj) + + # Update API key balance + key.balance += amount + key.save() + + return JsonResponse({"success": True, "new_balance": float(key.balance)}) + + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except MeasurementUnit.DoesNotExist: + return JsonResponse({"error": "Currency not found", "success": False}, status=404) + + # @stripe_webhook_only # TODO: Uncomment this when in production + def post(self, request): + event = request.stripe_event + + # Only process successful payment events + if event.type != "payment_intent.succeeded": + return JsonResponse({"success": True, "message": f"Ignored event type {event.type}"}) + + try: + payment_intent = event.data.object + metadata = payment_intent.metadata + + key = metadata.get("key") + amount = float(payment_intent.amount) / 100 # Convert from cents to BRL + currency = payment_intent.currency.upper() + + if not key: + raise ValueError("API key not found in payment metadata") + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + amount = Decimal(str(amount)) + key = Key.objects.get(hash=hashed_key) + currency_obj = MeasurementUnit.objects.get(slug=currency.lower()) + + # Create credit record + Credit.objects.create(key=key, amount=amount, currency=currency_obj) + + # Update API key balance + key.balance += amount + key.save() + + return JsonResponse({"success": True, "new_balance": float(key.balance)}) + + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except MeasurementUnit.DoesNotExist: + return JsonResponse({"error": "Currency not found", "success": False}, status=404) + + except Exception as e: + return JsonResponse({"error": str(e), "success": False}, status=400) + + +class DataAPICreditDeductView(View): + # TODO: remove GET method when in production + def get(self, request): + key = request.GET.get("key") + amount = request.GET.get("amount") + currency = request.GET.get("currency") + + if not all([key, amount, currency]): + return JsonResponse( + {"error": "Missing required parameters", "success": False}, status=400 + ) + + # Validate currency is BRL + if currency != "BRL": + return JsonResponse( + {"error": "Only BRL currency is supported", "success": False}, status=400 + ) + + try: + amount = float(amount) + except ValueError: + return JsonResponse({"error": "Invalid amount format", "success": False}, status=400) + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + amount = Decimal(str(amount)) + key = Key.objects.get(hash=hashed_key) + currency = MeasurementUnit.objects.get(slug="brl") + + # Check if there's enough balance + if key.balance < amount: + return JsonResponse({"error": "Insufficient balance", "success": False}, status=400) + + # Update API key balance + key.balance -= amount + key.save() + + return JsonResponse({"success": True, "new_balance": float(key.balance)}) + + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except MeasurementUnit.DoesNotExist: + return JsonResponse({"error": "Currency not found", "success": False}, status=404) + + # @cloud_function_only # TODO: Uncomment this when in production + def post(self, request): + event = request.stripe_event + + # Only process successful payment events + if event.type != "payment_intent.succeeded": + return JsonResponse({"success": True, "message": f"Ignored event type {event.type}"}) + + try: + payment_intent = event.data.object + metadata = payment_intent.metadata + + key = metadata.get("key") + amount = float(payment_intent.amount) / 100 # Convert from cents to currency units + currency = payment_intent.currency.upper() + + if not key: + raise ValueError("API key not found in payment metadata") + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + amount = Decimal(str(amount)) + key = Key.objects.get(hash=hashed_key) + currency_obj = MeasurementUnit.objects.get(slug=currency.lower()) + + # Create credit record + Credit.objects.create(key=key, amount=amount, currency=currency_obj) + + # Update API key balance + key.balance += amount + key.save() + + return JsonResponse({"success": True, "new_balance": float(key.balance)}) + + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except MeasurementUnit.DoesNotExist: + return JsonResponse({"error": "Currency not found", "success": False}, status=404) + + except Exception as e: + return JsonResponse({"error": str(e), "success": False}, status=400) + + +class DataAPIEndpointValidateView(View): + def get(self, request): + category_slug = request.GET.get("category") + endpoint_slug = request.GET.get("endpoint") + + if not all([category_slug, endpoint_slug]): + return JsonResponse( + {"error": "Both category and endpoint slugs are required", "success": False}, + status=400, + ) + + try: + endpoint = Endpoint.objects.get(category__slug=category_slug, slug=endpoint_slug) + + return JsonResponse( + { + "success": True, + "resource": { + "isActive": endpoint.is_active and not endpoint.is_deprecated, + "isDeprecated": endpoint.is_deprecated, + "createdAt": endpoint.created_at, + }, + } + ) + + except Endpoint.DoesNotExist: + return JsonResponse({"error": "Endpoint not found", "success": False}, status=404) + + +class DataAPIRequestRegisterView(View): + # TODO: remove GET method when in production + def get(self, request): + key = request.GET.get("key") + category_slug = request.GET.get("category") + endpoint_slug = request.GET.get("endpoint") + parameters_str = request.GET.get("parameters", "") + error_message = request.GET.get("error_message", "") + response_time = request.GET.get("response_time", "0.0") # Changed default to "0.0" + bytes_processed = request.GET.get("bytes_processed", "0") + + if not all([key, category_slug, endpoint_slug]): + return JsonResponse( + {"error": "Missing required parameters", "success": False}, status=400 + ) + + # Parse parameters from x:2,y:tfwas format to dict + parameters = {} + if parameters_str: + try: + for param in parameters_str.split(","): + if ":" in param: + k, v = param.split(":", 1) + parameters[k.strip()] = v.strip() + except Exception: + return JsonResponse( + { + "error": ( + "Invalid parameters format. " "Use format: param1:value1,param2:value2" + ), + "success": False, + }, + status=400, + ) + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + # Get API key and endpoint first + key = Key.objects.get(hash=hashed_key) + endpoint = Endpoint.objects.get(category__slug=category_slug, slug=endpoint_slug) + + # Get required parameters for this endpoint + required_params = endpoint.parameters.filter(required=True).values_list( + "name", flat=True + ) + + # Check if all required parameters are present + missing_params = [param for param in required_params if param not in parameters] + if missing_params: + return JsonResponse( + { + "error": ( + f"Missing required endpoint parameters: " f"{', '.join(missing_params)}" + ), + "success": False, + }, + status=400, + ) + + # Convert numeric values after parameter validation + try: + response_time = float(response_time) + bytes_processed = int(bytes_processed) + except (ValueError, TypeError): + return JsonResponse( + { + "error": "Invalid numeric values for response_time or bytes_processed", + "success": False, + }, + status=400, + ) + + # Create request record + Request.objects.create( + key=key, + endpoint=endpoint, + parameters=parameters, + error_message=error_message, + response_time=response_time, + bytes_processed=bytes_processed, + ) + + return JsonResponse({"success": True}) + + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except Endpoint.DoesNotExist: + return JsonResponse({"error": "Endpoint not found", "success": False}, status=404) + + # @cloud_function_only # TODO: Uncomment this when in production + def post(self, request): + key = request.POST.get("key") + category_slug = request.POST.get("category") + endpoint_slug = request.POST.get("endpoint") + parameters = request.POST.get("parameters", "{}") + error_message = request.POST.get("error_message", "") + response_time = request.POST.get("response_time", "0") + bytes_processed = request.POST.get("bytes_processed", "0") + + if not all([key, category_slug, endpoint_slug, parameters]): + return JsonResponse( + {"error": "Missing required parameters", "success": False}, status=400 + ) + + # Hash the API key + hashed_key = sha256(key.encode()).hexdigest() + + try: + # Convert parameters + parameters = json.loads(parameters) + response_time = float(response_time) + bytes_processed = int(bytes_processed) + + # Get API key and endpoint + key = Key.objects.get(hash=hashed_key) + endpoint = Endpoint.objects.get(category__slug=category_slug, slug=endpoint_slug) + + # Get required parameters for this endpoint + required_params = endpoint.parameters.filter(required=True).values_list( + "name", flat=True + ) + + # Check if all required parameters are present + missing_params = [param for param in required_params if param not in parameters] + if missing_params: + return JsonResponse( + { + "error": ( + f"Missing required endpoint parameters: " f"{', '.join(missing_params)}" + ), + "success": False, + }, + status=400, + ) + + # Create request record + Request.objects.create( + key=key, + endpoint=endpoint, + parameters=parameters, + error_message=error_message, + response_time=response_time, + bytes_processed=bytes_processed, + ) + + return JsonResponse({"success": True}) + + except json.JSONDecodeError: + return JsonResponse( + {"error": "Invalid parameters JSON format", "success": False}, status=400 + ) + except (ValueError, TypeError): + return JsonResponse({"error": "Invalid numeric values", "success": False}, status=400) + except Key.DoesNotExist: + return JsonResponse({"error": "API key not found", "success": False}, status=404) + except Endpoint.DoesNotExist: + return JsonResponse({"error": "Endpoint not found", "success": False}, status=404) diff --git a/backend/settings/base.py b/backend/settings/base.py index 72e25fbf..d3f95a8b 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -65,6 +65,7 @@ "backend.apps.account_auth", "backend.apps.account_payment.apps.PaymentConfig", "backend.apps.api.v1", + "backend.apps.data_api", "backend.apps.core", ] @@ -311,3 +312,7 @@ # reCAPTCHA RECAPTCHA_SITE_KEY = getenv("RECAPTCHA_SITE_KEY") RECAPTCHA_SECRET_KEY = getenv("RECAPTCHA_SECRET_KEY") + +# Data API +CLOUD_FUNCTION_KEY = getenv("CLOUD_FUNCTION_KEY") +STRIPE_WEBHOOK_SECRET = getenv("STRIPE_WEBHOOK_SECRET") diff --git a/backend/urls.py b/backend/urls.py index 9c86c1df..2cf3c793 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -26,5 +26,6 @@ path("", include("backend.apps.account.urls")), path("", include("backend.apps.account_auth.urls")), path("", include("backend.apps.account_payment.urls")), + path("", include("backend.apps.data_api.urls")), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)