initial commit
This commit is contained in:
commit
c6961c03c3
33 changed files with 1782 additions and 0 deletions
36
.dockerignore
Normal file
36
.dockerignore
Normal file
|
|
@ -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
|
||||||
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
76
Dockerfile
Normal file
76
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
4
entrypoint.sh
Normal file
4
entrypoint.sh
Normal file
|
|
@ -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
|
||||||
22
manage.py
Normal file
22
manage.py
Normal file
|
|
@ -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()
|
||||||
45
nginx/nginx.conf
Normal file
45
nginx/nginx.conf
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
requirements-prod.txt
Normal file
1
requirements-prod.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
daphne
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
|
|
@ -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
|
||||||
0
stratoflights/__init__.py
Normal file
0
stratoflights/__init__.py
Normal file
16
stratoflights/asgi.py
Normal file
16
stratoflights/asgi.py
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
13
stratoflights/routing.py
Normal file
13
stratoflights/routing.py
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
204
stratoflights/settings.py
Normal file
204
stratoflights/settings.py
Normal file
|
|
@ -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)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
27
stratoflights/urls.py
Normal file
27
stratoflights/urls.py
Normal file
|
|
@ -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'),
|
||||||
|
]
|
||||||
16
stratoflights/wsgi.py
Normal file
16
stratoflights/wsgi.py
Normal file
|
|
@ -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()
|
||||||
0
stratoflights_api/__init__.py
Normal file
0
stratoflights_api/__init__.py
Normal file
8
stratoflights_api/admin.py
Normal file
8
stratoflights_api/admin.py
Normal 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)
|
||||||
6
stratoflights_api/apps.py
Normal file
6
stratoflights_api/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StratoflightsApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'stratoflights_api'
|
||||||
38
stratoflights_api/build_qstring.py
Normal file
38
stratoflights_api/build_qstring.py
Normal 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)
|
||||||
106
stratoflights_api/consumers.py
Normal file
106
stratoflights_api/consumers.py
Normal 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
|
||||||
17
stratoflights_api/custom_pagination.py
Normal file
17
stratoflights_api/custom_pagination.py
Normal 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
|
||||||
|
})
|
||||||
149
stratoflights_api/migrations/0001_initial.py
Normal file
149
stratoflights_api/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
stratoflights_api/migrations/__init__.py
Normal file
0
stratoflights_api/migrations/__init__.py
Normal file
106
stratoflights_api/models.py
Normal file
106
stratoflights_api/models.py
Normal 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')
|
||||||
16
stratoflights_api/permissions.py
Normal file
16
stratoflights_api/permissions.py
Normal 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
|
||||||
7
stratoflights_api/routing.py
Normal file
7
stratoflights_api/routing.py
Normal 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()),
|
||||||
|
]
|
||||||
185
stratoflights_api/serializers.py
Normal file
185
stratoflights_api/serializers.py
Normal 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)
|
||||||
52
stratoflights_api/services/tawhiri.py
Normal file
52
stratoflights_api/services/tawhiri.py
Normal 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()
|
||||||
3
stratoflights_api/tests.py
Normal file
3
stratoflights_api/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
48
stratoflights_api/urls.py
Normal file
48
stratoflights_api/urls.py
Normal 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
|
||||||
31
stratoflights_api/validators.py
Normal file
31
stratoflights_api/validators.py
Normal 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
354
stratoflights_api/views.py
Normal 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']
|
||||||
62
stratoflights_api/ws_client.py
Normal file
62
stratoflights_api/ws_client.py
Normal 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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue