initial commit

This commit is contained in:
straitz 2025-08-15 00:46:18 +09:00
commit c6961c03c3
33 changed files with 1782 additions and 0 deletions

View file

View file

@ -0,0 +1,8 @@
from django.contrib import admin
from .models import User, Prediction, Satellite, TelemetryPacket
admin.site.register(User)
admin.site.register(Prediction)
admin.site.register(Satellite)
admin.site.register(TelemetryPacket)

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class StratoflightsApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'stratoflights_api'

View file

@ -0,0 +1,38 @@
import urllib.parse
def build_query_string(data: dict) -> str:
required_keys = [
"profile",
"pred_type",
"launch_datetime",
"launch_latitude",
"launch_longitude",
"launch_altitude",
"ascent_rate",
"burst_altitude",
"descent_rate"
]
# Проверяем, что все ключи на месте
missing_keys = [key for key in required_keys if key not in data]
if missing_keys:
raise ValueError(f"Missing required keys: {', '.join(missing_keys)}")
# Собираем строку запроса
return urllib.parse.urlencode({k: data[k] for k in required_keys})
# Пример:
json_data = {
"profile": "standard_profile",
"pred_type": "single",
"launch_datetime": "2025-03-16T08:47:00Z",
"launch_latitude": "56.6992",
"launch_longitude": "38.8247",
"launch_altitude": "0",
"ascent_rate": "5",
"burst_altitude": "30000",
"descent_rate": "5"
}
query_string = build_query_string(json_data)
print(query_string)

View file

@ -0,0 +1,106 @@
import json
import time
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
class TelemetryConsumer(AsyncWebsocketConsumer):
group_prefix = None
write_enabled = False
async def connect(self):
User = get_user_model()
from rest_framework.authtoken.models import Token
from .models import Satellite
token_key = self.scope["query_string"].decode().split("token=")[-1]
self.satellite_id = self.scope["url_route"]["kwargs"]["pk"]
self.group_name = f"telemetry_{self.satellite_id}"
user = await self.get_user_from_token(token_key, Token)
if not user:
await self.send(text_data=json.dumps({"error": "Invalid token"}))
await self.close()
return
self.scope["user"] = user
try:
self.satellite = await database_sync_to_async(Satellite.objects.get)(id=self.satellite_id)
except Satellite.DoesNotExist:
await self.send(text_data=json.dumps({"error": "Invalid satellite"}))
await self.close()
return
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
async def receive(self, text_data):
from .serializers import TelemetryPacketSerializer
if not self.write_enabled:
await self.send(text_data=json.dumps({"error": "Read-only mode"}))
return
data = json.loads(text_data)
serializer = TelemetryPacketSerializer(data=data)
if not serializer.is_valid():
await self.send(text_data=json.dumps({"error": serializer.errors}))
return
saved_data = await self.save_telemetry(data)
await self.channel_layer.group_send(
self.group_name,
{
"type": "telemetry_message",
"data": {
"id": str(saved_data.id),
"timestamp": saved_data.timestamp,
"lat": saved_data.lat,
"lon": saved_data.lon,
"alt": saved_data.alt,
"payload": saved_data.payload,
"raw_data": saved_data.raw_data,
},
},
)
async def telemetry_message(self, event):
await self.send(text_data=json.dumps(event["data"]))
@database_sync_to_async
def get_user_from_token(self, token_key, Token):
User = get_user_model()
try:
token = Token.objects.select_related("user").get(key=token_key)
return token.user
except Token.DoesNotExist:
return None
@database_sync_to_async
def save_telemetry(self, data):
User = get_user_model()
from .models import TelemetryPacket
packet = TelemetryPacket.objects.create(
satellite=self.satellite,
user=self.scope["user"],
timestamp=data.get("timestamp", int(time.time())),
lat=data.get("lat", 0.0),
lon=data.get("lon", 0.0),
alt=data.get("alt", 0.0),
payload=data.get("payload", {}),
raw_data=data.get("raw_data", {}),
)
return packet
class SatelliteTelemetryConsumer(TelemetryConsumer):
group_prefix = "satellite"
write_enabled = True
class StationTelemetryConsumer(TelemetryConsumer):
group_prefix = "station"
write_enabled = False

View file

