From c6961c03c3a7d42f499217ec31de2fa3488e9e03 Mon Sep 17 00:00:00 2001 From: straitz Date: Fri, 15 Aug 2025 00:46:18 +0900 Subject: [PATCH] initial commit --- .dockerignore | 36 ++ .gitignore | 60 ++++ Dockerfile | 76 ++++ docker-compose.yml | 61 ++++ entrypoint.sh | 4 + manage.py | 22 ++ nginx/nginx.conf | 45 +++ requirements-prod.txt | 1 + requirements.txt | 13 + stratoflights/__init__.py | 0 stratoflights/asgi.py | 16 + stratoflights/routing.py | 13 + stratoflights/settings.py | 204 +++++++++++ stratoflights/urls.py | 27 ++ stratoflights/wsgi.py | 16 + stratoflights_api/__init__.py | 0 stratoflights_api/admin.py | 8 + stratoflights_api/apps.py | 6 + stratoflights_api/build_qstring.py | 38 ++ stratoflights_api/consumers.py | 106 ++++++ stratoflights_api/custom_pagination.py | 17 + stratoflights_api/migrations/0001_initial.py | 149 ++++++++ stratoflights_api/migrations/__init__.py | 0 stratoflights_api/models.py | 106 ++++++ stratoflights_api/permissions.py | 16 + stratoflights_api/routing.py | 7 + stratoflights_api/serializers.py | 185 ++++++++++ stratoflights_api/services/tawhiri.py | 52 +++ stratoflights_api/tests.py | 3 + stratoflights_api/urls.py | 48 +++ stratoflights_api/validators.py | 31 ++ stratoflights_api/views.py | 354 +++++++++++++++++++ stratoflights_api/ws_client.py | 62 ++++ 33 files changed, 1782 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 manage.py create mode 100644 nginx/nginx.conf create mode 100644 requirements-prod.txt create mode 100644 requirements.txt create mode 100644 stratoflights/__init__.py create mode 100644 stratoflights/asgi.py create mode 100644 stratoflights/routing.py create mode 100644 stratoflights/settings.py create mode 100644 stratoflights/urls.py create mode 100644 stratoflights/wsgi.py create mode 100644 stratoflights_api/__init__.py create mode 100644 stratoflights_api/admin.py create mode 100644 stratoflights_api/apps.py create mode 100644 stratoflights_api/build_qstring.py create mode 100644 stratoflights_api/consumers.py create mode 100644 stratoflights_api/custom_pagination.py create mode 100644 stratoflights_api/migrations/0001_initial.py create mode 100644 stratoflights_api/migrations/__init__.py create mode 100644 stratoflights_api/models.py create mode 100644 stratoflights_api/permissions.py create mode 100644 stratoflights_api/routing.py create mode 100644 stratoflights_api/serializers.py create mode 100644 stratoflights_api/services/tawhiri.py create mode 100644 stratoflights_api/tests.py create mode 100644 stratoflights_api/urls.py create mode 100644 stratoflights_api/validators.py create mode 100644 stratoflights_api/views.py create mode 100644 stratoflights_api/ws_client.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f1d630 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +__pycache__ +**/__pycache__ +**/__pycache__/* +venv +*.pyc +*.pyo +*.pyd +*.sqlite3 +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.vscode +.prettierrc +.mypy_cache +.pytest_cache +.hypothesis +Dockerfile +.env +.dockerignore +docker-compose.override.yml +docker-compose.override +docker-compose.yml +nginx +media +static +postgres \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06d4c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Python +__pycache__/ +*/__pycache__ +*.py[cod] +*pyc +*.pyo +*.pyd +*.so +*.egg-info/ +*.egg +*.log + +# Environments +.env +.env.* +venv/ +venv*/ +ENV/ +.envrc + +# Django +*.sqlite3 +db.sqlite3 +media/ +staticfiles/ +*.pot +*.pyc + +# Migrations (если хочешь игнорировать сгенерированные миграции) +# migrations/ +# */migrations/ + +# IDEs +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Tests +.coverage +htmlcov/ +*.cover +*.py,cover +.cache/ +.tox/ + +# Git +*.orig + +# Docker +*.pid +*.pid.lock +docker-compose.override.yml + +# Build +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9cd3eaf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +FROM python:3.13-slim AS builder + +RUN mkdir /www + +WORKDIR /www + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# RUN apt-get update && apt-get install -y --no-install-recommends \ +# libpq-dev \ +# gcc \ +# g++ \ +# libffi-dev \ +# libssl-dev \ +# libxml2-dev \ +# libxslt1-dev \ +# zlib1g-dev \ +# libjpeg-dev \ +# libfreetype6-dev && \ +# rm -rf /var/lib/apt/lists/* && \ +# apt-get clean && \ +# apt-get autoclean + +RUN pip install --upgrade pip + +COPY requirements.txt /www/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY requirements-prod.txt /www/ +RUN pip install --no-cache-dir -r requirements-prod.txt + +# Stage 2: Production stage +FROM python:3.13-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + libssl-dev \ + gettext && \ + apt-get clean && \ + apt-get autoclean + +RUN useradd -m -r wwwuser && \ + mkdir /www && \ + chown -R wwwuser /www + +# Copy the Python dependencies from the builder stage +COPY --from=builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ + +# Set the working directory +WORKDIR /www + +# Copy application code +COPY --chown=wwwuser:wwwuser . . + +RUN chmod +x /www/entrypoint.sh && \ + chmod +x /www/manage.py + +RUN mkdir -p /static && \ + mkdir -p /media && \ + chown -R wwwuser:wwwuser /static && \ + chown -R wwwuser:wwwuser /media + +# Set environment variables to optimize Python +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Switch to non-root user +USER wwwuser + +# Expose the application port +EXPOSE 8000 + +# Run Django’s development server +CMD ["/www/entrypoint.sh"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1900b10 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ + +services: + db: + image: postgres:14 + environment: + POSTGRES_DB: mydb + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypass + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app_network + + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - app_network + + web: + build: . + command: daphne -b 0.0.0.0 -p 8000 stratoflights.asgi:application + environment: + DJANGO_SETTINGS_MODULE: stratoflights.settings + ports: + - "8000:8000" + volumes: + - ./static:/static:rw + - ./media:/media:rw + env_file: .env + depends_on: + - db + networks: + - app_network + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./static:/var/www/static + - ./media:/var/www/media + depends_on: + - web + networks: + - app_network + +volumes: + redis_data: + postgres_data: + static_volume: + media_volume: + +networks: + app_network: + driver: bridge \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..04f2ae4 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +python manage.py collectstatic --noinput +python manage.py migrate --noinput +python -m daphne stratoflights.asgi:application -b 0.0.0.0 -p 8000 diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..92b9bc6 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'stratoflights.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..8edc625 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,45 @@ +events {} + +http { + upstream django { + server web:8000; + } + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + server { + listen 80; + server_name localhost; + + # Static files + location /static/ { + alias /app/static/; + expires 1y; + access_log off; + add_header Cache-Control "public"; + gzip_static on; + } + + # Media files + location /media/ { + alias /app/media/; + expires 7d; + access_log off; + add_header Cache-Control "public"; + } + + # Django app + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } +} \ No newline at end of file diff --git a/requirements-prod.txt b/requirements-prod.txt new file mode 100644 index 0000000..2679c52 --- /dev/null +++ b/requirements-prod.txt @@ -0,0 +1 @@ +daphne \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2672235 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +Django>=4.0,<5.0 +djangorestframework +djangorestframework-simplejwt +psycopg2-binary +drf-spectacular +requests +django-cors-headers +Pillow +python-dotenv +channels>=4.0 +channels_redis +celery +websockets \ No newline at end of file diff --git a/stratoflights/__init__.py b/stratoflights/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stratoflights/asgi.py b/stratoflights/asgi.py new file mode 100644 index 0000000..6772182 --- /dev/null +++ b/stratoflights/asgi.py @@ -0,0 +1,16 @@ +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "stratoflights.settings") +from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +import stratoflights_api.routing + + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter( + stratoflights_api.routing.websocket_urlpatterns + ) + ), +}) diff --git a/stratoflights/routing.py b/stratoflights/routing.py new file mode 100644 index 0000000..bf1b03f --- /dev/null +++ b/stratoflights/routing.py @@ -0,0 +1,13 @@ +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +from django.core.asgi import get_asgi_application +import stratoflights_api.routing # вот тут важно подключить маршруты приложения + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter( + stratoflights_api.routing.websocket_urlpatterns + ) + ), +}) diff --git a/stratoflights/settings.py b/stratoflights/settings.py new file mode 100644 index 0000000..bb5b63e --- /dev/null +++ b/stratoflights/settings.py @@ -0,0 +1,204 @@ +""" +Django settings for stratoflights project. + +Generated by 'django-admin startproject' using Django 4.2.23. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path +import os +from dotenv import load_dotenv +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# Environment flag +PRODUCTION = os.getenv('DJANGO_ENV', "") == 'production' + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv( + 'SECRET_KEY', 'django-insecure-np(nxnh6mw)v4pa2n2z3pl_5&!2z$jshhak9r3v=y1u9rd*sl!') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG', 'True') == 'True' + +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost').split(',') + +# Static files (CSS, JavaScript, Images) +STATIC_URL = os.getenv('STATIC_URL', '/static/') +STATIC_ROOT = os.getenv('STATIC_ROOT', os.path.join(BASE_DIR, 'static')) + +# Media files (user uploaded) +MEDIA_URL = os.getenv('MEDIA_URL', '/media/') +MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, 'media')) # Куда сохранять загруженные файлы + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework.authtoken', + 'drf_spectacular', + 'corsheaders', + 'stratoflights_api.apps.StratoflightsApiConfig', + 'channels', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +CORS_ALLOWED_ORIGINS = [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', +] + +CORS_EXPOSE_HEADERS = ['Content-Type', 'X-CSRFToken'] +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = True + +ROOT_URLCONF = 'stratoflights.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'stratoflights.wsgi.application' +ASGI_APPLICATION = 'stratoflights.asgi.application' + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +if PRODUCTION: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('DB_NAME'), + 'USER': os.getenv('DB_USER'), + 'PASSWORD': os.getenv('DB_PASSWORD'), + 'HOST': os.getenv('DB_HOST'), + 'PORT': os.getenv('DB_PORT'), + } + } + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +AUTH_USER_MODEL = 'stratoflights_api.User' + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 100, + + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + # 'rest_framework.permissions.AllowAny', + ], + + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], +} + + +CSRF_COOKIE_SAMESITE = 'Lax' # temp +CSRF_COOKIE_SECURE = False +SESSION_COOKIE_SAMESITE = 'Lax' # temp +SESSION_COOKIE_SECURE = False +CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:5173, http://localhost:8000').split(',') + + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("redis", 6379)], + }, + }, +} \ No newline at end of file diff --git a/stratoflights/urls.py b/stratoflights/urls.py new file mode 100644 index 0000000..d97bf9b --- /dev/null +++ b/stratoflights/urls.py @@ -0,0 +1,27 @@ +""" +URL configuration for stratoflights project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView +from drf_spectacular.views import SpectacularSwaggerView + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/', include('stratoflights_api.urls')), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='docs'), +] \ No newline at end of file diff --git a/stratoflights/wsgi.py b/stratoflights/wsgi.py new file mode 100644 index 0000000..c0dddf7 --- /dev/null +++ b/stratoflights/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for stratoflights project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'stratoflights.settings') + +application = get_wsgi_application() diff --git a/stratoflights_api/__init__.py b/stratoflights_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stratoflights_api/admin.py b/stratoflights_api/admin.py new file mode 100644 index 0000000..45e6514 --- /dev/null +++ b/stratoflights_api/admin.py @@ -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) \ No newline at end of file diff --git a/stratoflights_api/apps.py b/stratoflights_api/apps.py new file mode 100644 index 0000000..ccd4c4e --- /dev/null +++ b/stratoflights_api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StratoflightsApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'stratoflights_api' diff --git a/stratoflights_api/build_qstring.py b/stratoflights_api/build_qstring.py new file mode 100644 index 0000000..f724f6c --- /dev/null +++ b/stratoflights_api/build_qstring.py @@ -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) \ No newline at end of file diff --git a/stratoflights_api/consumers.py b/stratoflights_api/consumers.py new file mode 100644 index 0000000..5db343d --- /dev/null +++ b/stratoflights_api/consumers.py @@ -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 \ No newline at end of file diff --git a/stratoflights_api/custom_pagination.py b/stratoflights_api/custom_pagination.py new file mode 100644 index 0000000..6d37de8 --- /dev/null +++ b/stratoflights_api/custom_pagination.py @@ -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 + }) \ No newline at end of file diff --git a/stratoflights_api/migrations/0001_initial.py b/stratoflights_api/migrations/0001_initial.py new file mode 100644 index 0000000..09fe097 --- /dev/null +++ b/stratoflights_api/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/stratoflights_api/migrations/__init__.py b/stratoflights_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stratoflights_api/models.py b/stratoflights_api/models.py new file mode 100644 index 0000000..9a331c7 --- /dev/null +++ b/stratoflights_api/models.py @@ -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') \ No newline at end of file diff --git a/stratoflights_api/permissions.py b/stratoflights_api/permissions.py new file mode 100644 index 0000000..84c2ed4 --- /dev/null +++ b/stratoflights_api/permissions.py @@ -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 \ No newline at end of file diff --git a/stratoflights_api/routing.py b/stratoflights_api/routing.py new file mode 100644 index 0000000..a4dcb62 --- /dev/null +++ b/stratoflights_api/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r"^api/ws/satellite/(?P[0-9a-f-]+)/telemetry/$", consumers.SatelliteTelemetryConsumer.as_asgi()), + re_path(r"^api/ws/station/(?P[0-9a-f-]+)/telemetry/$", consumers.StationTelemetryConsumer.as_asgi()), +] \ No newline at end of file diff --git a/stratoflights_api/serializers.py b/stratoflights_api/serializers.py new file mode 100644 index 0000000..dacdf57 --- /dev/null +++ b/stratoflights_api/serializers.py @@ -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) \ No newline at end of file diff --git a/stratoflights_api/services/tawhiri.py b/stratoflights_api/services/tawhiri.py new file mode 100644 index 0000000..44d1fb0 --- /dev/null +++ b/stratoflights_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/stratoflights_api/tests.py b/stratoflights_api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/stratoflights_api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/stratoflights_api/urls.py b/stratoflights_api/urls.py new file mode 100644 index 0000000..3a438c5 --- /dev/null +++ b/stratoflights_api/urls.py @@ -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("/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 diff --git a/stratoflights_api/validators.py b/stratoflights_api/validators.py new file mode 100644 index 0000000..222c170 --- /dev/null +++ b/stratoflights_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/stratoflights_api/views.py b/stratoflights_api/views.py new file mode 100644 index 0000000..319f2a1 --- /dev/null +++ b/stratoflights_api/views.py @@ -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'] diff --git a/stratoflights_api/ws_client.py b/stratoflights_api/ws_client.py new file mode 100644 index 0000000..816263a --- /dev/null +++ b/stratoflights_api/ws_client.py @@ -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())