diff --git a/stratoflights/settings.py b/stratoflights/settings.py index 224cb3a..bb5b63e 100644 --- a/stratoflights/settings.py +++ b/stratoflights/settings.py @@ -45,7 +45,6 @@ MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, 'media')) # Куд # Application definition INSTALLED_APPS = [ - 'daphne', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -104,7 +103,7 @@ ASGI_APPLICATION = 'stratoflights.asgi.application' # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases -if not PRODUCTION: +if PRODUCTION: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -168,7 +167,7 @@ AUTH_USER_MODEL = 'stratoflights_api.User' REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 1000, + 'PAGE_SIZE': 100, 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', @@ -197,9 +196,9 @@ CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:5173, CHANNEL_LAYERS = { "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", - # "CONFIG": { - # "hosts": [("redis", 6379)], - # }, + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("redis", 6379)], + }, }, } \ No newline at end of file diff --git a/stratoflights_api/custom_pagination.py b/stratoflights_api/custom_pagination.py index 2e5070b..6d37de8 100644 --- a/stratoflights_api/custom_pagination.py +++ b/stratoflights_api/custom_pagination.py @@ -4,7 +4,7 @@ from rest_framework.response import Response class CustomLimitOffsetPagination(LimitOffsetPagination): limit_query_param = 'limit' offset_query_param = 'skip' - max_limit = 1000 + max_limit = 100 default_limit = 10 diff --git a/stratoflights_api/migrations/0002_telemetrypacket_user_nullable.py b/stratoflights_api/migrations/0002_telemetrypacket_user_nullable.py deleted file mode 100644 index b3d2e4a..0000000 --- a/stratoflights_api/migrations/0002_telemetrypacket_user_nullable.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('stratoflights_api', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='telemetrypacket', - name='user', - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/stratoflights_api/models.py b/stratoflights_api/models.py index cdeeb12..9a331c7 100644 --- a/stratoflights_api/models.py +++ b/stratoflights_api/models.py @@ -39,7 +39,7 @@ class Satellite(models.Model): class TelemetryPacket(models.Model): user = models.ForeignKey( - get_user_model(), on_delete=models.SET_NULL, null=True, blank=True) + get_user_model(), on_delete=models.CASCADE, default=0) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) satellite = models.ForeignKey( Satellite, on_delete=models.CASCADE, related_name="telemetry") @@ -50,10 +50,6 @@ class TelemetryPacket(models.Model): payload = models.JSONField(blank=True, default=dict) raw_data = models.JSONField(blank=True, default=dict) created_at = models.DateTimeField(auto_now_add=True) - def __str__(self): - return f"packet {self.satellite} {self.lat} {self.lon} {self.alt} {self.timestamp}" - class Meta: - ordering = ["-timestamp"] class PreditctionTemplate(models.Model): diff --git a/stratoflights_api/serializers.py b/stratoflights_api/serializers.py index 88f02c5..dacdf57 100644 --- a/stratoflights_api/serializers.py +++ b/stratoflights_api/serializers.py @@ -5,7 +5,6 @@ from django.contrib.auth.password_validation import validate_password from django.core.validators import validate_email from django.core.exceptions import ValidationError as DjangoValidationError from django.contrib.auth import get_user_model -from .services.tawhiri import LATEST_DATASET_KEYWORD from .validators import ( validate_custom_curve, rate_clip, _rfc3339_to_timestamp, base64_to_curve @@ -22,23 +21,13 @@ PROFILE_STANDARD = "standard_profile" PROFILE_FLOAT = "float_profile" PROFILE_REVERSE = "reverse_profile" PROFILE_CUSTOM = "custom_profile" +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) - # Deliberately unbounded. Longitude is on a circle, so every real number names - # a real meridian and any interval here is a policy about typos, not places — - # and that policy belongs to the one service that owns the wind grid. The - # endpoint this calls, GET /api/v1/prediction, applies no longitude bound: it - # normalises and lets the grid refuse what it cannot use. - # - # This field was min_value=0, narrower than that endpoint, which refused every - # launch west of Greenwich — Canada, Greenland, Alaska. Two layers each - # guessing at the range is what produced that, so this one no longer guesses. - # FloatField still refuses anything that is not a number; NaN and infinity get - # through here and are rejected by the predictor's request decoder. - launch_longitude = serializers.FloatField() + 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") @@ -135,7 +124,6 @@ class TelemetryPacketSerializer(serializers.ModelSerializer): model = TelemetryPacket fields = ['id', 'timestamp', 'lat', 'lon', 'alt', 'payload'] read_only_fields = ['id'] - extra_kwargs = {'timestamp': {'required': False}} class SavedPointSerializer(serializers.ModelSerializer): diff --git a/stratoflights_api/services/tawhiri.py b/stratoflights_api/services/tawhiri.py index c82b5c6..44d1fb0 100644 --- a/stratoflights_api/services/tawhiri.py +++ b/stratoflights_api/services/tawhiri.py @@ -5,14 +5,8 @@ from typing import Any from zoneinfo import ZoneInfo from collections import OrderedDict -# Request value meaning "let the predictor use whatever run it has loaded". -# Lives here because this module is the one that decides what reaches the -# predictor; the serializer imports it as its default. -LATEST_DATASET_KEYWORD = "latest" - - class TawhiriClient: - BASE_URL = "http://127.0.0.1:8080/api/v1/prediction" + BASE_URL = "https://fly.stratonautica.ru/api/v2/" TIMEOUT = 15 @staticmethod @@ -42,20 +36,7 @@ class TawhiriClient: query["burst_altitude"] = params.get("burst_altitude") query["descent_rate"] = params.get("descent_rate") query["interpolate"] = str(params.get("interpolate", False)).lower() - # Forwarded only when the operator actually named a run. - # - # The predictor now honours this parameter and refuses a run it does not - # hold, so anything that is not a real epoch must not be sent. Two values - # reach here that are not: "" from the UI, and LATEST_DATASET_KEYWORD, the - # serializer's default, which means "server chooses" — exactly what - # omitting the parameter does. The `filtered` comprehension below drops - # None but neither of these. - # - # A malformed epoch is deliberately still forwarded, so the predictor - # rejects it instead of the value being quietly ignored. - dataset = params.get("dataset") - if dataset and dataset != LATEST_DATASET_KEYWORD: - query["dataset"] = cls._convert_value(dataset) + #query["dataset"] = cls._convert_value(params.get("dataset")) query["format"] = params.get("format", "json") query["pred_type"] = "single" # <-- в конце diff --git a/stratoflights_api/test_launch_bounds.py b/stratoflights_api/test_launch_bounds.py deleted file mode 100644 index 645cd97..0000000 --- a/stratoflights_api/test_launch_bounds.py +++ /dev/null @@ -1,74 +0,0 @@ -from django.test import SimpleTestCase - -from .serializers import PredictionRequestSerializer - - -class LaunchLongitudeTests(SimpleTestCase): - """ - This API does not bound longitude. The predictor does. - - Longitude lives on a circle: every real number names a real meridian, and - 5170 is a legitimate way to write 50 E. So any interval is a policy about - typos, not about places, and there is exactly one place that policy belongs — - the service that owns the wind grid. Two layers each guessing at it is how the - original defect happened: this serializer declared min_value=0, narrower than - the endpoint it calls, and refused every launch west of Greenwich. Canada, - Greenland and Alaska, i.e. most of the Arctic sites this product targets. - - The endpoint actually called is GET /api/v1/prediction, which applies no - longitude bound at all — it normalises and lets the grid refuse what it cannot - use. Mirroring that exactly is the point: this layer must never be the narrower - one. - - Nuuk is -51.7. The same meridian written unsigned is 308.3, and that always - worked, which is what showed the constraint was about notation, not place. - - Non-numbers are still refused here, by FloatField. NaN and infinity are not: - FloatField parses them, and the predictor's request decoder rejects them with a - 400 (measured). Deliberately not re-checked here, so this layer keeps a single - responsibility. - """ - - def payload(self, lng): - return { - "launch_latitude": 64.1, - "launch_longitude": lng, - "launch_datetime": "2026-08-05T12:00:00Z", - "launch_altitude": 0, - "ascent_rate": 5, - "burst_altitude": 30000, - "descent_rate": 5, - "profile": "standard_profile", - } - - def assert_accepted(self, lng): - s = PredictionRequestSerializer(data=self.payload(lng)) - self.assertTrue(s.is_valid(), f"lng={lng} rejected: {s.errors}") - - def test_accepts_a_western_launch(self): - self.assert_accepted(-51.7) - - def test_accepts_the_same_meridian_written_unsigned(self): - self.assert_accepted(308.3) - - def test_accepts_every_notation_the_predictor_accepts(self): - # Measured against GET /api/v1/prediction: each of these returns 200, and - # the pairs below name the same meridian, so each returns the same - # trajectory as its twin. -180/180 are one meridian; 0/360 are one - # meridian; -90/270 are one meridian. - for lng in (-180, 180, 0, 360, -90, 270, -200, -180.0001, 359.999): - with self.subTest(lng=lng): - self.assert_accepted(lng) - - def test_does_not_bound_the_range(self): - # 1e30 is nonsense, and it is the predictor that says so — it answers 400 - # "lng=1e+30 out of range". Asserting acceptance here is the explicit - # statement that range is not this layer's job; a bound added here would - # again risk being narrower than the service behind it. - self.assert_accepted(1e30) - - def test_still_refuses_things_that_are_not_numbers(self): - for lng in ("abc", None, "", []): - with self.subTest(lng=lng): - s = PredictionRequestSerializer(data=self.payload(lng)) - self.assertFalse(s.is_valid(), f"lng={lng!r} accepted but is not a number") diff --git a/stratoflights_api/views.py b/stratoflights_api/views.py index b358bd2..319f2a1 100644 --- a/stratoflights_api/views.py +++ b/stratoflights_api/views.py @@ -156,47 +156,16 @@ class TelemetryListCreateView(generics.ListCreateAPIView): if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - try: - satellite = Satellite.objects.get(id=pk) - except Satellite.DoesNotExist: - return Response({"detail": "Satellite not found"}, status=status.HTTP_404_NOT_FOUND) - validated_data = serializer.validated_data - packet = TelemetryPacket.objects.create( - satellite=satellite, - user=request.user if request.user.is_authenticated else None, - timestamp=validated_data.get('timestamp', int(time.time())), - lat=validated_data['lat'], - lon=validated_data['lon'], - alt=validated_data['alt'], - payload=validated_data.get('payload', {}), - ) - - # Broadcast to WebSocket subscribers so the tracking page updates live - try: - from asgiref.sync import async_to_sync - from channels.layers import get_channel_layer - from .consumers import SatelliteTelemetryConsumer - channel_layer = get_channel_layer() - if channel_layer is not None: - async_to_sync(SatelliteTelemetryConsumer.broadcast_to_satellite_group)( - str(pk), - { - 'id': str(packet.id), - 'timestamp': packet.timestamp, - 'lat': packet.lat, - 'lon': packet.lon, - 'alt': packet.alt, - 'payload': packet.payload, - 'raw_data': {}, - }, - channel_layer, - ) - except Exception: - pass # WS broadcast is best-effort; don't fail the REST response - - return Response({'id': str(packet.id)}, status=status.HTTP_201_CREATED) + TelemetryPacket.objects.create(timestamp=time.time(), + satellite=Satellite.objects.get(id=pk), + lat=validated_data["lat"], + lon=validated_data["lon"], + alt=validated_data["alt"], + payload=validated_data['payload'], + ) + return Response(serializer.errors, status=status.HTTP_201_CREATED) class SessionView(APIView):