@ -0,0 +1,17 @@
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.response import Response
class CustomLimitOffsetPagination(LimitOffsetPagination):
limit_query_param = 'limit'
offset_query_param = 'skip'
max_limit = 100
default_limit = 10
def get_paginated_response(self, data):
return Response({
'total': self.count,
'limit': self.limit,
'skip': self.offset,
'predictions': data
})

View file

@ -0,0 +1,149 @@
# Generated by Django 4.2.23 on 2025-08-11 16:20
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Satellite',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('metadata', models.JSONField(blank=True, default=dict)),
('image', models.ImageField(blank=True, null=True, upload_to='satellite_images/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TelemetryPacket',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('timestamp', models.BigIntegerField()),
('lat', models.FloatField(default=0.0)),
('lon', models.FloatField(default=0.0)),
('alt', models.FloatField(default=0.0)),
('payload', models.JSONField(blank=True, default=dict)),
('raw_data', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('satellite', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='telemetry', to='stratoflights_api.satellite')),
('user', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='SavedRateProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('type', models.CharField(default='ascent', max_length=50)),
('rate_profile_data', models.JSONField(blank=True, default=dict)),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'name')},
},
),
migrations.CreateModel(
name='SavedPoint',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('lat', models.FloatField(default=0.0)),
('lon', models.FloatField(default=0.0)),
('alt', models.FloatField(default=0.0)),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'name')},
},
),
migrations.CreateModel(
name='PreditctionTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_default', models.BooleanField(default=False)),
('description', models.TextField(blank=True, null=True)),
('prediction_mode', models.CharField(default='', max_length=50)),
('model', models.CharField(default='', max_length=50)),
('dataset', models.CharField(default='', max_length=50)),
('flight_parameters', models.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'name')},
},
),
migrations.CreateModel(
name='Prediction',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('request', models.JSONField(default=dict)),
('result', models.JSONField(default=dict)),
('rate_profile', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='predictions', to='stratoflights_api.savedrateprofile')),
('satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='predictions', to='stratoflights_api.satellite')),
('start_point', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='predictions', to='stratoflights_api.savedpoint')),
('template', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='predictions', to='stratoflights_api.preditctiontemplate')),
('user', models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='predictions', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='user',
name='satellites',
field=models.ManyToManyField(related_name='users', to='stratoflights_api.satellite'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'),
),
]

View file

106
stratoflights_api/models.py Normal file
View file

