diff --git a/api/__pycache__/models.cpython-313.pyc b/api/__pycache__/models.cpython-313.pyc index 1468336..23cd358 100644 Binary files a/api/__pycache__/models.cpython-313.pyc and b/api/__pycache__/models.cpython-313.pyc differ diff --git a/api/__pycache__/serializers.cpython-313.pyc b/api/__pycache__/serializers.cpython-313.pyc index 70e2ce5..5099a66 100644 Binary files a/api/__pycache__/serializers.cpython-313.pyc and b/api/__pycache__/serializers.cpython-313.pyc differ diff --git a/api/__pycache__/urls.cpython-313.pyc b/api/__pycache__/urls.cpython-313.pyc index a73920c..7ace33c 100644 Binary files a/api/__pycache__/urls.cpython-313.pyc and b/api/__pycache__/urls.cpython-313.pyc differ diff --git a/api/__pycache__/validators.cpython-313.pyc b/api/__pycache__/validators.cpython-313.pyc new file mode 100644 index 0000000..57e4db6 Binary files /dev/null and b/api/__pycache__/validators.cpython-313.pyc differ diff --git a/api/__pycache__/views.cpython-313.pyc b/api/__pycache__/views.cpython-313.pyc index f1db0f6..a7c45b3 100644 Binary files a/api/__pycache__/views.cpython-313.pyc and b/api/__pycache__/views.cpython-313.pyc differ diff --git a/api/migrations/0003_alter_userprediction_unique_together_and_more.py b/api/migrations/0003_alter_userprediction_unique_together_and_more.py new file mode 100644 index 0000000..332ea40 --- /dev/null +++ b/api/migrations/0003_alter_userprediction_unique_together_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.7 on 2025-04-05 10:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_prediction_user_userprediction_delete_todo'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userprediction', + unique_together=set(), + ), + migrations.AlterField( + model_name='userprediction', + name='created_at', + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name='userprediction', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/migrations/__pycache__/0003_alter_userprediction_unique_together_and_more.cpython-313.pyc b/api/migrations/__pycache__/0003_alter_userprediction_unique_together_and_more.cpython-313.pyc new file mode 100644 index 0000000..e13512f Binary files /dev/null and b/api/migrations/__pycache__/0003_alter_userprediction_unique_together_and_more.cpython-313.pyc differ diff --git a/api/models.py b/api/models.py index a66861d..61621af 100644 --- a/api/models.py +++ b/api/models.py @@ -1,6 +1,6 @@ import uuid from django.db import models - +from django.contrib.auth import get_user_model class User(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -11,10 +11,10 @@ class Prediction(models.Model): result = models.JSONField() deleted_at = models.DateTimeField(null=True, blank=True) -class UserPrediction(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - prediction = models.ForeignKey(Prediction, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - class Meta: - unique_together = ('user', 'prediction') + +class UserPrediction(models.Model): + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + prediction = models.ForeignKey("Prediction", on_delete=models.CASCADE) + created_at = models.DateTimeField() + diff --git a/api/serializers.py b/api/serializers.py index 3f0dc4f..4823d4f 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,7 +1,86 @@ from rest_framework import serializers from .models import Prediction +from datetime import datetime +from .validators import ( + validate_custom_curve, rate_clip, + _rfc3339_to_timestamp, base64_to_curve +) class PredictionSerializer(serializers.ModelSerializer): class Meta: model = Prediction fields = ['id', 'created_at', 'updated_at', 'result'] + + + +PROFILE_STANDARD = "standard_profile" +PROFILE_FLOAT = "float" +PROFILE_REVERSE = "reverse" +PROFILE_CUSTOM = "custom" +LATEST_DATASET_KEYWORD = "latest" +SUPPORTED_PROFILES = [PROFILE_STANDARD, PROFILE_FLOAT, PROFILE_REVERSE, PROFILE_CUSTOM] + + +class PredictionRequestSerializer(serializers.Serializer): + launch_latitude = serializers.FloatField(min_value=-90, max_value=90) + launch_longitude = serializers.FloatField(min_value=0, max_value=360) + launch_datetime = serializers.DateTimeField() + launch_altitude = serializers.FloatField(required=False) + format = serializers.CharField(default="json") + profile = serializers.ChoiceField(choices=SUPPORTED_PROFILES, default=PROFILE_STANDARD) + dataset = serializers.CharField(default=LATEST_DATASET_KEYWORD) + + # --- профиль-dependent поля --- + ascent_rate = serializers.FloatField(required=False, min_value=0.01) + descent_rate = serializers.FloatField(required=False, min_value=0.01) + burst_altitude = serializers.FloatField(required=False) + float_altitude = serializers.FloatField(required=False) + stop_datetime = serializers.DateTimeField(required=False) + ascent_curve = serializers.CharField(required=False) + descent_curve = serializers.CharField(required=False) + interpolate = serializers.BooleanField(required=False, default=False) + + def validate(self, data): + profile = data.get("profile", PROFILE_STANDARD) + launch_alt = data.get("launch_altitude", 0) + + if profile == PROFILE_STANDARD: + if 'burst_altitude' not in data: + raise serializers.ValidationError("burst_altitude is required for standard profile.") + if data['burst_altitude'] <= launch_alt: + raise serializers.ValidationError("burst_altitude must be greater than launch_altitude.") + + elif profile == PROFILE_FLOAT: + if 'float_altitude' not in data or data['float_altitude'] <= launch_alt: + raise serializers.ValidationError("float_altitude must be greater than launch_altitude.") + if 'stop_datetime' not in data or data['stop_datetime'] <= data['launch_datetime']: + raise serializers.ValidationError("stop_datetime must be later than launch_datetime.") + + elif profile == PROFILE_CUSTOM: + if 'ascent_curve' not in data or not validate_custom_curve(data['ascent_curve']): + raise serializers.ValidationError("Invalid ascent_curve.") + if 'descent_curve' not in data or not validate_custom_curve(data['descent_curve']): + raise serializers.ValidationError("Invalid descent_curve.") + if 'burst_altitude' not in data or data['burst_altitude'] <= launch_alt: + raise serializers.ValidationError("burst_altitude must be greater than launch_altitude.") + + # кастомная логика clipping'а + if 'ascent_rate' in data: + data['ascent_rate'] = rate_clip(data['ascent_rate']) + if 'descent_rate' in data: + data['descent_rate'] = rate_clip(data['descent_rate']) + + return data + + + +class PredictionListSerializer(serializers.ModelSerializer): + class Meta: + model = Prediction + fields = ["id", "created_at", "updated_at"] + + +class PredictionDetailSerializer(serializers.ModelSerializer): + class Meta: + model = Prediction + fields = ["id", "created_at", "updated_at", "result"] diff --git a/api/services/__pycache__/tawhiri.cpython-313.pyc b/api/services/__pycache__/tawhiri.cpython-313.pyc new file mode 100644 index 0000000..4e13d6e Binary files /dev/null and b/api/services/__pycache__/tawhiri.cpython-313.pyc differ diff --git a/api/services/tawhiri.py b/api/services/tawhiri.py new file mode 100644 index 0000000..44d1fb0 --- /dev/null +++ b/api/services/tawhiri.py @@ -0,0 +1,52 @@ +import requests +from urllib.parse import urlencode +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo +from collections import OrderedDict + +class TawhiriClient: + BASE_URL = "https://fly.stratonautica.ru/api/v2/" + TIMEOUT = 15 + + @staticmethod + def _convert_value(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat().replace("+00:00", "Z") + return value + + @classmethod + def get_prediction(cls, params: dict) -> dict: + url = cls.build_url(params) + print("🔍 URL:", url) + response = requests.get(url, timeout=cls.TIMEOUT) + response.raise_for_status() + return response.json() + + @classmethod + def build_url(cls, params: dict) -> str: + query = OrderedDict() + + query["profile"] = params.get("profile") + query["launch_datetime"] = cls._convert_value(params.get("launch_datetime")) + query["launch_latitude"] = params.get("launch_latitude") + query["launch_longitude"] = params.get("launch_longitude") + query["launch_altitude"] = params.get("launch_altitude", 0) + query["ascent_rate"] = params.get("ascent_rate") + query["burst_altitude"] = params.get("burst_altitude") + query["descent_rate"] = params.get("descent_rate") + query["interpolate"] = str(params.get("interpolate", False)).lower() + #query["dataset"] = cls._convert_value(params.get("dataset")) + query["format"] = params.get("format", "json") + query["pred_type"] = "single" # <-- в конце + + filtered = {k: v for k, v in query.items() if v is not None} + return f"{cls.BASE_URL}?{urlencode(filtered)}" + + + @classmethod + def get_prediction(cls, params: dict) -> dict: + url = cls.build_url(params) + response = requests.get(url, timeout=cls.TIMEOUT) + response.raise_for_status() + return response.json() diff --git a/api/urls.py b/api/urls.py index c256ed2..eaff5cb 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,5 +1,8 @@ from django.urls import path -from .views import PredictionCreateView, PredictionListView, PredictionDeleteView +from .views import (PredictionCreateView, PredictionListView, PredictionDeleteView, + PredictionHistoryListView, + PredictionHistoryDetailView, + PredictionHistoryDeleteView,) from rest_framework.authtoken.views import obtain_auth_token urlpatterns = [ @@ -7,4 +10,7 @@ urlpatterns = [ path('predictions', PredictionListView.as_view(), name='get_predictions'), path('predictions/', PredictionDeleteView.as_view(), name='delete_prediction'), path('token/', obtain_auth_token), + path("history/", PredictionHistoryListView.as_view()), + path("history//", PredictionHistoryDetailView.as_view()), + path("history//delete/", PredictionHistoryDeleteView.as_view()), ] diff --git a/api/validators.py b/api/validators.py new file mode 100644 index 0000000..222c170 --- /dev/null +++ b/api/validators.py @@ -0,0 +1,31 @@ +import base64 +import json +from datetime import datetime + + +def rate_clip(rate): + """Ограничивает допустимые значения скорости (например, 0.1 ≤ x ≤ 100)""" + return min(max(rate, 0.1), 100.0) + + +def _rfc3339_to_timestamp(value): + """Парсинг RFC 3339 строки в datetime""" + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def base64_to_curve(encoded): + """Декодирует base64-encoded curve""" + try: + decoded = base64.b64decode(encoded).decode('utf-8') + return json.loads(decoded) + except Exception as e: + raise ValueError(f"Invalid curve format: {e}") + + +def validate_custom_curve(curve): + """Проверяет, что curve имеет ожидаемую структуру (например, список точек)""" + try: + points = base64_to_curve(curve) + return isinstance(points, list) and all(isinstance(p, list) and len(p) == 2 for p in points) + except Exception: + return False diff --git a/api/views.py b/api/views.py index 803abe6..a4420fc 100644 --- a/api/views.py +++ b/api/views.py @@ -1,11 +1,19 @@ -from rest_framework import status, generics +from rest_framework import status, generics, permissions from rest_framework.response import Response from rest_framework.views import APIView from django.utils import timezone from .models import Prediction, User, UserPrediction -from .serializers import PredictionSerializer +from .serializers import PredictionSerializer, PredictionRequestSerializer, PredictionListSerializer, PredictionDetailSerializer from rest_framework.permissions import IsAuthenticated import requests +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from rest_framework.permissions import AllowAny +from .services.tawhiri import TawhiriClient +from django.contrib.auth import get_user_model + +User = get_user_model() + def get_prediction_from_tawhiri(params): base_url = "https://fly.stratonautica.ru/api/v2" @@ -15,25 +23,34 @@ def get_prediction_from_tawhiri(params): return response.json() # получаем результат предсказания else: raise Exception(f"Tawhiri error: {response.status_code} {response.text}") - - + class PredictionCreateView(APIView): + permission_classes = [IsAuthenticated] + def post(self, request): - user_id = request.data.get('user_id') - user = User.objects.get(id=user_id) + print("DEBUG: request.user =", request.user) + print("DEBUG: request.user.id =", request.user.id) + + serializer = PredictionRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + validated_data = serializer.validated_data - # Передаём остальные параметры (кроме user_id) в Tawhiri - tawhiri_params = {k: v for k, v in request.data.items() if k != 'user_id'} - try: - prediction_result = get_prediction_from_tawhiri(tawhiri_params) - except Exception as e: - return Response({"error": str(e)}, status=500) + prediction_result = TawhiriClient.get_prediction(validated_data) + except requests.RequestException as e: + print("❌ Tawhiri error:", str(e), e.response.text if e.response else "no response") + return Response({"error": f"Tawhiri error: {str(e)}"}, status=status.HTTP_502_BAD_GATEWAY) prediction = Prediction.objects.create(result=prediction_result) - UserPrediction.objects.create(user=user, prediction=prediction) + UserPrediction.objects.create(user=request.user, prediction=prediction, created_at=timezone.now()) - return Response(PredictionSerializer(prediction).data) + return Response({ + "id": prediction.id, + "created_at": prediction.created_at, + "result": prediction_result + }, status=status.HTTP_201_CREATED) class PredictionListView(APIView): def get(self, request): @@ -59,5 +76,43 @@ class PredictionDeleteView(APIView): except Prediction.DoesNotExist: return Response({"error": "Not found"}, status=404) -class PredictionCreateView(APIView): - permission_classes = [IsAuthenticated] \ No newline at end of file + + +class PredictionHistoryListView(generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = PredictionListSerializer + + def get_queryset(self): + return Prediction.objects.filter( + id__in=UserPrediction.objects.filter(user=self.request.user).values_list('prediction_id', flat=True), + deleted_at__isnull=True + ) + + +class PredictionHistoryDetailView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = PredictionDetailSerializer + + def get_queryset(self): + return Prediction.objects.filter( + id__in=UserPrediction.objects.filter(user=self.request.user).values_list('prediction_id', flat=True), + deleted_at__isnull=True + ) + + +class PredictionHistoryDeleteView(generics.DestroyAPIView): + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Prediction.objects.filter( + id__in=UserPrediction.objects.filter(user=self.request.user).values_list('prediction_id', flat=True), + deleted_at__isnull=True + ) + + def perform_destroy(self, instance): + instance.deleted_at = timezone.now() + instance.save() + + +#class PredictionCreateView(APIView): + #permission_classes = [IsAuthenticated] \ No newline at end of file diff --git a/db.sqlite3 b/db.sqlite3 index a39dc9f..41a74d0 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/testapi/__pycache__/settings.cpython-313.pyc b/testapi/__pycache__/settings.cpython-313.pyc index 4edb2ac..b7622bf 100644 Binary files a/testapi/__pycache__/settings.cpython-313.pyc and b/testapi/__pycache__/settings.cpython-313.pyc differ diff --git a/testapi/settings.py b/testapi/settings.py index 89ff0c7..9409c61 100644 --- a/testapi/settings.py +++ b/testapi/settings.py @@ -40,10 +40,12 @@ INSTALLED_APPS = [ 'rest_framework', 'rest_framework.authtoken', 'drf_spectacular', + 'corsheaders', 'api' ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -53,6 +55,8 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +CORS_ALLOW_ALL_ORIGINS = True + ROOT_URLCONF = 'testapi.urls' TEMPLATES = [ @@ -138,9 +142,10 @@ REST_FRAMEWORK = { # ВАШИ НАСТРОЙКИ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.TokenAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', + #'rest_framework.permissions.AllowAny', ] }