@ -0,0 +1,106 @@
import uuid
from django.db import models
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, AnonymousUser
class User(AbstractUser):
satellites = models.ManyToManyField("Satellite", related_name="users")
class Prediction(models.Model):
user = models.ForeignKey(get_user_model(
), on_delete=models.CASCADE, default=0, related_name='predictions')
satellite = models.ForeignKey(
'Satellite', on_delete=models.CASCADE, related_name='predictions', null=True)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
request = models.JSONField(default=dict)
result = models.JSONField(default=dict)
start_point = models.ForeignKey(
'SavedPoint', on_delete=models.SET_NULL, related_name='predictions', null=True)
template = models.ForeignKey(
'PreditctionTemplate', on_delete=models.SET_NULL, related_name='predictions', null=True)
rate_profile = models.ForeignKey(
'SavedRateProfile', on_delete=models.SET_NULL, related_name='predictions', null=True)
class Satellite(models.Model):
user = models.ForeignKey(
get_user_model(), on_delete=models.CASCADE, default=0)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=100)
metadata = models.JSONField(blank=True, default=dict)
image = models.ImageField(
upload_to='satellite_images/', blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
class TelemetryPacket(models.Model):
user = models.ForeignKey(
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")
timestamp = models.BigIntegerField() # unix time
lat = models.FloatField(default=0.0)
lon = models.FloatField(default=0.0)
alt = models.FloatField(default=0.0)
payload = models.JSONField(blank=True, default=dict)
raw_data = models.JSONField(blank=True, default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class PreditctionTemplate(models.Model):
user = models.ForeignKey(
get_user_model(), on_delete=models.CASCADE, default=0)
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_default = models.BooleanField(default=False)
description = models.TextField(blank=True, null=True)
prediction_mode = models.CharField(
max_length=50,
default="") # TODO: add choices for prediction modes
model = models.CharField(
max_length=50,
default="") # TODO: add choices for models
dataset = models.CharField(
max_length=50,
default="")
flight_parameters = models.JSONField(blank=True, default=dict)
class Meta:
unique_together = ('user', 'name')
class SavedPoint(models.Model):
user = models.ForeignKey(
get_user_model(), on_delete=models.CASCADE, default=0)
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
lat = models.FloatField(default=0.0)
lon = models.FloatField(default=0.0)
alt = models.FloatField(default=0.0)
is_default = models.BooleanField(default=False)
class Meta:
unique_together = ('user', 'name')
class SavedRateProfile(models.Model):
user = models.ForeignKey(
get_user_model(), on_delete=models.CASCADE, default=0)
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
type = models.CharField(
max_length=50, default="ascent") # TODO: add choices for rate profile types
rate_profile_data = models.JSONField(blank=True, default=dict)
is_default = models.BooleanField(default=False)
class Meta:
unique_together = ('user', 'name')

View file

@ -0,0 +1,16 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
class ReadOnlyOrAuthenticated(BasePermission):
def has_permission(self, request, view):
return (
request.method in SAFE_METHODS or
request.user and request.user.is_authenticated
)
class IsOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.user == request.user
def has_permission(self, request, view):
return request.user and request.user.is_authenticated

View file

@ -0,0 +1,7 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"^api/ws/satellite/(?P<pk>[0-9a-f-]+)/telemetry/$", consumers.SatelliteTelemetryConsumer.as_asgi()),
re_path(r"^api/ws/station/(?P<pk>[0-9a-f-]+)/telemetry/$", consumers.StationTelemetryConsumer.as_asgi()),
]

View file

@ -0,0 +1,185 @@
from rest_framework import serializers
from .models import Prediction, SavedPoint, SavedRateProfile, PreditctionTemplate
from datetime import datetime
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 .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']
User = get_user_model()
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)
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)
start_point = serializers.PrimaryKeyRelatedField(
queryset=SavedPoint.objects.all(), required=False, allow_null=True
)
rate_profile = serializers.PrimaryKeyRelatedField(
queryset=SavedRateProfile.objects.all(), required=False, allow_null=True
)
template = serializers.PrimaryKeyRelatedField(
queryset=PreditctionTemplate.objects.all(), required=False, allow_null=True
)
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
def create(self, validated_data):
if 'ascent_curve' in validated_data:
validated_data['ascent_curve'] = base64_to_curve(validated_data['ascent_curve'])
if 'descent_curve' in validated_data:
validated_data['descent_curve'] = base64_to_curve(validated_data['descent_curve'])
prediction = Prediction(
user=validated_data.get('user'),
request=validated_data.get('request', {}),
result=validated_data.get('result', {}),
start_point=validated_data.get('start_point'),
template=validated_data.get('template'),
rate_profile=validated_data.get('rate_profile')
)
prediction.save()
return prediction
class PredictionListSerializer(serializers.ModelSerializer):
class Meta:
model = Prediction
fields = ["id", "created_at", "updated_at", "start_point", "template", "rate_profile"]
class PredictionDetailSerializer(serializers.ModelSerializer):
class Meta:
model = Prediction
fields = ["id", "created_at", "updated_at", "result", "start_point", "template", "rate_profile"]
from rest_framework import serializers
from .models import TelemetryPacket
class TelemetryPacketSerializer(serializers.ModelSerializer):
class Meta:
model = TelemetryPacket
fields = ['id', 'timestamp', 'lat', 'lon', 'alt', 'payload']
read_only_fields = ['id']
class SavedPointSerializer(serializers.ModelSerializer):
user = serializers.HiddenField(
default=serializers.CurrentUserDefault()
)
class Meta:
model = SavedPoint
fields = ['user', 'id', 'name', 'lat', 'lon', 'alt']
read_only_fields = ['id']
validators = [
serializers.UniqueTogetherValidator(
queryset=SavedPoint.objects.all(),
fields=['user', 'name'],
message="A saved point with this name already exists for the user."
)
]
class SavedRateProfileSerializer(serializers.ModelSerializer):
class Meta:
model = SavedRateProfile
fields = ['id', 'name', 'type', 'rate_profile_data']
read_only_fields = ['id']
class PreditctionTemplateSerializer(serializers.ModelSerializer):
class Meta:
model = PreditctionTemplate
fields = ['id', 'name', 'is_default', 'description', 'prediction_mode', 'model', 'dataset', 'flight_parameters']
read_only_fields = ['id']
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['username', 'email', 'first_name', 'last_name']
extra_kwargs = {
'username': {'read_only': True}
}
def validate_email(self, value):
try:
validate_email(value)
except DjangoValidationError:
raise serializers.ValidationError("Invalid email format")
return value
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
def validate_new_password(self, value):
validate_password(value)
return value
class DeleteAccountSerializer(serializers.Serializer):
password = serializers.CharField(required=True)

View file

@ -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()

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

48
stratoflights_api/urls.py Normal file
View file

@ -0,0 +1,48 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from rest_framework.authtoken.views import obtain_auth_token
from .views import (
PredictionViewSet,
SavedPointViewset,
PreditctionTemplateViewset,
TelemetryListCreateView,
get_csrf,
login_view,
logout_view,
SessionView,
WhoAmIView,
UserProfileView,
ChangePasswordView,
TokenManagementView,
DeleteUserDataView,
DeleteAccountView
)
router = DefaultRouter()
router.register(r'predictions', PredictionViewSet, basename='predictions')
router.register(r'saved-points', SavedPointViewset, basename='saved-points')
router.register(r'saved-templates', PreditctionTemplateViewset, basename='saved-templates')
urlpatterns = [
path("csrf/", get_csrf, name='api-csrf'),
path('token', obtain_auth_token, name = 'get_token'),
path("login/", login_view, name='api-login'),
path("logout/", logout_view, name='api-logout'),
path("session/", SessionView.as_view(), name='api-session'),
path("whoami/", WhoAmIView.as_view(), name='api-whoami'),
path("<uuid:pk>/telemetry/", TelemetryListCreateView.as_view(), name="create_telemetry"),
path('csrf/', get_csrf, name='api-csrf'),
path('login/', login_view, name='api-login'),
path('logout/', logout_view, name='api-logout'),
path('session/', SessionView.as_view(), name='api-session'),
path('whoami/', WhoAmIView.as_view(), name='api-whoami'),
path("profile/", UserProfileView.as_view(), name='api-profile'),
path("profile/change-password/", ChangePasswordView.as_view(), name='api-change-password'),
path("profile/token/", TokenManagementView.as_view(), name='api-token'),
path("profile/delete-data/", DeleteUserDataView.as_view(), name='api-delete-data'),
path("profile/delete-account/", DeleteAccountView.as_view(), name='api-delete-account'),
]
urlpatterns += router.urls

View file

@ -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

354
stratoflights_api/views.py Normal file
View file

@ -0,0 +1,354 @@
import requests
import time
import json
from rest_framework import status, generics, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ViewSet, GenericViewSet
from rest_framework.exceptions import APIException
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.authentication import SessionAuthentication, BasicAuthentication, TokenAuthentication
from rest_framework.decorators import api_view, permission_classes, authentication_classes, action
from rest_framework.authtoken.models import Token
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.http import JsonResponse
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.middleware.csrf import get_token
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_datetime
from .models import Prediction, User, Satellite, SavedPoint, SavedRateProfile, PreditctionTemplate, TelemetryPacket
from .serializers import PredictionSerializer, TelemetryPacketSerializer, PredictionRequestSerializer, PredictionListSerializer, PredictionDetailSerializer, SavedPointSerializer, SavedRateProfileSerializer, PreditctionTemplateSerializer, UserSerializer, ChangePasswordSerializer, DeleteAccountSerializer
from .services.tawhiri import TawhiriClient
from drf_spectacular.utils import extend_schema
from .permissions import ReadOnlyOrAuthenticated, IsOwner
from .custom_pagination import CustomLimitOffsetPagination
from datetime import datetime
User = get_user_model()
def get_prediction_from_tawhiri(params):
base_url = "https://fly.stratonautica.ru/api/v2"
response = requests.get(base_url, params=params)
if response.status_code == 200:
return response.json() # получаем результат предсказания
else:
raise Exception(
f"Tawhiri error: {response.status_code} {response.text}")
class PredictionViewSet(GenericViewSet):
permission_classes = [IsAuthenticated]
pagination_class = CustomLimitOffsetPagination
def list(self, request):
queryset = Prediction.objects.filter(user=request.user)
return Response(PredictionSerializer(queryset, many=True).data)
def create(self, request):
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
try:
prediction_result = TawhiriClient.get_prediction(validated_data)
except requests.RequestException as e:
return Response({"error": f"Tawhiri error: {str(e)}"}, status=status.HTTP_502_BAD_GATEWAY)
# prediction = Prediction.objects.create(
# result=prediction_result, user=request.user, request=request.data, validated_data=validated_data)
prediction = serializer.save(
user=request.user,
result=prediction_result,
request=request.data
)
return Response({
"id": prediction.id,
"created_at": prediction.created_at,
"result": prediction_result
}, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['get'])
def list_user(self, request):
user = request.user
satellite_id = request.query_params.get('satellite_id')
created_from = request.query_params.get('created_from')
created_till = request.query_params.get('created_till')
filters = {
'user': user,
}
if created_from:
filters['created_at__gte'] = parse_datetime(created_from)
if created_till:
filters['created_at__lte'] = parse_datetime(created_till)
if satellite_id:
if not user.satellites.filter(id=satellite_id).exists():
return Response({'detail': 'Access denied'}, status=403)
filters['satellite_id'] = satellite_id
queryset = Prediction.objects.filter(**filters)
queryset = self.filter_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = PredictionSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = PredictionSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def history(self, request):
queryset = Prediction.objects.filter(user=request.user)
return Response(PredictionListSerializer(queryset, many=True).data)
@action(detail=True, methods=["get"])
def detail(self, request, pk=None):
prediction = Prediction.objects.filter(
user=request.user, pk=pk).first()
if not prediction:
return Response({'detail': 'Not found'}, status=404)
return Response(PredictionDetailSerializer(prediction).data)
@action(detail=True, methods=["delete"])
def delete(self, request, pk=None):
prediction = Prediction.objects.filter(
user=request.user, pk=pk).first()
if not prediction:
return Response({'detail': 'Not found'}, status=404)
prediction.delete()
return Response(status=204)
class TelemetryListCreateView(generics.ListCreateAPIView):
serializer_class = TelemetryPacketSerializer
permission_classes = [permissions.AllowAny]
def get_queryset(self):
qs = TelemetryPacket.objects.filter(satellite_id=self.kwargs["pk"])
from_ts = self.request.query_params.get("from")
till_ts = self.request.query_params.get("till")
if from_ts:
qs = qs.filter(timestamp__gte=int(from_ts))
if till_ts:
qs = qs.filter(timestamp__lte=int(till_ts))
return qs.order_by("-timestamp")
def post(self, request, pk):
serializer = TelemetryPacketSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
validated_data = serializer.validated_data
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):
permission_classes = [IsAuthenticated]
@staticmethod
def get(request, format=None):
return JsonResponse({'isAuthenticated': True})
class WhoAmIView(APIView):
permission_classes = [IsAuthenticated]
@staticmethod
def get(request, format=None):
return JsonResponse({'username': request.user.username})
@extend_schema(methods=["GET"], description="Get CSRF token")
@csrf_exempt
@api_view(["GET"])
@permission_classes([AllowAny])
def get_csrf(request):
response = JsonResponse({'detail': 'CSRF cookie set'})
response['X-CSRFToken'] = get_token(request)
return response
@extend_schema(methods=["POST"], description="Login user")
@csrf_exempt
@api_view(["POST"])
@authentication_classes([BasicAuthentication])
@permission_classes([AllowAny])
def login_view(request):
data = json.loads(request.body)
username = data.get('username')
password = data.get('password')
if username is None or password is None:
return JsonResponse({'detail': 'Please provide username and password.'}, status=400)
user = authenticate(username=username, password=password)
if user is None:
return JsonResponse({'detail': 'Invalid credentials.'}, status=400)
login(request, user)
return JsonResponse({'detail': 'Successfully logged in.'})
@extend_schema(methods=["POST"], description="Logout user")
@api_view(["POST"])
@permission_classes([AllowAny])
def logout_view(request):
if not request.user.is_authenticated:
return JsonResponse({'detail': 'You\'re not logged in.'}, status=400)
logout(request)
return JsonResponse({'detail': 'Successfully logged out.'})
class SavedPointViewset(ModelViewSet):
permission_classes = [IsOwner]
serializer_class = SavedPointSerializer
pagination_class = None
def get_queryset(self):
return SavedPoint.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class PreditctionTemplateViewset(ModelViewSet):
permission_classes = [IsOwner]
serializer_class = PreditctionTemplateSerializer
pagination_class = None
def get_queryset(self):
return PreditctionTemplate.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class UserProfileView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
serializer = UserSerializer(request.user)
return Response(serializer.data)
def patch(self, request):
user = request.user
serializer = UserSerializer(user, data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data)
class ChangePasswordView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
user = request.user
serializer = ChangePasswordSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if not user.check_password(serializer.validated_data['old_password']):
return Response({'detail': 'Old password is incorrect'},
status=status.HTTP_400_BAD_REQUEST)
user.set_password(serializer.validated_data['new_password'])
user.save()
return Response({'detail': 'Password changed successfully'})
class DeleteAccountView(APIView):
permission_classes = [IsAuthenticated]
def delete(self, request):
user = request.user
serializer = DeleteAccountSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if not user.check_password(serializer.validated_data['password']):
return Response({'detail': 'Incorrect password'},
status=status.HTTP_400_BAD_REQUEST)
Prediction.objects.filter(user=user).delete()
SavedPoint.objects.filter(user=user).delete()
PreditctionTemplate.objects.filter(user=user).delete()
user.delete()
return Response({'detail': 'Account deleted successfully'})
class DeleteUserDataView(APIView):
permission_classes = [IsAuthenticated]
def delete(self, request):
user = request.user
Prediction.objects.filter(user=user).delete()
SavedPoint.objects.filter(user=user).delete()
PreditctionTemplate.objects.filter(user=user).delete()
return Response({'detail': 'All user data deleted successfully'})
class TokenManagementView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
token, created = Token.objects.get_or_create(user=request.user)
return Response({"token": token.key})
def post(self, request):
Token.objects.filter(user=request.user).delete()
token = Token.objects.create(user=request.user)
return Response({"token": token.key})
# class PredictionCreateView(APIView):
# permission_classes = [IsAuthenticated]
# class TelemetryPacket(models.Model):
# id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# satellite = models.ForeignKey(Satellite, on_delete=models.CASCADE, related_name="telemetry")
# timestamp = models.BigIntegerField() # unix time
# lat = models.FloatField()
# lon = models.FloatField()
# alt = models.FloatField()
# payload = models.JSONField(null=True, blank=True)
# created_at = models.DateTimeField(auto_now_add=True)
# fields = ['id', 'timestamp', 'lat', 'lon', 'alt', 'payload']

View file

@ -0,0 +1,62 @@
import asyncio
import json
import websockets
import uuid
import time
BASE_URL = "ws://localhost:8000/api/ws"
TOKEN = "ae397229f9ca50cd6320cb0416671ecc780671ac"
PK = "cf1e36ff-c5ce-4852-8b90-046737974b97"
async def satellite_mode():
"""
Клиент для отправки данных телеметрии.
"""
url = f"{BASE_URL}/satellite/{PK}/telemetry/?token={TOKEN}"
async with websockets.connect(url) as ws:
print(f"[satellite] Connected to {url}")
while True:
telemetry_data = {
"timestamp": int(time.time()),
"lat": 55.75,
"lon": 37.61,
"alt": 200.0,
"payload": {"temp": 22.5, "status": "OK"},
"raw_data": {"sensor": "gps", "accuracy": "high"}
}
await ws.send(json.dumps(telemetry_data))
print(f"[satellite] Sent: {telemetry_data}")
try:
response = await asyncio.wait_for(ws.recv(), timeout=2)
print(f"[satellite] Received: {response}")
except asyncio.TimeoutError:
print("[satellite] No response yet")
await asyncio.sleep(5)
async def station_mode():
"""
Клиент для приёма данных телеметрии.
"""
url = f"{BASE_URL}/station/{PK}/telemetry/?token={TOKEN}"
async with websockets.connect(url) as ws:
print(f"[station] Connected to {url}")
while True:
try:
message = await ws.recv()
print(f"[station] Received: {message}")
except websockets.ConnectionClosed:
print("[station] Connection closed")
break
if __name__ == "__main__":
mode = input("Enter mode (satellite/station): ").strip().lower()
if mode == "satellite":
asyncio.run(satellite_mode())
else:
asyncio.run(station_mode())