Compare commits
35 Commits
bd1541d622
...
8d04f90810
| Author | SHA1 | Date |
|---|---|---|
|
|
8d04f90810 | |
|
|
2cfd7f2ede | |
|
|
e76f253c0a | |
|
|
7868009195 | |
|
|
84668ca1e1 | |
|
|
e2dbbf52f3 | |
|
|
d2d965d0e1 | |
|
|
c2af4652c0 | |
|
|
eb611e809d | |
|
|
0ba0be36e6 | |
|
|
b2ec252ae0 | |
|
|
4dde350e59 | |
|
|
5fe15456f6 | |
|
|
5e9bbfcf8f | |
|
|
44b822aa11 | |
|
|
bc1c1702ba | |
|
|
56d2f752c4 | |
|
|
7a3fff73dd | |
|
|
cfd891d41b | |
|
|
a02460aa09 | |
|
|
3181705968 | |
|
|
ee49e3260e | |
|
|
815288621f | |
|
|
7885d50a3b | |
|
|
bf0f4755af | |
|
|
0461d57d5d | |
|
|
9924f3a069 | |
|
|
2f31006211 | |
|
|
853de5b4ea | |
|
|
dc3d2b5f25 | |
|
|
50c7d50b49 | |
|
|
191b20d253 | |
|
|
22905e6552 | |
|
|
b34ec4509e | |
|
|
adf395e521 |
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
We are building an educational SaaS platform using a monolithic Django architecture with Django REST Framework for APIs, Django Channels for WebSocket real-time communication (video calls via Mediasoup integration and interactive Miro-like board), and Celery with Redis for background tasks (notifications, subscription checks, cleanup). The tech stack includes Next.js with TypeScript frontend, PostgreSQL database, Docker containerization, and follows microservices-inspired separation with dedicated Django apps for users, schedule, materials, video, notifications, subscriptions, analytics, and board functionality. Key features include role-based authentication (mentor/client/parent), dynamic scheduling with conflict prevention, file storage with access controls, Telegram bot integration, subscription management with payment processing, and comprehensive analytics dashboards, all while maintaining strict security practices including JWT authentication, rate limiting, file validation, and data encryption. The project must be fully in Russian language for all user-facing interfaces, content, and communications. After each code change, you must update the project documentation, including architectural decisions, API endpoints, data models, and deployment instructions, to keep the documentation always up-to-date and in sync with the code. read ROADMAP.md
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# ==============================================
|
||||
# DEV Environment Configuration
|
||||
# ==============================================
|
||||
|
||||
ENVIRONMENT=development
|
||||
DEBUG=True
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# Server Configuration
|
||||
DEV_HOST=85.192.56.185
|
||||
DEV_USER=root
|
||||
DEV_PATH=/var/www/platform/dev
|
||||
DEV_PORT_WEB=8124
|
||||
DEV_PORT_FRONTEND=3002
|
||||
DEV_PORT_DB=5433
|
||||
DEV_PORT_REDIS=6380
|
||||
|
||||
# Database Configuration
|
||||
POSTGRES_DB=platform_dev_db
|
||||
POSTGRES_USER=platform_dev_user
|
||||
POSTGRES_PASSWORD=platform_dev_password
|
||||
DATABASE_URL=postgresql://platform_dev_user:platform_dev_password@db:5432/platform_dev_db
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Frontend URLs
|
||||
NEXT_PUBLIC_API_URL=http://devapi.uchill.online/api
|
||||
NEXT_PUBLIC_WS_URL=ws://devapi.uchill.online/ws
|
||||
NEXT_PUBLIC_LIVEKIT_URL=wss://devapi.uchill.online/livekit
|
||||
|
||||
# Docker Compose Project Name
|
||||
COMPOSE_PROJECT_NAME=platform_dev
|
||||
|
||||
# Deployment Settings
|
||||
AUTO_MIGRATE=true
|
||||
AUTO_COLLECTSTATIC=true
|
||||
RESTART_AFTER_DEPLOY=true
|
||||
|
||||
# Health Check
|
||||
HEALTH_CHECK_URL=http://localhost:8124/health/
|
||||
HEALTH_CHECK_TIMEOUT=30
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# ==============================================
|
||||
# PRODUCTION Environment Configuration
|
||||
# ==============================================
|
||||
|
||||
ENVIRONMENT=production
|
||||
DEBUG=False
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Server Configuration
|
||||
PROD_HOST=85.192.56.185
|
||||
PROD_USER=root
|
||||
PROD_PATH=/var/www/platform/prod
|
||||
PROD_PORT_WEB=8123
|
||||
PROD_PORT_FRONTEND=3000
|
||||
PROD_PORT_DB=5432
|
||||
PROD_PORT_REDIS=6379
|
||||
|
||||
# Database Configuration
|
||||
POSTGRES_DB=platform_db
|
||||
POSTGRES_USER=platform_user
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
DATABASE_URL=postgresql://platform_user:${POSTGRES_PASSWORD}@db:5432/platform_db
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Frontend URLs
|
||||
NEXT_PUBLIC_API_URL=https://api.uchill.online/api
|
||||
NEXT_PUBLIC_WS_URL=wss://api.uchill.online/ws
|
||||
NEXT_PUBLIC_LIVEKIT_URL=wss://api.uchill.online/livekit
|
||||
|
||||
# Docker Compose Project Name
|
||||
COMPOSE_PROJECT_NAME=platform
|
||||
|
||||
# Deployment Settings
|
||||
AUTO_MIGRATE=true
|
||||
AUTO_COLLECTSTATIC=true
|
||||
RESTART_AFTER_DEPLOY=true
|
||||
BACKUP_BEFORE_DEPLOY=true
|
||||
BACKUP_PATH=/var/www/platform/backups
|
||||
|
||||
# Health Check
|
||||
HEALTH_CHECK_URL=http://localhost:8123/health/
|
||||
HEALTH_CHECK_TIMEOUT=30
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS=api.uchill.online,app.uchill.online,uchill.online
|
||||
SECRET_KEY=${SECRET_KEY}
|
||||
|
||||
# Email Configuration (if needed)
|
||||
EMAIL_HOST=${EMAIL_HOST}
|
||||
EMAIL_PORT=${EMAIL_PORT}
|
||||
EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||
EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
name: Deploy to Dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, develop, dev ]
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy to Dev Server
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.DEV_HOST }}
|
||||
username: ${{ secrets.DEV_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
use_insecure_cipher: true
|
||||
debug: true
|
||||
script_stop: true
|
||||
script: |
|
||||
set -e
|
||||
cd /var/www/platform/dev
|
||||
|
||||
echo "📦 Pulling latest changes from repository..."
|
||||
git pull origin main || git pull origin master || git pull origin develop || git pull origin dev || true
|
||||
|
||||
echo "🔄 Restarting Docker services..."
|
||||
docker compose restart
|
||||
|
||||
echo "📊 Running migrations (if needed)..."
|
||||
docker compose exec -T web python manage.py migrate || true
|
||||
|
||||
echo "📁 Collecting static files (if needed)..."
|
||||
docker compose exec -T web python manage.py collectstatic --noinput --clear || echo "⚠️ collectstatic failed, but continuing..."
|
||||
|
||||
echo "✅ Dev deployment completed successfully"
|
||||
echo "ℹ️ You can continue working directly on the server"
|
||||
|
||||
- name: Health Check
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.DEV_HOST }}
|
||||
username: ${{ secrets.DEV_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
use_insecure_cipher: true
|
||||
debug: true
|
||||
script_stop: true
|
||||
script: |
|
||||
set -e
|
||||
cd /var/www/platform/dev
|
||||
echo "⏳ Waiting for services to start..."
|
||||
sleep 10
|
||||
echo "📊 Checking Docker containers status..."
|
||||
docker compose ps | head -10
|
||||
echo ""
|
||||
echo "🏥 Checking health endpoint..."
|
||||
HEALTH_RESPONSE=$(curl -s http://localhost:8124/health/ 2>&1)
|
||||
if [ -n "$HEALTH_RESPONSE" ]; then
|
||||
echo "✅ Health endpoint is responding"
|
||||
echo "$HEALTH_RESPONSE" | python3 -m json.tool 2>/dev/null | head -10 || echo "$HEALTH_RESPONSE" | head -5
|
||||
if echo "$HEALTH_RESPONSE" | grep -q '"database".*"healthy"'; then
|
||||
echo "✅ Database is healthy - deployment successful!"
|
||||
else
|
||||
echo "⚠️ Database check unclear, but endpoint responds"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Health endpoint not available yet, but deployment completed"
|
||||
fi
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags: [ 'v*' ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- '.cursor/**'
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Deploy to Production Server
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.PROD_HOST }}
|
||||
username: ${{ secrets.PROD_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
cd /var/www/platform/prod
|
||||
|
||||
# Load environment configuration
|
||||
if [ -f .end.prod ]; then
|
||||
source .end.prod
|
||||
fi
|
||||
|
||||
# Pull latest changes
|
||||
git pull origin main || git pull origin master || true
|
||||
|
||||
# Backup database before deployment
|
||||
if [ "$BACKUP_BEFORE_DEPLOY" = "true" ]; then
|
||||
mkdir -p /var/www/platform/backups
|
||||
docker compose exec -T db pg_dump -U ${POSTGRES_USER} ${POSTGRES_DB} > /var/www/platform/backups/backup_$(date +%Y%m%d_%H%M%S).sql || true
|
||||
fi
|
||||
|
||||
# Stop services gracefully
|
||||
docker compose down --timeout 30 || true
|
||||
|
||||
# Build and start services
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Wait for services to be ready
|
||||
sleep 15
|
||||
|
||||
# Run migrations
|
||||
docker compose exec -T web python manage.py migrate || true
|
||||
|
||||
# Collect static files
|
||||
docker compose exec -T web python manage.py collectstatic --noinput || true
|
||||
|
||||
# Clear cache
|
||||
docker compose exec -T web python manage.py clearcache || true
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
|
||||
echo "✅ Production deployment completed successfully"
|
||||
|
||||
- name: Health Check
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.PROD_HOST }}
|
||||
username: ${{ secrets.PROD_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
sleep 15
|
||||
docker compose ps
|
||||
curl -f http://localhost:8123/health/ || exit 1
|
||||
echo "✅ Health check passed"
|
||||
|
||||
- name: Notify Deployment
|
||||
if: always()
|
||||
run: |
|
||||
echo "Deployment status: ${{ job.status }}"
|
||||
# Здесь можно добавить уведомления (Telegram, Slack, Email и т.д.)
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
env:
|
||||
POSTGRES_DB: platform_test
|
||||
POSTGRES_USER: platform_user
|
||||
POSTGRES_PASSWORD: test_password
|
||||
DATABASE_URL: "postgresql://platform_user:test_password@localhost:5432/platform_test"
|
||||
REDIS_URL: "redis://localhost:6379/0"
|
||||
|
||||
jobs:
|
||||
# Backend тесты
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: ${{ env.POSTGRES_DB }}
|
||||
POSTGRES_USER: ${{ env.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run Django checks
|
||||
working-directory: ./backend
|
||||
env:
|
||||
DJANGO_SETTINGS_MODULE: config.settings
|
||||
SECRET_KEY: test-secret-key-for-ci
|
||||
DEBUG: False
|
||||
ALLOWED_HOSTS: "*"
|
||||
run: python manage.py check
|
||||
|
||||
- name: Run tests with coverage
|
||||
working-directory: ./backend
|
||||
env:
|
||||
DJANGO_SETTINGS_MODULE: config.settings
|
||||
SECRET_KEY: test-secret-key-for-ci
|
||||
DEBUG: False
|
||||
ALLOWED_HOSTS: "*"
|
||||
run: |
|
||||
pytest --cov=apps --cov-report=xml --cov-report=html --cov-report=term-missing
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./backend/coverage.xml
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
|
||||
# Backend linting
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install linting tools
|
||||
run: pip install flake8 black isort
|
||||
|
||||
- name: Run flake8
|
||||
working-directory: ./backend
|
||||
run: flake8 apps config --max-line-length=120 --exclude=migrations,__pycache__
|
||||
|
||||
- name: Run black
|
||||
working-directory: ./backend
|
||||
run: black --check apps config --exclude migrations
|
||||
|
||||
- name: Run isort
|
||||
working-directory: ./backend
|
||||
run: isort --check-only apps config --skip migrations
|
||||
|
||||
# Frontend тесты
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run linting
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run type checking
|
||||
working-directory: ./frontend
|
||||
run: npm run type-check || true
|
||||
|
||||
- name: Build
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
|
||||
# Security проверки
|
||||
security-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install security tools
|
||||
run: pip install safety bandit
|
||||
|
||||
- name: Run safety check
|
||||
working-directory: ./backend
|
||||
run: safety check --json || true
|
||||
|
||||
- name: Run bandit
|
||||
working-directory: ./backend
|
||||
run: bandit -r apps config -ll || true
|
||||
|
||||
# Сборка Docker образов
|
||||
build:
|
||||
needs: [test-backend, test-frontend, lint-backend]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v'))
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/platform-backend
|
||||
${{ secrets.DOCKER_USERNAME }}/platform-frontend
|
||||
|
||||
- name: Build and push Backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/platform-backend:${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Frontend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/platform-frontend:${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Deploy на Staging
|
||||
deploy-staging:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging.platform.example.com
|
||||
|
||||
steps:
|
||||
- name: Deploy to Staging
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.STAGING_HOST }}
|
||||
username: ${{ secrets.STAGING_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /opt/platform
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Deploy на Production
|
||||
deploy-production:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
environment:
|
||||
name: production
|
||||
url: https://platform.example.com
|
||||
|
||||
steps:
|
||||
- name: Deploy to Production
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.PRODUCTION_HOST }}
|
||||
username: ${{ secrets.PRODUCTION_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /opt/platform
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
- name: Notify deployment
|
||||
if: always()
|
||||
run: echo "Deployment completed with status ${{ job.status }}"
|
||||
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.11'
|
||||
NODE_VERSION: '18'
|
||||
|
||||
jobs:
|
||||
# ==============================================
|
||||
# Backend тесты
|
||||
# ==============================================
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: test_db
|
||||
POSTGRES_USER: test_user
|
||||
POSTGRES_PASSWORD: test_password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Lint with flake8
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
pip install flake8
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./backend
|
||||
env:
|
||||
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
SECRET_KEY: test-secret-key
|
||||
DEBUG: 'False'
|
||||
run: |
|
||||
python manage.py test --noinput
|
||||
|
||||
- name: Check migrations
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
python manage.py makemigrations --check --dry-run
|
||||
|
||||
# ==============================================
|
||||
# Frontend тесты
|
||||
# ==============================================
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ./frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./frontend
|
||||
run: npm run lint || true
|
||||
|
||||
- name: Type check
|
||||
working-directory: ./frontend
|
||||
run: npm run type-check || true
|
||||
|
||||
- name: Build
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:8123/api
|
||||
run: npm run build
|
||||
|
||||
# ==============================================
|
||||
# Docker build
|
||||
# ==============================================
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-tests, frontend-tests]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./backend
|
||||
push: false
|
||||
tags: platform-backend:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build frontend image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./frontend
|
||||
push: false
|
||||
tags: platform-frontend:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ==============================================
|
||||
# Deploy (только для main ветки)
|
||||
# ==============================================
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-build]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Deploy to production server"
|
||||
# Здесь будет команда деплоя на сервер
|
||||
# Например, через SSH или через Docker registry
|
||||
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# ==============================================
|
||||
# Python
|
||||
# ==============================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.hypothesis/
|
||||
*.mo
|
||||
*.pot
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
instance/
|
||||
.webassets-cache
|
||||
.scrapy
|
||||
docs/_build/
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# ==============================================
|
||||
# Django
|
||||
# ==============================================
|
||||
*.log
|
||||
staticfiles/
|
||||
media/
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Django Silk profiling
|
||||
*.prof
|
||||
profiles/
|
||||
backend/profiles/
|
||||
|
||||
# ==============================================
|
||||
# Node / NPM
|
||||
# ==============================================
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.next/
|
||||
out/
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# ==============================================
|
||||
# Environment variables
|
||||
# ==============================================
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*.local
|
||||
|
||||
# ==============================================
|
||||
# IDE
|
||||
# ==============================================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# ==============================================
|
||||
# Docker
|
||||
# ==============================================
|
||||
docker-compose.override.yml
|
||||
|
||||
# ==============================================
|
||||
# Logs
|
||||
# ==============================================
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# ==============================================
|
||||
# Backups
|
||||
# ==============================================
|
||||
backups/
|
||||
*.backup
|
||||
*.dump
|
||||
*.sql
|
||||
|
||||
# ==============================================
|
||||
# Certificates
|
||||
# ==============================================
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
|
||||
# ==============================================
|
||||
# Temporary files
|
||||
# ==============================================
|
||||
*.tmp
|
||||
*.temp
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
11
README.md
11
README.md
|
|
@ -1 +1,12 @@
|
|||
# Platform Dev Environment
|
||||
# Test CI/CD
|
||||
# Test CI/CD
|
||||
# Test SSH
|
||||
# Test Thu Feb 12 10:43:38 PM MSK 2026
|
||||
test-ssh-fix
|
||||
# Test SSH fix
|
||||
|
||||
# Test SSH connection - 2026-02-12 22:51:25
|
||||
|
||||
# Test SSH after key update - 2026-02-12 22:59:03
|
||||
# Test secret Thu Feb 12 11:04:06 PM MSK 2026
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Database
|
||||
*.sqlite3
|
||||
db.sqlite3
|
||||
|
||||
# Media files (собираются в volume)
|
||||
media/
|
||||
|
||||
# Static files (collectstatic при старте)
|
||||
staticfiles/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Tests и dev-инструменты (не нужны в образе)
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
conftest.py
|
||||
pytest.ini
|
||||
**/tests/
|
||||
integration_tests/
|
||||
create_test_users.py
|
||||
update_plans.py
|
||||
scripts/
|
||||
|
||||
# Документация
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# ==============================================
|
||||
# Dockerfile для Django Backend (оптимизирован)
|
||||
# ==============================================
|
||||
# Сборка: DOCKER_BUILDKIT=1 docker build ... (BuildKit включён по умолчанию в Docker 23+)
|
||||
|
||||
# syntax=docker/dockerfile:1
|
||||
# ------------------------------
|
||||
# Базовый образ Python
|
||||
# ------------------------------
|
||||
ARG PYTHON_VERSION=3.11
|
||||
FROM python:${PYTHON_VERSION}-slim AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_DEFAULT_TIMEOUT=100
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Системные зависимости — один слой, очистка в том же RUN
|
||||
# poppler-utils: pdftotext для конвертации PDF → txt при проверке ДЗ
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
gettext \
|
||||
curl \
|
||||
netcat-traditional \
|
||||
poppler-utils \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
|
||||
# ==============================================
|
||||
# Стадия зависимостей (кэш pip между сборками)
|
||||
# ==============================================
|
||||
FROM base AS dependencies
|
||||
|
||||
COPY requirements.txt /app/
|
||||
|
||||
# Кэш pip ускоряет повторные сборки при неизменных requirements
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --upgrade pip && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# ==============================================
|
||||
# Финальная стадия
|
||||
# ==============================================
|
||||
FROM dependencies AS final
|
||||
|
||||
COPY . /app/
|
||||
|
||||
# Создание директорий для статики, медиа и профилей
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/profiles
|
||||
|
||||
# Создание непривилегированного пользователя
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
# Переключение на непривилегированного пользователя
|
||||
USER appuser
|
||||
|
||||
# Открытие порта
|
||||
EXPOSE 8000
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health/ || exit 1
|
||||
|
||||
# Команда запуска (будет переопределена в docker-compose)
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"]
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Инициализация пакета приложений
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
Аналитика и отчеты.
|
||||
"""
|
||||
default_app_config = 'apps.analytics.apps.AnalyticsConfig'
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Admin для analytics
|
||||
|
||||
from django.contrib import admin
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"""
|
||||
Конфигурация приложения аналитики.
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.analytics'
|
||||
verbose_name = 'Аналитика'
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
"""
|
||||
Экспортеры отчетов в PDF и Excel.
|
||||
"""
|
||||
import io
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PDFExporter:
|
||||
"""Экспорт отчетов в PDF."""
|
||||
|
||||
@staticmethod
|
||||
def generate_report(mentor, start_date, end_date, report_type='overview'):
|
||||
"""
|
||||
Генерация PDF отчета.
|
||||
|
||||
Args:
|
||||
mentor: Пользователь-ментор
|
||||
start_date: Начало периода
|
||||
end_date: Конец периода
|
||||
report_type: Тип отчета ('overview', 'detailed', 'revenue')
|
||||
|
||||
Returns:
|
||||
io.BytesIO: Буфер с PDF файлом
|
||||
"""
|
||||
try:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||
from .services import AnalyticsService
|
||||
|
||||
buffer = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18)
|
||||
story = []
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Заголовок
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=24,
|
||||
textColor=colors.HexColor('#1f2937'),
|
||||
spaceAfter=30,
|
||||
alignment=TA_CENTER
|
||||
)
|
||||
|
||||
story.append(Paragraph('Отчет по аналитике', title_style))
|
||||
story.append(Spacer(1, 0.2 * inch))
|
||||
|
||||
# Информация о менторе и периоде
|
||||
info_style = ParagraphStyle(
|
||||
'Info',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#6b7280'),
|
||||
)
|
||||
|
||||
story.append(Paragraph(f'<b>Ментор:</b> {mentor.get_full_name() or mentor.email}', info_style))
|
||||
story.append(Paragraph(f'<b>Период:</b> {start_date.strftime("%d.%m.%Y")} - {end_date.strftime("%d.%m.%Y")}', info_style))
|
||||
story.append(Paragraph(f'<b>Дата формирования:</b> {datetime.now().strftime("%d.%m.%Y %H:%M")}', info_style))
|
||||
story.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Получаем данные
|
||||
if report_type == 'overview':
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# Вычисляем период
|
||||
now = timezone.now()
|
||||
if (end_date - start_date).days <= 1:
|
||||
period = 'day'
|
||||
elif (end_date - start_date).days <= 7:
|
||||
period = 'week'
|
||||
elif (end_date - start_date).days <= 31:
|
||||
period = 'month'
|
||||
else:
|
||||
period = 'year'
|
||||
|
||||
# Используем сервис напрямую для получения данных
|
||||
from .services import AnalyticsService
|
||||
|
||||
service = AnalyticsService()
|
||||
overview_data = service.get_overview_data(mentor, start_date, end_date)
|
||||
revenue_data = service.get_revenue_data(mentor, start_date, end_date)
|
||||
|
||||
# Общая статистика
|
||||
story.append(Paragraph('<b>Общая статистика</b>', styles['Heading2']))
|
||||
story.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
overview_table_data = [
|
||||
['Метрика', 'Значение'],
|
||||
['Всего занятий', str(overview_data['lessons']['total'])],
|
||||
['Завершено', str(overview_data['lessons']['completed'])],
|
||||
['Отменено', str(overview_data['lessons']['cancelled'])],
|
||||
['Активных учеников', str(overview_data['students']['active'])],
|
||||
['Общий доход', f"{overview_data['revenue']['total']:.2f} ₽"],
|
||||
['Средний доход за занятие', f"{overview_data['revenue']['average_per_lesson']:.2f} ₽"],
|
||||
['Средний балл', f"{overview_data['grades']['average']}"],
|
||||
]
|
||||
|
||||
overview_table = Table(overview_table_data, colWidths=[4 * inch, 2 * inch])
|
||||
overview_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3b82f6')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 12),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
]))
|
||||
story.append(overview_table)
|
||||
story.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Доходы
|
||||
story.append(Paragraph('<b>Доходы</b>', styles['Heading2']))
|
||||
story.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
revenue_table_data = [['Дата', 'Доход (₽)', 'Занятий']]
|
||||
for item in revenue_data.get('by_day', [])[:30]: # Ограничиваем 30 записями
|
||||
revenue_table_data.append([
|
||||
item['date'],
|
||||
f"{item['revenue']:.2f}",
|
||||
str(item['lessons_count'])
|
||||
])
|
||||
|
||||
if len(revenue_table_data) > 1:
|
||||
revenue_table = Table(revenue_table_data, colWidths=[2 * inch, 2 * inch, 2 * inch])
|
||||
revenue_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#10b981')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 11),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.lightgrey),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
]))
|
||||
story.append(revenue_table)
|
||||
|
||||
elif report_type == 'detailed':
|
||||
stats = AnalyticsService.get_detailed_lesson_stats(mentor, start_date, end_date)
|
||||
|
||||
story.append(Paragraph('<b>Детальная статистика</b>', styles['Heading2']))
|
||||
story.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
detailed_table_data = [
|
||||
['Метрика', 'Значение'],
|
||||
['Всего занятий', str(stats['summary']['total_lessons'])],
|
||||
['Завершено', str(stats['summary']['completed'])],
|
||||
['Процент завершения', f"{stats['summary']['completion_rate']}%"],
|
||||
['Общее время (часы)', f"{stats['time']['total_duration_hours']}"],
|
||||
['Средняя длительность (мин)', f"{stats['time']['average_duration_minutes']}"],
|
||||
['Общий доход', f"{stats['revenue']['total']:.2f} ₽"],
|
||||
['Средний доход за занятие', f"{stats['revenue']['average_per_lesson']:.2f} ₽"],
|
||||
['Средний балл', f"{stats['grades']['average']}"],
|
||||
]
|
||||
|
||||
detailed_table = Table(detailed_table_data, colWidths=[4 * inch, 2 * inch])
|
||||
detailed_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#8b5cf6')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 12),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
]))
|
||||
story.append(detailed_table)
|
||||
story.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Топ клиентов
|
||||
if stats.get('top_clients'):
|
||||
story.append(Paragraph('<b>Топ клиентов</b>', styles['Heading2']))
|
||||
story.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
clients_table_data = [['Клиент', 'Занятий', 'Доход (₽)', 'Средний балл']]
|
||||
for client in stats['top_clients'][:10]:
|
||||
clients_table_data.append([
|
||||
client['name'],
|
||||
str(client['lessons_count']),
|
||||
f"{client['total_revenue']:.2f}",
|
||||
f"{client['average_grade']}"
|
||||
])
|
||||
|
||||
clients_table = Table(clients_table_data, colWidths=[2.5 * inch, 1 * inch, 1.5 * inch, 1 * inch])
|
||||
clients_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f59e0b')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.lightgrey),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
]))
|
||||
story.append(clients_table)
|
||||
|
||||
# Генерируем PDF
|
||||
doc.build(story)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
except ImportError:
|
||||
logger.error("reportlab not installed. Install it with: pip install reportlab")
|
||||
raise Exception("PDF export requires reportlab library. Install it with: pip install reportlab")
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating PDF: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
class ExcelExporter:
|
||||
"""Экспорт отчетов в Excel."""
|
||||
|
||||
@staticmethod
|
||||
def generate_report(mentor, start_date, end_date, report_type='overview'):
|
||||
"""
|
||||
Генерация Excel отчета.
|
||||
|
||||
Args:
|
||||
mentor: Пользователь-ментор
|
||||
start_date: Начало периода
|
||||
end_date: Конец периода
|
||||
report_type: Тип отчета ('overview', 'detailed', 'revenue', 'lessons')
|
||||
|
||||
Returns:
|
||||
io.BytesIO: Буфер с Excel файлом
|
||||
"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from .services import AnalyticsService
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Отчет"
|
||||
|
||||
# Стили
|
||||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=12)
|
||||
title_font = Font(bold=True, size=16)
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
# Заголовок
|
||||
ws['A1'] = 'Отчет по аналитике'
|
||||
ws['A1'].font = title_font
|
||||
ws.merge_cells('A1:D1')
|
||||
|
||||
ws['A2'] = f'Ментор: {mentor.get_full_name() or mentor.email}'
|
||||
ws['A3'] = f'Период: {start_date.strftime("%d.%m.%Y")} - {end_date.strftime("%d.%m.%Y")}'
|
||||
ws['A4'] = f'Дата формирования: {datetime.now().strftime("%d.%m.%Y %H:%M")}'
|
||||
|
||||
row = 6
|
||||
|
||||
if report_type == 'overview':
|
||||
# Используем сервис напрямую для получения данных
|
||||
from .services import AnalyticsService
|
||||
|
||||
service = AnalyticsService()
|
||||
overview_data = service.get_overview_data(mentor, start_date, end_date)
|
||||
revenue_data = service.get_revenue_data(mentor, start_date, end_date)
|
||||
|
||||
# Общая статистика
|
||||
ws[f'A{row}'] = 'Общая статистика'
|
||||
ws[f'A{row}'].font = Font(bold=True, size=14)
|
||||
row += 1
|
||||
|
||||
stats_data = [
|
||||
['Метрика', 'Значение'],
|
||||
['Всего занятий', overview_data['lessons']['total']],
|
||||
['Завершено', overview_data['lessons']['completed']],
|
||||
['Отменено', overview_data['lessons']['cancelled']],
|
||||
['Активных учеников', overview_data['students']['active']],
|
||||
['Общий доход', f"{overview_data['revenue']['total']:.2f} ₽"],
|
||||
['Средний доход за занятие', f"{overview_data['revenue']['average_per_lesson']:.2f} ₽"],
|
||||
['Средний балл', overview_data['grades']['average']],
|
||||
]
|
||||
|
||||
for i, (metric, value) in enumerate(stats_data):
|
||||
ws[f'A{row}'] = metric
|
||||
ws[f'B{row}'] = value
|
||||
if i == 0: # Заголовок
|
||||
ws[f'A{row}'].fill = header_fill
|
||||
ws[f'A{row}'].font = header_font
|
||||
ws[f'B{row}'].fill = header_fill
|
||||
ws[f'B{row}'].font = header_font
|
||||
ws[f'A{row}'].border = border
|
||||
ws[f'B{row}'].border = border
|
||||
row += 1
|
||||
|
||||
row += 2
|
||||
|
||||
# Доходы по дням
|
||||
ws[f'A{row}'] = 'Доходы по дням'
|
||||
ws[f'A{row}'].font = Font(bold=True, size=14)
|
||||
row += 1
|
||||
|
||||
revenue_headers = ['Дата', 'Доход (₽)', 'Занятий']
|
||||
for col, header in enumerate(revenue_headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
row += 1
|
||||
for item in revenue_data.get('by_day', []):
|
||||
ws.cell(row=row, column=1, value=item['date']).border = border
|
||||
ws.cell(row=row, column=2, value=round(item['revenue'], 2)).border = border
|
||||
ws.cell(row=row, column=3, value=item['lessons_count']).border = border
|
||||
row += 1
|
||||
|
||||
# Доходы по предметам
|
||||
row += 2
|
||||
ws[f'A{row}'] = 'Доходы по предметам'
|
||||
ws[f'A{row}'].font = Font(bold=True, size=14)
|
||||
row += 1
|
||||
|
||||
subject_headers = ['Предмет', 'Доход (₽)', 'Занятий']
|
||||
for col, header in enumerate(subject_headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
row += 1
|
||||
for item in revenue_data.get('by_subject', []):
|
||||
ws.cell(row=row, column=1, value=item['subject']).border = border
|
||||
ws.cell(row=row, column=2, value=round(item['revenue'], 2)).border = border
|
||||
ws.cell(row=row, column=3, value=item['lessons_count']).border = border
|
||||
row += 1
|
||||
|
||||
elif report_type == 'detailed':
|
||||
stats = AnalyticsService.get_detailed_lesson_stats(mentor, start_date, end_date)
|
||||
|
||||
ws[f'A{row}'] = 'Детальная статистика'
|
||||
ws[f'A{row}'].font = Font(bold=True, size=14)
|
||||
row += 1
|
||||
|
||||
detailed_data = [
|
||||
['Метрика', 'Значение'],
|
||||
['Всего занятий', stats['summary']['total_lessons']],
|
||||
['Завершено', stats['summary']['completed']],
|
||||
['Процент завершения', f"{stats['summary']['completion_rate']}%"],
|
||||
['Общее время (часы)', stats['time']['total_duration_hours']],
|
||||
['Средняя длительность (мин)', stats['time']['average_duration_minutes']],
|
||||
['Общий доход', f"{stats['revenue']['total']:.2f} ₽"],
|
||||
['Средний доход за занятие', f"{stats['revenue']['average_per_lesson']:.2f} ₽"],
|
||||
['Средний балл', stats['grades']['average']],
|
||||
]
|
||||
|
||||
for i, (metric, value) in enumerate(detailed_data):
|
||||
ws.cell(row=row, column=1, value=metric).border = border
|
||||
ws.cell(row=row, column=2, value=value).border = border
|
||||
if i == 0:
|
||||
ws.cell(row=row, column=1).fill = header_fill
|
||||
ws.cell(row=row, column=1).font = header_font
|
||||
ws.cell(row=row, column=2).fill = header_fill
|
||||
ws.cell(row=row, column=2).font = header_font
|
||||
row += 1
|
||||
|
||||
# Топ клиентов
|
||||
if stats.get('top_clients'):
|
||||
row += 2
|
||||
ws[f'A{row}'] = 'Топ клиентов'
|
||||
ws[f'A{row}'].font = Font(bold=True, size=14)
|
||||
row += 1
|
||||
|
||||
clients_headers = ['Клиент', 'Занятий', 'Доход (₽)', 'Средний балл']
|
||||
for col, header in enumerate(clients_headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
row += 1
|
||||
for client in stats['top_clients']:
|
||||
ws.cell(row=row, column=1, value=client['name']).border = border
|
||||
ws.cell(row=row, column=2, value=client['lessons_count']).border = border
|
||||
ws.cell(row=row, column=3, value=round(client['total_revenue'], 2)).border = border
|
||||
ws.cell(row=row, column=4, value=client['average_grade']).border = border
|
||||
row += 1
|
||||
|
||||
# Настройка ширины столбцов
|
||||
ws.column_dimensions['A'].width = 30
|
||||
ws.column_dimensions['B'].width = 20
|
||||
ws.column_dimensions['C'].width = 20
|
||||
ws.column_dimensions['D'].width = 20
|
||||
|
||||
# Сохраняем в буфер
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
except ImportError:
|
||||
logger.error("openpyxl not installed. Install it with: pip install openpyxl")
|
||||
raise Exception("Excel export requires openpyxl library. Install it with: pip install openpyxl")
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating Excel: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Модели для analytics
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Сериализаторы для analytics
|
||||
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"""
|
||||
Сервисы для аналитики и отчетов.
|
||||
"""
|
||||
from django.db.models import Sum, Avg, Count, Q, F, Min, Max
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta, datetime
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""Сервис для аналитики ментора."""
|
||||
|
||||
@staticmethod
|
||||
def get_detailed_lesson_stats(mentor, start_date, end_date):
|
||||
"""
|
||||
Детальная статистика по занятиям.
|
||||
|
||||
Returns:
|
||||
dict: Детальная статистика
|
||||
"""
|
||||
from apps.schedule.models import Lesson
|
||||
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date
|
||||
).select_related('client__user', 'subject')
|
||||
|
||||
# Общая статистика
|
||||
total_lessons = lessons.count()
|
||||
completed_lessons = lessons.filter(status='completed')
|
||||
cancelled_lessons = lessons.filter(status='cancelled')
|
||||
|
||||
# Время занятий
|
||||
total_duration_minutes = completed_lessons.aggregate(
|
||||
total=Sum('duration')
|
||||
)['total'] or 0
|
||||
avg_duration = completed_lessons.aggregate(
|
||||
avg=Avg('duration')
|
||||
)['avg'] or 0
|
||||
|
||||
# Доходы
|
||||
total_revenue = completed_lessons.filter(
|
||||
price__isnull=False
|
||||
).aggregate(
|
||||
total=Sum('price')
|
||||
)['total'] or Decimal('0')
|
||||
|
||||
avg_price = completed_lessons.filter(
|
||||
price__isnull=False
|
||||
).aggregate(
|
||||
avg=Avg('price')
|
||||
)['avg'] or Decimal('0')
|
||||
|
||||
# Оценки
|
||||
graded_lessons = completed_lessons.filter(mentor_grade__isnull=False)
|
||||
avg_grade = graded_lessons.aggregate(avg=Avg('mentor_grade'))['avg'] or 0
|
||||
max_grade = graded_lessons.aggregate(max=Max('mentor_grade'))['max'] or 0
|
||||
min_grade = graded_lessons.aggregate(min=Min('mentor_grade'))['min'] or 0
|
||||
|
||||
# По часам дня
|
||||
# Оптимизация: используем один запрос с Extract вместо 24 запросов count()
|
||||
from django.db.models.functions import ExtractHour
|
||||
by_hour_data = completed_lessons.annotate(
|
||||
hour=ExtractHour('start_time')
|
||||
).values('hour').annotate(count=Count('id')).order_by('hour')
|
||||
|
||||
# Создаем словарь для быстрого доступа
|
||||
by_hour_dict = {item['hour']: item['count'] for item in by_hour_data}
|
||||
|
||||
# Заполняем все 24 часа (если для какого-то часа нет данных, count = 0)
|
||||
by_hour = [
|
||||
{
|
||||
'hour': hour,
|
||||
'count': by_hour_dict.get(hour, 0),
|
||||
}
|
||||
for hour in range(24)
|
||||
]
|
||||
|
||||
# По времени суток
|
||||
# Оптимизация: используем один запрос с фильтрацией вместо 4 запросов count()
|
||||
time_of_day_stats = completed_lessons.aggregate(
|
||||
morning=Count('id', filter=Q(start_time__hour__gte=6, start_time__hour__lt=12)),
|
||||
afternoon=Count('id', filter=Q(start_time__hour__gte=12, start_time__hour__lt=18)),
|
||||
evening=Count('id', filter=Q(start_time__hour__gte=18, start_time__hour__lt=24)),
|
||||
night=Count('id', filter=Q(start_time__hour__gte=0, start_time__hour__lt=6))
|
||||
)
|
||||
morning = time_of_day_stats['morning'] or 0
|
||||
afternoon = time_of_day_stats['afternoon'] or 0
|
||||
evening = time_of_day_stats['evening'] or 0
|
||||
night = time_of_day_stats['night'] or 0
|
||||
|
||||
# Топ клиентов по количеству занятий
|
||||
top_clients = lessons.values(
|
||||
'client__user__first_name',
|
||||
'client__user__last_name',
|
||||
'client__user__email'
|
||||
).annotate(
|
||||
lessons_count=Count('id'),
|
||||
completed_count=Count('id', filter=Q(status='completed')),
|
||||
total_revenue=Sum('price', filter=Q(status='completed', price__isnull=False)),
|
||||
avg_grade=Avg('mentor_grade', filter=Q(status='completed', mentor_grade__isnull=False))
|
||||
).order_by('-lessons_count')[:10]
|
||||
|
||||
return {
|
||||
'summary': {
|
||||
'total_lessons': total_lessons,
|
||||
'completed': completed_lessons.count(),
|
||||
'cancelled': cancelled_lessons.count(),
|
||||
'completion_rate': round((completed_lessons.count() / total_lessons * 100) if total_lessons > 0 else 0, 1),
|
||||
},
|
||||
'time': {
|
||||
'total_duration_minutes': total_duration_minutes,
|
||||
'total_duration_hours': round(total_duration_minutes / 60, 1),
|
||||
'average_duration_minutes': round(avg_duration, 1),
|
||||
'by_hour': by_hour,
|
||||
'by_time_of_day': {
|
||||
'morning': morning,
|
||||
'afternoon': afternoon,
|
||||
'evening': evening,
|
||||
'night': night,
|
||||
},
|
||||
},
|
||||
'revenue': {
|
||||
'total': float(total_revenue),
|
||||
'average_per_lesson': float(avg_price),
|
||||
'potential_revenue': float(
|
||||
cancelled_lessons.filter(price__isnull=False).aggregate(
|
||||
total=Sum('price')
|
||||
)['total'] or Decimal('0')
|
||||
),
|
||||
},
|
||||
'grades': {
|
||||
'average': round(avg_grade, 1),
|
||||
'max': max_grade,
|
||||
'min': min_grade,
|
||||
'graded_count': graded_lessons.count(),
|
||||
},
|
||||
'top_clients': [
|
||||
{
|
||||
'name': f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or item['client__user__email'],
|
||||
'email': item['client__user__email'],
|
||||
'lessons_count': item['lessons_count'],
|
||||
'completed_count': item['completed_count'],
|
||||
'total_revenue': float(item['total_revenue'] or 0),
|
||||
'average_grade': round(item['avg_grade'] or 0, 1),
|
||||
}
|
||||
for item in top_clients
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_time_series_data(mentor, start_date, end_date, group_by='day'):
|
||||
"""
|
||||
Временные ряды для графиков.
|
||||
|
||||
Args:
|
||||
group_by: 'day', 'week', 'month'
|
||||
"""
|
||||
from apps.schedule.models import Lesson
|
||||
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date,
|
||||
status='completed'
|
||||
)
|
||||
|
||||
if group_by == 'day':
|
||||
# Группируем по дням
|
||||
data = lessons.extra(
|
||||
select={'date': "DATE(start_time AT TIME ZONE 'UTC')"}
|
||||
).values('date').annotate(
|
||||
lessons_count=Count('id'),
|
||||
revenue=Sum('price', filter=Q(price__isnull=False)),
|
||||
avg_duration=Avg('duration'),
|
||||
avg_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False))
|
||||
).order_by('date')
|
||||
|
||||
return [
|
||||
{
|
||||
'date': item['date'].strftime('%Y-%m-%d'),
|
||||
'lessons_count': item['lessons_count'],
|
||||
'revenue': float(item['revenue'] or 0),
|
||||
'avg_duration': round(item['avg_duration'] or 0, 1),
|
||||
'avg_grade': round(item['avg_grade'] or 0, 1),
|
||||
}
|
||||
for item in data
|
||||
]
|
||||
|
||||
elif group_by == 'week':
|
||||
# Группируем по неделям
|
||||
data = lessons.extra(
|
||||
select={'week': "DATE_TRUNC('week', start_time AT TIME ZONE 'UTC')"}
|
||||
).values('week').annotate(
|
||||
lessons_count=Count('id'),
|
||||
revenue=Sum('price', filter=Q(price__isnull=False)),
|
||||
avg_duration=Avg('duration'),
|
||||
avg_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False))
|
||||
).order_by('week')
|
||||
|
||||
return [
|
||||
{
|
||||
'week': item['week'].strftime('%Y-W%W'),
|
||||
'week_start': item['week'].strftime('%Y-%m-%d'),
|
||||
'lessons_count': item['lessons_count'],
|
||||
'revenue': float(item['revenue'] or 0),
|
||||
'avg_duration': round(item['avg_duration'] or 0, 1),
|
||||
'avg_grade': round(item['avg_grade'] or 0, 1),
|
||||
}
|
||||
for item in data
|
||||
]
|
||||
|
||||
elif group_by == 'month':
|
||||
# Группируем по месяцам
|
||||
data = lessons.extra(
|
||||
select={'month': "DATE_TRUNC('month', start_time AT TIME ZONE 'UTC')"}
|
||||
).values('month').annotate(
|
||||
lessons_count=Count('id'),
|
||||
revenue=Sum('price', filter=Q(price__isnull=False)),
|
||||
avg_duration=Avg('duration'),
|
||||
avg_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False))
|
||||
).order_by('month')
|
||||
|
||||
return [
|
||||
{
|
||||
'month': item['month'].strftime('%Y-%m'),
|
||||
'month_name': item['month'].strftime('%B %Y'),
|
||||
'lessons_count': item['lessons_count'],
|
||||
'revenue': float(item['revenue'] or 0),
|
||||
'avg_duration': round(item['avg_duration'] or 0, 1),
|
||||
'avg_grade': round(item['avg_grade'] or 0, 1),
|
||||
}
|
||||
for item in data
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_comparison_data(mentor, current_start, current_end, previous_start, previous_end):
|
||||
"""
|
||||
Сравнение текущего периода с предыдущим.
|
||||
"""
|
||||
from apps.schedule.models import Lesson
|
||||
|
||||
# Текущий период
|
||||
current_lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=current_start,
|
||||
start_time__lte=current_end,
|
||||
status='completed'
|
||||
)
|
||||
|
||||
current_stats = {
|
||||
'lessons_count': current_lessons.count(),
|
||||
'revenue': float(
|
||||
current_lessons.filter(price__isnull=False).aggregate(
|
||||
total=Sum('price')
|
||||
)['total'] or Decimal('0')
|
||||
),
|
||||
'avg_duration': current_lessons.aggregate(avg=Avg('duration'))['avg'] or 0,
|
||||
'avg_grade': current_lessons.filter(mentor_grade__isnull=False).aggregate(
|
||||
avg=Avg('mentor_grade')
|
||||
)['avg'] or 0,
|
||||
}
|
||||
|
||||
# Предыдущий период
|
||||
previous_lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=previous_start,
|
||||
start_time__lte=previous_end,
|
||||
status='completed'
|
||||
)
|
||||
|
||||
previous_stats = {
|
||||
'lessons_count': previous_lessons.count(),
|
||||
'revenue': float(
|
||||
previous_lessons.filter(price__isnull=False).aggregate(
|
||||
total=Sum('price')
|
||||
)['total'] or Decimal('0')
|
||||
),
|
||||
'avg_duration': previous_lessons.aggregate(avg=Avg('duration'))['avg'] or 0,
|
||||
'avg_grade': previous_lessons.filter(mentor_grade__isnull=False).aggregate(
|
||||
avg=Avg('mentor_grade')
|
||||
)['avg'] or 0,
|
||||
}
|
||||
|
||||
# Вычисляем изменения
|
||||
def calculate_change(current, previous):
|
||||
if previous == 0:
|
||||
return 100.0 if current > 0 else 0.0
|
||||
return round(((current - previous) / previous) * 100, 1)
|
||||
|
||||
return {
|
||||
'current': current_stats,
|
||||
'previous': previous_stats,
|
||||
'changes': {
|
||||
'lessons_count': calculate_change(
|
||||
current_stats['lessons_count'],
|
||||
previous_stats['lessons_count']
|
||||
),
|
||||
'revenue': calculate_change(
|
||||
current_stats['revenue'],
|
||||
previous_stats['revenue']
|
||||
),
|
||||
'avg_duration': calculate_change(
|
||||
current_stats['avg_duration'],
|
||||
previous_stats['avg_duration']
|
||||
),
|
||||
'avg_grade': calculate_change(
|
||||
current_stats['avg_grade'],
|
||||
previous_stats['avg_grade']
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_overview_data(mentor, start_date, end_date):
|
||||
"""
|
||||
Получить данные для overview отчета.
|
||||
|
||||
Args:
|
||||
mentor: Пользователь-ментор
|
||||
start_date: Начало периода
|
||||
end_date: Конец периода
|
||||
|
||||
Returns:
|
||||
dict: Данные overview
|
||||
"""
|
||||
from apps.schedule.models import Lesson
|
||||
from apps.users.models import Client
|
||||
from apps.homework.models import Homework, HomeworkSubmission
|
||||
|
||||
# Занятия
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date
|
||||
)
|
||||
|
||||
total_lessons = lessons.count()
|
||||
completed_lessons = lessons.filter(status='completed').count()
|
||||
cancelled_lessons = lessons.filter(status='cancelled').count()
|
||||
|
||||
# Доходы
|
||||
total_revenue = lessons.filter(
|
||||
status='completed',
|
||||
price__isnull=False
|
||||
).aggregate(total=Sum('price'))['total'] or Decimal('0')
|
||||
|
||||
# Ученики
|
||||
active_students = Client.objects.filter(
|
||||
mentors=mentor
|
||||
).count()
|
||||
|
||||
# Домашние задания
|
||||
homeworks = Homework.objects.filter(
|
||||
mentor=mentor,
|
||||
created_at__gte=start_date,
|
||||
created_at__lte=end_date
|
||||
)
|
||||
|
||||
total_homeworks = homeworks.count()
|
||||
pending_submissions = HomeworkSubmission.objects.filter(
|
||||
homework__mentor=mentor,
|
||||
status='pending'
|
||||
).count()
|
||||
|
||||
# Средний балл
|
||||
average_grade = lessons.filter(
|
||||
status='completed',
|
||||
mentor_grade__isnull=False
|
||||
).aggregate(avg=Avg('mentor_grade'))['avg'] or 0
|
||||
|
||||
return {
|
||||
'period': {
|
||||
'start': start_date.isoformat(),
|
||||
'end': end_date.isoformat(),
|
||||
},
|
||||
'lessons': {
|
||||
'total': total_lessons,
|
||||
'completed': completed_lessons,
|
||||
'cancelled': cancelled_lessons,
|
||||
},
|
||||
'revenue': {
|
||||
'total': float(total_revenue),
|
||||
'average_per_lesson': float(total_revenue / completed_lessons) if completed_lessons > 0 else 0,
|
||||
},
|
||||
'students': {
|
||||
'active': active_students,
|
||||
},
|
||||
'homeworks': {
|
||||
'total': total_homeworks,
|
||||
'pending': pending_submissions,
|
||||
},
|
||||
'grades': {
|
||||
'average': round(average_grade, 1),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_revenue_data(mentor, start_date, end_date):
|
||||
"""
|
||||
Получить данные для revenue отчета.
|
||||
|
||||
Args:
|
||||
mentor: Пользователь-ментор
|
||||
start_date: Начало периода
|
||||
end_date: Конец периода
|
||||
|
||||
Returns:
|
||||
dict: Данные revenue
|
||||
"""
|
||||
from apps.schedule.models import Lesson
|
||||
|
||||
# Доходы по дням
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date,
|
||||
status='completed',
|
||||
price__isnull=False
|
||||
)
|
||||
|
||||
# Группируем по дням
|
||||
revenue_by_day = lessons.extra(
|
||||
select={'day': "DATE(start_time AT TIME ZONE 'UTC')"}
|
||||
).values('day').annotate(
|
||||
revenue=Sum('price'),
|
||||
lessons_count=Count('id')
|
||||
).order_by('day')
|
||||
|
||||
# Доходы по предметам
|
||||
revenue_by_subject = lessons.values('subject__name').annotate(
|
||||
revenue=Sum('price', filter=Q(price__isnull=False)),
|
||||
lessons_count=Count('id')
|
||||
).order_by('-revenue')[:5]
|
||||
|
||||
total_revenue = lessons.aggregate(total=Sum('price'))['total'] or Decimal('0')
|
||||
total_lessons = lessons.count()
|
||||
|
||||
return {
|
||||
'total_revenue': float(total_revenue),
|
||||
'total_lessons': total_lessons,
|
||||
'average_per_lesson': float(total_revenue / total_lessons) if total_lessons > 0 else 0,
|
||||
'by_day': [
|
||||
{
|
||||
'date': item['day'].strftime('%Y-%m-%d'),
|
||||
'revenue': float(item['revenue']),
|
||||
'lessons_count': item['lessons_count'],
|
||||
}
|
||||
for item in revenue_by_day
|
||||
],
|
||||
'by_subject': [
|
||||
{
|
||||
'subject': item['subject__name'] or 'Без предмета',
|
||||
'revenue': float(item['revenue'] or 0),
|
||||
'lessons_count': item['lessons_count'],
|
||||
}
|
||||
for item in revenue_by_subject
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
Пример оптимизации с сырым SQL для самых медленных запросов.
|
||||
Использовать только если ORM оптимизация недостаточна.
|
||||
"""
|
||||
from django.db import connection
|
||||
from django.db.models import Count, Sum, Avg, Q
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def get_detailed_lesson_stats_raw_sql(mentor, start_date, end_date):
|
||||
"""
|
||||
Оптимизированная версия с сырым SQL.
|
||||
Использовать только если get_detailed_lesson_stats все еще медленный.
|
||||
|
||||
Выигрыш: ~30-50% быстрее за счет одного сложного запроса вместо множественных.
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
# Один большой запрос вместо множественных
|
||||
cursor.execute("""
|
||||
WITH lesson_stats AS (
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') as completed,
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled,
|
||||
SUM(duration) FILTER (WHERE status = 'completed') as total_duration,
|
||||
AVG(duration) FILTER (WHERE status = 'completed') as avg_duration,
|
||||
SUM(price) FILTER (WHERE status = 'completed' AND price IS NOT NULL) as total_revenue,
|
||||
AVG(price) FILTER (WHERE status = 'completed' AND price IS NOT NULL) as avg_price,
|
||||
AVG(mentor_grade) FILTER (WHERE status = 'completed' AND mentor_grade IS NOT NULL) as avg_grade,
|
||||
MAX(mentor_grade) FILTER (WHERE status = 'completed' AND mentor_grade IS NOT NULL) as max_grade,
|
||||
MIN(mentor_grade) FILTER (WHERE status = 'completed' AND mentor_grade IS NOT NULL) as min_grade,
|
||||
SUM(price) FILTER (WHERE status = 'cancelled' AND price IS NOT NULL) as cancelled_revenue,
|
||||
COUNT(*) FILTER (WHERE status = 'completed' AND mentor_grade IS NOT NULL) as graded_count
|
||||
FROM lessons
|
||||
WHERE mentor_id = %s
|
||||
AND start_time >= %s
|
||||
AND start_time <= %s
|
||||
),
|
||||
by_hour_stats AS (
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC')::int as hour,
|
||||
COUNT(*) as count
|
||||
FROM lessons
|
||||
WHERE mentor_id = %s
|
||||
AND start_time >= %s
|
||||
AND start_time <= %s
|
||||
AND status = 'completed'
|
||||
GROUP BY hour
|
||||
),
|
||||
time_of_day_stats AS (
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') >= 6 AND EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') < 12) as morning,
|
||||
COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') >= 12 AND EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') < 18) as afternoon,
|
||||
COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') >= 18 AND EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') < 24) as evening,
|
||||
COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') >= 0 AND EXTRACT(HOUR FROM start_time AT TIME ZONE 'UTC') < 6) as night
|
||||
FROM lessons
|
||||
WHERE mentor_id = %s
|
||||
AND start_time >= %s
|
||||
AND start_time <= %s
|
||||
AND status = 'completed'
|
||||
),
|
||||
top_clients_stats AS (
|
||||
SELECT
|
||||
c.id as client_id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
COUNT(l.id) as lessons_count,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'completed') as completed_count,
|
||||
SUM(l.price) FILTER (WHERE l.status = 'completed' AND l.price IS NOT NULL) as total_revenue,
|
||||
AVG(l.mentor_grade) FILTER (WHERE l.status = 'completed' AND l.mentor_grade IS NOT NULL) as avg_grade
|
||||
FROM lessons l
|
||||
JOIN clients c ON l.client_id = c.id
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE l.mentor_id = %s
|
||||
AND l.start_time >= %s
|
||||
AND l.start_time <= %s
|
||||
GROUP BY c.id, u.first_name, u.last_name, u.email
|
||||
ORDER BY lessons_count DESC
|
||||
LIMIT 10
|
||||
)
|
||||
SELECT
|
||||
(SELECT row_to_json(s) FROM lesson_stats s) as stats,
|
||||
(SELECT json_agg(row_to_json(b)) FROM by_hour_stats b) as by_hour,
|
||||
(SELECT row_to_json(t) FROM time_of_day_stats t) as time_of_day,
|
||||
(SELECT json_agg(row_to_json(tc)) FROM top_clients_stats tc) as top_clients
|
||||
""", [
|
||||
mentor.id, start_date, end_date, # lesson_stats
|
||||
mentor.id, start_date, end_date, # by_hour_stats
|
||||
mentor.id, start_date, end_date, # time_of_day_stats
|
||||
mentor.id, start_date, end_date, # top_clients_stats
|
||||
])
|
||||
|
||||
row = cursor.fetchone()
|
||||
stats_data = row[0]
|
||||
by_hour_data = row[1] or []
|
||||
time_of_day_data = row[2] or {}
|
||||
top_clients_data = row[3] or []
|
||||
|
||||
# Форматируем результат
|
||||
stats = stats_data.get('stats', {})
|
||||
by_hour_dict = {item['hour']: item['count'] for item in by_hour_data}
|
||||
|
||||
return {
|
||||
'summary': {
|
||||
'total_lessons': stats.get('total', 0),
|
||||
'completed': stats.get('completed', 0),
|
||||
'cancelled': stats.get('cancelled', 0),
|
||||
'completion_rate': round(
|
||||
(stats.get('completed', 0) / stats.get('total', 1) * 100) if stats.get('total', 0) > 0 else 0,
|
||||
1
|
||||
),
|
||||
},
|
||||
'time': {
|
||||
'total_duration_minutes': stats.get('total_duration', 0) or 0,
|
||||
'total_duration_hours': round((stats.get('total_duration', 0) or 0) / 60, 1),
|
||||
'average_duration_minutes': round(stats.get('avg_duration', 0) or 0, 1),
|
||||
'by_hour': [
|
||||
{'hour': hour, 'count': by_hour_dict.get(hour, 0)}
|
||||
for hour in range(24)
|
||||
],
|
||||
'by_time_of_day': {
|
||||
'morning': time_of_day_data.get('morning', 0) or 0,
|
||||
'afternoon': time_of_day_data.get('afternoon', 0) or 0,
|
||||
'evening': time_of_day_data.get('evening', 0) or 0,
|
||||
'night': time_of_day_data.get('night', 0) or 0,
|
||||
},
|
||||
},
|
||||
'revenue': {
|
||||
'total': float(stats.get('total_revenue', 0) or 0),
|
||||
'average_per_lesson': float(stats.get('avg_price', 0) or 0),
|
||||
'potential_revenue': float(stats.get('cancelled_revenue', 0) or 0),
|
||||
},
|
||||
'grades': {
|
||||
'average': round(stats.get('avg_grade', 0) or 0, 1),
|
||||
'max': stats.get('max_grade', 0) or 0,
|
||||
'min': stats.get('min_grade', 0) or 0,
|
||||
'graded_count': stats.get('graded_count', 0) or 0,
|
||||
},
|
||||
'top_clients': [
|
||||
{
|
||||
'name': f"{item['first_name']} {item['last_name']}".strip() or item['email'],
|
||||
'email': item['email'],
|
||||
'lessons_count': item['lessons_count'],
|
||||
'completed_count': item['completed_count'],
|
||||
'total_revenue': float(item['total_revenue'] or 0),
|
||||
'average_grade': round(item['avg_grade'] or 0, 1),
|
||||
}
|
||||
for item in top_clients_data
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ВАЖНО: Использовать только после измерения производительности!
|
||||
# Сначала убедитесь, что оптимизация ORM недостаточна.
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Celery задачи для analytics
|
||||
|
||||
from celery import shared_task
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
URL маршруты для аналитики.
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import AnalyticsViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', AnalyticsViewSet, basename='analytics')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
@ -0,0 +1,737 @@
|
|||
"""
|
||||
API views для аналитики и отчетов.
|
||||
"""
|
||||
import logging
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Avg, Count, Q, F
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
from datetime import timedelta, datetime
|
||||
from django.http import HttpResponse
|
||||
|
||||
from apps.users.models import User, Client
|
||||
from apps.schedule.models import Lesson
|
||||
from apps.homework.models import Homework, HomeworkSubmission
|
||||
from apps.materials.models import Material
|
||||
from apps.users.utils import format_datetime_for_user
|
||||
from .services import AnalyticsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet для аналитики и отчетов ментора.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _check_mentor(self, request):
|
||||
"""Проверка что пользователь - ментор"""
|
||||
if request.user.role != 'mentor':
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_period_dates(self, request):
|
||||
"""Получить даты начала и конца периода"""
|
||||
period = request.query_params.get('period', 'month')
|
||||
start_date_str = request.query_params.get('start_date')
|
||||
end_date_str = request.query_params.get('end_date')
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
if period == 'day':
|
||||
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end_date = now
|
||||
elif period == 'week':
|
||||
start_date = now - timedelta(days=now.weekday())
|
||||
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end_date = now
|
||||
elif period == 'month':
|
||||
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_date = now
|
||||
elif period == 'year':
|
||||
start_date = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_date = now
|
||||
elif period == 'custom' and start_date_str and end_date_str:
|
||||
start_date = timezone.make_aware(datetime.strptime(start_date_str, '%Y-%m-%d'))
|
||||
end_date = timezone.make_aware(datetime.strptime(end_date_str, '%Y-%m-%d'))
|
||||
end_date = end_date.replace(hour=23, minute=59, second=59)
|
||||
else:
|
||||
# По умолчанию - текущий месяц
|
||||
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end_date = now
|
||||
|
||||
return start_date, end_date
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def overview(self, request):
|
||||
"""
|
||||
Общая статистика ментора.
|
||||
|
||||
GET /api/analytics/overview/?period=month&start_date=2024-01-01&end_date=2024-12-31
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
period = request.query_params.get('period', 'month')
|
||||
|
||||
# Кеширование: кеш на 2 минуты для каждого пользователя и периода
|
||||
cache_key = f'analytics_overview_{mentor.id}_{period}'
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return Response(cached_data)
|
||||
|
||||
# Занятия - оптимизация: используем only() для ограничения полей
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date
|
||||
).only('id', 'status', 'price', 'mentor_grade', 'start_time')
|
||||
|
||||
# Оптимизация: один запрос для всех статистик занятий
|
||||
lessons_stats = lessons.aggregate(
|
||||
total=Count('id'),
|
||||
completed=Count('id', filter=Q(status='completed')),
|
||||
cancelled=Count('id', filter=Q(status='cancelled')),
|
||||
total_revenue=Sum('price', filter=Q(status='completed', price__isnull=False)),
|
||||
avg_grade=Avg('mentor_grade', filter=Q(status='completed', mentor_grade__isnull=False))
|
||||
)
|
||||
total_lessons = lessons_stats['total']
|
||||
completed_lessons = lessons_stats['completed']
|
||||
cancelled_lessons = lessons_stats['cancelled']
|
||||
total_revenue = lessons_stats['total_revenue'] or 0
|
||||
average_grade = lessons_stats['avg_grade'] or 0
|
||||
|
||||
# Ученики - оптимизация: используем exists() вместо count() если нужно только проверить наличие
|
||||
active_students = Client.objects.filter(
|
||||
mentors=mentor
|
||||
).select_related('user').count()
|
||||
|
||||
# Домашние задания - оптимизация: используем aggregate для подсчета
|
||||
homeworks_stats = Homework.objects.filter(
|
||||
mentor=mentor,
|
||||
created_at__gte=start_date,
|
||||
created_at__lte=end_date
|
||||
).aggregate(total=Count('id'))
|
||||
|
||||
total_homeworks = homeworks_stats['total'] or 0
|
||||
|
||||
# Пending submissions - оптимизация: используем aggregate
|
||||
pending_submissions = HomeworkSubmission.objects.filter(
|
||||
homework__mentor=mentor,
|
||||
status='pending'
|
||||
).aggregate(count=Count('id'))['count'] or 0
|
||||
|
||||
|
||||
response_data = {
|
||||
'period': {
|
||||
'start': format_datetime_for_user(start_date, request.user.timezone) if start_date else None,
|
||||
'end': format_datetime_for_user(end_date, request.user.timezone) if end_date else None,
|
||||
},
|
||||
'lessons': {
|
||||
'total': total_lessons,
|
||||
'completed': completed_lessons,
|
||||
'cancelled': cancelled_lessons,
|
||||
},
|
||||
'revenue': {
|
||||
'total': float(total_revenue),
|
||||
'average_per_lesson': float(total_revenue / completed_lessons) if completed_lessons > 0 else 0,
|
||||
},
|
||||
'students': {
|
||||
'active': active_students,
|
||||
},
|
||||
'homeworks': {
|
||||
'total': total_homeworks,
|
||||
'pending': pending_submissions,
|
||||
},
|
||||
'grades': {
|
||||
'average': round(average_grade, 1),
|
||||
},
|
||||
}
|
||||
|
||||
# Сохраняем в кеш на 5 минут (300 секунд) - аналитика не требует мгновенного обновления
|
||||
cache.set(cache_key, response_data, 300)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def students(self, request):
|
||||
"""
|
||||
Статистика по ученикам.
|
||||
|
||||
GET /api/analytics/students/
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
|
||||
# Оптимизация: получаем только ID студентов одним запросом
|
||||
student_ids = list(Client.objects.filter(mentors=mentor).values_list('id', flat=True))
|
||||
|
||||
if not student_ids:
|
||||
return Response({
|
||||
'students': [],
|
||||
'total_count': 0,
|
||||
})
|
||||
|
||||
# Оптимизация: batch-запрос для всех статистик студентов
|
||||
students_lessons_stats = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
client_id__in=student_ids,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date
|
||||
).values('client_id').annotate(
|
||||
total=Count('id'),
|
||||
completed=Count('id', filter=Q(status='completed')),
|
||||
avg_grade=Avg('mentor_grade', filter=Q(status='completed', mentor_grade__isnull=False)),
|
||||
revenue=Sum('price', filter=Q(status='completed', price__isnull=False))
|
||||
)
|
||||
lessons_by_student = {item['client_id']: item for item in students_lessons_stats}
|
||||
|
||||
# Получаем данные студентов только для тех, у кого есть статистика
|
||||
students = Client.objects.filter(
|
||||
id__in=student_ids
|
||||
).select_related('user').only('id', 'user__first_name', 'user__last_name', 'user__email')
|
||||
|
||||
students_data = []
|
||||
for student in students:
|
||||
stats = lessons_by_student.get(student.id, {
|
||||
'total': 0, 'completed': 0, 'avg_grade': 0, 'revenue': 0
|
||||
})
|
||||
|
||||
total = stats['total']
|
||||
completed = stats['completed']
|
||||
avg_grade = stats['avg_grade'] or 0
|
||||
revenue = stats['revenue'] or 0
|
||||
|
||||
students_data.append({
|
||||
'id': student.id,
|
||||
'name': student.user.get_full_name(),
|
||||
'email': student.user.email,
|
||||
'lessons_total': total,
|
||||
'lessons_completed': completed,
|
||||
'average_grade': round(avg_grade, 1),
|
||||
'revenue': float(revenue),
|
||||
})
|
||||
|
||||
# Сортируем по доходу
|
||||
students_data.sort(key=lambda x: x['revenue'], reverse=True)
|
||||
|
||||
return Response({
|
||||
'students': students_data,
|
||||
'total_count': len(students_data),
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def revenue(self, request):
|
||||
"""
|
||||
Финансовая аналитика.
|
||||
|
||||
GET /api/analytics/revenue/
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
period = request.query_params.get('period', 'month')
|
||||
|
||||
# Кеширование: кеш на 2 минуты для каждого пользователя и периода
|
||||
cache_key = f'analytics_revenue_{mentor.id}_{period}'
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return Response(cached_data)
|
||||
|
||||
# Доходы по дням
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date,
|
||||
status='completed',
|
||||
price__isnull=False
|
||||
).select_related('subject').only('start_time', 'price', 'subject__name')
|
||||
|
||||
# Группируем по дням
|
||||
revenue_by_day = lessons.extra(
|
||||
select={'day': "DATE(start_time AT TIME ZONE 'UTC')"}
|
||||
).values('day').annotate(
|
||||
revenue=Sum('price'),
|
||||
lessons_count=Count('id')
|
||||
).order_by('day')
|
||||
|
||||
# Доходы по предметам
|
||||
revenue_by_subject = lessons.values('subject__name').annotate(
|
||||
revenue=Sum('price', filter=Q(price__isnull=False)),
|
||||
lessons_count=Count('id')
|
||||
).order_by('-revenue')[:5]
|
||||
|
||||
# Общий доход
|
||||
total_revenue = lessons.aggregate(total=Sum('price'))['total'] or 0
|
||||
|
||||
response_data = {
|
||||
'total_revenue': float(total_revenue),
|
||||
'by_day': [
|
||||
{
|
||||
'date': item['day'],
|
||||
'revenue': float(item['revenue']),
|
||||
'lessons_count': item['lessons_count'],
|
||||
}
|
||||
for item in revenue_by_day
|
||||
],
|
||||
'by_subject': [
|
||||
{
|
||||
'subject': item['subject__name'] or 'Без предмета',
|
||||
'revenue': float(item['revenue']),
|
||||
'lessons_count': item['lessons_count'],
|
||||
}
|
||||
for item in revenue_by_subject
|
||||
],
|
||||
}
|
||||
|
||||
# Сохраняем в кеш на 5 минут (300 секунд) - аналитика не требует мгновенного обновления
|
||||
cache.set(cache_key, response_data, 300)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def grades_by_day(self, request):
|
||||
"""
|
||||
Средняя оценка по дням за период (успех учеников / продуктивность репетитора).
|
||||
|
||||
GET /api/analytics/grades_by_day/?period=custom&start_date=2024-01-01&end_date=2024-01-31
|
||||
|
||||
Возвращает по каждому дню: дата, средняя оценка, количество занятий и оцененных занятий.
|
||||
Репетитор видит прогресс и общую продуктивность по оценкам за выбранные дни.
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
period = request.query_params.get('period', 'month')
|
||||
|
||||
cache_key = f'analytics_grades_by_day_{mentor.id}_{period}_{start_date.date()}_{end_date.date()}'
|
||||
cached_data = cache.get(cache_key)
|
||||
if cached_data is not None:
|
||||
return Response(cached_data)
|
||||
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date,
|
||||
status='completed',
|
||||
).only('id', 'start_time', 'mentor_grade')
|
||||
|
||||
by_day_qs = (
|
||||
lessons.annotate(day=TruncDate('start_time'))
|
||||
.values('day')
|
||||
.annotate(
|
||||
average_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)),
|
||||
lessons_count=Count('id'),
|
||||
graded_count=Count('id', filter=Q(mentor_grade__isnull=False)),
|
||||
)
|
||||
.order_by('day')
|
||||
)
|
||||
stats_by_date = {}
|
||||
for item in by_day_qs:
|
||||
day = item['day']
|
||||
if day is not None:
|
||||
stats_by_date[day.strftime('%Y-%m-%d')] = {
|
||||
'average_grade': item['average_grade'],
|
||||
'lessons_count': item['lessons_count'],
|
||||
'graded_count': item['graded_count'],
|
||||
}
|
||||
|
||||
# Одна запись на каждый календарный день в диапазоне — без «склейки» и пропусков
|
||||
start_d = start_date.date()
|
||||
end_d = end_date.date()
|
||||
by_day = []
|
||||
d = start_d
|
||||
while d <= end_d:
|
||||
date_str = d.strftime('%Y-%m-%d')
|
||||
st = stats_by_date.get(date_str)
|
||||
if st:
|
||||
avg = st['average_grade']
|
||||
by_day.append({
|
||||
'date': date_str,
|
||||
'average_grade': round(float(avg), 1) if avg is not None else None,
|
||||
'lessons_count': st['lessons_count'],
|
||||
'graded_count': st['graded_count'],
|
||||
})
|
||||
else:
|
||||
by_day.append({
|
||||
'date': date_str,
|
||||
'average_grade': None,
|
||||
'lessons_count': 0,
|
||||
'graded_count': 0,
|
||||
})
|
||||
d += timedelta(days=1)
|
||||
|
||||
summary_agg = lessons.aggregate(
|
||||
total_lessons=Count('id'),
|
||||
graded_lessons=Count('id', filter=Q(mentor_grade__isnull=False)),
|
||||
average_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)),
|
||||
)
|
||||
summary = {
|
||||
'total_lessons': summary_agg['total_lessons'] or 0,
|
||||
'graded_lessons': summary_agg['graded_lessons'] or 0,
|
||||
'average_grade': round(float(summary_agg['average_grade'] or 0), 1),
|
||||
}
|
||||
|
||||
response_data = {
|
||||
'period': {
|
||||
'start': format_datetime_for_user(start_date, request.user.timezone) if start_date else None,
|
||||
'end': format_datetime_for_user(end_date, request.user.timezone) if end_date else None,
|
||||
},
|
||||
'by_day': by_day,
|
||||
'summary': summary,
|
||||
}
|
||||
cache.set(cache_key, response_data, 300)
|
||||
return Response(response_data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def lessons_stats(self, request):
|
||||
"""
|
||||
Статистика занятий.
|
||||
|
||||
GET /api/analytics/lessons_stats/
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
|
||||
lessons = Lesson.objects.filter(
|
||||
mentor=mentor,
|
||||
start_time__gte=start_date,
|
||||
start_time__lte=end_date
|
||||
)
|
||||
|
||||
# По статусам
|
||||
by_status = lessons.values('status').annotate(
|
||||
count=Count('id')
|
||||
)
|
||||
|
||||
# По предметам
|
||||
by_subject = lessons.values('subject__name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:10]
|
||||
|
||||
# По дням недели
|
||||
by_weekday = lessons.extra(
|
||||
select={'weekday': "EXTRACT(DOW FROM start_time)"}
|
||||
).values('weekday').annotate(
|
||||
count=Count('id')
|
||||
).order_by('weekday')
|
||||
|
||||
weekday_names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||
|
||||
return Response({
|
||||
'by_status': list(by_status),
|
||||
'by_subject': [
|
||||
{
|
||||
'subject': item['subject__name'] or 'Без предмета',
|
||||
'count': item['count'],
|
||||
}
|
||||
for item in by_subject
|
||||
],
|
||||
'by_weekday': [
|
||||
{
|
||||
'day': weekday_names[int(item['weekday'])],
|
||||
'count': item['count'],
|
||||
}
|
||||
for item in by_weekday
|
||||
],
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def homework_stats(self, request):
|
||||
"""
|
||||
Статистика по домашним заданиям.
|
||||
|
||||
GET /api/analytics/homework_stats/
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
|
||||
# Домашние задания
|
||||
homeworks = Homework.objects.filter(
|
||||
mentor=mentor,
|
||||
created_at__gte=start_date,
|
||||
created_at__lte=end_date
|
||||
)
|
||||
|
||||
# Сдачи
|
||||
submissions = HomeworkSubmission.objects.filter(
|
||||
homework__mentor=mentor,
|
||||
submitted_at__gte=start_date,
|
||||
submitted_at__lte=end_date
|
||||
)
|
||||
|
||||
# Статистика
|
||||
total_homeworks = homeworks.count()
|
||||
total_submissions = submissions.count()
|
||||
graded_submissions = submissions.filter(status='graded').count()
|
||||
average_score = submissions.filter(
|
||||
status='graded'
|
||||
).aggregate(avg=Avg('score'))['avg'] or 0
|
||||
|
||||
# По статусам
|
||||
by_status = submissions.values('status').annotate(
|
||||
count=Count('id')
|
||||
)
|
||||
|
||||
return Response({
|
||||
'total_homeworks': total_homeworks,
|
||||
'total_submissions': total_submissions,
|
||||
'graded': graded_submissions,
|
||||
'average_score': round(average_score, 1),
|
||||
'by_status': list(by_status),
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def detailed_lessons(self, request):
|
||||
"""
|
||||
Детальная статистика по занятиям.
|
||||
|
||||
GET /api/analytics/detailed_lessons/?period=month
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
period = request.query_params.get('period', 'month')
|
||||
|
||||
# Кеширование: кеш на 2 минуты для каждого пользователя и периода
|
||||
cache_key = f'analytics_detailed_lessons_{mentor.id}_{period}'
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return Response(cached_data)
|
||||
|
||||
stats = AnalyticsService.get_detailed_lesson_stats(mentor, start_date, end_date)
|
||||
|
||||
# Сохраняем в кеш на 2 минуты (120 секунд)
|
||||
cache.set(cache_key, stats, 120)
|
||||
|
||||
return Response(stats)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def time_series(self, request):
|
||||
"""
|
||||
Временные ряды для графиков.
|
||||
|
||||
GET /api/analytics/time_series/?period=month&group_by=day
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
period = request.query_params.get('period', 'month')
|
||||
group_by = request.query_params.get('group_by', 'day')
|
||||
|
||||
if group_by not in ['day', 'week', 'month']:
|
||||
group_by = 'day'
|
||||
|
||||
# Кеширование: кеш на 2 минуты для каждого пользователя, периода и группировки
|
||||
cache_key = f'analytics_time_series_{mentor.id}_{period}_{group_by}'
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return Response(cached_data)
|
||||
|
||||
data = AnalyticsService.get_time_series_data(mentor, start_date, end_date, group_by)
|
||||
|
||||
response_data = {
|
||||
'group_by': group_by,
|
||||
'data': data,
|
||||
}
|
||||
|
||||
# Сохраняем в кеш на 5 минут (300 секунд) - аналитика не требует мгновенного обновления
|
||||
cache.set(cache_key, response_data, 300)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def comparison(self, request):
|
||||
"""
|
||||
Сравнение текущего периода с предыдущим.
|
||||
|
||||
GET /api/analytics/comparison/?period=month
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = request.user
|
||||
current_start, current_end = self._get_period_dates(request)
|
||||
period = request.query_params.get('period', 'month')
|
||||
|
||||
# Кеширование: кеш на 2 минуты для каждого пользователя и периода
|
||||
cache_key = f'analytics_comparison_{mentor.id}_{period}'
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return Response(cached_data)
|
||||
|
||||
# Вычисляем предыдущий период
|
||||
now = timezone.now()
|
||||
|
||||
if period == 'day':
|
||||
previous_start = current_start - timedelta(days=1)
|
||||
previous_end = current_start
|
||||
elif period == 'week':
|
||||
previous_start = current_start - timedelta(weeks=1)
|
||||
previous_end = current_start
|
||||
elif period == 'month':
|
||||
# Предыдущий месяц
|
||||
if current_start.month == 1:
|
||||
previous_start = current_start.replace(year=current_start.year - 1, month=12, day=1)
|
||||
else:
|
||||
previous_start = current_start.replace(month=current_start.month - 1, day=1)
|
||||
previous_end = current_start
|
||||
elif period == 'year':
|
||||
previous_start = current_start.replace(year=current_start.year - 1, month=1, day=1)
|
||||
previous_end = current_start
|
||||
else:
|
||||
# По умолчанию - предыдущий месяц
|
||||
if current_start.month == 1:
|
||||
previous_start = current_start.replace(year=current_start.year - 1, month=12, day=1)
|
||||
else:
|
||||
previous_start = current_start.replace(month=current_start.month - 1, day=1)
|
||||
previous_end = current_start
|
||||
|
||||
comparison = AnalyticsService.get_comparison_data(
|
||||
mentor,
|
||||
current_start,
|
||||
current_end,
|
||||
previous_start,
|
||||
previous_end
|
||||
)
|
||||
|
||||
# Сохраняем в кеш на 2 минуты (120 секунд)
|
||||
cache.set(cache_key, comparison, 120)
|
||||
|
||||
return Response(comparison)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def export_pdf(self, request):
|
||||
"""
|
||||
Экспорт отчета в PDF.
|
||||
|
||||
GET /api/analytics/export_pdf/?period=month&type=overview
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
from .exporters import PDFExporter
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
report_type = request.query_params.get('type', 'overview')
|
||||
|
||||
try:
|
||||
pdf_buffer = PDFExporter.generate_report(
|
||||
mentor=mentor,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
report_type=report_type
|
||||
)
|
||||
|
||||
response = HttpResponse(pdf_buffer.getvalue(), content_type='application/pdf')
|
||||
filename = f"analytics_report_{start_date.strftime('%Y%m%d')}_{end_date.strftime('%Y%m%d')}.pdf"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating PDF report: {e}", exc_info=True)
|
||||
return Response(
|
||||
{'error': f'Ошибка генерации PDF: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def export_excel(self, request):
|
||||
"""
|
||||
Экспорт отчета в Excel.
|
||||
|
||||
GET /api/analytics/export_excel/?period=month&type=overview
|
||||
"""
|
||||
if not self._check_mentor(request):
|
||||
return Response(
|
||||
{'error': 'Только для менторов'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
from .exporters import ExcelExporter
|
||||
|
||||
mentor = request.user
|
||||
start_date, end_date = self._get_period_dates(request)
|
||||
report_type = request.query_params.get('type', 'overview')
|
||||
|
||||
try:
|
||||
excel_buffer = ExcelExporter.generate_report(
|
||||
mentor=mentor,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
report_type=report_type
|
||||
)
|
||||
|
||||
response = HttpResponse(
|
||||
excel_buffer.getvalue(),
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
filename = f"analytics_report_{start_date.strftime('%Y%m%d')}_{end_date.strftime('%Y%m%d')}.xlsx"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating Excel report: {e}", exc_info=True)
|
||||
return Response(
|
||||
{'error': f'Ошибка генерации Excel: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
"""
|
||||
Административная панель для интерактивной доски.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from .models import Board, BoardElement, BoardSnapshot
|
||||
|
||||
|
||||
@admin.register(Board)
|
||||
class BoardAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для досок."""
|
||||
|
||||
list_display = [
|
||||
'title',
|
||||
'owner_link',
|
||||
'access_type_badge',
|
||||
'elements_count',
|
||||
'snapshot_stats',
|
||||
'views_count',
|
||||
'is_active',
|
||||
'is_template',
|
||||
'last_edited_at',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'access_type',
|
||||
'is_active',
|
||||
'is_template',
|
||||
'created_at',
|
||||
'last_edited_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'title',
|
||||
'description',
|
||||
'board_id',
|
||||
'owner__email',
|
||||
'owner__first_name',
|
||||
'owner__last_name'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'board_id',
|
||||
'views_count',
|
||||
'elements_count',
|
||||
'snapshot_stats',
|
||||
'snapshot_preview',
|
||||
'last_edited_by',
|
||||
'last_edited_at',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
|
||||
filter_horizontal = ['participants']
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': (
|
||||
'board_id',
|
||||
'title',
|
||||
'description',
|
||||
'owner',
|
||||
'mentor',
|
||||
'student'
|
||||
)
|
||||
}),
|
||||
('Доступ', {
|
||||
'fields': (
|
||||
'access_type',
|
||||
'participants',
|
||||
'is_active'
|
||||
)
|
||||
}),
|
||||
('Настройки', {
|
||||
'fields': (
|
||||
'background_color',
|
||||
'grid_enabled',
|
||||
'width',
|
||||
'height',
|
||||
'is_template'
|
||||
)
|
||||
}),
|
||||
('Статистика', {
|
||||
'fields': (
|
||||
'views_count',
|
||||
'elements_count',
|
||||
'snapshot_stats',
|
||||
'last_edited_by',
|
||||
'last_edited_at'
|
||||
)
|
||||
}),
|
||||
('Данные доски (Excalidraw Snapshot)', {
|
||||
'fields': (
|
||||
'snapshot_preview',
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Временные метки', {
|
||||
'fields': (
|
||||
'created_at',
|
||||
'updated_at'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
actions = ['make_template', 'make_public', 'make_private']
|
||||
|
||||
def owner_link(self, obj):
|
||||
"""Ссылка на владельца."""
|
||||
url = reverse('admin:users_user_change', args=[obj.owner.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.owner.get_full_name())
|
||||
owner_link.short_description = 'Владелец'
|
||||
|
||||
def access_type_badge(self, obj):
|
||||
"""Бейдж типа доступа."""
|
||||
colors = {
|
||||
'private': '#6c757d',
|
||||
'mentor_student': '#17a2b8',
|
||||
'public': '#28a745'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.access_type, '#000'),
|
||||
obj.get_access_type_display()
|
||||
)
|
||||
access_type_badge.short_description = 'Доступ'
|
||||
|
||||
@admin.action(description='Сделать шаблоном')
|
||||
def make_template(self, request, queryset):
|
||||
"""Сделать доски шаблонами."""
|
||||
queryset.update(is_template=True)
|
||||
|
||||
@admin.action(description='Сделать публичными')
|
||||
def make_public(self, request, queryset):
|
||||
"""Сделать доски публичными."""
|
||||
queryset.update(access_type='public')
|
||||
|
||||
@admin.action(description='Сделать приватными')
|
||||
def make_private(self, request, queryset):
|
||||
"""Сделать доски приватными."""
|
||||
queryset.update(access_type='private')
|
||||
|
||||
def snapshot_stats(self, obj):
|
||||
"""Статистика по snapshot."""
|
||||
if not obj.tldraw_snapshot:
|
||||
return format_html('<span style="color: #999;">Нет данных</span>')
|
||||
|
||||
files_count = obj.get_files_count()
|
||||
elements_count = obj.get_elements_count_from_snapshot()
|
||||
|
||||
return format_html(
|
||||
'<div style="display: flex; gap: 10px;">'
|
||||
'<span style="background-color: #17a2b8; color: white; padding: 3px 8px; border-radius: 3px;">'
|
||||
'📝 Элементы: <strong>{}</strong>'
|
||||
'</span>'
|
||||
'<span style="background-color: #28a745; color: white; padding: 3px 8px; border-radius: 3px;">'
|
||||
'🖼️ Файлы: <strong>{}</strong>'
|
||||
'</span>'
|
||||
'</div>',
|
||||
elements_count,
|
||||
files_count
|
||||
)
|
||||
snapshot_stats.short_description = 'Статистика Snapshot'
|
||||
|
||||
def snapshot_preview(self, obj):
|
||||
"""Предпросмотр структуры snapshot."""
|
||||
if not obj.tldraw_snapshot:
|
||||
return format_html('<p style="color: #999;">Нет данных snapshot</p>')
|
||||
|
||||
import json
|
||||
|
||||
snapshot = obj.tldraw_snapshot
|
||||
elements = snapshot.get('elements', [])
|
||||
files = snapshot.get('files', {})
|
||||
app_state = snapshot.get('appState', {})
|
||||
|
||||
# Форматируем JSON для отображения
|
||||
try:
|
||||
formatted_json = json.dumps(snapshot, ensure_ascii=False, indent=2)
|
||||
except:
|
||||
formatted_json = str(snapshot)
|
||||
|
||||
# Статистика
|
||||
files_count = len(files) if isinstance(files, dict) else 0
|
||||
elements_count = len(elements) if isinstance(elements, list) else 0
|
||||
|
||||
# Размер данных
|
||||
snapshot_size = len(json.dumps(snapshot, ensure_ascii=False))
|
||||
size_kb = snapshot_size / 1024
|
||||
|
||||
return format_html(
|
||||
'<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border: 1px solid #dee2e6;">'
|
||||
'<h4 style="margin-top: 0;">📊 Структура данных доски (Excalidraw Snapshot)</h4>'
|
||||
'<div style="margin-bottom: 15px;">'
|
||||
'<strong>Элементы:</strong> {} | '
|
||||
'<strong>Файлы:</strong> {} | '
|
||||
'<strong>Размер:</strong> {:.2f} KB'
|
||||
'</div>'
|
||||
'<details style="margin-top: 10px;">'
|
||||
'<summary style="cursor: pointer; color: #007bff; font-weight: bold;">📄 Показать полную структуру JSON</summary>'
|
||||
'<pre style="background: white; padding: 10px; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto; max-height: 500px; overflow-y: auto; margin-top: 10px; font-size: 11px;">{}</pre>'
|
||||
'</details>'
|
||||
'</div>',
|
||||
elements_count,
|
||||
files_count,
|
||||
size_kb,
|
||||
formatted_json
|
||||
)
|
||||
snapshot_preview.short_description = 'Структура данных доски'
|
||||
|
||||
|
||||
@admin.register(BoardElement)
|
||||
class BoardElementAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для элементов доски."""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'board_link',
|
||||
'element_type_badge',
|
||||
'position',
|
||||
'size',
|
||||
'created_by_link',
|
||||
'locked',
|
||||
'is_deleted',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'element_type',
|
||||
'locked',
|
||||
'is_deleted',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'board__title',
|
||||
'content',
|
||||
'created_by__email'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'created_by',
|
||||
'locked_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at'
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': (
|
||||
'board',
|
||||
'element_type',
|
||||
'created_by'
|
||||
)
|
||||
}),
|
||||
('Позиция и размер', {
|
||||
'fields': (
|
||||
'x',
|
||||
'y',
|
||||
'width',
|
||||
'height',
|
||||
'rotation',
|
||||
'z_index'
|
||||
)
|
||||
}),
|
||||
('Текст', {
|
||||
'fields': (
|
||||
'content',
|
||||
'font_size',
|
||||
'font_family',
|
||||
'font_weight',
|
||||
'text_align',
|
||||
'text_color'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Фигура', {
|
||||
'fields': (
|
||||
'shape_type',
|
||||
'fill_color',
|
||||
'stroke_color',
|
||||
'stroke_width',
|
||||
'opacity'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Изображение/Рисунок', {
|
||||
'fields': (
|
||||
'image_url',
|
||||
'drawing_data'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Стрелка', {
|
||||
'fields': (
|
||||
'arrow_start_element',
|
||||
'arrow_end_element'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Блокировка', {
|
||||
'fields': (
|
||||
'locked',
|
||||
'locked_by'
|
||||
)
|
||||
}),
|
||||
('Удаление', {
|
||||
'fields': (
|
||||
'is_deleted',
|
||||
'deleted_at'
|
||||
)
|
||||
}),
|
||||
('Временные метки', {
|
||||
'fields': (
|
||||
'created_at',
|
||||
'updated_at'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
def board_link(self, obj):
|
||||
"""Ссылка на доску."""
|
||||
url = reverse('admin:board_board_change', args=[obj.board.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.board.title)
|
||||
board_link.short_description = 'Доска'
|
||||
|
||||
def element_type_badge(self, obj):
|
||||
"""Бейдж типа элемента."""
|
||||
colors = {
|
||||
'text': '#007bff',
|
||||
'shape': '#28a745',
|
||||
'image': '#ffc107',
|
||||
'drawing': '#17a2b8',
|
||||
'sticky': '#fd7e14',
|
||||
'arrow': '#6c757d',
|
||||
'line': '#343a40'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.element_type, '#000'),
|
||||
obj.get_element_type_display()
|
||||
)
|
||||
element_type_badge.short_description = 'Тип'
|
||||
|
||||
def position(self, obj):
|
||||
"""Позиция элемента."""
|
||||
return f"({obj.x:.0f}, {obj.y:.0f})"
|
||||
position.short_description = 'Позиция'
|
||||
|
||||
def size(self, obj):
|
||||
"""Размер элемента."""
|
||||
return f"{obj.width:.0f}x{obj.height:.0f}"
|
||||
size.short_description = 'Размер'
|
||||
|
||||
def created_by_link(self, obj):
|
||||
"""Ссылка на автора."""
|
||||
if obj.created_by:
|
||||
url = reverse('admin:users_user_change', args=[obj.created_by.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.created_by.get_full_name())
|
||||
return '-'
|
||||
created_by_link.short_description = 'Автор'
|
||||
|
||||
|
||||
@admin.register(BoardSnapshot)
|
||||
class BoardSnapshotAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для снимков досок."""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'board_link',
|
||||
'created_by_link',
|
||||
'description',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'board__title',
|
||||
'description',
|
||||
'created_by__email'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'board',
|
||||
'snapshot_data',
|
||||
'created_by',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': (
|
||||
'board',
|
||||
'created_by',
|
||||
'description'
|
||||
)
|
||||
}),
|
||||
('Данные', {
|
||||
'fields': (
|
||||
'snapshot_data',
|
||||
)
|
||||
}),
|
||||
('Временные метки', {
|
||||
'fields': (
|
||||
'created_at',
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
def board_link(self, obj):
|
||||
"""Ссылка на доску."""
|
||||
url = reverse('admin:board_board_change', args=[obj.board.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.board.title)
|
||||
board_link.short_description = 'Доска'
|
||||
|
||||
def created_by_link(self, obj):
|
||||
"""Ссылка на автора."""
|
||||
if obj.created_by:
|
||||
url = reverse('admin:users_user_change', args=[obj.created_by.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.created_by.get_full_name())
|
||||
return '-'
|
||||
created_by_link.short_description = 'Автор'
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
Конфигурация приложения board.
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BoardConfig(AppConfig):
|
||||
"""Конфигурация приложения board."""
|
||||
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.board'
|
||||
verbose_name = 'Интерактивная доска'
|
||||
|
||||
def ready(self):
|
||||
"""Инициализация приложения."""
|
||||
# import apps.board.signals
|
||||
pass
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
"""
|
||||
WebSocket consumer для реал-тайм синхронизации доски
|
||||
"""
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from redis.exceptions import BusyLoadingError, ConnectionError as RedisConnectionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BoardConsumer(AsyncWebsocketConsumer):
|
||||
"""Consumer для синхронизации доски между пользователями"""
|
||||
|
||||
async def connect(self):
|
||||
"""Подключение к WebSocket"""
|
||||
self.board_id = self.scope['url_route']['kwargs']['board_id']
|
||||
self.room_group_name = f'board_{self.board_id}'
|
||||
self.user = self.scope.get('user', AnonymousUser())
|
||||
|
||||
# Логируем информацию о подключении для отладки
|
||||
query_string = self.scope.get('query_string', b'').decode()
|
||||
logger.info(f'[BoardConsumer] Попытка подключения: board_id={self.board_id}, user={self.user}, is_anonymous={self.user.is_anonymous}, query_string={query_string}')
|
||||
|
||||
# Проверяем авторизацию
|
||||
if self.user.is_anonymous:
|
||||
logger.warning(f'[BoardConsumer] Отклонено: пользователь не авторизован для доски {self.board_id}')
|
||||
await self.close(code=4001) # Unauthorized
|
||||
return
|
||||
|
||||
# Присоединяемся к группе доски с обработкой ошибок Redis
|
||||
max_retries = 5
|
||||
retry_delay = 1
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await self.channel_layer.group_add(
|
||||
self.room_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
break
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f'Redis загружается, повторная попытка {attempt + 1}/{max_retries}: {e}')
|
||||
await asyncio.sleep(retry_delay * (attempt + 1))
|
||||
else:
|
||||
logger.error(f'Не удалось подключиться к Redis после {max_retries} попыток: {e}')
|
||||
await self.close()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при подключении к channel layer: {e}', exc_info=True)
|
||||
await self.close()
|
||||
return
|
||||
|
||||
await self.accept()
|
||||
|
||||
logger.info(f'[BoardConsumer] Пользователь {self.user.email} подключился к доске {self.board_id}')
|
||||
|
||||
# Отправляем всем что пользователь присоединился
|
||||
try:
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_joined',
|
||||
'user': {
|
||||
'id': self.user.id,
|
||||
'name': f'{self.user.first_name} {self.user.last_name}'.strip() or self.user.email,
|
||||
}
|
||||
}
|
||||
)
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
logger.warning(f'Redis недоступен при отправке сообщения user_joined: {e}')
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
"""Отключение от WebSocket"""
|
||||
if hasattr(self, 'room_group_name'):
|
||||
# Проверяем, что пользователь авторизован перед отправкой сообщений
|
||||
user = getattr(self, 'user', None)
|
||||
if user and not user.is_anonymous:
|
||||
try:
|
||||
# Отправляем всем что пользователь отключился
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_left',
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'name': f'{user.first_name} {user.last_name}'.strip() or user.email,
|
||||
}
|
||||
}
|
||||
)
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
logger.warning(f'Redis недоступен при отправке сообщения user_left: {e}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Ошибка при отправке сообщения user_left: {e}')
|
||||
|
||||
try:
|
||||
# Покидаем группу
|
||||
await self.channel_layer.group_discard(
|
||||
self.room_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
logger.warning(f'Redis недоступен при покидании группы: {e}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Ошибка при покидании группы: {e}')
|
||||
|
||||
# Логируем отключение с проверкой пользователя
|
||||
if user and not user.is_anonymous:
|
||||
logger.info(f'[BoardConsumer] Пользователь {user.email} отключился от доски {self.board_id}')
|
||||
else:
|
||||
logger.info(f'[BoardConsumer] Анонимный пользователь отключился от доски {self.board_id}')
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""Получение сообщения от клиента"""
|
||||
# Проверяем, что пользователь авторизован
|
||||
if not hasattr(self, 'user') or not self.user or self.user.is_anonymous:
|
||||
logger.warning('[BoardConsumer] Попытка отправить сообщение неавторизованным пользователем')
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(text_data)
|
||||
message_type = data.get('type')
|
||||
|
||||
if message_type == 'draw':
|
||||
board_data = data.get('data', {})
|
||||
elements = board_data.get('elements', [])
|
||||
files = board_data.get('files', {})
|
||||
|
||||
# Логируем размер данных
|
||||
size_kb = len(text_data) / 1024
|
||||
user_email = getattr(self.user, 'email', 'unknown')
|
||||
print(f'[BoardConsumer] Получено от {user_email}: {len(elements)} элементов, {len(files)} файлов, размер: {size_kb:.2f} KB')
|
||||
|
||||
if len(files) > 0:
|
||||
print(f'[BoardConsumer] Files: {list(files.keys())}')
|
||||
|
||||
# Отправляем изменения всем кроме отправителя
|
||||
try:
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'board_update',
|
||||
'data': board_data,
|
||||
'user_id': self.user.id,
|
||||
}
|
||||
)
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
logger.warning(f'Redis недоступен при отправке board_update: {e}')
|
||||
|
||||
elif message_type == 'clear':
|
||||
# Очистка доски
|
||||
try:
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'board_clear',
|
||||
'user_id': self.user.id,
|
||||
}
|
||||
)
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
logger.warning(f'Redis недоступен при отправке board_clear: {e}')
|
||||
|
||||
elif message_type == 'undo':
|
||||
# Отменить действие
|
||||
try:
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'board_undo',
|
||||
'user_id': self.user.id,
|
||||
}
|
||||
)
|
||||
except (BusyLoadingError, RedisConnectionError) as e:
|
||||
logger.warning(f'Redis недоступен при отправке board_undo: {e}')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print('[BoardConsumer] Ошибка декодирования JSON')
|
||||
|
||||
async def board_update(self, event):
|
||||
"""Отправка обновления доски клиенту"""
|
||||
# Проверяем, что пользователь авторизован
|
||||
if not hasattr(self, 'user') or not self.user or self.user.is_anonymous:
|
||||
return
|
||||
|
||||
# Не отправляем обратно отправителю
|
||||
if event.get('user_id') != self.user.id:
|
||||
board_data = event['data']
|
||||
elements = board_data.get('elements', [])
|
||||
files = board_data.get('files', {})
|
||||
|
||||
message = json.dumps({
|
||||
'type': 'draw',
|
||||
'data': board_data,
|
||||
})
|
||||
|
||||
size_kb = len(message) / 1024
|
||||
user_email = getattr(self.user, 'email', 'unknown')
|
||||
print(f'[BoardConsumer] Отправка {user_email}: {len(elements)} элементов, {len(files)} файлов, размер: {size_kb:.2f} KB')
|
||||
|
||||
await self.send(text_data=message)
|
||||
|
||||
async def board_clear(self, event):
|
||||
"""Очистка доски"""
|
||||
if not hasattr(self, 'user') or not self.user or self.user.is_anonymous:
|
||||
return
|
||||
if event.get('user_id') != self.user.id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'clear',
|
||||
}))
|
||||
|
||||
async def board_undo(self, event):
|
||||
"""Отменить действие"""
|
||||
if not hasattr(self, 'user') or not self.user or self.user.is_anonymous:
|
||||
return
|
||||
if event.get('user_id') != self.user.id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'undo',
|
||||
}))
|
||||
|
||||
async def user_joined(self, event):
|
||||
"""Уведомление о присоединении пользователя"""
|
||||
if not hasattr(self, 'user') or not self.user or self.user.is_anonymous:
|
||||
return
|
||||
if event['user']['id'] != self.user.id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_joined',
|
||||
'user': event['user'],
|
||||
}))
|
||||
|
||||
async def user_left(self, event):
|
||||
"""Уведомление об отключении пользователя"""
|
||||
if not hasattr(self, 'user') or not self.user or self.user.is_anonymous:
|
||||
return
|
||||
if event['user']['id'] != self.user.id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_left',
|
||||
'user': event['user'],
|
||||
}))
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
# Generated by Django 4.2.7 on 2025-12-09 21:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("schedule", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Board",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"board_id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
unique=True,
|
||||
verbose_name="ID доски",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="Название")),
|
||||
("description", models.TextField(blank=True, verbose_name="Описание")),
|
||||
(
|
||||
"access_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("private", "Приватная"),
|
||||
("lesson", "Для занятия"),
|
||||
("public", "Публичная"),
|
||||
],
|
||||
db_index=True,
|
||||
default="private",
|
||||
max_length=20,
|
||||
verbose_name="Тип доступа",
|
||||
),
|
||||
),
|
||||
(
|
||||
"background_color",
|
||||
models.CharField(
|
||||
default="#FFFFFF", max_length=7, verbose_name="Цвет фона"
|
||||
),
|
||||
),
|
||||
(
|
||||
"grid_enabled",
|
||||
models.BooleanField(default=True, verbose_name="Сетка включена"),
|
||||
),
|
||||
(
|
||||
"width",
|
||||
models.IntegerField(default=5000, verbose_name="Ширина (px)"),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.IntegerField(default=5000, verbose_name="Высота (px)"),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
db_index=True, default=True, verbose_name="Активна"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_template",
|
||||
models.BooleanField(default=False, verbose_name="Шаблон"),
|
||||
),
|
||||
(
|
||||
"tldraw_snapshot",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Полное состояние Tldraw доски",
|
||||
verbose_name="Tldraw состояние",
|
||||
),
|
||||
),
|
||||
(
|
||||
"views_count",
|
||||
models.IntegerField(
|
||||
default=0, verbose_name="Количество просмотров"
|
||||
),
|
||||
),
|
||||
(
|
||||
"elements_count",
|
||||
models.IntegerField(default=0, verbose_name="Количество элементов"),
|
||||
),
|
||||
(
|
||||
"last_edited_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Дата последнего редактирования",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата создания"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
|
||||
),
|
||||
(
|
||||
"last_edited_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="last_edited_boards",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Последний редактор",
|
||||
),
|
||||
),
|
||||
(
|
||||
"lesson",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="boards",
|
||||
to="schedule.lesson",
|
||||
verbose_name="Занятие",
|
||||
),
|
||||
),
|
||||
(
|
||||
"mentor",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="mentor_boards",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Ментор",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="owned_boards",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Владелец",
|
||||
),
|
||||
),
|
||||
(
|
||||
"participants",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="boards",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Участники",
|
||||
),
|
||||
),
|
||||
(
|
||||
"student",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="student_boards",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Студент",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Доска",
|
||||
"verbose_name_plural": "Доски",
|
||||
"db_table": "boards",
|
||||
"ordering": ["-last_edited_at", "-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BoardSnapshot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("snapshot_data", models.JSONField(verbose_name="Данные снимка")),
|
||||
(
|
||||
"description",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Описание"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата создания"
|
||||
),
|
||||
),
|
||||
(
|
||||
"board",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="snapshots",
|
||||
to="board.board",
|
||||
verbose_name="Доска",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="board_snapshots",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Автор",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Снимок доски",
|
||||
"verbose_name_plural": "Снимки досок",
|
||||
"db_table": "board_snapshots",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BoardElement",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"element_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("text", "Текст"),
|
||||
("shape", "Фигура"),
|
||||
("image", "Изображение"),
|
||||
("drawing", "Рисунок"),
|
||||
("sticky", "Стикер"),
|
||||
("arrow", "Стрелка"),
|
||||
("line", "Линия"),
|
||||
],
|
||||
db_index=True,
|
||||
max_length=20,
|
||||
verbose_name="Тип элемента",
|
||||
),
|
||||
),
|
||||
("x", models.FloatField(verbose_name="Позиция X")),
|
||||
("y", models.FloatField(verbose_name="Позиция Y")),
|
||||
("width", models.FloatField(default=100, verbose_name="Ширина")),
|
||||
("height", models.FloatField(default=100, verbose_name="Высота")),
|
||||
(
|
||||
"rotation",
|
||||
models.FloatField(default=0, verbose_name="Поворот (градусы)"),
|
||||
),
|
||||
(
|
||||
"z_index",
|
||||
models.IntegerField(default=0, verbose_name="Z-индекс (слой)"),
|
||||
),
|
||||
("content", models.TextField(blank=True, verbose_name="Содержимое")),
|
||||
(
|
||||
"font_size",
|
||||
models.IntegerField(default=16, verbose_name="Размер шрифта"),
|
||||
),
|
||||
(
|
||||
"font_family",
|
||||
models.CharField(
|
||||
default="Arial", max_length=100, verbose_name="Шрифт"
|
||||
),
|
||||
),
|
||||
(
|
||||
"font_weight",
|
||||
models.CharField(
|
||||
default="normal", max_length=20, verbose_name="Жирность"
|
||||
),
|
||||
),
|
||||
(
|
||||
"text_align",
|
||||
models.CharField(
|
||||
default="left", max_length=20, verbose_name="Выравнивание"
|
||||
),
|
||||
),
|
||||
(
|
||||
"text_color",
|
||||
models.CharField(
|
||||
default="#000000", max_length=7, verbose_name="Цвет текста"
|
||||
),
|
||||
),
|
||||
(
|
||||
"shape_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("rectangle", "Прямоугольник"),
|
||||
("circle", "Круг"),
|
||||
("triangle", "Треугольник"),
|
||||
("star", "Звезда"),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name="Тип фигуры",
|
||||
),
|
||||
),
|
||||
(
|
||||
"fill_color",
|
||||
models.CharField(
|
||||
default="#FFFFFF", max_length=7, verbose_name="Цвет заливки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"stroke_color",
|
||||
models.CharField(
|
||||
default="#000000", max_length=7, verbose_name="Цвет границы"
|
||||
),
|
||||
),
|
||||
(
|
||||
"stroke_width",
|
||||
models.FloatField(default=1, verbose_name="Толщина границы"),
|
||||
),
|
||||
(
|
||||
"opacity",
|
||||
models.FloatField(default=1.0, verbose_name="Прозрачность (0-1)"),
|
||||
),
|
||||
(
|
||||
"image_url",
|
||||
models.URLField(
|
||||
blank=True, max_length=500, verbose_name="URL изображения"
|
||||
),
|
||||
),
|
||||
(
|
||||
"drawing_data",
|
||||
models.TextField(blank=True, verbose_name="Данные рисунка"),
|
||||
),
|
||||
(
|
||||
"locked",
|
||||
models.BooleanField(default=False, verbose_name="Заблокирован"),
|
||||
),
|
||||
(
|
||||
"is_deleted",
|
||||
models.BooleanField(
|
||||
db_index=True, default=False, verbose_name="Удален"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата удаления"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата создания"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
|
||||
),
|
||||
(
|
||||
"arrow_end_element",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="arrow_sources",
|
||||
to="board.boardelement",
|
||||
verbose_name="Конец стрелки",
|
||||
),
|
||||
),
|
||||
(
|
||||
"arrow_start_element",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="arrow_targets",
|
||||
to="board.boardelement",
|
||||
verbose_name="Начало стрелки",
|
||||
),
|
||||
),
|
||||
(
|
||||
"board",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="elements",
|
||||
to="board.board",
|
||||
verbose_name="Доска",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="board_elements",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Автор",
|
||||
),
|
||||
),
|
||||
(
|
||||
"locked_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="locked_elements",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Заблокировал",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Элемент доски",
|
||||
"verbose_name_plural": "Элементы доски",
|
||||
"db_table": "board_elements",
|
||||
"ordering": ["z_index", "created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["board", "is_deleted"],
|
||||
name="board_eleme_board_i_725a9d_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["element_type"], name="board_eleme_element_5c6211_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["z_index"], name="board_eleme_z_index_2e5f56_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="board",
|
||||
index=models.Index(fields=["board_id"], name="boards_board_i_980415_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="board",
|
||||
index=models.Index(
|
||||
fields=["owner", "is_active"], name="boards_owner_i_e9c6be_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="board",
|
||||
index=models.Index(fields=["lesson"], name="boards_lesson__a0967e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="board",
|
||||
index=models.Index(
|
||||
fields=["mentor", "student"], name="boards_mentor__6310ad_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="board",
|
||||
index=models.Index(
|
||||
fields=["access_type", "is_active"], name="boards_access__9e9d8f_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="board",
|
||||
unique_together={("mentor", "student")},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 4.2.7 on 2025-12-10 20:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("board", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name="board",
|
||||
name="boards_lesson__a0967e_idx",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="board",
|
||||
name="lesson",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="board",
|
||||
name="access_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("private", "Приватная"),
|
||||
("mentor_student", "Для пары ментор-студент"),
|
||||
("public", "Публичная"),
|
||||
],
|
||||
db_index=True,
|
||||
default="private",
|
||||
max_length=20,
|
||||
verbose_name="Тип доступа",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Generated manually for Spacedeck integration
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('board', '0002_remove_board_boards_lesson__a0967e_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='board',
|
||||
name='board_type',
|
||||
field=models.CharField(
|
||||
choices=[('tldraw', 'Tldraw'), ('whiteboard', 'Whiteboard'), ('spacedeck', 'Spacedeck')],
|
||||
db_index=True,
|
||||
default='whiteboard',
|
||||
max_length=20,
|
||||
verbose_name='Тип доски'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='board',
|
||||
name='spacedeck_id',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name='Spacedeck Space ID'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='board',
|
||||
name='spacedeck_edit_hash',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name='Spacedeck Edit Hash'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,574 @@
|
|||
"""
|
||||
Модели для интерактивной доски.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Board(models.Model):
|
||||
"""
|
||||
Модель интерактивной доски.
|
||||
Miro-подобная доска для совместной работы.
|
||||
"""
|
||||
|
||||
ACCESS_CHOICES = [
|
||||
('private', 'Приватная'),
|
||||
('mentor_student', 'Для пары ментор-студент'),
|
||||
('public', 'Публичная'),
|
||||
]
|
||||
|
||||
# Уникальный идентификатор
|
||||
board_id = models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name='ID доски'
|
||||
)
|
||||
|
||||
# Основная информация
|
||||
title = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Название'
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Описание'
|
||||
)
|
||||
|
||||
# Владелец
|
||||
owner = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='owned_boards',
|
||||
verbose_name='Владелец'
|
||||
)
|
||||
|
||||
# Связь ментор-студент (для персональной доски)
|
||||
mentor = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='mentor_boards',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Ментор'
|
||||
)
|
||||
|
||||
student = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='student_boards',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Студент'
|
||||
)
|
||||
|
||||
# Поле lesson удалено - доска привязана к паре mentor-student, а не к уроку
|
||||
# Доска доступна и вне занятия
|
||||
|
||||
# Доступ
|
||||
access_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ACCESS_CHOICES,
|
||||
default='private',
|
||||
verbose_name='Тип доступа',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Участники (для приватных досок)
|
||||
participants = models.ManyToManyField(
|
||||
'users.User',
|
||||
related_name='boards',
|
||||
blank=True,
|
||||
verbose_name='Участники'
|
||||
)
|
||||
|
||||
# Настройки доски
|
||||
background_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#FFFFFF',
|
||||
verbose_name='Цвет фона'
|
||||
)
|
||||
|
||||
grid_enabled = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Сетка включена'
|
||||
)
|
||||
|
||||
width = models.IntegerField(
|
||||
default=5000,
|
||||
verbose_name='Ширина (px)'
|
||||
)
|
||||
|
||||
height = models.IntegerField(
|
||||
default=5000,
|
||||
verbose_name='Высота (px)'
|
||||
)
|
||||
|
||||
# Статус
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Активна',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
is_template = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Шаблон'
|
||||
)
|
||||
|
||||
# Тип доски
|
||||
BOARD_TYPE_CHOICES = [
|
||||
('tldraw', 'Tldraw'),
|
||||
('whiteboard', 'Whiteboard'),
|
||||
('spacedeck', 'Spacedeck'),
|
||||
]
|
||||
|
||||
board_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=BOARD_TYPE_CHOICES,
|
||||
default='whiteboard',
|
||||
verbose_name='Тип доски',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Tldraw данные
|
||||
tldraw_snapshot = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name='Tldraw состояние',
|
||||
help_text='Полное состояние Tldraw доски'
|
||||
)
|
||||
|
||||
# Spacedeck данные
|
||||
spacedeck_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Spacedeck Space ID'
|
||||
)
|
||||
|
||||
spacedeck_edit_hash = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name='Spacedeck Edit Hash'
|
||||
)
|
||||
|
||||
# Статистика
|
||||
views_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Количество просмотров'
|
||||
)
|
||||
|
||||
elements_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Количество элементов'
|
||||
)
|
||||
|
||||
last_edited_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='last_edited_boards',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Последний редактор'
|
||||
)
|
||||
|
||||
last_edited_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата последнего редактирования'
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата создания'
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name='Дата обновления'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'boards'
|
||||
verbose_name = 'Доска'
|
||||
verbose_name_plural = 'Доски'
|
||||
ordering = ['-last_edited_at', '-created_at']
|
||||
unique_together = [('mentor', 'student')]
|
||||
indexes = [
|
||||
models.Index(fields=['board_id']),
|
||||
models.Index(fields=['owner', 'is_active']),
|
||||
models.Index(fields=['mentor', 'student']),
|
||||
models.Index(fields=['access_type', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def increment_views(self):
|
||||
"""Увеличить счетчик просмотров."""
|
||||
self.views_count += 1
|
||||
self.save(update_fields=['views_count'])
|
||||
|
||||
def update_elements_count(self):
|
||||
"""Обновить количество элементов."""
|
||||
self.elements_count = self.elements.filter(is_deleted=False).count()
|
||||
self.save(update_fields=['elements_count'])
|
||||
|
||||
def get_files_count(self):
|
||||
"""Получить количество файлов изображений из tldraw_snapshot."""
|
||||
if not self.tldraw_snapshot or not isinstance(self.tldraw_snapshot, dict):
|
||||
return 0
|
||||
|
||||
files = self.tldraw_snapshot.get('files', {})
|
||||
if not isinstance(files, dict):
|
||||
return 0
|
||||
|
||||
# Подсчитываем только файлы с dataURL (валидные изображения)
|
||||
count = 0
|
||||
for file_id, file_data in files.items():
|
||||
if isinstance(file_data, dict) and file_data.get('dataURL'):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def get_elements_count_from_snapshot(self):
|
||||
"""Получить количество элементов из tldraw_snapshot."""
|
||||
if not self.tldraw_snapshot or not isinstance(self.tldraw_snapshot, dict):
|
||||
return 0
|
||||
|
||||
elements = self.tldraw_snapshot.get('elements', [])
|
||||
if not isinstance(elements, list):
|
||||
return 0
|
||||
|
||||
return len(elements)
|
||||
|
||||
def mark_edited(self, user):
|
||||
"""Отметить изменение доски."""
|
||||
self.last_edited_by = user
|
||||
self.last_edited_at = timezone.now()
|
||||
self.save(update_fields=['last_edited_by', 'last_edited_at'])
|
||||
|
||||
def has_access(self, user):
|
||||
"""Проверка доступа пользователя к доске."""
|
||||
# Владелец всегда имеет доступ
|
||||
if self.owner == user:
|
||||
return True
|
||||
|
||||
# Публичные доски доступны всем
|
||||
if self.access_type == 'public':
|
||||
return True
|
||||
|
||||
# Участники имеют доступ
|
||||
if self.participants.filter(id=user.id).exists():
|
||||
return True
|
||||
|
||||
# Для досок пары ментор-студент проверяем, что пользователь - ментор или студент
|
||||
if self.access_type == 'mentor_student':
|
||||
if self.mentor and self.student:
|
||||
return user in [self.mentor, self.student]
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class BoardElement(models.Model):
|
||||
"""
|
||||
Элемент доски (текст, фигура, изображение, рисунок).
|
||||
"""
|
||||
|
||||
TYPE_CHOICES = [
|
||||
('text', 'Текст'),
|
||||
('shape', 'Фигура'),
|
||||
('image', 'Изображение'),
|
||||
('drawing', 'Рисунок'),
|
||||
('sticky', 'Стикер'),
|
||||
('arrow', 'Стрелка'),
|
||||
('line', 'Линия'),
|
||||
]
|
||||
|
||||
SHAPE_CHOICES = [
|
||||
('rectangle', 'Прямоугольник'),
|
||||
('circle', 'Круг'),
|
||||
('triangle', 'Треугольник'),
|
||||
('star', 'Звезда'),
|
||||
]
|
||||
|
||||
# Доска
|
||||
board = models.ForeignKey(
|
||||
Board,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='elements',
|
||||
verbose_name='Доска'
|
||||
)
|
||||
|
||||
# Тип элемента
|
||||
element_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name='Тип элемента',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Позиция и размер
|
||||
x = models.FloatField(
|
||||
verbose_name='Позиция X'
|
||||
)
|
||||
|
||||
y = models.FloatField(
|
||||
verbose_name='Позиция Y'
|
||||
)
|
||||
|
||||
width = models.FloatField(
|
||||
default=100,
|
||||
verbose_name='Ширина'
|
||||
)
|
||||
|
||||
height = models.FloatField(
|
||||
default=100,
|
||||
verbose_name='Высота'
|
||||
)
|
||||
|
||||
rotation = models.FloatField(
|
||||
default=0,
|
||||
verbose_name='Поворот (градусы)'
|
||||
)
|
||||
|
||||
z_index = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Z-индекс (слой)'
|
||||
)
|
||||
|
||||
# Содержимое (для текста)
|
||||
content = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Содержимое'
|
||||
)
|
||||
|
||||
# Стиль текста
|
||||
font_size = models.IntegerField(
|
||||
default=16,
|
||||
verbose_name='Размер шрифта'
|
||||
)
|
||||
|
||||
font_family = models.CharField(
|
||||
max_length=100,
|
||||
default='Arial',
|
||||
verbose_name='Шрифт'
|
||||
)
|
||||
|
||||
font_weight = models.CharField(
|
||||
max_length=20,
|
||||
default='normal',
|
||||
verbose_name='Жирность'
|
||||
)
|
||||
|
||||
text_align = models.CharField(
|
||||
max_length=20,
|
||||
default='left',
|
||||
verbose_name='Выравнивание'
|
||||
)
|
||||
|
||||
text_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#000000',
|
||||
verbose_name='Цвет текста'
|
||||
)
|
||||
|
||||
# Стиль фигуры
|
||||
shape_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=SHAPE_CHOICES,
|
||||
blank=True,
|
||||
verbose_name='Тип фигуры'
|
||||
)
|
||||
|
||||
fill_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#FFFFFF',
|
||||
verbose_name='Цвет заливки'
|
||||
)
|
||||
|
||||
stroke_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#000000',
|
||||
verbose_name='Цвет границы'
|
||||
)
|
||||
|
||||
stroke_width = models.FloatField(
|
||||
default=1,
|
||||
verbose_name='Толщина границы'
|
||||
)
|
||||
|
||||
opacity = models.FloatField(
|
||||
default=1.0,
|
||||
verbose_name='Прозрачность (0-1)'
|
||||
)
|
||||
|
||||
# Для изображений
|
||||
image_url = models.URLField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
verbose_name='URL изображения'
|
||||
)
|
||||
|
||||
# Для рисунков (SVG path или JSON)
|
||||
drawing_data = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Данные рисунка'
|
||||
)
|
||||
|
||||
# Связь со стрелкой
|
||||
arrow_start_element = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='arrow_targets',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Начало стрелки'
|
||||
)
|
||||
|
||||
arrow_end_element = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='arrow_sources',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Конец стрелки'
|
||||
)
|
||||
|
||||
# Автор
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='board_elements',
|
||||
null=True,
|
||||
verbose_name='Автор'
|
||||
)
|
||||
|
||||
# Блокировка редактирования
|
||||
locked = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Заблокирован'
|
||||
)
|
||||
|
||||
locked_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='locked_elements',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Заблокировал'
|
||||
)
|
||||
|
||||
# Удаление (мягкое)
|
||||
is_deleted = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Удален',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
deleted_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата удаления'
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата создания'
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name='Дата обновления'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'board_elements'
|
||||
verbose_name = 'Элемент доски'
|
||||
verbose_name_plural = 'Элементы доски'
|
||||
ordering = ['z_index', 'created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['board', 'is_deleted']),
|
||||
models.Index(fields=['element_type']),
|
||||
models.Index(fields=['z_index']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_element_type_display()} - {self.board.title}"
|
||||
|
||||
def soft_delete(self):
|
||||
"""Мягкое удаление элемента."""
|
||||
self.is_deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
# Обновляем счетчик элементов доски
|
||||
self.board.update_elements_count()
|
||||
|
||||
def lock(self, user):
|
||||
"""Заблокировать элемент для редактирования."""
|
||||
self.locked = True
|
||||
self.locked_by = user
|
||||
self.save(update_fields=['locked', 'locked_by'])
|
||||
|
||||
def unlock(self):
|
||||
"""Разблокировать элемент."""
|
||||
self.locked = False
|
||||
self.locked_by = None
|
||||
self.save(update_fields=['locked', 'locked_by'])
|
||||
|
||||
|
||||
class BoardSnapshot(models.Model):
|
||||
"""
|
||||
Снимок доски (для истории изменений).
|
||||
"""
|
||||
|
||||
board = models.ForeignKey(
|
||||
Board,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='snapshots',
|
||||
verbose_name='Доска'
|
||||
)
|
||||
|
||||
# Снимок данных (JSON)
|
||||
snapshot_data = models.JSONField(
|
||||
verbose_name='Данные снимка'
|
||||
)
|
||||
|
||||
# Автор изменений
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='board_snapshots',
|
||||
null=True,
|
||||
verbose_name='Автор'
|
||||
)
|
||||
|
||||
# Описание изменений
|
||||
description = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name='Описание'
|
||||
)
|
||||
|
||||
# Временная метка
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата создания'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'board_snapshots'
|
||||
verbose_name = 'Снимок доски'
|
||||
verbose_name_plural = 'Снимки досок'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Снимок {self.board.title} - {self.created_at}"
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""
|
||||
Permissions для board модуля.
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class IsBoardOwner(permissions.BasePermission):
|
||||
"""
|
||||
Проверка что пользователь - владелец доски.
|
||||
"""
|
||||
|
||||
message = 'Только владелец доски может выполнить это действие.'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
# Для Board
|
||||
if hasattr(obj, 'owner'):
|
||||
return obj.owner == request.user
|
||||
|
||||
# Для BoardElement
|
||||
if hasattr(obj, 'board'):
|
||||
return obj.board.owner == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsBoardOwnerOrParticipant(permissions.BasePermission):
|
||||
"""
|
||||
Проверка что пользователь - владелец или участник доски.
|
||||
"""
|
||||
|
||||
message = 'У вас нет доступа к этой доске.'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
# Для Board
|
||||
if hasattr(obj, 'has_access'):
|
||||
return obj.has_access(request.user)
|
||||
|
||||
# Для BoardElement
|
||||
if hasattr(obj, 'board'):
|
||||
return obj.board.has_access(request.user)
|
||||
|
||||
# Для BoardSnapshot
|
||||
if hasattr(obj, 'board'):
|
||||
return obj.board.has_access(request.user)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsBoardOwnerOrReadOnly(permissions.BasePermission):
|
||||
"""
|
||||
Владелец может редактировать, остальные - только чтение.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
# Чтение разрешено всем с доступом
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
if hasattr(obj, 'has_access'):
|
||||
return obj.has_access(request.user)
|
||||
if hasattr(obj, 'board'):
|
||||
return obj.board.has_access(request.user)
|
||||
|
||||
# Редактирование только владельцу
|
||||
if hasattr(obj, 'owner'):
|
||||
return obj.owner == request.user
|
||||
if hasattr(obj, 'board'):
|
||||
return obj.board.owner == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
WebSocket routing для доски
|
||||
"""
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/board/(?P<board_id>[^/]+)/$', consumers.BoardConsumer.as_asgi()),
|
||||
]
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
"""
|
||||
Сериализаторы для интерактивной доски.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .models import Board, BoardElement, BoardSnapshot
|
||||
from apps.users.serializers import UserSerializer
|
||||
|
||||
|
||||
class BoardElementSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор элемента доски."""
|
||||
|
||||
created_by = UserSerializer(read_only=True)
|
||||
locked_by = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BoardElement
|
||||
fields = [
|
||||
'id',
|
||||
'board',
|
||||
'element_type',
|
||||
'x',
|
||||
'y',
|
||||
'width',
|
||||
'height',
|
||||
'rotation',
|
||||
'z_index',
|
||||
'content',
|
||||
'font_size',
|
||||
'font_family',
|
||||
'font_weight',
|
||||
'text_align',
|
||||
'text_color',
|
||||
'shape_type',
|
||||
'fill_color',
|
||||
'stroke_color',
|
||||
'stroke_width',
|
||||
'opacity',
|
||||
'image_url',
|
||||
'drawing_data',
|
||||
'arrow_start_element',
|
||||
'arrow_end_element',
|
||||
'created_by',
|
||||
'locked',
|
||||
'locked_by',
|
||||
'is_deleted',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'created_by',
|
||||
'locked_by',
|
||||
'is_deleted',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
|
||||
|
||||
class BoardElementCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания элемента доски."""
|
||||
|
||||
board_id = serializers.UUIDField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BoardElement
|
||||
fields = [
|
||||
'board_id',
|
||||
'element_type',
|
||||
'x',
|
||||
'y',
|
||||
'width',
|
||||
'height',
|
||||
'rotation',
|
||||
'z_index',
|
||||
'content',
|
||||
'font_size',
|
||||
'font_family',
|
||||
'font_weight',
|
||||
'text_align',
|
||||
'text_color',
|
||||
'shape_type',
|
||||
'fill_color',
|
||||
'stroke_color',
|
||||
'stroke_width',
|
||||
'opacity',
|
||||
'image_url',
|
||||
'drawing_data',
|
||||
]
|
||||
|
||||
def validate_board_id(self, value):
|
||||
"""Проверка доски."""
|
||||
try:
|
||||
board = Board.objects.get(board_id=value)
|
||||
except Board.DoesNotExist:
|
||||
raise serializers.ValidationError('Доска не найдена')
|
||||
|
||||
# Проверяем доступ
|
||||
user = self.context['request'].user
|
||||
if not board.has_access(user):
|
||||
raise serializers.ValidationError('У вас нет доступа к этой доске')
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание элемента."""
|
||||
board_id = validated_data.pop('board_id')
|
||||
board = Board.objects.get(board_id=board_id)
|
||||
user = self.context['request'].user
|
||||
|
||||
element = BoardElement.objects.create(
|
||||
board=board,
|
||||
created_by=user,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
# Обновляем счетчик элементов
|
||||
board.update_elements_count()
|
||||
|
||||
# Отмечаем редактирование
|
||||
board.mark_edited(user)
|
||||
|
||||
return element
|
||||
|
||||
|
||||
class BoardSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор доски."""
|
||||
|
||||
owner = UserSerializer(read_only=True)
|
||||
mentor = UserSerializer(read_only=True)
|
||||
student = UserSerializer(read_only=True)
|
||||
participants = UserSerializer(many=True, read_only=True)
|
||||
last_edited_by = UserSerializer(read_only=True)
|
||||
elements = BoardElementSerializer(many=True, read_only=True)
|
||||
files_count = serializers.SerializerMethodField()
|
||||
snapshot_elements_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Board
|
||||
fields = [
|
||||
'id',
|
||||
'board_id',
|
||||
'title',
|
||||
'description',
|
||||
'owner',
|
||||
'mentor',
|
||||
'student',
|
||||
'access_type',
|
||||
'participants',
|
||||
'background_color',
|
||||
'grid_enabled',
|
||||
'width',
|
||||
'height',
|
||||
'is_active',
|
||||
'is_template',
|
||||
'views_count',
|
||||
'elements_count',
|
||||
'files_count',
|
||||
'snapshot_elements_count',
|
||||
'last_edited_by',
|
||||
'last_edited_at',
|
||||
'elements',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'board_id',
|
||||
'owner',
|
||||
'mentor',
|
||||
'student',
|
||||
'views_count',
|
||||
'elements_count',
|
||||
'last_edited_by',
|
||||
'last_edited_at',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
|
||||
def get_files_count(self, obj):
|
||||
"""Получить количество файлов изображений из snapshot."""
|
||||
return obj.get_files_count()
|
||||
|
||||
def get_snapshot_elements_count(self, obj):
|
||||
"""Получить количество элементов из snapshot."""
|
||||
return obj.get_elements_count_from_snapshot()
|
||||
|
||||
|
||||
class BoardListSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор списка досок (упрощенный)."""
|
||||
|
||||
owner = UserSerializer(read_only=True)
|
||||
mentor = UserSerializer(read_only=True)
|
||||
student = UserSerializer(read_only=True)
|
||||
last_edited_by = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Board
|
||||
fields = [
|
||||
'id',
|
||||
'board_id',
|
||||
'title',
|
||||
'description',
|
||||
'owner',
|
||||
'mentor',
|
||||
'student',
|
||||
'access_type',
|
||||
'background_color',
|
||||
'is_active',
|
||||
'is_template',
|
||||
'views_count',
|
||||
'elements_count',
|
||||
'last_edited_by',
|
||||
'last_edited_at',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
|
||||
class BoardCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания доски."""
|
||||
|
||||
# mentor и student обязательны для доски типа mentor_student
|
||||
mentor = serializers.IntegerField(required=False, allow_null=True)
|
||||
student = serializers.IntegerField(required=False, allow_null=True)
|
||||
participant_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_empty=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Board
|
||||
fields = [
|
||||
'title',
|
||||
'description',
|
||||
'mentor',
|
||||
'student',
|
||||
'access_type',
|
||||
'participant_ids',
|
||||
'background_color',
|
||||
'grid_enabled',
|
||||
'width',
|
||||
'height',
|
||||
'is_template'
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Валидация данных доски."""
|
||||
# Для доски типа mentor_student требуются mentor и student
|
||||
if attrs.get('access_type') == 'mentor_student':
|
||||
if not attrs.get('mentor') or not attrs.get('student'):
|
||||
raise serializers.ValidationError(
|
||||
'Для доски типа mentor_student требуются поля mentor и student'
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание доски."""
|
||||
from apps.users.models import User
|
||||
|
||||
mentor_id = validated_data.pop('mentor', None)
|
||||
student_id = validated_data.pop('student', None)
|
||||
participant_ids = validated_data.pop('participant_ids', [])
|
||||
user = self.context['request'].user
|
||||
|
||||
# Создаем доску
|
||||
board_data = {
|
||||
'owner': user,
|
||||
**validated_data
|
||||
}
|
||||
|
||||
# Добавляем ментора и студента
|
||||
if mentor_id:
|
||||
board_data['mentor_id'] = mentor_id
|
||||
if student_id:
|
||||
board_data['student_id'] = student_id
|
||||
|
||||
board = Board.objects.create(**board_data)
|
||||
|
||||
# Добавляем участников
|
||||
if participant_ids:
|
||||
participants = User.objects.filter(id__in=participant_ids)
|
||||
board.participants.set(participants)
|
||||
|
||||
# Если указаны ментор и студент, добавляем их как участников
|
||||
if mentor_id or student_id:
|
||||
if mentor_id:
|
||||
board.participants.add(User.objects.get(id=mentor_id))
|
||||
if student_id:
|
||||
board.participants.add(User.objects.get(id=student_id))
|
||||
|
||||
return board
|
||||
|
||||
|
||||
class BoardSnapshotSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор снимка доски."""
|
||||
|
||||
created_by = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BoardSnapshot
|
||||
fields = [
|
||||
'id',
|
||||
'board',
|
||||
'snapshot_data',
|
||||
'created_by',
|
||||
'description',
|
||||
'created_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'created_by',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
|
||||
class BoardSnapshotCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания снимка доски."""
|
||||
|
||||
board_id = serializers.UUIDField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BoardSnapshot
|
||||
fields = [
|
||||
'board_id',
|
||||
'description'
|
||||
]
|
||||
|
||||
def validate_board_id(self, value):
|
||||
"""Проверка доски."""
|
||||
try:
|
||||
board = Board.objects.get(board_id=value)
|
||||
except Board.DoesNotExist:
|
||||
raise serializers.ValidationError('Доска не найдена')
|
||||
|
||||
# Проверяем доступ
|
||||
user = self.context['request'].user
|
||||
if not board.has_access(user):
|
||||
raise serializers.ValidationError('У вас нет доступа к этой доске')
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание снимка."""
|
||||
board_id = validated_data.pop('board_id')
|
||||
board = Board.objects.get(board_id=board_id)
|
||||
user = self.context['request'].user
|
||||
|
||||
# Собираем данные всех элементов
|
||||
elements = board.elements.filter(is_deleted=False)
|
||||
elements_data = BoardElementSerializer(elements, many=True).data
|
||||
|
||||
snapshot_data = {
|
||||
'board': BoardSerializer(board).data,
|
||||
'elements': elements_data,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
|
||||
snapshot = BoardSnapshot.objects.create(
|
||||
board=board,
|
||||
created_by=user,
|
||||
snapshot_data=snapshot_data,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
return snapshot
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Celery задачи для board
|
||||
|
||||
from celery import shared_task
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"""
|
||||
URL routing для board API.
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
BoardViewSet,
|
||||
BoardElementViewSet,
|
||||
BoardSnapshotViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'boards', BoardViewSet, basename='board')
|
||||
router.register(r'elements', BoardElementViewSet, basename='boardelement')
|
||||
router.register(r'snapshots', BoardSnapshotViewSet, basename='boardsnapshot')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
"""
|
||||
API views для интерактивной доски.
|
||||
"""
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db import models
|
||||
from .models import Board, BoardElement, BoardSnapshot
|
||||
from .serializers import (
|
||||
BoardSerializer,
|
||||
BoardListSerializer,
|
||||
BoardCreateSerializer,
|
||||
BoardElementSerializer,
|
||||
BoardElementCreateSerializer,
|
||||
BoardSnapshotSerializer,
|
||||
BoardSnapshotCreateSerializer
|
||||
)
|
||||
from .permissions import IsBoardOwnerOrParticipant, IsBoardOwner
|
||||
|
||||
|
||||
class BoardViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet для управления досками.
|
||||
|
||||
list: Список досок пользователя
|
||||
create: Создать доску
|
||||
retrieve: Получить доску с элементами
|
||||
update: Обновить доску
|
||||
destroy: Удалить доску
|
||||
join: Получить информацию для присоединения
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = 'board_id'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Получение досок пользователя."""
|
||||
user = self.request.user
|
||||
|
||||
# Фильтры из query params
|
||||
mentor_id = self.request.query_params.get('mentor')
|
||||
student_id = self.request.query_params.get('student')
|
||||
|
||||
# Базовый queryset - доски где пользователь владелец, участник, ментор или студент
|
||||
queryset = Board.objects.filter(
|
||||
models.Q(owner=user) |
|
||||
models.Q(participants=user) |
|
||||
models.Q(mentor=user) |
|
||||
models.Q(student=user) |
|
||||
models.Q(access_type='public')
|
||||
).distinct()
|
||||
|
||||
# Фильтр по паре ментор-студент (для получения персональной доски)
|
||||
if mentor_id and student_id:
|
||||
queryset = queryset.filter(mentor_id=mentor_id, student_id=student_id)
|
||||
|
||||
# Фильтр по занятию удален - доска привязана к паре mentor-student, а не к уроку
|
||||
|
||||
queryset = queryset.select_related(
|
||||
'owner',
|
||||
'mentor',
|
||||
'student',
|
||||
'last_edited_by'
|
||||
).prefetch_related('participants')
|
||||
|
||||
# Оптимизация: для списка используем only() для ограничения полей
|
||||
if self.action == 'list':
|
||||
queryset = queryset.only(
|
||||
'id', 'board_id', 'title', 'description', 'owner_id', 'mentor_id',
|
||||
'student_id', 'access_type', 'is_template', 'is_active',
|
||||
'views_count', 'last_edited_by_id', 'last_edited_at',
|
||||
'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Выбор сериализатора."""
|
||||
if self.action == 'list':
|
||||
return BoardListSerializer
|
||||
elif self.action == 'create':
|
||||
return BoardCreateSerializer
|
||||
return BoardSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создание доски."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
board = serializer.save()
|
||||
|
||||
# Возвращаем полную информацию
|
||||
response_serializer = BoardSerializer(board)
|
||||
return Response(
|
||||
response_serializer.data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""Получить доску."""
|
||||
board = self.get_object()
|
||||
|
||||
# Проверяем доступ
|
||||
if not board.has_access(request.user):
|
||||
return Response(
|
||||
{'error': 'У вас нет доступа к этой доске'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Увеличиваем счетчик просмотров
|
||||
board.increment_views()
|
||||
|
||||
serializer = self.get_serializer(board)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def join(self, request, board_id=None):
|
||||
"""
|
||||
Получить информацию для присоединения к доске.
|
||||
|
||||
GET /api/board/boards/{board_id}/join/
|
||||
"""
|
||||
board = self.get_object()
|
||||
|
||||
# Проверяем доступ
|
||||
if not board.has_access(request.user):
|
||||
return Response(
|
||||
{'error': 'У вас нет доступа к этой доске'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# WebSocket URL
|
||||
ws_url = f"ws://{request.get_host()}/ws/board/{board.board_id}/"
|
||||
if request.is_secure():
|
||||
ws_url = f"wss://{request.get_host()}/ws/board/{board.board_id}/"
|
||||
|
||||
# Получаем элементы доски
|
||||
elements = board.elements.filter(is_deleted=False).order_by('z_index', 'created_at')
|
||||
elements_data = BoardElementSerializer(elements, many=True).data
|
||||
|
||||
return Response({
|
||||
'board_id': str(board.board_id),
|
||||
'ws_url': ws_url,
|
||||
'board': BoardSerializer(board).data,
|
||||
'elements': elements_data
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get', 'post'], url_path='get-or-create-mentor-student')
|
||||
def get_or_create_mentor_student(self, request):
|
||||
"""
|
||||
Одна доска на пару ментор–студент (вне зависимости от урока).
|
||||
Атомарно вернуть существующую или создать новую.
|
||||
|
||||
GET/POST /api/board/boards/get-or-create-mentor-student/?mentor=<id>&student=<id>
|
||||
"""
|
||||
from apps.users.models import User
|
||||
|
||||
mentor_id = request.query_params.get('mentor') or (request.data.get('mentor') if request.data else None)
|
||||
student_id = request.query_params.get('student') or (request.data.get('student') if request.data else None)
|
||||
|
||||
try:
|
||||
mentor_id = int(mentor_id) if mentor_id is not None else None
|
||||
student_id = int(student_id) if student_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'error': 'Укажите mentor и student (числовые id)'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not mentor_id or not student_id:
|
||||
return Response(
|
||||
{'error': 'Укажите mentor и student'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
user = request.user
|
||||
if user.id not in (mentor_id, student_id):
|
||||
return Response(
|
||||
{'error': 'Доступ только для ментора или студента этой пары'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mentor = get_object_or_404(User, id=mentor_id)
|
||||
student = get_object_or_404(User, id=student_id)
|
||||
|
||||
board, created = Board.objects.get_or_create(
|
||||
mentor_id=mentor_id,
|
||||
student_id=student_id,
|
||||
defaults={
|
||||
'title': 'Доска для совместной работы',
|
||||
'description': 'Интерактивная доска для занятия',
|
||||
'access_type': 'mentor_student',
|
||||
'owner': mentor,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
board.participants.add(mentor, student)
|
||||
|
||||
serializer = BoardSerializer(board)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_boards(self, request):
|
||||
"""
|
||||
Получить доски пользователя.
|
||||
|
||||
GET /api/board/boards/my_boards/
|
||||
"""
|
||||
boards = Board.objects.filter(owner=request.user, is_active=True)
|
||||
serializer = BoardListSerializer(boards, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def shared_with_me(self, request):
|
||||
"""
|
||||
Получить доски, к которым пользователь имеет доступ.
|
||||
|
||||
GET /api/board/boards/shared_with_me/
|
||||
"""
|
||||
boards = Board.objects.filter(
|
||||
participants=request.user,
|
||||
is_active=True
|
||||
).exclude(owner=request.user)
|
||||
serializer = BoardListSerializer(boards, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get', 'post'])
|
||||
def tldraw(self, request, board_id=None):
|
||||
"""
|
||||
Получить или сохранить Tldraw состояние.
|
||||
|
||||
GET /api/board/boards/{board_id}/tldraw/ - получить snapshot
|
||||
POST /api/board/boards/{board_id}/tldraw/ - сохранить snapshot
|
||||
"""
|
||||
board = self.get_object()
|
||||
|
||||
# Проверяем доступ
|
||||
if not board.has_access(request.user):
|
||||
return Response(
|
||||
{'error': 'У вас нет доступа к этой доске'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if request.method == 'GET':
|
||||
snapshot = board.tldraw_snapshot or {}
|
||||
files_count = board.get_files_count()
|
||||
elements_count = board.get_elements_count_from_snapshot()
|
||||
|
||||
return Response({
|
||||
'board_id': str(board.board_id),
|
||||
'snapshot': snapshot,
|
||||
'stats': {
|
||||
'files_count': files_count,
|
||||
'elements_count': elements_count,
|
||||
}
|
||||
})
|
||||
|
||||
elif request.method == 'POST':
|
||||
# Сохраняем новое состояние
|
||||
snapshot = request.data.get('snapshot', {})
|
||||
|
||||
# Очищаем неиспользуемые файлы из snapshot
|
||||
if isinstance(snapshot, dict):
|
||||
elements = snapshot.get('elements', [])
|
||||
files = snapshot.get('files', {})
|
||||
|
||||
if isinstance(files, dict) and isinstance(elements, list):
|
||||
# Собираем все используемые fileId
|
||||
used_file_ids = set()
|
||||
for element in elements:
|
||||
if isinstance(element, dict):
|
||||
# Проверяем fileId в элементе
|
||||
file_id = element.get('fileId')
|
||||
if file_id and isinstance(file_id, str):
|
||||
used_file_ids.add(file_id)
|
||||
|
||||
# Проверяем все строковые значения, которые могут быть fileId
|
||||
for value in element.values():
|
||||
if isinstance(value, str) and value in files:
|
||||
used_file_ids.add(value)
|
||||
|
||||
# Оставляем только используемые файлы
|
||||
cleaned_files = {
|
||||
file_id: file_data
|
||||
for file_id, file_data in files.items()
|
||||
if file_id in used_file_ids
|
||||
}
|
||||
|
||||
removed_count = len(files) - len(cleaned_files)
|
||||
if removed_count > 0:
|
||||
print(f'[BoardViewSet] Очищено {removed_count} неиспользуемых файлов')
|
||||
|
||||
snapshot['files'] = cleaned_files
|
||||
|
||||
board.tldraw_snapshot = snapshot
|
||||
board.mark_edited(request.user)
|
||||
board.save(update_fields=['tldraw_snapshot', 'last_edited_by', 'last_edited_at', 'updated_at'])
|
||||
|
||||
# Подсчитываем файлы и элементы из snapshot
|
||||
files_count = board.get_files_count()
|
||||
elements_count = board.get_elements_count_from_snapshot()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'board_id': str(board.board_id),
|
||||
'message': 'Состояние доски сохранено',
|
||||
'stats': {
|
||||
'files_count': files_count,
|
||||
'elements_count': elements_count,
|
||||
}
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def templates(self, request):
|
||||
"""
|
||||
Получить шаблоны досок.
|
||||
|
||||
GET /api/board/boards/templates/
|
||||
"""
|
||||
boards = Board.objects.filter(is_template=True, is_active=True)
|
||||
serializer = BoardListSerializer(boards, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, board_id=None):
|
||||
"""
|
||||
Дублировать доску.
|
||||
|
||||
POST /api/board/boards/{board_id}/duplicate/
|
||||
"""
|
||||
original_board = self.get_object()
|
||||
|
||||
# Проверяем доступ
|
||||
if not original_board.has_access(request.user):
|
||||
return Response(
|
||||
{'error': 'У вас нет доступа к этой доске'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Создаем копию доски
|
||||
new_board = Board.objects.create(
|
||||
title=f"{original_board.title} (копия)",
|
||||
description=original_board.description,
|
||||
owner=request.user,
|
||||
access_type='private',
|
||||
background_color=original_board.background_color,
|
||||
grid_enabled=original_board.grid_enabled,
|
||||
width=original_board.width,
|
||||
height=original_board.height
|
||||
)
|
||||
|
||||
# Копируем элементы
|
||||
# Оптимизация: используем bulk_create вместо цикла с create()
|
||||
elements = list(original_board.elements.filter(is_deleted=False))
|
||||
new_elements = []
|
||||
for element in elements:
|
||||
new_elements.append(
|
||||
BoardElement(
|
||||
board=new_board,
|
||||
created_by=request.user,
|
||||
element_type=element.element_type,
|
||||
x=element.x,
|
||||
y=element.y,
|
||||
width=element.width,
|
||||
height=element.height,
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index,
|
||||
content=element.content,
|
||||
font_size=element.font_size,
|
||||
font_family=element.font_family,
|
||||
font_weight=element.font_weight,
|
||||
text_align=element.text_align,
|
||||
text_color=element.text_color,
|
||||
shape_type=element.shape_type,
|
||||
fill_color=element.fill_color,
|
||||
stroke_color=element.stroke_color,
|
||||
stroke_width=element.stroke_width,
|
||||
opacity=element.opacity,
|
||||
image_url=element.image_url,
|
||||
drawing_data=element.drawing_data
|
||||
)
|
||||
)
|
||||
if new_elements:
|
||||
BoardElement.objects.bulk_create(new_elements)
|
||||
|
||||
new_board.update_elements_count()
|
||||
|
||||
serializer = BoardSerializer(new_board)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class BoardElementViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet для управления элементами доски.
|
||||
|
||||
list: Список элементов доски
|
||||
create: Создать элемент
|
||||
retrieve: Получить элемент
|
||||
update: Обновить элемент
|
||||
destroy: Удалить элемент
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsBoardOwnerOrParticipant]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Получение элементов."""
|
||||
board_id = self.request.query_params.get('board_id')
|
||||
|
||||
if board_id:
|
||||
queryset = BoardElement.objects.filter(
|
||||
board__board_id=board_id,
|
||||
is_deleted=False
|
||||
).select_related('board', 'created_by', 'locked_by')
|
||||
else:
|
||||
queryset = BoardElement.objects.filter(
|
||||
is_deleted=False
|
||||
).select_related('board', 'created_by', 'locked_by')
|
||||
|
||||
# Оптимизация: для списка используем only() для ограничения полей
|
||||
if self.action == 'list':
|
||||
queryset = queryset.only(
|
||||
'id', 'board_id', 'element_type', 'element_data', 'position_x', 'position_y',
|
||||
'width', 'height', 'z_index', 'created_by_id', 'locked_by_id', 'is_locked',
|
||||
'is_deleted', 'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Выбор сериализатора."""
|
||||
if self.action == 'create':
|
||||
return BoardElementCreateSerializer
|
||||
return BoardElementSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создание элемента."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
element = serializer.save()
|
||||
|
||||
response_serializer = BoardElementSerializer(element)
|
||||
return Response(
|
||||
response_serializer.data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Удаление элемента (мягкое)."""
|
||||
element = self.get_object()
|
||||
element.soft_delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class BoardSnapshotViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet для управления снимками досок.
|
||||
|
||||
list: Список снимков доски
|
||||
create: Создать снимок
|
||||
retrieve: Получить снимок
|
||||
destroy: Удалить снимок
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Получение снимков."""
|
||||
board_id = self.request.query_params.get('board_id')
|
||||
|
||||
if board_id:
|
||||
queryset = BoardSnapshot.objects.filter(
|
||||
board__board_id=board_id
|
||||
).select_related('board', 'created_by')
|
||||
else:
|
||||
# Снимки досок пользователя
|
||||
queryset = BoardSnapshot.objects.filter(
|
||||
board__owner=self.request.user
|
||||
).select_related('board', 'created_by')
|
||||
|
||||
# Оптимизация: для списка используем only() для ограничения полей
|
||||
if self.action == 'list':
|
||||
queryset = queryset.only(
|
||||
'id', 'board_id', 'snapshot_data', 'created_by_id', 'description', 'created_at'
|
||||
)
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Выбор сериализатора."""
|
||||
if self.action == 'create':
|
||||
return BoardSnapshotCreateSerializer
|
||||
return BoardSnapshotSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создание снимка."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
snapshot = serializer.save()
|
||||
|
||||
response_serializer = BoardSnapshotSerializer(snapshot)
|
||||
return Response(
|
||||
response_serializer.data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
"""
|
||||
Административная панель для чата.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
||||
|
||||
|
||||
@admin.register(Chat)
|
||||
class ChatAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для чатов."""
|
||||
|
||||
list_display = [
|
||||
'uuid_short',
|
||||
'name_display',
|
||||
'type_badge',
|
||||
'created_by_link',
|
||||
'participants_count',
|
||||
'messages_count',
|
||||
'last_message_at',
|
||||
'is_archived_badge',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'chat_type',
|
||||
'is_archived',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'uuid',
|
||||
'messages_count',
|
||||
'last_message_at',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
|
||||
def uuid_short(self, obj):
|
||||
"""Короткий UUID."""
|
||||
return str(obj.uuid)[:8]
|
||||
uuid_short.short_description = 'ID'
|
||||
|
||||
def name_display(self, obj):
|
||||
"""Название чата."""
|
||||
return obj.name or f"Чат {str(obj.uuid)[:8]}"
|
||||
name_display.short_description = 'Название'
|
||||
|
||||
def type_badge(self, obj):
|
||||
"""Бейдж типа чата."""
|
||||
colors = {
|
||||
'direct': '#007bff',
|
||||
'group': '#28a745'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.chat_type, '#000'),
|
||||
obj.get_chat_type_display()
|
||||
)
|
||||
type_badge.short_description = 'Тип'
|
||||
|
||||
def created_by_link(self, obj):
|
||||
"""Ссылка на создателя."""
|
||||
if obj.created_by:
|
||||
url = reverse('admin:users_user_change', args=[obj.created_by.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.created_by.get_full_name())
|
||||
return '-'
|
||||
created_by_link.short_description = 'Создатель'
|
||||
|
||||
def participants_count(self, obj):
|
||||
"""Количество участников."""
|
||||
return obj.participants.count()
|
||||
participants_count.short_description = 'Участников'
|
||||
|
||||
def is_archived_badge(self, obj):
|
||||
"""Бейдж архивации."""
|
||||
if obj.is_archived:
|
||||
return format_html('<span style="color: red;">✗ Архивирован</span>')
|
||||
return format_html('<span style="color: green;">✓ Активен</span>')
|
||||
is_archived_badge.short_description = 'Статус'
|
||||
|
||||
|
||||
@admin.register(ChatParticipant)
|
||||
class ChatParticipantAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для участников чата."""
|
||||
|
||||
list_display = [
|
||||
'user_link',
|
||||
'chat_link',
|
||||
'role_badge',
|
||||
'unread_count',
|
||||
'is_muted',
|
||||
'is_pinned',
|
||||
'joined_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'role',
|
||||
'is_muted',
|
||||
'is_pinned',
|
||||
'joined_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'user__email',
|
||||
'chat__name'
|
||||
]
|
||||
|
||||
def user_link(self, obj):
|
||||
"""Ссылка на пользователя."""
|
||||
url = reverse('admin:users_user_change', args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.get_full_name())
|
||||
user_link.short_description = 'Пользователь'
|
||||
|
||||
def chat_link(self, obj):
|
||||
"""Ссылка на чат."""
|
||||
url = reverse('admin:chat_chat_change', args=[obj.chat.id])
|
||||
name = obj.chat.name or f"Чат {str(obj.chat.uuid)[:8]}"
|
||||
return format_html('<a href="{}">{}</a>', url, name)
|
||||
chat_link.short_description = 'Чат'
|
||||
|
||||
def role_badge(self, obj):
|
||||
"""Бейдж роли."""
|
||||
colors = {
|
||||
'admin': '#dc3545',
|
||||
'member': '#6c757d'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.role, '#000'),
|
||||
obj.get_role_display()
|
||||
)
|
||||
role_badge.short_description = 'Роль'
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для сообщений."""
|
||||
|
||||
list_display = [
|
||||
'uuid_short',
|
||||
'sender_link',
|
||||
'chat_link',
|
||||
'content_preview',
|
||||
'type_badge',
|
||||
'is_edited',
|
||||
'is_deleted',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'message_type',
|
||||
'is_edited',
|
||||
'is_deleted',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'uuid',
|
||||
'content',
|
||||
'sender__email'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'uuid',
|
||||
'is_edited',
|
||||
'edited_at',
|
||||
'is_deleted',
|
||||
'deleted_at',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
def uuid_short(self, obj):
|
||||
"""Короткий UUID."""
|
||||
return str(obj.uuid)[:8]
|
||||
uuid_short.short_description = 'ID'
|
||||
|
||||
def sender_link(self, obj):
|
||||
"""Ссылка на отправителя."""
|
||||
if obj.sender:
|
||||
url = reverse('admin:users_user_change', args=[obj.sender.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.sender.get_full_name())
|
||||
return 'System'
|
||||
sender_link.short_description = 'Отправитель'
|
||||
|
||||
def chat_link(self, obj):
|
||||
"""Ссылка на чат."""
|
||||
url = reverse('admin:chat_chat_change', args=[obj.chat.id])
|
||||
name = obj.chat.name or f"Чат {str(obj.chat.uuid)[:8]}"
|
||||
return format_html('<a href="{}">{}</a>', url, name)
|
||||
chat_link.short_description = 'Чат'
|
||||
|
||||
def content_preview(self, obj):
|
||||
"""Превью контента."""
|
||||
return obj.content[:100] if len(obj.content) > 100 else obj.content
|
||||
content_preview.short_description = 'Содержимое'
|
||||
|
||||
def type_badge(self, obj):
|
||||
"""Бейдж типа."""
|
||||
colors = {
|
||||
'text': '#007bff',
|
||||
'file': '#28a745',
|
||||
'image': '#17a2b8',
|
||||
'video': '#dc3545',
|
||||
'audio': '#ffc107',
|
||||
'system': '#6c757d'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.message_type, '#000'),
|
||||
obj.get_message_type_display()
|
||||
)
|
||||
type_badge.short_description = 'Тип'
|
||||
|
||||
|
||||
@admin.register(MessageFile)
|
||||
class MessageFileAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для файлов сообщений."""
|
||||
|
||||
list_display = [
|
||||
'file_name',
|
||||
'message_link',
|
||||
'file_size_display',
|
||||
'file_type',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = ['file_name', 'file_type']
|
||||
|
||||
def message_link(self, obj):
|
||||
"""Ссылка на сообщение."""
|
||||
url = reverse('admin:chat_message_change', args=[obj.message.id])
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.message.uuid)[:8])
|
||||
message_link.short_description = 'Сообщение'
|
||||
|
||||
def file_size_display(self, obj):
|
||||
"""Отображение размера."""
|
||||
size_mb = obj.file_size / (1024 * 1024)
|
||||
if size_mb > 1:
|
||||
return f"{size_mb:.2f} МБ"
|
||||
size_kb = obj.file_size / 1024
|
||||
return f"{size_kb:.2f} КБ"
|
||||
file_size_display.short_description = 'Размер'
|
||||
|
||||
|
||||
@admin.register(MessageRead)
|
||||
class MessageReadAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для прочтений."""
|
||||
|
||||
list_display = [
|
||||
'user_link',
|
||||
'message_link',
|
||||
'read_at'
|
||||
]
|
||||
|
||||
search_fields = ['user__email']
|
||||
|
||||
def user_link(self, obj):
|
||||
"""Ссылка на пользователя."""
|
||||
url = reverse('admin:users_user_change', args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.get_full_name())
|
||||
user_link.short_description = 'Пользователь'
|
||||
|
||||
def message_link(self, obj):
|
||||
"""Ссылка на сообщение."""
|
||||
url = reverse('admin:chat_message_change', args=[obj.message.id])
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.message.uuid)[:8])
|
||||
message_link.short_description = 'Сообщение'
|
||||
|
||||
|
||||
@admin.register(MessageReaction)
|
||||
class MessageReactionAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для реакций."""
|
||||
|
||||
list_display = [
|
||||
'user_link',
|
||||
'message_link',
|
||||
'emoji',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = ['user__email', 'emoji']
|
||||
|
||||
def user_link(self, obj):
|
||||
"""Ссылка на пользователя."""
|
||||
url = reverse('admin:users_user_change', args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.get_full_name())
|
||||
user_link.short_description = 'Пользователь'
|
||||
|
||||
def message_link(self, obj):
|
||||
"""Ссылка на сообщение."""
|
||||
url = reverse('admin:chat_message_change', args=[obj.message.id])
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.message.uuid)[:8])
|
||||
message_link.short_description = 'Сообщение'
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
Конфигурация приложения chat.
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
"""Конфигурация приложения chat."""
|
||||
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.chat'
|
||||
verbose_name = 'Чат и сообщения'
|
||||
|
||||
def ready(self):
|
||||
"""Инициализация приложения."""
|
||||
# import apps.chat.signals
|
||||
pass
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"""
|
||||
WebSocket consumers для чата.
|
||||
"""
|
||||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"""
|
||||
WebSocket consumer для чата.
|
||||
|
||||
URL: ws://domain/ws/chat/{chat_uuid}/
|
||||
"""
|
||||
|
||||
async def connect(self):
|
||||
"""Подключение к WebSocket."""
|
||||
try:
|
||||
self.chat_uuid = self.scope['url_route']['kwargs']['chat_uuid']
|
||||
self.room_group_name = f'chat_{self.chat_uuid}'
|
||||
self.user = self.scope['user']
|
||||
|
||||
logger.info(f"WebSocket connection attempt: user={self.user}, chat_uuid={self.chat_uuid}, authenticated={self.user.is_authenticated if self.user else False}")
|
||||
|
||||
# Проверяем авторизацию
|
||||
if not self.user or not self.user.is_authenticated:
|
||||
logger.warning(f"WebSocket connection rejected: user not authenticated")
|
||||
await self.close(code=4001) # Unauthorized
|
||||
return
|
||||
|
||||
# Проверяем доступ к чату
|
||||
has_access = await self.check_chat_access()
|
||||
if not has_access:
|
||||
logger.warning(f"WebSocket connection rejected: user {self.user.id} has no access to chat {self.chat_uuid}")
|
||||
await self.close(code=4003) # Forbidden
|
||||
return
|
||||
|
||||
# Проверяем что channel_layer доступен
|
||||
if not self.channel_layer:
|
||||
logger.error("Channel layer not available")
|
||||
await self.close(code=1011)
|
||||
return
|
||||
|
||||
# Присоединяемся к группе чата
|
||||
try:
|
||||
await self.channel_layer.group_add(
|
||||
self.room_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding to channel group: {e}", exc_info=True)
|
||||
await self.close(code=1011)
|
||||
return
|
||||
|
||||
await self.accept()
|
||||
|
||||
# Отправляем подтверждение подключения
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'message': 'Подключено к чату',
|
||||
'chat_uuid': self.chat_uuid
|
||||
}))
|
||||
|
||||
# Уведомляем других участников
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_joined',
|
||||
'user_id': self.user.id,
|
||||
'username': self.user.get_full_name()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"User {self.user.id} connected to chat {self.chat_uuid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in WebSocket connect: {e}", exc_info=True)
|
||||
await self.close(code=1011) # Internal Error
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
"""Отключение от WebSocket."""
|
||||
# Уведомляем других участников
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_left',
|
||||
'user_id': self.user.id,
|
||||
'username': self.user.get_full_name()
|
||||
}
|
||||
)
|
||||
|
||||
# Покидаем группу
|
||||
await self.channel_layer.group_discard(
|
||||
self.room_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
logger.info(f"User {self.user.id} disconnected from chat {self.chat_uuid}")
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""Получение сообщения от клиента."""
|
||||
try:
|
||||
data = json.loads(text_data)
|
||||
message_type = data.get('type')
|
||||
|
||||
if message_type == 'chat_message':
|
||||
# Отправляем сообщение в группу
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'chat_message',
|
||||
'message': data.get('content'),
|
||||
'reply_to': data.get('reply_to'),
|
||||
'user_id': self.user.id,
|
||||
'username': self.user.get_full_name()
|
||||
}
|
||||
)
|
||||
elif message_type == 'typing':
|
||||
# Отправляем статус печати в группу
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_typing',
|
||||
'user_id': self.user.id,
|
||||
'username': self.user.get_full_name(),
|
||||
'is_typing': data.get('is_typing', False)
|
||||
}
|
||||
)
|
||||
elif message_type == 'read_messages':
|
||||
# Отправляем информацию о прочитанных сообщениях
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'message_read',
|
||||
'user_id': self.user.id,
|
||||
'message_uuids': data.get('message_uuids', [])
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Invalid JSON received")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing message: {e}", exc_info=True)
|
||||
|
||||
async def chat_message(self, event):
|
||||
"""Отправка сообщения клиенту."""
|
||||
# Это сообщение уже обработано в модели Message, просто уведомляем клиента
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'chat_message',
|
||||
'message': event.get('message')
|
||||
}))
|
||||
|
||||
async def user_typing(self, event):
|
||||
"""Отправка статуса печати клиенту."""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_typing',
|
||||
'user_id': event.get('user_id'),
|
||||
'username': event.get('username'),
|
||||
'is_typing': event.get('is_typing', False)
|
||||
}))
|
||||
|
||||
async def message_read(self, event):
|
||||
"""Отправка информации о прочитанных сообщениях."""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'message_read',
|
||||
'user_id': event.get('user_id'),
|
||||
'message_uuids': event.get('message_uuids', [])
|
||||
}))
|
||||
|
||||
async def user_joined(self, event):
|
||||
"""Уведомление о присоединении пользователя."""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_joined',
|
||||
'user_id': event.get('user_id'),
|
||||
'username': event.get('username')
|
||||
}))
|
||||
|
||||
async def user_left(self, event):
|
||||
"""Уведомление об уходе пользователя."""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_left',
|
||||
'user_id': event.get('user_id'),
|
||||
'username': event.get('username')
|
||||
}))
|
||||
|
||||
@database_sync_to_async
|
||||
def check_chat_access(self):
|
||||
"""Проверка доступа пользователя к чату."""
|
||||
from .models import Chat, ChatParticipant
|
||||
|
||||
try:
|
||||
chat = Chat.objects.get(uuid=self.chat_uuid)
|
||||
return ChatParticipant.objects.filter(chat=chat, user=self.user).exists()
|
||||
except Chat.DoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
class UserPresenceConsumer(AsyncWebsocketConsumer):
|
||||
"""
|
||||
WebSocket consumer для отслеживания статусов пользователей (онлайн/оффлайн).
|
||||
|
||||
URL: ws://domain/ws/presence/
|
||||
"""
|
||||
|
||||
async def connect(self):
|
||||
"""Подключение к WebSocket."""
|
||||
try:
|
||||
self.user = self.scope['user']
|
||||
self.presence_group_name = 'user_presence'
|
||||
|
||||
logger.info(f"Presence WebSocket connection attempt: user={self.user}, authenticated={self.user.is_authenticated if self.user else False}")
|
||||
|
||||
# Проверяем авторизацию
|
||||
if not self.user or not self.user.is_authenticated:
|
||||
logger.warning(f"Presence WebSocket connection rejected: user not authenticated")
|
||||
await self.close(code=4001) # Unauthorized
|
||||
return
|
||||
|
||||
# Проверяем что channel_layer доступен
|
||||
if not self.channel_layer:
|
||||
logger.error("Channel layer not available")
|
||||
await self.close(code=1011)
|
||||
return
|
||||
|
||||
# Присоединяемся к группе присутствия
|
||||
try:
|
||||
await self.channel_layer.group_add(
|
||||
self.presence_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding to presence channel group: {e}", exc_info=True)
|
||||
await self.close(code=1011)
|
||||
return
|
||||
|
||||
await self.accept()
|
||||
|
||||
# Обновляем статус пользователя на онлайн
|
||||
await self.update_user_presence(True)
|
||||
|
||||
# Отправляем подтверждение подключения
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'message': 'Подключено к отслеживанию статусов',
|
||||
'user_id': self.user.id
|
||||
}))
|
||||
|
||||
logger.info(f"User {self.user.id} connected to presence WebSocket")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Presence WebSocket connect: {e}", exc_info=True)
|
||||
await self.close(code=1011) # Internal Error
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
"""Отключение от WebSocket."""
|
||||
# Обновляем статус пользователя на оффлайн
|
||||
await self.update_user_presence(False)
|
||||
|
||||
# Покидаем группу
|
||||
if self.channel_layer:
|
||||
await self.channel_layer.group_discard(
|
||||
self.presence_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
logger.info(f"User {self.user.id} disconnected from presence WebSocket")
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""Получение сообщения от клиента."""
|
||||
try:
|
||||
data = json.loads(text_data)
|
||||
message_type = data.get('type')
|
||||
|
||||
if message_type == 'ping':
|
||||
# Отвечаем на ping для поддержания соединения
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'pong'
|
||||
}))
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Invalid JSON received in presence WebSocket")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing presence message: {e}", exc_info=True)
|
||||
|
||||
async def user_status_update(self, event):
|
||||
"""Отправка обновления статуса пользователя клиенту."""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_status_update',
|
||||
'user_id': event.get('user_id'),
|
||||
'is_online': event.get('is_online'),
|
||||
'last_activity': event.get('last_activity')
|
||||
}))
|
||||
|
||||
@database_sync_to_async
|
||||
def update_user_presence(self, is_online):
|
||||
"""Обновление статуса присутствия пользователя."""
|
||||
try:
|
||||
now = timezone.now()
|
||||
# Обновляем last_activity при подключении/отключении
|
||||
User.objects.filter(id=self.user.id).update(
|
||||
last_activity=now
|
||||
)
|
||||
|
||||
# Отправляем обновление статуса всем подписчикам
|
||||
if self.channel_layer:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils import timezone as tz
|
||||
|
||||
self.channel_layer.group_send(
|
||||
self.presence_group_name,
|
||||
{
|
||||
'type': 'user_status_update',
|
||||
'user_id': self.user.id,
|
||||
'is_online': is_online,
|
||||
'last_activity': now.isoformat() if now else None
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user presence: {e}", exc_info=True)
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
# Generated by Django 4.2.7 on 2025-12-09 21:02
|
||||
|
||||
import apps.chat.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("schedule", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Chat",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
unique=True,
|
||||
verbose_name="UUID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"chat_type",
|
||||
models.CharField(
|
||||
choices=[("direct", "Личный"), ("group", "Групповой")],
|
||||
db_index=True,
|
||||
default="direct",
|
||||
max_length=20,
|
||||
verbose_name="Тип чата",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Название"
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True, verbose_name="Описание")),
|
||||
(
|
||||
"avatar",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to="chat/avatars/",
|
||||
verbose_name="Аватар",
|
||||
),
|
||||
),
|
||||
(
|
||||
"messages_count",
|
||||
models.IntegerField(default=0, verbose_name="Количество сообщений"),
|
||||
),
|
||||
(
|
||||
"last_message_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
null=True,
|
||||
verbose_name="Последнее сообщение",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_archived",
|
||||
models.BooleanField(
|
||||
db_index=True, default=False, verbose_name="Архивирован"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата создания"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_chats",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Создатель",
|
||||
),
|
||||
),
|
||||
(
|
||||
"lesson",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="chats",
|
||||
to="schedule.lesson",
|
||||
verbose_name="Занятие",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Чат",
|
||||
"verbose_name_plural": "Чаты",
|
||||
"db_table": "chats",
|
||||
"ordering": ["-last_message_at", "-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Message",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
unique=True,
|
||||
verbose_name="UUID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"message_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("text", "Текст"),
|
||||
("file", "Файл"),
|
||||
("image", "Изображение"),
|
||||
("video", "Видео"),
|
||||
("audio", "Аудио"),
|
||||
("system", "Системное"),
|
||||
],
|
||||
default="text",
|
||||
max_length=20,
|
||||
verbose_name="Тип сообщения",
|
||||
),
|
||||
),
|
||||
("content", models.TextField(verbose_name="Содержимое")),
|
||||
(
|
||||
"is_edited",
|
||||
models.BooleanField(default=False, verbose_name="Отредактировано"),
|
||||
),
|
||||
(
|
||||
"edited_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата редактирования"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_deleted",
|
||||
models.BooleanField(
|
||||
db_index=True, default=False, verbose_name="Удалено"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата удаления"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, db_index=True, verbose_name="Дата отправки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"chat",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="chat.chat",
|
||||
verbose_name="Чат",
|
||||
),
|
||||
),
|
||||
(
|
||||
"reply_to",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="replies",
|
||||
to="chat.message",
|
||||
verbose_name="Ответ на",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sender",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sent_messages",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Отправитель",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Сообщение",
|
||||
"verbose_name_plural": "Сообщения",
|
||||
"db_table": "messages",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MessageFile",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
max_length=500,
|
||||
upload_to=apps.chat.models.message_file_upload_path,
|
||||
verbose_name="Файл",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_name",
|
||||
models.CharField(max_length=255, verbose_name="Имя файла"),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.BigIntegerField(verbose_name="Размер файла (bytes)"),
|
||||
),
|
||||
(
|
||||
"file_type",
|
||||
models.CharField(max_length=100, verbose_name="MIME тип"),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата загрузки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"message",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="chat.message",
|
||||
verbose_name="Сообщение",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Файл сообщения",
|
||||
"verbose_name_plural": "Файлы сообщений",
|
||||
"db_table": "message_files",
|
||||
"ordering": ["created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChatParticipant",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[("admin", "Администратор"), ("member", "Участник")],
|
||||
default="member",
|
||||
max_length=20,
|
||||
verbose_name="Роль",
|
||||
),
|
||||
),
|
||||
(
|
||||
"unread_count",
|
||||
models.IntegerField(default=0, verbose_name="Непрочитанных"),
|
||||
),
|
||||
(
|
||||
"last_read_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Последнее прочтение"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_muted",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="Уведомления отключены"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_pinned",
|
||||
models.BooleanField(default=False, verbose_name="Закреплен"),
|
||||
),
|
||||
(
|
||||
"joined_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата присоединения"
|
||||
),
|
||||
),
|
||||
(
|
||||
"left_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата выхода"
|
||||
),
|
||||
),
|
||||
(
|
||||
"chat",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="participants",
|
||||
to="chat.chat",
|
||||
verbose_name="Чат",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_participations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Пользователь",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Участник чата",
|
||||
"verbose_name_plural": "Участники чата",
|
||||
"db_table": "chat_participants",
|
||||
"ordering": ["-joined_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MessageRead",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"read_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, db_index=True, verbose_name="Дата прочтения"
|
||||
),
|
||||
),
|
||||
(
|
||||
"message",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reads",
|
||||
to="chat.message",
|
||||
verbose_name="Сообщение",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="message_reads",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Пользователь",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Прочтение сообщения",
|
||||
"verbose_name_plural": "Прочтения сообщений",
|
||||
"db_table": "message_reads",
|
||||
"ordering": ["-read_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["message", "user"],
|
||||
name="message_rea_message_19da3c_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "read_at"],
|
||||
name="message_rea_user_id_8d07ab_idx",
|
||||
),
|
||||
],
|
||||
"unique_together": {("message", "user")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MessageReaction",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("emoji", models.CharField(max_length=10, verbose_name="Эмодзи")),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, db_index=True, verbose_name="Дата"
|
||||
),
|
||||
),
|
||||
(
|
||||
"message",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reactions",
|
||||
to="chat.message",
|
||||
verbose_name="Сообщение",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="message_reactions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Пользователь",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Реакция на сообщение",
|
||||
"verbose_name_plural": "Реакции на сообщения",
|
||||
"db_table": "message_reactions",
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["message"], name="message_rea_message_3fb393_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["user"], name="message_rea_user_id_565363_idx"
|
||||
),
|
||||
],
|
||||
"unique_together": {("message", "user", "emoji")},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="message",
|
||||
index=models.Index(
|
||||
fields=["chat", "created_at"], name="messages_chat_id_ec31ea_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="message",
|
||||
index=models.Index(fields=["sender"], name="messages_sender__6ae55a_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="message",
|
||||
index=models.Index(
|
||||
fields=["is_deleted"], name="messages_is_dele_54348c_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chatparticipant",
|
||||
index=models.Index(
|
||||
fields=["user", "chat"], name="chat_partic_user_id_69302d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chatparticipant",
|
||||
index=models.Index(
|
||||
fields=["chat", "user"], name="chat_partic_chat_id_e53f2b_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="chatparticipant",
|
||||
unique_together={("chat", "user")},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chat",
|
||||
index=models.Index(fields=["chat_type"], name="chats_chat_ty_9d2e2e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chat",
|
||||
index=models.Index(fields=["is_archived"], name="chats_is_arch_cae907_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chat",
|
||||
index=models.Index(
|
||||
fields=["last_message_at"], name="chats_last_me_63c84f_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
"""
|
||||
Модели для системы чата и сообщений.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
import os
|
||||
|
||||
|
||||
def message_file_upload_path(instance, filename):
|
||||
"""Путь для загрузки файлов сообщений."""
|
||||
ext = filename.split('.')[-1]
|
||||
new_filename = f"{uuid.uuid4()}.{ext}"
|
||||
return os.path.join('file_chat', str(instance.message.chat.id), new_filename)
|
||||
|
||||
|
||||
class Chat(models.Model):
|
||||
"""
|
||||
Модель чата.
|
||||
"""
|
||||
|
||||
CHAT_TYPE_CHOICES = [
|
||||
('direct', 'Личный'),
|
||||
('group', 'Групповой'),
|
||||
]
|
||||
|
||||
# Основная информация
|
||||
uuid = models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name='UUID'
|
||||
)
|
||||
|
||||
chat_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CHAT_TYPE_CHOICES,
|
||||
default='direct',
|
||||
verbose_name='Тип чата',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name='Название'
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Описание'
|
||||
)
|
||||
|
||||
avatar = models.ImageField(
|
||||
upload_to='chat/avatars/',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Аватар'
|
||||
)
|
||||
|
||||
# Создатель
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='created_chats',
|
||||
null=True,
|
||||
verbose_name='Создатель'
|
||||
)
|
||||
|
||||
# Связь с занятием (опционально)
|
||||
lesson = models.ForeignKey(
|
||||
'schedule.Lesson',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='chats',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Занятие'
|
||||
)
|
||||
|
||||
# Статистика
|
||||
messages_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Количество сообщений'
|
||||
)
|
||||
|
||||
last_message_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Последнее сообщение',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Настройки
|
||||
is_archived = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Архивирован',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата создания'
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name='Дата обновления'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'chats'
|
||||
verbose_name = 'Чат'
|
||||
verbose_name_plural = 'Чаты'
|
||||
ordering = ['-last_message_at', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['chat_type']),
|
||||
models.Index(fields=['is_archived']),
|
||||
models.Index(fields=['last_message_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
return f"Чат {self.uuid}"
|
||||
|
||||
def update_last_message(self):
|
||||
"""Обновить время последнего сообщения."""
|
||||
self.last_message_at = timezone.now()
|
||||
self.save(update_fields=['last_message_at'])
|
||||
|
||||
def increment_messages_count(self):
|
||||
"""Увеличить счетчик сообщений."""
|
||||
self.messages_count += 1
|
||||
self.save(update_fields=['messages_count'])
|
||||
|
||||
def get_participants_ids(self):
|
||||
"""Получить ID участников чата."""
|
||||
return list(self.participants.values_list('user_id', flat=True))
|
||||
|
||||
|
||||
class ChatParticipant(models.Model):
|
||||
"""
|
||||
Модель участника чата.
|
||||
"""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
('admin', 'Администратор'),
|
||||
('member', 'Участник'),
|
||||
]
|
||||
|
||||
chat = models.ForeignKey(
|
||||
Chat,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='participants',
|
||||
verbose_name='Чат'
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='chat_participations',
|
||||
verbose_name='Пользователь'
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=ROLE_CHOICES,
|
||||
default='member',
|
||||
verbose_name='Роль'
|
||||
)
|
||||
|
||||
# Статистика
|
||||
unread_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Непрочитанных'
|
||||
)
|
||||
|
||||
last_read_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Последнее прочтение'
|
||||
)
|
||||
|
||||
# Настройки
|
||||
is_muted = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Уведомления отключены'
|
||||
)
|
||||
|
||||
is_pinned = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Закреплен'
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
joined_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата присоединения'
|
||||
)
|
||||
|
||||
left_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата выхода'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'chat_participants'
|
||||
verbose_name = 'Участник чата'
|
||||
verbose_name_plural = 'Участники чата'
|
||||
unique_together = ['chat', 'user']
|
||||
ordering = ['-joined_at']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'chat']),
|
||||
models.Index(fields=['chat', 'user']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} в {self.chat}"
|
||||
|
||||
def mark_as_read(self):
|
||||
"""Отметить все сообщения как прочитанные."""
|
||||
self.unread_count = 0
|
||||
self.last_read_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def increment_unread(self):
|
||||
"""Увеличить счетчик непрочитанных."""
|
||||
self.unread_count += 1
|
||||
self.save(update_fields=['unread_count'])
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
"""
|
||||
Модель сообщения.
|
||||
"""
|
||||
|
||||
MESSAGE_TYPE_CHOICES = [
|
||||
('text', 'Текст'),
|
||||
('file', 'Файл'),
|
||||
('image', 'Изображение'),
|
||||
('video', 'Видео'),
|
||||
('audio', 'Аудио'),
|
||||
('system', 'Системное'),
|
||||
]
|
||||
|
||||
# Основная информация
|
||||
uuid = models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name='UUID'
|
||||
)
|
||||
|
||||
chat = models.ForeignKey(
|
||||
Chat,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='messages',
|
||||
verbose_name='Чат'
|
||||
)
|
||||
|
||||
sender = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='sent_messages',
|
||||
null=True,
|
||||
verbose_name='Отправитель'
|
||||
)
|
||||
|
||||
message_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=MESSAGE_TYPE_CHOICES,
|
||||
default='text',
|
||||
verbose_name='Тип сообщения'
|
||||
)
|
||||
|
||||
# Контент
|
||||
content = models.TextField(
|
||||
verbose_name='Содержимое'
|
||||
)
|
||||
|
||||
# Ответ на сообщение
|
||||
reply_to = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='replies',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Ответ на'
|
||||
)
|
||||
|
||||
# Редактирование
|
||||
is_edited = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Отредактировано'
|
||||
)
|
||||
|
||||
edited_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата редактирования'
|
||||
)
|
||||
|
||||
# Удаление
|
||||
is_deleted = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Удалено',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
deleted_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата удаления'
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата отправки',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'messages'
|
||||
verbose_name = 'Сообщение'
|
||||
verbose_name_plural = 'Сообщения'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['chat', 'created_at']),
|
||||
models.Index(fields=['sender']),
|
||||
models.Index(fields=['is_deleted']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
preview = self.content[:50] if len(self.content) > 50 else self.content
|
||||
return f"{self.sender.email if self.sender else 'System'}: {preview}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Переопределяем save."""
|
||||
is_new = self.pk is None
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# При создании нового сообщения
|
||||
if is_new:
|
||||
# Обновляем счетчик и время последнего сообщения в чате
|
||||
self.chat.increment_messages_count()
|
||||
self.chat.update_last_message()
|
||||
|
||||
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||
participants = list(self.chat.participants.exclude(user=self.sender))
|
||||
for participant in participants:
|
||||
participant.unread_count += 1
|
||||
if participants:
|
||||
ChatParticipant.objects.bulk_update(participants, ['unread_count'])
|
||||
|
||||
def mark_as_edited(self):
|
||||
"""Отметить как отредактированное."""
|
||||
self.is_edited = True
|
||||
self.edited_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def soft_delete(self):
|
||||
"""Мягкое удаление."""
|
||||
self.is_deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
||||
class MessageFile(models.Model):
|
||||
"""
|
||||
Модель файла сообщения.
|
||||
"""
|
||||
|
||||
message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='files',
|
||||
verbose_name='Сообщение'
|
||||
)
|
||||
|
||||
file = models.FileField(
|
||||
upload_to=lambda instance, filename: os.path.join('file_chat', str(instance.message.chat.id), filename),
|
||||
max_length=500,
|
||||
verbose_name='Файл'
|
||||
)
|
||||
|
||||
file_name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Имя файла'
|
||||
)
|
||||
|
||||
file_size = models.BigIntegerField(
|
||||
verbose_name='Размер файла (bytes)'
|
||||
)
|
||||
|
||||
file_type = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name='MIME тип'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата загрузки'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'message_files'
|
||||
verbose_name = 'Файл сообщения'
|
||||
verbose_name_plural = 'Файлы сообщений'
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.file_name} ({self.message.uuid})"
|
||||
|
||||
|
||||
class MessageRead(models.Model):
|
||||
"""
|
||||
Модель прочтения сообщений.
|
||||
Отслеживает кто и когда прочитал сообщение.
|
||||
"""
|
||||
|
||||
message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reads',
|
||||
verbose_name='Сообщение'
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='message_reads',
|
||||
verbose_name='Пользователь'
|
||||
)
|
||||
|
||||
read_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата прочтения',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'message_reads'
|
||||
verbose_name = 'Прочтение сообщения'
|
||||
verbose_name_plural = 'Прочтения сообщений'
|
||||
unique_together = ['message', 'user']
|
||||
ordering = ['-read_at']
|
||||
indexes = [
|
||||
models.Index(fields=['message', 'user']),
|
||||
models.Index(fields=['user', 'read_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} прочитал {self.message.uuid}"
|
||||
|
||||
|
||||
class MessageReaction(models.Model):
|
||||
"""
|
||||
Модель реакции на сообщение.
|
||||
"""
|
||||
|
||||
message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reactions',
|
||||
verbose_name='Сообщение'
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='message_reactions',
|
||||
verbose_name='Пользователь'
|
||||
)
|
||||
|
||||
emoji = models.CharField(
|
||||
max_length=10,
|
||||
verbose_name='Эмодзи'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'message_reactions'
|
||||
verbose_name = 'Реакция на сообщение'
|
||||
verbose_name_plural = 'Реакции на сообщения'
|
||||
unique_together = ['message', 'user', 'emoji']
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['message']),
|
||||
models.Index(fields=['user']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.emoji}"
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"""
|
||||
Permissions для chat модуля.
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class IsChatParticipant(permissions.BasePermission):
|
||||
"""
|
||||
Проверка что пользователь - участник чата.
|
||||
"""
|
||||
|
||||
message = 'У вас нет доступа к этому чату.'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
from .models import ChatParticipant
|
||||
|
||||
return ChatParticipant.objects.filter(
|
||||
chat=obj,
|
||||
user=request.user
|
||||
).exists()
|
||||
|
||||
|
||||
class IsMessageSender(permissions.BasePermission):
|
||||
"""
|
||||
Проверка что пользователь - отправитель сообщения.
|
||||
"""
|
||||
|
||||
message = 'Вы можете редактировать/удалять только свои сообщения.'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
# Для GET запросов разрешаем всем участникам чата
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
from .models import ChatParticipant
|
||||
return ChatParticipant.objects.filter(
|
||||
chat=obj.chat,
|
||||
user=request.user
|
||||
).exists()
|
||||
|
||||
# Для изменения/удаления только отправителю
|
||||
return obj.sender == request.user
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"""
|
||||
WebSocket URL routing для chat приложения.
|
||||
"""
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
chat_websocket_urlpatterns = [
|
||||
re_path(r'ws/chat/(?P<chat_uuid>[0-9a-f-]+)/$', consumers.ChatConsumer.as_asgi()),
|
||||
re_path(r'ws/presence/$', consumers.UserPresenceConsumer.as_asgi()),
|
||||
]
|
||||
|
||||
|
|
@ -0,0 +1,582 @@
|
|||
"""
|
||||
Сериализаторы для чата и сообщений.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from django.db import models
|
||||
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
||||
from apps.users.serializers import UserSerializer
|
||||
from apps.users.mixins import TimezoneAwareSerializerMixin
|
||||
from apps.users.utils import format_datetime_for_user
|
||||
|
||||
|
||||
class ChatParticipantSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор участника чата."""
|
||||
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ChatParticipant
|
||||
fields = [
|
||||
'id',
|
||||
'user',
|
||||
'role',
|
||||
'unread_count',
|
||||
'last_read_at',
|
||||
'is_muted',
|
||||
'is_pinned',
|
||||
'joined_at'
|
||||
]
|
||||
read_only_fields = ['unread_count', 'last_read_at', 'joined_at']
|
||||
|
||||
|
||||
class MessageFileSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор файла сообщения."""
|
||||
|
||||
file = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MessageFile
|
||||
fields = [
|
||||
'id',
|
||||
'file',
|
||||
'file_name',
|
||||
'file_size',
|
||||
'file_type',
|
||||
'created_at'
|
||||
]
|
||||
read_only_fields = ['file_name', 'file_size', 'file_type', 'created_at']
|
||||
|
||||
def get_file(self, obj):
|
||||
"""Получить полный URL файла."""
|
||||
request = self.context.get('request')
|
||||
if request and obj.file:
|
||||
return request.build_absolute_uri(obj.file.url)
|
||||
elif obj.file:
|
||||
# Если нет request, возвращаем относительный URL
|
||||
return obj.file.url
|
||||
return None
|
||||
|
||||
|
||||
class MessageReactionSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор реакции на сообщение."""
|
||||
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MessageReaction
|
||||
fields = ['id', 'user', 'emoji', 'created_at']
|
||||
read_only_fields = ['user', 'created_at']
|
||||
|
||||
|
||||
class MessageSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
|
||||
"""Сериализатор сообщения."""
|
||||
|
||||
sender = UserSerializer(read_only=True)
|
||||
files = MessageFileSerializer(many=True, read_only=True)
|
||||
reactions = MessageReactionSerializer(many=True, read_only=True)
|
||||
reply_to = serializers.SerializerMethodField()
|
||||
is_read = serializers.SerializerMethodField()
|
||||
is_read_by_others = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = [
|
||||
'id',
|
||||
'uuid',
|
||||
'chat',
|
||||
'sender',
|
||||
'message_type',
|
||||
'content',
|
||||
'reply_to',
|
||||
'files',
|
||||
'reactions',
|
||||
'is_edited',
|
||||
'edited_at',
|
||||
'is_deleted',
|
||||
'is_read',
|
||||
'is_read_by_others',
|
||||
'created_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'uuid',
|
||||
'sender',
|
||||
'is_edited',
|
||||
'edited_at',
|
||||
'is_deleted',
|
||||
'created_at'
|
||||
]
|
||||
timezone_aware_fields = ['created_at', 'edited_at']
|
||||
|
||||
def get_reply_to(self, obj):
|
||||
"""Получить сообщение, на которое отвечают."""
|
||||
if obj.reply_to:
|
||||
return {
|
||||
'uuid': str(obj.reply_to.uuid),
|
||||
'sender': obj.reply_to.sender.get_full_name() if obj.reply_to.sender else 'System',
|
||||
'content': obj.reply_to.content[:100]
|
||||
}
|
||||
return None
|
||||
|
||||
def get_is_read(self, obj):
|
||||
"""Проверка прочитано ли сообщение текущим пользователем."""
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
# Оптимизация: используем предзагруженные reads если доступны
|
||||
if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache:
|
||||
reads = obj._prefetched_objects_cache['reads']
|
||||
return any(read.user_id == request.user.id for read in reads)
|
||||
|
||||
# Fallback на запрос, если prefetch не был выполнен
|
||||
return MessageRead.objects.filter(
|
||||
message=obj,
|
||||
user=request.user
|
||||
).exists()
|
||||
return False
|
||||
|
||||
def get_is_read_by_others(self, obj):
|
||||
"""Проверка прочитано ли сообщение другими участниками чата (для отображения статуса прочитанности)."""
|
||||
request = self.context.get('request')
|
||||
if not request or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Проверяем только для сообщений, отправленных текущим пользователем
|
||||
if obj.sender_id != request.user.id:
|
||||
return False
|
||||
|
||||
# Получаем всех участников чата кроме отправителя
|
||||
chat = obj.chat
|
||||
other_participants = chat.participants.exclude(user_id=obj.sender_id)
|
||||
|
||||
if not other_participants.exists():
|
||||
return False
|
||||
|
||||
# Проверяем, прочитано ли сообщение хотя бы одним другим участником
|
||||
# Оптимизация: используем предзагруженные reads если доступны
|
||||
if hasattr(obj, '_prefetched_objects_cache') and 'reads' in obj._prefetched_objects_cache:
|
||||
reads = obj._prefetched_objects_cache['reads']
|
||||
other_participant_ids = set(other_participants.values_list('user_id', flat=True))
|
||||
return any(read.user_id in other_participant_ids for read in reads)
|
||||
|
||||
# Fallback на запрос
|
||||
other_participant_ids = list(other_participants.values_list('user_id', flat=True))
|
||||
return MessageRead.objects.filter(
|
||||
message=obj,
|
||||
user_id__in=other_participant_ids
|
||||
).exists()
|
||||
|
||||
|
||||
class MessageCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания сообщения."""
|
||||
|
||||
reply_to_uuid = serializers.UUIDField(required=False, allow_null=True)
|
||||
content = serializers.CharField(required=False, allow_blank=True)
|
||||
files = serializers.ListField(
|
||||
child=serializers.FileField(),
|
||||
required=False,
|
||||
allow_empty=True
|
||||
)
|
||||
preloaded_files = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="JSON строка со списком предзагруженных файлов: [{'filename': 'uuid.ext', 'original_name': 'name.ext', 'size': 1234, 'content_type': 'image/jpeg'}]"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['chat', 'content', 'message_type', 'reply_to_uuid', 'files', 'preloaded_files']
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Валидация."""
|
||||
# Проверяем доступ к чату
|
||||
request = self.context['request']
|
||||
chat = attrs['chat']
|
||||
|
||||
if not ChatParticipant.objects.filter(chat=chat, user=request.user).exists():
|
||||
raise serializers.ValidationError({
|
||||
'chat': 'У вас нет доступа к этому чату'
|
||||
})
|
||||
|
||||
# Проверяем, что есть либо content, либо файлы
|
||||
content = attrs.get('content', '').strip()
|
||||
files = attrs.get('files', [])
|
||||
preloaded_files = attrs.get('preloaded_files', [])
|
||||
|
||||
# Парсим preloaded_files если это строка
|
||||
if isinstance(preloaded_files, str):
|
||||
import json
|
||||
try:
|
||||
preloaded_files = json.loads(preloaded_files)
|
||||
except json.JSONDecodeError:
|
||||
preloaded_files = []
|
||||
|
||||
has_content = bool(content)
|
||||
has_files = bool(files) or bool(preloaded_files)
|
||||
|
||||
if not has_content and not has_files:
|
||||
raise serializers.ValidationError({
|
||||
'content': 'Сообщение не может быть пустым. Укажите текст или прикрепите файлы.'
|
||||
})
|
||||
|
||||
# Проверяем reply_to
|
||||
reply_to_uuid = attrs.pop('reply_to_uuid', None)
|
||||
if reply_to_uuid:
|
||||
try:
|
||||
attrs['reply_to'] = Message.objects.get(uuid=reply_to_uuid, chat=chat)
|
||||
except Message.DoesNotExist:
|
||||
raise serializers.ValidationError({
|
||||
'reply_to_uuid': 'Сообщение не найдено'
|
||||
})
|
||||
|
||||
# Устанавливаем пустую строку для content если его нет
|
||||
if not content:
|
||||
attrs['content'] = ''
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание сообщения."""
|
||||
files_data = validated_data.pop('files', [])
|
||||
preloaded_files_data = validated_data.pop('preloaded_files', '')
|
||||
user = self.context['request'].user
|
||||
chat = validated_data['chat']
|
||||
|
||||
message = Message.objects.create(
|
||||
sender=user,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
# Обрабатываем предзагруженные файлы
|
||||
from .utils import move_file_from_preload_to_chat
|
||||
from django.core.files import File
|
||||
from django.conf import settings
|
||||
import os
|
||||
import json
|
||||
|
||||
# Парсим JSON строку
|
||||
preloaded_files_list = []
|
||||
if preloaded_files_data:
|
||||
if isinstance(preloaded_files_data, str):
|
||||
try:
|
||||
parsed = json.loads(preloaded_files_data)
|
||||
if isinstance(parsed, list):
|
||||
preloaded_files_list = [item for item in parsed if isinstance(item, dict)]
|
||||
except json.JSONDecodeError:
|
||||
preloaded_files_list = []
|
||||
elif isinstance(preloaded_files_data, list):
|
||||
preloaded_files_list = [item for item in preloaded_files_data if isinstance(item, dict)]
|
||||
|
||||
for preloaded_file in preloaded_files_list:
|
||||
try:
|
||||
filename = preloaded_file.get('filename')
|
||||
original_name = preloaded_file.get('original_name', filename)
|
||||
file_size = preloaded_file.get('size', 0)
|
||||
content_type = preloaded_file.get('content_type', 'application/octet-stream')
|
||||
|
||||
# Перемещаем файл из preload в основную директорию
|
||||
new_file_path = move_file_from_preload_to_chat(chat.id, filename)
|
||||
|
||||
# Создаем запись MessageFile
|
||||
full_path = os.path.join(settings.MEDIA_ROOT, new_file_path)
|
||||
with open(full_path, 'rb') as f:
|
||||
django_file = File(f, name=os.path.basename(new_file_path))
|
||||
MessageFile.objects.create(
|
||||
message=message,
|
||||
file=django_file,
|
||||
file_name=original_name,
|
||||
file_size=file_size,
|
||||
file_type=content_type
|
||||
)
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не прерываем создание сообщения
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Ошибка при обработке предзагруженного файла {preloaded_file}: {e}")
|
||||
|
||||
# Обрабатываем файлы, загруженные напрямую (для обратной совместимости)
|
||||
for file in files_data:
|
||||
MessageFile.objects.create(
|
||||
message=message,
|
||||
file=file,
|
||||
file_name=file.name,
|
||||
file_size=file.size,
|
||||
file_type=file.content_type
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
class ChatSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
|
||||
"""Сериализатор чата (список)."""
|
||||
|
||||
created_by = UserSerializer(read_only=True)
|
||||
last_message = serializers.SerializerMethodField()
|
||||
my_participant = serializers.SerializerMethodField()
|
||||
participants_count = serializers.SerializerMethodField()
|
||||
other_participant = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Chat
|
||||
fields = [
|
||||
'id',
|
||||
'uuid',
|
||||
'chat_type',
|
||||
'name',
|
||||
'description',
|
||||
'avatar',
|
||||
'created_by',
|
||||
'lesson',
|
||||
'participants_count',
|
||||
'last_message',
|
||||
'my_participant',
|
||||
'other_participant',
|
||||
'messages_count',
|
||||
'last_message_at',
|
||||
'is_archived',
|
||||
'created_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'uuid',
|
||||
'created_by',
|
||||
'messages_count',
|
||||
'last_message_at',
|
||||
'created_at'
|
||||
]
|
||||
timezone_aware_fields = ['created_at', 'last_message_at']
|
||||
|
||||
def get_participants_count(self, obj):
|
||||
"""Количество участников."""
|
||||
# Оптимизация: используем предзагруженные participants если доступны
|
||||
if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache:
|
||||
return len(obj._prefetched_objects_cache['participants'])
|
||||
return obj.participants.count()
|
||||
|
||||
def get_last_message(self, obj):
|
||||
"""Получить последнее сообщение."""
|
||||
# Оптимизация: используем предзагруженные messages если доступны
|
||||
if hasattr(obj, '_prefetched_objects_cache') and 'messages' in obj._prefetched_objects_cache:
|
||||
messages = [m for m in obj._prefetched_objects_cache['messages'] if not m.is_deleted]
|
||||
last_message = messages[0] if messages else None
|
||||
else:
|
||||
last_message = obj.messages.filter(is_deleted=False).select_related('sender').prefetch_related('files', 'reads').first()
|
||||
|
||||
if last_message:
|
||||
# Получаем информацию о файлах
|
||||
files_data = []
|
||||
if hasattr(last_message, '_prefetched_objects_cache') and 'files' in last_message._prefetched_objects_cache:
|
||||
files = last_message._prefetched_objects_cache['files']
|
||||
else:
|
||||
files = last_message.files.all()
|
||||
|
||||
for file in files:
|
||||
files_data.append({
|
||||
'id': file.id,
|
||||
'file_name': file.file_name,
|
||||
'file_type': file.file_type,
|
||||
'file_size': file.file_size
|
||||
})
|
||||
|
||||
# Проверяем, прочитано ли сообщение другими участниками (для отображения статуса)
|
||||
is_read_by_others = False
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated and last_message.sender_id == request.user.id:
|
||||
# Получаем всех участников чата кроме отправителя
|
||||
other_participants = obj.participants.exclude(user_id=last_message.sender_id)
|
||||
if other_participants.exists():
|
||||
# Проверяем, прочитано ли сообщение хотя бы одним другим участником
|
||||
if hasattr(last_message, '_prefetched_objects_cache') and 'reads' in last_message._prefetched_objects_cache:
|
||||
reads = last_message._prefetched_objects_cache['reads']
|
||||
other_participant_ids = set(other_participants.values_list('user_id', flat=True))
|
||||
is_read_by_others = any(read.user_id in other_participant_ids for read in reads)
|
||||
else:
|
||||
other_participant_ids = list(other_participants.values_list('user_id', flat=True))
|
||||
is_read_by_others = MessageRead.objects.filter(
|
||||
message=last_message,
|
||||
user_id__in=other_participant_ids
|
||||
).exists()
|
||||
|
||||
return {
|
||||
'uuid': str(last_message.uuid),
|
||||
'sender': last_message.sender.get_full_name() if last_message.sender else 'System',
|
||||
'sender_id': last_message.sender_id,
|
||||
'content': last_message.content[:100] if last_message.content else '',
|
||||
'message_type': last_message.message_type,
|
||||
'files': files_data,
|
||||
'is_read_by_others': is_read_by_others,
|
||||
'created_at': format_datetime_for_user(last_message.created_at, request.user.timezone) if last_message.created_at and request and request.user.is_authenticated else (last_message.created_at.isoformat() if last_message.created_at else None)
|
||||
}
|
||||
return None
|
||||
|
||||
def get_my_participant(self, obj):
|
||||
"""Получить данные участника для текущего пользователя."""
|
||||
request = self.context.get('request')
|
||||
if request and request.user.is_authenticated:
|
||||
# Оптимизация: используем предзагруженные participants если доступны
|
||||
if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache:
|
||||
participants = obj._prefetched_objects_cache['participants']
|
||||
participant = next((p for p in participants if p.user_id == request.user.id), None)
|
||||
else:
|
||||
try:
|
||||
participant = obj.participants.get(user=request.user)
|
||||
except ChatParticipant.DoesNotExist:
|
||||
participant = None
|
||||
|
||||
if participant:
|
||||
return {
|
||||
'unread_count': participant.unread_count,
|
||||
'is_muted': participant.is_muted,
|
||||
'is_pinned': participant.is_pinned
|
||||
}
|
||||
return None
|
||||
|
||||
def get_other_participant(self, obj):
|
||||
"""Получить информацию о собеседнике для личных чатов."""
|
||||
if obj.chat_type != 'direct':
|
||||
return None
|
||||
|
||||
request = self.context.get('request')
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Оптимизация: используем предзагруженные participants если доступны
|
||||
if hasattr(obj, '_prefetched_objects_cache') and 'participants' in obj._prefetched_objects_cache:
|
||||
participants = obj._prefetched_objects_cache['participants']
|
||||
other_participant = next((p for p in participants if p.user_id != request.user.id), None)
|
||||
else:
|
||||
other_participant = obj.participants.exclude(user=request.user).select_related('user').first()
|
||||
|
||||
if other_participant and other_participant.user:
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
user = other_participant.user
|
||||
# КРИТИЧНО: Обновляем объект пользователя из базы данных, чтобы получить актуальное значение last_activity
|
||||
# Это необходимо, так как middleware мог обновить last_activity после загрузки объекта
|
||||
try:
|
||||
user.refresh_from_db(fields=['last_activity'])
|
||||
except Exception:
|
||||
# Если не удалось обновить, используем текущее значение
|
||||
pass
|
||||
|
||||
# Определяем статус онлайн (активен в последние 15 минут)
|
||||
# Интервал 15 минут для определения онлайн статуса
|
||||
is_online = False
|
||||
if user.last_activity:
|
||||
time_diff = timezone.now() - user.last_activity
|
||||
# Пользователь считается онлайн если активен в последние 15 минут
|
||||
is_online = time_diff.total_seconds() < 900 # 15 минут = 900 секунд
|
||||
# Если last_activity отсутствует, пользователь точно не онлайн
|
||||
|
||||
# Получаем полный URL аватара
|
||||
avatar_url = None
|
||||
if user.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
avatar_url = request.build_absolute_uri(user.avatar.url)
|
||||
else:
|
||||
avatar_url = user.avatar.url
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'full_name': user.get_full_name() or user.email,
|
||||
'avatar': avatar_url,
|
||||
'role': user.role,
|
||||
'is_online': is_online,
|
||||
'last_activity': format_datetime_for_user(user.last_activity, request.user.timezone) if user.last_activity and request and request.user.is_authenticated else (user.last_activity.isoformat() if user.last_activity else None)
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class ChatCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания чата."""
|
||||
|
||||
participant_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=True,
|
||||
allow_empty=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Chat
|
||||
fields = ['chat_type', 'name', 'description', 'avatar', 'participant_ids']
|
||||
|
||||
def validate_participant_ids(self, value):
|
||||
"""Валидация участников."""
|
||||
from apps.users.models import User
|
||||
|
||||
# Проверяем что все пользователи существуют
|
||||
users = User.objects.filter(id__in=value)
|
||||
if users.count() != len(value):
|
||||
raise serializers.ValidationError('Некоторые пользователи не найдены')
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Валидация."""
|
||||
# Для личного чата нужно ровно 2 участника (текущий + 1)
|
||||
if attrs['chat_type'] == 'direct':
|
||||
if len(attrs['participant_ids']) != 1:
|
||||
raise serializers.ValidationError({
|
||||
'participant_ids': 'Для личного чата нужен ровно 1 участник'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание чата."""
|
||||
from apps.users.models import User
|
||||
|
||||
participant_ids = validated_data.pop('participant_ids')
|
||||
user = self.context['request'].user
|
||||
|
||||
# Для личного чата проверяем что такой чат уже не существует
|
||||
if validated_data['chat_type'] == 'direct':
|
||||
existing_chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=user
|
||||
).filter(
|
||||
participants__user_id=participant_ids[0]
|
||||
).first()
|
||||
|
||||
if existing_chat:
|
||||
return existing_chat
|
||||
|
||||
# Создаем чат
|
||||
chat = Chat.objects.create(
|
||||
created_by=user,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
# Добавляем создателя как участника
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=user,
|
||||
role='admin'
|
||||
)
|
||||
|
||||
# Добавляем остальных участников
|
||||
# Оптимизация: используем bulk_create вместо цикла с create()
|
||||
users = list(User.objects.filter(id__in=participant_ids))
|
||||
participants_to_create = [
|
||||
ChatParticipant(
|
||||
chat=chat,
|
||||
user=participant_user,
|
||||
role='member'
|
||||
)
|
||||
for participant_user in users
|
||||
]
|
||||
if participants_to_create:
|
||||
ChatParticipant.objects.bulk_create(participants_to_create)
|
||||
|
||||
return chat
|
||||
|
||||
|
||||
class ChatDetailSerializer(ChatSerializer):
|
||||
"""Детальный сериализатор чата (с участниками)."""
|
||||
|
||||
participants = ChatParticipantSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(ChatSerializer.Meta):
|
||||
fields = ChatSerializer.Meta.fields + ['participants']
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Celery задачи для chat
|
||||
|
||||
from celery import shared_task
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
URL routing для chat API.
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ChatViewSet, MessageViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'chats', ChatViewSet, basename='chat')
|
||||
router.register(r'messages', MessageViewSet, basename='message')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
Утилиты для работы с файлами чата.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
|
||||
def get_preload_chat_directory(chat_id: int) -> str:
|
||||
"""Получить путь к директории предзагрузки для чата."""
|
||||
return os.path.join('preload_file_chat', str(chat_id))
|
||||
|
||||
|
||||
def get_chat_file_directory(chat_id: int) -> str:
|
||||
"""Получить путь к директории файлов чата."""
|
||||
return os.path.join('file_chat', str(chat_id))
|
||||
|
||||
|
||||
def save_file_to_preload(chat_id: int, file, filename: str) -> str:
|
||||
"""
|
||||
Сохранить файл в директорию предзагрузки.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
file: Файл для сохранения
|
||||
filename: Имя файла
|
||||
|
||||
Returns:
|
||||
Путь к сохраненному файлу относительно MEDIA_ROOT
|
||||
"""
|
||||
preload_dir = get_preload_chat_directory(chat_id)
|
||||
|
||||
# Создаем директорию если не существует
|
||||
full_path = os.path.join(settings.MEDIA_ROOT, preload_dir)
|
||||
os.makedirs(full_path, exist_ok=True)
|
||||
|
||||
# Сохраняем файл
|
||||
file_path = os.path.join(preload_dir, filename)
|
||||
path = default_storage.save(file_path, ContentFile(file.read()))
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def move_file_from_preload_to_chat(chat_id: int, filename: str) -> str:
|
||||
"""
|
||||
Переместить файл из preload в основную директорию чата.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
filename: Имя файла
|
||||
|
||||
Returns:
|
||||
Новый путь к файлу относительно MEDIA_ROOT
|
||||
"""
|
||||
preload_dir = get_preload_chat_directory(chat_id)
|
||||
chat_dir = get_chat_file_directory(chat_id)
|
||||
|
||||
old_path = os.path.join(settings.MEDIA_ROOT, preload_dir, filename)
|
||||
new_dir = os.path.join(settings.MEDIA_ROOT, chat_dir)
|
||||
new_path = os.path.join(new_dir, filename)
|
||||
|
||||
# Создаем директорию если не существует
|
||||
os.makedirs(new_dir, exist_ok=True)
|
||||
|
||||
# Перемещаем файл
|
||||
if os.path.exists(old_path):
|
||||
shutil.move(old_path, new_path)
|
||||
# Возвращаем путь относительно MEDIA_ROOT
|
||||
return os.path.join(chat_dir, filename)
|
||||
|
||||
raise FileNotFoundError(f"Файл {old_path} не найден")
|
||||
|
||||
|
||||
def cleanup_preload_files(chat_id: int, filenames: list = None):
|
||||
"""
|
||||
Очистить файлы из директории предзагрузки.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
filenames: Список имен файлов для удаления (если None - удалить все)
|
||||
"""
|
||||
preload_dir = get_preload_chat_directory(chat_id)
|
||||
full_path = os.path.join(settings.MEDIA_ROOT, preload_dir)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return
|
||||
|
||||
if filenames:
|
||||
# Удаляем только указанные файлы
|
||||
for filename in filenames:
|
||||
file_path = os.path.join(full_path, filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
else:
|
||||
# Удаляем все файлы в директории
|
||||
for filename in os.listdir(full_path):
|
||||
file_path = os.path.join(full_path, filename)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
|
|
@ -0,0 +1,798 @@
|
|||
"""
|
||||
Views для системы чата.
|
||||
"""
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q, Max
|
||||
from django.utils import timezone
|
||||
from .models import Chat, ChatParticipant, Message, MessageFile
|
||||
from .serializers import (
|
||||
ChatSerializer,
|
||||
ChatDetailSerializer,
|
||||
MessageSerializer,
|
||||
MessageCreateSerializer,
|
||||
ChatParticipantSerializer
|
||||
)
|
||||
from .permissions import IsChatParticipant
|
||||
from .utils import (
|
||||
save_file_to_preload,
|
||||
move_file_from_preload_to_chat,
|
||||
cleanup_preload_files
|
||||
)
|
||||
|
||||
|
||||
class ChatListPagination(PageNumberPagination):
|
||||
page_size = 30
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class ChatViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet для чатов."""
|
||||
|
||||
queryset = Chat.objects.all()
|
||||
serializer_class = ChatSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = 'uuid'
|
||||
pagination_class = ChatListPagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Только чаты пользователя со связанными участниками."""
|
||||
archived = self.request.query_params.get('archived', 'false').lower() == 'true'
|
||||
user = self.request.user
|
||||
|
||||
queryset = Chat.objects.filter(
|
||||
participants__user=user,
|
||||
is_archived=archived
|
||||
)
|
||||
|
||||
# Если не суперпользователь, фильтруем по связям
|
||||
if not user.is_superuser:
|
||||
from apps.users.models import User
|
||||
contact_ids = set()
|
||||
if user.role == 'mentor':
|
||||
# Студенты ментора
|
||||
contact_ids.update(User.objects.filter(role='client', client_profile__mentors=user).values_list('id', flat=True))
|
||||
# Родители студентов ментора
|
||||
contact_ids.update(User.objects.filter(role='parent', parent_profile__children__mentors=user).values_list('id', flat=True))
|
||||
elif user.role == 'client':
|
||||
# Менторы студента
|
||||
contact_ids.update(User.objects.filter(role='mentor', clients__user=user).values_list('id', flat=True))
|
||||
# Родители студента
|
||||
contact_ids.update(User.objects.filter(role='parent', parent_profile__children__user=user).values_list('id', flat=True))
|
||||
elif user.role == 'parent':
|
||||
# Менторы детей родителя
|
||||
contact_ids.update(User.objects.filter(role='mentor', clients__parents__user=user).values_list('id', flat=True))
|
||||
# Дети родителя
|
||||
contact_ids.update(User.objects.filter(role='client', client_profile__parents__user=user).values_list('id', flat=True))
|
||||
|
||||
# Фильтруем чаты: либо групповой, либо личный со связанным пользователем
|
||||
# Для личных чатов проверяем, что второй участник (не текущий юзер) есть в списке контактов
|
||||
queryset = queryset.filter(
|
||||
Q(chat_type='group') |
|
||||
Q(chat_type='direct', participants__user_id__in=contact_ids)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
last_msg_time=Max('messages__created_at')
|
||||
).select_related('created_by')
|
||||
|
||||
# Для личных чатов нужно предзагрузить участников с их данными
|
||||
from django.db.models import Prefetch
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
'participants',
|
||||
queryset=ChatParticipant.objects.select_related('user')
|
||||
)
|
||||
)
|
||||
|
||||
# Оптимизация: для списка prefetch только последнее сообщение вместо всех
|
||||
if self.action == 'list':
|
||||
from django.db.models import Prefetch
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
'messages',
|
||||
queryset=Message.objects.filter(is_deleted=False).select_related('sender').prefetch_related('files').order_by('-created_at')[:1],
|
||||
to_attr='last_message_prefetch'
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Для детального просмотра загружаем все сообщения
|
||||
queryset = queryset.prefetch_related('messages')
|
||||
|
||||
queryset = queryset.order_by('-last_msg_time', '-created_at').distinct()
|
||||
|
||||
# Оптимизация: для списка используем only() для ограничения полей
|
||||
if self.action == 'list':
|
||||
queryset = queryset.only(
|
||||
'id', 'uuid', 'chat_type', 'name', 'description', 'avatar',
|
||||
'created_by_id', 'lesson_id', 'messages_count', 'last_message_at',
|
||||
'is_archived', 'created_at'
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Выбор сериализатора."""
|
||||
if self.action == 'retrieve':
|
||||
return ChatDetailSerializer
|
||||
return ChatSerializer
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def messages(self, request, uuid=None):
|
||||
"""
|
||||
Получить сообщения чата.
|
||||
|
||||
GET /api/chat/chats/{uuid}/messages/
|
||||
"""
|
||||
chat = self.get_object()
|
||||
messages = Message.objects.filter(
|
||||
chat=chat,
|
||||
is_deleted=False
|
||||
).select_related('sender', 'reply_to', 'reply_to__sender').prefetch_related(
|
||||
'files',
|
||||
'reactions__user',
|
||||
'reads' # Предзагружаем reads для get_is_read
|
||||
)
|
||||
|
||||
# Сортируем по убыванию даты (последние сообщения сначала)
|
||||
# НЕ используем only() так как нужны все поля для сериализации (sender, files, reactions)
|
||||
# only() может вызвать дополнительные запросы при доступе к связанным полям
|
||||
messages = messages.order_by('-created_at')
|
||||
|
||||
# Используем пагинацию для сообщений
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
paginator = PageNumberPagination()
|
||||
paginator.page_size = 30 # Размер страницы по умолчанию
|
||||
|
||||
# Получаем параметры пагинации из запроса
|
||||
page_size = request.query_params.get('page_size', '30')
|
||||
try:
|
||||
page_size = int(page_size)
|
||||
if page_size > 0 and page_size <= 100: # Ограничиваем максимум 100
|
||||
paginator.page_size = page_size
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Применяем пагинацию
|
||||
page = paginator.paginate_queryset(messages, request)
|
||||
if page is not None:
|
||||
serializer = MessageSerializer(page, many=True, context={'request': request})
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
# Если пагинация не применилась (не должно быть), ограничиваем вручную
|
||||
limited_messages = messages[:30]
|
||||
serializer = MessageSerializer(limited_messages, many=True, context={'request': request})
|
||||
return Response({
|
||||
'count': messages.count(),
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'results': serializer.data
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def create_direct(self, request):
|
||||
"""
|
||||
Создать личный чат с пользователем.
|
||||
|
||||
Проверяет наличие связи между пользователями.
|
||||
"""
|
||||
other_user_id = request.data.get('user_id')
|
||||
|
||||
if not other_user_id:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'user_id обязателен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Проверяем связь
|
||||
from apps.users.models import User, Client, Parent
|
||||
try:
|
||||
other_user = User.objects.get(id=other_user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Пользователь не найден'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Логика проверки связей
|
||||
has_connection = False
|
||||
user = request.user
|
||||
|
||||
if user.role == 'mentor':
|
||||
# Ментор <-> Студент
|
||||
if other_user.role == 'client' and Client.objects.filter(user=other_user, mentors=user).exists():
|
||||
has_connection = True
|
||||
# Ментор <-> Родитель студента
|
||||
elif other_user.role == 'parent' and Parent.objects.filter(user=other_user, children__mentors=user).exists():
|
||||
has_connection = True
|
||||
|
||||
elif user.role == 'client':
|
||||
# Студент <-> Ментор
|
||||
if other_user.role == 'mentor' and Client.objects.filter(user=user, mentors=other_user).exists():
|
||||
has_connection = True
|
||||
# Студент <-> Родитель
|
||||
elif other_user.role == 'parent' and Parent.objects.filter(user=other_user, children__user=user).exists():
|
||||
has_connection = True
|
||||
|
||||
elif user.role == 'parent':
|
||||
# Родитель <-> Ментор
|
||||
if other_user.role == 'mentor' and User.objects.filter(id=other_user.id, role='mentor', clients__parents__user=user).exists():
|
||||
has_connection = True
|
||||
# Родитель <-> Ребенок
|
||||
elif other_user.role == 'client' and Client.objects.filter(user=other_user, parents__user=user).exists():
|
||||
has_connection = True
|
||||
|
||||
if not has_connection and not user.is_superuser:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Вы можете создавать чаты только со связанными пользователями'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Проверяем существует ли уже чат
|
||||
existing_chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=request.user
|
||||
).filter(
|
||||
participants__user_id=other_user_id
|
||||
).first()
|
||||
|
||||
if existing_chat:
|
||||
serializer = ChatDetailSerializer(existing_chat)
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data,
|
||||
'message': 'Чат уже существует'
|
||||
})
|
||||
|
||||
# Создаем новый чат
|
||||
chat = Chat.objects.create(
|
||||
chat_type='direct',
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
# Добавляем участников
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=request.user,
|
||||
role='admin'
|
||||
)
|
||||
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=other_user,
|
||||
role='member'
|
||||
)
|
||||
|
||||
serializer = ChatDetailSerializer(chat)
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mark_read(self, request, uuid=None):
|
||||
"""
|
||||
Отметить сообщения как прочитанные.
|
||||
|
||||
POST /api/chat/chats/{uuid}/mark_read/
|
||||
Body: {
|
||||
"message_uuids": ["uuid1", "uuid2", ...] # опционально, если не указано - все сообщения
|
||||
}
|
||||
"""
|
||||
chat = self.get_object()
|
||||
|
||||
try:
|
||||
participant = chat.participants.get(user=request.user)
|
||||
except ChatParticipant.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Вы не участник этого чата'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Если указаны конкретные UUID сообщений
|
||||
message_uuids = request.data.get('message_uuids', [])
|
||||
if message_uuids:
|
||||
from .models import MessageRead
|
||||
messages = Message.objects.filter(
|
||||
chat=chat,
|
||||
uuid__in=message_uuids,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
# Оптимизация: получаем существующие записи о прочтении одним запросом
|
||||
existing_reads = set(
|
||||
MessageRead.objects.filter(
|
||||
message__in=messages,
|
||||
user=request.user
|
||||
).values_list('message_id', flat=True)
|
||||
)
|
||||
|
||||
# Создаем только новые записи через bulk_create
|
||||
messages_list = list(messages)
|
||||
new_reads = [
|
||||
MessageRead(message=message, user=request.user)
|
||||
for message in messages_list
|
||||
if message.id not in existing_reads
|
||||
]
|
||||
|
||||
if new_reads:
|
||||
MessageRead.objects.bulk_create(new_reads, ignore_conflicts=True)
|
||||
|
||||
# Собираем UUID новых прочитанных сообщений
|
||||
read_message_uuids = [
|
||||
str(message.uuid) for message in messages_list
|
||||
if message.id not in existing_reads
|
||||
]
|
||||
|
||||
# Отправляем уведомление через WebSocket о прочтении сообщений
|
||||
if read_message_uuids:
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
room_group_name = f'chat_{chat.uuid}'
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
room_group_name,
|
||||
{
|
||||
'type': 'message_read',
|
||||
'user_id': request.user.id,
|
||||
'message_uuids': read_message_uuids
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error sending message_read via WebSocket: {e}", exc_info=True)
|
||||
|
||||
# Уведомляем по WS об обновлении бейджей нижнего меню
|
||||
try:
|
||||
from apps.notifications.services import WebSocketNotificationService
|
||||
WebSocketNotificationService.send_nav_badges_updated(request.user.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Пересчитываем непрочитанные
|
||||
unread_count = Message.objects.filter(
|
||||
chat=chat,
|
||||
is_deleted=False
|
||||
).exclude(
|
||||
reads__user=request.user
|
||||
).exclude(
|
||||
sender=request.user
|
||||
).count()
|
||||
|
||||
participant.unread_count = unread_count
|
||||
participant.save(update_fields=['unread_count'])
|
||||
else:
|
||||
# Отмечаем все сообщения как прочитанные
|
||||
from .models import MessageRead
|
||||
messages = Message.objects.filter(
|
||||
chat=chat,
|
||||
is_deleted=False
|
||||
).exclude(sender=request.user)
|
||||
|
||||
# Оптимизация: получаем существующие записи о прочтении одним запросом
|
||||
messages_list = list(messages)
|
||||
existing_reads = set(
|
||||
MessageRead.objects.filter(
|
||||
message__in=messages_list,
|
||||
user=request.user
|
||||
).values_list('message_id', flat=True)
|
||||
)
|
||||
|
||||
# Создаем только новые записи через bulk_create
|
||||
new_reads = [
|
||||
MessageRead(message=message, user=request.user)
|
||||
for message in messages_list
|
||||
if message.id not in existing_reads
|
||||
]
|
||||
|
||||
if new_reads:
|
||||
MessageRead.objects.bulk_create(new_reads, ignore_conflicts=True)
|
||||
|
||||
# Обновляем счетчик и время последнего прочтения
|
||||
participant.unread_count = 0
|
||||
participant.last_read_at = timezone.now()
|
||||
participant.save(update_fields=['unread_count', 'last_read_at'])
|
||||
|
||||
# Уведомляем по WS об обновлении бейджей нижнего меню
|
||||
try:
|
||||
from apps.notifications.services import WebSocketNotificationService
|
||||
WebSocketNotificationService.send_nav_badges_updated(request.user.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Сообщения отмечены как прочитанные'
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get', 'post'])
|
||||
def lesson_chat(self, request):
|
||||
"""
|
||||
Получить или создать личный чат между участниками урока.
|
||||
|
||||
Ищет существующий личный чат между ментором и клиентом урока.
|
||||
Если чата нет, создает новый личный чат.
|
||||
|
||||
GET /api/chat/chats/lesson_chat/?lesson_id=123
|
||||
POST /api/chat/chats/lesson_chat/
|
||||
Body: {
|
||||
"lesson_id": 123
|
||||
}
|
||||
"""
|
||||
lesson_id = request.query_params.get('lesson_id') or request.data.get('lesson_id')
|
||||
|
||||
if not lesson_id:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'lesson_id обязателен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
from apps.schedule.models import Lesson
|
||||
lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id)
|
||||
except Lesson.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Урок не найден'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Определяем участников чата: ментор и клиент
|
||||
mentor = lesson.mentor
|
||||
client_user = lesson.client.user if lesson.client and lesson.client.user else None
|
||||
|
||||
if not mentor or not client_user:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'У урока должны быть указаны ментор и клиент'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Ищем существующий личный чат между ментором и клиентом
|
||||
existing_chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=mentor
|
||||
).filter(
|
||||
participants__user=client_user
|
||||
).distinct().first()
|
||||
|
||||
if existing_chat:
|
||||
# Проверяем, является ли текущий пользователь участником
|
||||
participant = existing_chat.participants.filter(user=request.user).first()
|
||||
if not participant:
|
||||
# Если текущий пользователь не участник, но это ментор или клиент урока - добавляем
|
||||
if request.user == mentor or request.user == client_user:
|
||||
ChatParticipant.objects.get_or_create(
|
||||
chat=existing_chat,
|
||||
user=request.user,
|
||||
defaults={'role': 'member'}
|
||||
)
|
||||
|
||||
serializer = ChatDetailSerializer(existing_chat, context={'request': request})
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
})
|
||||
|
||||
# Если чата нет, создаем новый личный чат между ментором и клиентом
|
||||
# Используем ту же логику, что и в create_direct
|
||||
chat = Chat.objects.create(
|
||||
chat_type='direct',
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
# Добавляем участников
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=mentor,
|
||||
role='admin'
|
||||
)
|
||||
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=client_user,
|
||||
role='member'
|
||||
)
|
||||
|
||||
# Если текущий пользователь не ментор и не клиент, добавляем его тоже
|
||||
if request.user != mentor and request.user != client_user:
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=request.user,
|
||||
role='member'
|
||||
)
|
||||
|
||||
serializer = ChatDetailSerializer(chat, context={'request': request})
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mark_as_read(self, request, uuid=None):
|
||||
"""
|
||||
Отметить все сообщения как прочитанные (алиас для mark_read).
|
||||
|
||||
POST /api/chat/chats/{uuid}/mark_as_read/
|
||||
"""
|
||||
return self.mark_read(request, uuid)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def archive(self, request, uuid=None):
|
||||
"""
|
||||
Архивировать чат.
|
||||
|
||||
POST /api/chat/chats/{uuid}/archive/
|
||||
"""
|
||||
chat = self.get_object()
|
||||
chat.is_archived = True
|
||||
chat.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Чат архивирован'
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def unarchive(self, request, uuid=None):
|
||||
"""
|
||||
Разархивировать чат.
|
||||
|
||||
POST /api/chat/chats/{uuid}/unarchive/
|
||||
"""
|
||||
chat = self.get_object()
|
||||
chat.is_archived = False
|
||||
chat.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Чат разархивирован'
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def upload_files(self, request, uuid=None):
|
||||
"""
|
||||
Предзагрузка файлов в директорию preload_file_chat{id чата}.
|
||||
|
||||
POST /api/chat/chats/{uuid}/upload_files/
|
||||
Body: FormData с файлами
|
||||
"""
|
||||
chat = self.get_object()
|
||||
|
||||
# Проверяем доступ
|
||||
if not ChatParticipant.objects.filter(chat=chat, user=request.user).exists():
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'У вас нет доступа к этому чату'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
uploaded_files = []
|
||||
errors = []
|
||||
|
||||
# Обрабатываем все переданные файлы
|
||||
files = request.FILES.getlist('files')
|
||||
if not files:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Файлы не переданы'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
import uuid as uuid_lib
|
||||
import os
|
||||
# Генерируем уникальное имя файла
|
||||
ext = os.path.splitext(file.name)[1]
|
||||
unique_filename = f"{uuid_lib.uuid4()}{ext}"
|
||||
|
||||
# Сохраняем в preload директорию
|
||||
file_path = save_file_to_preload(chat.id, file, unique_filename)
|
||||
|
||||
uploaded_files.append({
|
||||
'original_name': file.name,
|
||||
'filename': unique_filename,
|
||||
'file_path': file_path,
|
||||
'size': file.size,
|
||||
'content_type': file.content_type
|
||||
})
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
'filename': file.name,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'files': uploaded_files,
|
||||
'errors': errors if errors else None
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class MessageViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet для сообщений."""
|
||||
|
||||
queryset = Message.objects.all()
|
||||
serializer_class = MessageSerializer
|
||||
permission_classes = [IsAuthenticated, IsChatParticipant]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Сообщения чата."""
|
||||
chat_uuid = self.request.query_params.get('chat')
|
||||
|
||||
if chat_uuid:
|
||||
queryset = Message.objects.filter(
|
||||
chat__uuid=chat_uuid,
|
||||
is_deleted=False
|
||||
).select_related('sender', 'chat', 'reply_to', 'reply_to__sender').prefetch_related(
|
||||
'files',
|
||||
'reactions__user',
|
||||
'reads' # Предзагружаем reads для get_is_read
|
||||
)
|
||||
|
||||
# НЕ используем only() так как нужны все поля для сериализации (sender, files, reactions)
|
||||
# only() может вызвать дополнительные запросы при доступе к связанным полям
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
return Message.objects.none()
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Выбор сериализатора."""
|
||||
if self.action == 'create':
|
||||
return MessageCreateSerializer
|
||||
return MessageSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создание сообщения."""
|
||||
# Создаем копию данных
|
||||
data = request.data.copy()
|
||||
|
||||
# Обрабатываем preloaded_files из FormData (QueryDict может вернуть список)
|
||||
if 'preloaded_files' in data:
|
||||
preloaded_files = data.get('preloaded_files')
|
||||
# Если это список (из QueryDict), берем первый элемент (строку JSON)
|
||||
if isinstance(preloaded_files, list) and len(preloaded_files) > 0:
|
||||
data['preloaded_files'] = preloaded_files[0]
|
||||
# Если это уже строка, оставляем как есть
|
||||
elif not isinstance(preloaded_files, str):
|
||||
data['preloaded_files'] = ''
|
||||
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Создаем сообщение (sender уже устанавливается в сериализаторе)
|
||||
message = serializer.save()
|
||||
|
||||
# Отправляем уведомления
|
||||
from apps.notifications.services import NotificationService
|
||||
NotificationService.send_message_notification(message)
|
||||
|
||||
# Отправляем сообщение через WebSocket
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
from .serializers import MessageSerializer
|
||||
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
# Сериализуем сообщение для отправки через WebSocket
|
||||
message_serializer = MessageSerializer(message, context={'request': request})
|
||||
message_data = message_serializer.data
|
||||
|
||||
# Отправляем в группу чата
|
||||
room_group_name = f'chat_{message.chat.uuid}'
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
room_group_name,
|
||||
{
|
||||
'type': 'chat_message',
|
||||
'message': message_data
|
||||
}
|
||||
)
|
||||
# Уведомляем других участников чата об обновлении бейджей
|
||||
from apps.notifications.services import WebSocketNotificationService
|
||||
from .models import ChatParticipant
|
||||
for p in ChatParticipant.objects.filter(chat=message.chat).exclude(user=request.user).select_related('user'):
|
||||
WebSocketNotificationService.send_nav_badges_updated(p.user.id)
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не прерываем создание сообщения
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error sending message via WebSocket: {e}", exc_info=True)
|
||||
|
||||
# Возвращаем полные данные сообщения
|
||||
output_serializer = MessageSerializer(message, context={'request': request})
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': output_serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Редактирование сообщения."""
|
||||
message = self.get_object()
|
||||
|
||||
# Проверяем что пользователь - отправитель
|
||||
if message.sender != request.user:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Вы можете редактировать только свои сообщения'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
serializer = self.get_serializer(message, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
# Отмечаем как отредактированное
|
||||
message.mark_as_edited()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
})
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Удаление сообщения."""
|
||||
message = self.get_object()
|
||||
|
||||
# Проверяем что пользователь - отправитель
|
||||
if message.sender != request.user:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Вы можете удалять только свои сообщения'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Мягкое удаление
|
||||
message.soft_delete()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Сообщение удалено'
|
||||
}, status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def react(self, request, uuid=None):
|
||||
"""
|
||||
Добавить реакцию на сообщение.
|
||||
|
||||
POST /api/chat/messages/{uuid}/react/
|
||||
Body: {
|
||||
"emoji": "👍"
|
||||
}
|
||||
"""
|
||||
message = self.get_object()
|
||||
emoji = request.data.get('emoji')
|
||||
|
||||
if not emoji:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'emoji обязателен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from .models import MessageReaction
|
||||
|
||||
# Создаем или удаляем реакцию
|
||||
reaction, created = MessageReaction.objects.get_or_create(
|
||||
message=message,
|
||||
user=request.user,
|
||||
emoji=emoji
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Если уже есть такая реакция, удаляем
|
||||
reaction.delete()
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Реакция удалена'
|
||||
})
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Реакция добавлена'
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
Core приложение для системных операций (бэкапы, очистка и т.д.)
|
||||
"""
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"""
|
||||
Конфигурация приложения Core.
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.core'
|
||||
verbose_name = 'Core'
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"""
|
||||
Django management команда для анализа медленных SQL запросов.
|
||||
Использование: python manage.py analyze_slow_queries [--limit=10]
|
||||
"""
|
||||
import time
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection, reset_queries
|
||||
from django.test.utils import override_settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Анализирует медленные SQL запросы в приложении'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--limit',
|
||||
type=int,
|
||||
default=10,
|
||||
help='Количество самых медленных запросов для показа (по умолчанию: 10)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--min-time',
|
||||
type=float,
|
||||
default=0.01,
|
||||
help='Минимальное время запроса в секундах для отображения (по умолчанию: 0.01)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
limit = options['limit']
|
||||
min_time = options['min_time']
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write(self.style.SUCCESS('🔍 Анализ медленных SQL запросов'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'Минимальное время для отображения: {min_time}с')
|
||||
self.stdout.write(f'Лимит запросов: {limit}')
|
||||
self.stdout.write('')
|
||||
|
||||
# Включаем логирование запросов
|
||||
with override_settings(DEBUG=True):
|
||||
from django.db import connection
|
||||
|
||||
# Включаем отслеживание запросов
|
||||
connection.queries_log.clear()
|
||||
|
||||
self.stdout.write('📝 Включено логирование SQL запросов.')
|
||||
self.stdout.write(' Выполните тесты или запросы к API, затем проверьте результаты.')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('💡 Для использования:')
|
||||
self.stdout.write(' 1. Запустите сервер: python manage.py runserver')
|
||||
self.stdout.write(' 2. Выполните запросы к API через браузер или curl')
|
||||
self.stdout.write(' 3. Проверьте логи Django или используйте django-debug-toolbar')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('📊 Альтернативные способы анализа:')
|
||||
self.stdout.write(' - Django Debug Toolbar: откройте страницу в браузере')
|
||||
self.stdout.write(' - Django Silk: откройте http://localhost:8000/silk/')
|
||||
self.stdout.write(' - Pytest с логированием: pytest --log-sql')
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"""
|
||||
Django management команда для создания бэкапа базы данных.
|
||||
Использование: python manage.py backup_database
|
||||
"""
|
||||
|
||||
import os
|
||||
import gzip
|
||||
from datetime import datetime
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Создает резервную копию базы данных PostgreSQL'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--output-dir',
|
||||
type=str,
|
||||
default=os.path.join(settings.BASE_DIR.parent, 'backups', 'database'),
|
||||
help='Директория для сохранения бэкапа',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--retention-days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Количество дней для хранения бэкапов (по умолчанию 30)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--compress',
|
||||
action='store_true',
|
||||
help='Сжимать бэкап с помощью gzip',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
output_dir = options['output_dir']
|
||||
retention_days = options['retention_days']
|
||||
compress = options['compress']
|
||||
|
||||
# Создаем директорию для бэкапов
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Получаем параметры подключения к БД
|
||||
db_config = settings.DATABASES['default']
|
||||
db_name = db_config['NAME']
|
||||
db_user = db_config['USER']
|
||||
db_host = db_config.get('HOST', 'localhost')
|
||||
db_port = db_config.get('PORT', '5432')
|
||||
|
||||
# Формируем имя файла бэкапа
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'db_backup_{timestamp}.sql'
|
||||
if compress:
|
||||
backup_filename += '.gz'
|
||||
|
||||
backup_path = os.path.join(output_dir, backup_filename)
|
||||
|
||||
self.stdout.write(f'Создание бэкапа базы данных {db_name}...')
|
||||
|
||||
try:
|
||||
# Используем pg_dump через subprocess
|
||||
import subprocess
|
||||
|
||||
# Формируем команду pg_dump
|
||||
pg_dump_cmd = [
|
||||
'pg_dump',
|
||||
'-h', db_host,
|
||||
'-p', str(db_port),
|
||||
'-U', db_user,
|
||||
'-d', db_name,
|
||||
'--no-password', # Используем .pgpass или переменные окружения
|
||||
'--format=plain',
|
||||
'--verbose',
|
||||
]
|
||||
|
||||
# Устанавливаем пароль через переменную окружения
|
||||
env = os.environ.copy()
|
||||
if 'PASSWORD' in db_config:
|
||||
env['PGPASSWORD'] = db_config['PASSWORD']
|
||||
|
||||
# Выполняем pg_dump
|
||||
process = subprocess.Popen(
|
||||
pg_dump_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode('utf-8')
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Ошибка создания бэкапа: {error_msg}')
|
||||
)
|
||||
return
|
||||
|
||||
# Сохраняем бэкап
|
||||
if compress:
|
||||
with gzip.open(backup_path, 'wb') as f:
|
||||
f.write(stdout)
|
||||
else:
|
||||
with open(backup_path, 'wb') as f:
|
||||
f.write(stdout)
|
||||
|
||||
# Получаем размер файла
|
||||
file_size = os.path.getsize(backup_path)
|
||||
file_size_mb = file_size / (1024 * 1024)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'✓ Бэкап успешно создан: {backup_path} ({file_size_mb:.2f} MB)'
|
||||
)
|
||||
)
|
||||
|
||||
# Удаляем старые бэкапы
|
||||
self.cleanup_old_backups(output_dir, retention_days)
|
||||
|
||||
except FileNotFoundError:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
'pg_dump не найден. Убедитесь, что PostgreSQL клиент установлен.'
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Ошибка при создании бэкапа: {str(e)}')
|
||||
)
|
||||
logger.error(f'Backup error: {e}', exc_info=True)
|
||||
|
||||
def cleanup_old_backups(self, backup_dir, retention_days):
|
||||
"""Удаляет старые бэкапы."""
|
||||
from datetime import timedelta
|
||||
import time
|
||||
|
||||
cutoff_time = time.time() - (retention_days * 24 * 60 * 60)
|
||||
|
||||
deleted_count = 0
|
||||
for filename in os.listdir(backup_dir):
|
||||
if filename.startswith('db_backup_') and (filename.endswith('.sql') or filename.endswith('.sql.gz')):
|
||||
file_path = os.path.join(backup_dir, filename)
|
||||
if os.path.getmtime(file_path) < cutoff_time:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
self.stdout.write(f'Удален старый бэкап: {filename}')
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Не удалось удалить {filename}: {e}')
|
||||
)
|
||||
|
||||
if deleted_count > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Удалено старых бэкапов: {deleted_count}')
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Django management команда для бенчмарка API endpoints.
|
||||
Измеряет время ответа и количество SQL запросов для указанных endpoints.
|
||||
"""
|
||||
import time
|
||||
import json
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection, reset_queries
|
||||
from django.test import Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Бенчмарк API endpoints - измеряет время и SQL запросы'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--endpoint',
|
||||
type=str,
|
||||
help='URL endpoint для тестирования (например: /api/homework/homeworks/)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--email',
|
||||
type=str,
|
||||
default='mentor@test.com',
|
||||
help='Email пользователя для аутентификации (по умолчанию: mentor@test.com)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--iterations',
|
||||
type=int,
|
||||
default=5,
|
||||
help='Количество итераций теста (по умолчанию: 5)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
endpoint = options.get('endpoint')
|
||||
email = options['email']
|
||||
iterations = options['iterations']
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write(self.style.SUCCESS('⚡ Бенчмарк API Endpoints'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write('')
|
||||
|
||||
# Получаем пользователя или создаем тестового
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'Пользователь {email} не найден. Создаю тестового пользователя...'
|
||||
))
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
password='TestPass123!',
|
||||
first_name='Тест',
|
||||
last_name='Пользователь',
|
||||
role='mentor',
|
||||
is_email_verified=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Создаем клиент и аутентифицируем
|
||||
client = Client()
|
||||
refresh = RefreshToken.for_user(user)
|
||||
client.defaults['HTTP_AUTHORIZATION'] = f'Bearer {refresh.access_token}'
|
||||
|
||||
# Список endpoints для тестирования, если не указан конкретный
|
||||
endpoints_to_test = [
|
||||
endpoint,
|
||||
] if endpoint else [
|
||||
'/api/homework/homeworks/',
|
||||
'/api/homework/homeworks/created_by_me/',
|
||||
'/api/chat/chats/',
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for endpoint_url in endpoints_to_test:
|
||||
if not endpoint_url:
|
||||
continue
|
||||
|
||||
self.stdout.write(f'📊 Тестирую: {endpoint_url}')
|
||||
self.stdout.write('-' * 70)
|
||||
|
||||
times = []
|
||||
query_counts = []
|
||||
status_codes = []
|
||||
|
||||
for i in range(iterations):
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = client.get(endpoint_url)
|
||||
|
||||
end_time = time.time()
|
||||
elapsed = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
times.append(elapsed)
|
||||
query_counts.append(query_count)
|
||||
status_codes.append(response.status_code)
|
||||
|
||||
avg_time = sum(times) / len(times)
|
||||
avg_queries = sum(query_counts) / len(query_counts)
|
||||
min_time = min(times)
|
||||
max_time = max(times)
|
||||
min_queries = min(query_counts)
|
||||
max_queries = max(query_counts)
|
||||
|
||||
results.append({
|
||||
'endpoint': endpoint_url,
|
||||
'iterations': iterations,
|
||||
'avg_time': avg_time,
|
||||
'min_time': min_time,
|
||||
'max_time': max_time,
|
||||
'avg_queries': avg_queries,
|
||||
'min_queries': min_queries,
|
||||
'max_queries': max_queries,
|
||||
'status_code': status_codes[0],
|
||||
})
|
||||
|
||||
self.stdout.write(f' ✅ Статус: {status_codes[0]}')
|
||||
self.stdout.write(f' ⏱️ Среднее время: {avg_time:.3f}с')
|
||||
self.stdout.write(f' ⏱️ Минимальное время: {min_time:.3f}с')
|
||||
self.stdout.write(f' ⏱️ Максимальное время: {max_time:.3f}с')
|
||||
self.stdout.write(f' 📊 Среднее SQL запросов: {avg_queries:.1f}')
|
||||
self.stdout.write(f' 📊 Минимум SQL запросов: {min_queries}')
|
||||
self.stdout.write(f' 📊 Максимум SQL запросов: {max_queries}')
|
||||
|
||||
# Оценка производительности
|
||||
if avg_time > 0.5:
|
||||
self.stdout.write(self.style.ERROR(' ⚠️ МЕДЛЕННО: Время ответа > 0.5с'))
|
||||
elif avg_time > 0.3:
|
||||
self.stdout.write(self.style.WARNING(' ⚠️ Приемлемо: Время ответа 0.3-0.5с'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(' ✅ Быстро: Время ответа < 0.3с'))
|
||||
|
||||
if avg_queries > 20:
|
||||
self.stdout.write(self.style.ERROR(' ⚠️ МНОГО SQL: Возможны N+1 проблемы'))
|
||||
elif avg_queries > 10:
|
||||
self.stdout.write(self.style.WARNING(' ⚠️ Много SQL запросов'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(' ✅ Оптимально: SQL запросов < 10'))
|
||||
|
||||
self.stdout.write('')
|
||||
|
||||
# Показываем самые медленные запросы
|
||||
if connection.queries:
|
||||
self.stdout.write(' 🐌 Самые медленные SQL запросы:')
|
||||
query_times = []
|
||||
for query in connection.queries:
|
||||
query_time = float(query.get('time', 0))
|
||||
if query_time > min_time:
|
||||
query_times.append((query_time, query['sql'][:100]))
|
||||
|
||||
query_times.sort(reverse=True)
|
||||
for query_time, sql in query_times[:5]:
|
||||
self.stdout.write(f' {query_time:.3f}с: {sql}...')
|
||||
self.stdout.write('')
|
||||
|
||||
# Итоговая таблица
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write(self.style.SUCCESS('📋 Итоговая таблица результатов'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write(f'{"Endpoint":<40} {"Время (с)":<12} {"SQL":<8} {"Статус":<8}')
|
||||
self.stdout.write('-' * 70)
|
||||
for result in results:
|
||||
status_icon = '✅' if result['status_code'] == 200 else '❌'
|
||||
self.stdout.write(
|
||||
f"{result['endpoint']:<40} "
|
||||
f"{result['avg_time']:.3f} (<{result['min_time']:.3f}-{result['max_time']:.3f}) "
|
||||
f"{result['avg_queries']:.1f} (<{result['min_queries']}-{result['max_queries']}) "
|
||||
f"{status_icon} {result['status_code']}"
|
||||
)
|
||||
|
||||
self.stdout.write('')
|
||||
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""
|
||||
Management команда для очистки кэша Django.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Очищает весь кэш Django'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--pattern',
|
||||
type=str,
|
||||
help='Паттерн для очистки конкретных ключей (например, "mentor_dashboard_*")',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
pattern = options.get('pattern')
|
||||
|
||||
if pattern:
|
||||
# Очистка по паттерну (только для Redis)
|
||||
if hasattr(cache, 'delete_pattern'):
|
||||
deleted = cache.delete_pattern(pattern)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Удалено ключей по паттерну "{pattern}": {deleted}'
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
'Очистка по паттерну доступна только для Redis. '
|
||||
'Используется полная очистка кэша.'
|
||||
)
|
||||
)
|
||||
cache.clear()
|
||||
self.stdout.write(self.style.SUCCESS('Кэш полностью очищен'))
|
||||
else:
|
||||
# Полная очистка кэша
|
||||
cache.clear()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('✓ Кэш Django успешно очищен')
|
||||
)
|
||||
|
||||
# Показываем информацию о бэкенде кэша
|
||||
cache_backend = settings.CACHES['default']['BACKEND']
|
||||
self.stdout.write(f'Бэкенд кэша: {cache_backend}')
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
"""
|
||||
Django management команда для поиска мест в коде, где делаются запросы к БД.
|
||||
Находит:
|
||||
1. Queryset'ы в views.py
|
||||
2. SerializerMethodField с запросами к БД
|
||||
3. Модели с запросами в методах/properties
|
||||
4. Services с запросами
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class DatabaseQueryFinder(ast.NodeVisitor):
|
||||
"""AST visitor для поиска запросов к БД."""
|
||||
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
self.queries_found = []
|
||||
self.current_class = None
|
||||
self.current_method = None
|
||||
|
||||
def visit_ClassDef(self, node):
|
||||
"""Посещает классы."""
|
||||
old_class = self.current_class
|
||||
self.current_class = node.name
|
||||
self.generic_visit(node)
|
||||
self.current_class = old_class
|
||||
|
||||
def visit_FunctionDef(self, node):
|
||||
"""Посещает функции/методы."""
|
||||
old_method = self.current_method
|
||||
self.current_method = node.name
|
||||
self.generic_visit(node)
|
||||
self.current_method = old_method
|
||||
|
||||
def visit_Call(self, node):
|
||||
"""Посещает вызовы функций."""
|
||||
# Ищем queryset методы
|
||||
queryset_methods = [
|
||||
'filter', 'get', 'create', 'update', 'delete',
|
||||
'exclude', 'annotate', 'aggregate', 'count',
|
||||
'exists', 'values', 'values_list', 'select_related',
|
||||
'prefetch_related', 'only', 'defer', 'first', 'last',
|
||||
'all', 'order_by', 'distinct'
|
||||
]
|
||||
|
||||
# Проверяем вызовы типа obj.objects.filter(), obj.filter()
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
attr_name = node.func.attr
|
||||
if attr_name in queryset_methods:
|
||||
# Определяем контекст
|
||||
context = self._get_context()
|
||||
|
||||
self.queries_found.append({
|
||||
'line': node.lineno,
|
||||
'method': attr_name,
|
||||
'context': context,
|
||||
'code': self._get_code_snippet(node),
|
||||
})
|
||||
|
||||
# Ищем вызовы моделей напрямую (например, User.objects.get())
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
if node.func.attr == 'objects':
|
||||
context = self._get_context()
|
||||
self.queries_found.append({
|
||||
'line': node.lineno,
|
||||
'method': 'objects',
|
||||
'context': context,
|
||||
'code': self._get_code_snippet(node),
|
||||
})
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def _get_context(self):
|
||||
"""Получает контекст (класс.метод)."""
|
||||
if self.current_class and self.current_method:
|
||||
return f"{self.current_class}.{self.current_method}"
|
||||
elif self.current_method:
|
||||
return self.current_method
|
||||
elif self.current_class:
|
||||
return f"{self.current_class}."
|
||||
return "global"
|
||||
|
||||
def _get_code_snippet(self, node, max_length=100):
|
||||
"""Получает фрагмент кода (упрощенная версия)."""
|
||||
# В реальной версии нужно было бы использовать tokenize или другой метод
|
||||
return f"Line {node.lineno}"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Находит места в коде где делаются запросы к БД'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--app',
|
||||
type=str,
|
||||
help='Имя приложения для анализа (например: homework, chat)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--file',
|
||||
type=str,
|
||||
help='Конкретный файл для анализа',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--serializers',
|
||||
action='store_true',
|
||||
help='Проверить только serializers',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--views',
|
||||
action='store_true',
|
||||
help='Проверить только views',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
app_name = options.get('app')
|
||||
file_path = options.get('file')
|
||||
check_serializers = options.get('serializers')
|
||||
check_views = options.get('views')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write(self.style.SUCCESS('🔍 Поиск запросов к БД в коде'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write('')
|
||||
|
||||
base_dir = Path(__file__).resolve().parent.parent.parent.parent
|
||||
apps_dir = base_dir / 'apps'
|
||||
|
||||
files_to_check = []
|
||||
|
||||
if file_path:
|
||||
# Конкретный файл
|
||||
file_path = Path(file_path)
|
||||
if file_path.exists():
|
||||
files_to_check.append(file_path)
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f'Файл не найден: {file_path}'))
|
||||
return
|
||||
elif app_name:
|
||||
# Все файлы приложения
|
||||
app_dir = apps_dir / app_name
|
||||
if not app_dir.exists():
|
||||
self.stdout.write(self.style.ERROR(f'Приложение не найдено: {app_name}'))
|
||||
return
|
||||
|
||||
if check_serializers:
|
||||
files_to_check.append(app_dir / 'serializers.py')
|
||||
elif check_views:
|
||||
files_to_check.append(app_dir / 'views.py')
|
||||
else:
|
||||
files_to_check.extend([
|
||||
app_dir / 'views.py',
|
||||
app_dir / 'serializers.py',
|
||||
])
|
||||
else:
|
||||
# Все приложения
|
||||
for app_dir in apps_dir.iterdir():
|
||||
if app_dir.is_dir() and (app_dir / '__init__.py').exists():
|
||||
if check_serializers:
|
||||
ser_file = app_dir / 'serializers.py'
|
||||
if ser_file.exists():
|
||||
files_to_check.append(ser_file)
|
||||
elif check_views:
|
||||
views_file = app_dir / 'views.py'
|
||||
if views_file.exists():
|
||||
files_to_check.append(views_file)
|
||||
else:
|
||||
views_file = app_dir / 'views.py'
|
||||
ser_file = app_dir / 'serializers.py'
|
||||
if views_file.exists():
|
||||
files_to_check.append(views_file)
|
||||
if ser_file.exists():
|
||||
files_to_check.append(ser_file)
|
||||
|
||||
total_queries = 0
|
||||
files_with_issues = []
|
||||
|
||||
for file_path in files_to_check:
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
tree = ast.parse(content, filename=str(file_path))
|
||||
|
||||
finder = DatabaseQueryFinder(file_path)
|
||||
finder.visit(tree)
|
||||
|
||||
if finder.queries_found:
|
||||
relative_path = file_path.relative_to(base_dir)
|
||||
self.stdout.write(self.style.WARNING(f'\n📄 {relative_path}'))
|
||||
self.stdout.write('-' * 70)
|
||||
|
||||
# Группируем по контексту
|
||||
by_context = {}
|
||||
for query in finder.queries_found:
|
||||
context = query['context']
|
||||
if context not in by_context:
|
||||
by_context[context] = []
|
||||
by_context[context].append(query)
|
||||
|
||||
for context, queries in by_context.items():
|
||||
self.stdout.write(f' 📍 {context}: {len(queries)} запросов')
|
||||
for query in queries[:5]: # Показываем первые 5
|
||||
self.stdout.write(f' Line {query["line"]}: {query["method"]}')
|
||||
if len(queries) > 5:
|
||||
self.stdout.write(f' ... и еще {len(queries) - 5}')
|
||||
|
||||
total_queries += len(finder.queries_found)
|
||||
files_with_issues.append((file_path, finder.queries_found))
|
||||
|
||||
except SyntaxError as e:
|
||||
self.stdout.write(self.style.ERROR(f'Ошибка парсинга {file_path}: {e}'))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Ошибка при обработке {file_path}: {e}'))
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS('=' * 70))
|
||||
self.stdout.write(f'Всего найдено: {total_queries} потенциальных запросов в {len(files_with_issues)} файлах')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('💡 Рекомендации:')
|
||||
self.stdout.write(' 1. Проверьте SerializerMethodField - часто источник N+1 проблем')
|
||||
self.stdout.write(' 2. Используйте select_related() и prefetch_related() в queryset')
|
||||
self.stdout.write(' 3. Используйте aggregate() вместо множественных count()')
|
||||
self.stdout.write(' 4. Проверьте наличие индексов в БД для часто используемых полей')
|
||||
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
Django management команда для восстановления базы данных из бэкапа.
|
||||
Использование: python manage.py restore_database <путь_к_бэкапу>
|
||||
"""
|
||||
|
||||
import os
|
||||
import gzip
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Восстанавливает базу данных из резервной копии'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'backup_file',
|
||||
type=str,
|
||||
help='Путь к файлу бэкапа (.sql или .sql.gz)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--confirm',
|
||||
action='store_true',
|
||||
help='Подтверждение восстановления (обязательно для production)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
backup_file = options['backup_file']
|
||||
confirm = options['confirm']
|
||||
|
||||
if not os.path.exists(backup_file):
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Файл бэкапа не найден: {backup_file}')
|
||||
)
|
||||
return
|
||||
|
||||
# Проверка режима production
|
||||
if not settings.DEBUG and not confirm:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
'ВНИМАНИЕ: Вы пытаетесь восстановить базу данных в production режиме!\n'
|
||||
'Используйте --confirm для подтверждения.'
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем параметры подключения к БД
|
||||
db_config = settings.DATABASES['default']
|
||||
db_name = db_config['NAME']
|
||||
db_user = db_config['USER']
|
||||
db_host = db_config.get('HOST', 'localhost')
|
||||
db_port = db_config.get('PORT', '5432')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'ВНИМАНИЕ: Это действие перезапишет базу данных {db_name}!\n'
|
||||
f'Убедитесь, что у вас есть актуальный бэкап перед продолжением.'
|
||||
)
|
||||
)
|
||||
|
||||
if not confirm:
|
||||
response = input('Продолжить? (yes/no): ')
|
||||
if response.lower() != 'yes':
|
||||
self.stdout.write('Восстановление отменено.')
|
||||
return
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# Определяем, сжат ли файл
|
||||
is_compressed = backup_file.endswith('.gz')
|
||||
|
||||
# Формируем команду psql
|
||||
psql_cmd = [
|
||||
'psql',
|
||||
'-h', db_host,
|
||||
'-p', str(db_port),
|
||||
'-U', db_user,
|
||||
'-d', db_name,
|
||||
'--no-password',
|
||||
]
|
||||
|
||||
# Устанавливаем пароль через переменную окружения
|
||||
env = os.environ.copy()
|
||||
if 'PASSWORD' in db_config:
|
||||
env['PGPASSWORD'] = db_config['PASSWORD']
|
||||
|
||||
self.stdout.write(f'Восстановление из файла: {backup_file}...')
|
||||
|
||||
# Открываем файл бэкапа
|
||||
if is_compressed:
|
||||
file_handle = gzip.open(backup_file, 'rt', encoding='utf-8')
|
||||
else:
|
||||
file_handle = open(backup_file, 'r', encoding='utf-8')
|
||||
|
||||
# Выполняем восстановление
|
||||
with file_handle:
|
||||
process = subprocess.Popen(
|
||||
psql_cmd,
|
||||
stdin=file_handle,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode('utf-8')
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Ошибка восстановления: {error_msg}')
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('✓ База данных успешно восстановлена!')
|
||||
)
|
||||
|
||||
# Рекомендуем выполнить миграции
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
'Рекомендуется выполнить: python manage.py migrate'
|
||||
)
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
'psql не найден. Убедитесь, что PostgreSQL клиент установлен.'
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Ошибка при восстановлении: {str(e)}')
|
||||
)
|
||||
logger.error(f'Restore error: {e}', exc_info=True)
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""
|
||||
Celery задачи для системных операций (бэкапы, очистка и т.д.)
|
||||
"""
|
||||
|
||||
from celery import shared_task
|
||||
import logging
|
||||
from django.core.management import call_command
|
||||
from django.conf import settings
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def backup_database():
|
||||
"""
|
||||
Автоматическое создание бэкапа базы данных.
|
||||
Запускается через Celery Beat.
|
||||
"""
|
||||
try:
|
||||
backup_dir = os.path.join(settings.BASE_DIR.parent, 'backups', 'database')
|
||||
retention_days = int(os.getenv('BACKUP_RETENTION_DAYS', '30'))
|
||||
|
||||
logger.info(f'Starting database backup to {backup_dir}')
|
||||
|
||||
call_command(
|
||||
'backup_database',
|
||||
output_dir=backup_dir,
|
||||
retention_days=retention_days,
|
||||
compress=True,
|
||||
)
|
||||
|
||||
logger.info('Database backup completed successfully')
|
||||
return 'Database backup completed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating database backup: {e}', exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_old_backups():
|
||||
"""
|
||||
Очистка старых бэкапов.
|
||||
Удаляет бэкапы старше указанного количества дней.
|
||||
"""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
import glob
|
||||
|
||||
backup_dir = os.path.join(settings.BASE_DIR.parent, 'backups', 'database')
|
||||
retention_days = int(os.getenv('BACKUP_RETENTION_DAYS', '30'))
|
||||
cutoff_date = timezone.now() - timedelta(days=retention_days)
|
||||
|
||||
if not os.path.exists(backup_dir):
|
||||
logger.warning(f'Backup directory does not exist: {backup_dir}')
|
||||
return 'Backup directory does not exist'
|
||||
|
||||
deleted_count = 0
|
||||
total_size_freed = 0
|
||||
|
||||
# Ищем все файлы бэкапов
|
||||
backup_patterns = [
|
||||
os.path.join(backup_dir, 'db_backup_*.sql'),
|
||||
os.path.join(backup_dir, 'db_backup_*.sql.gz'),
|
||||
]
|
||||
|
||||
for pattern in backup_patterns:
|
||||
for backup_file in glob.glob(pattern):
|
||||
try:
|
||||
file_mtime = os.path.getmtime(backup_file)
|
||||
file_date = timezone.datetime.fromtimestamp(file_mtime, tz=timezone.utc)
|
||||
|
||||
if file_date < cutoff_date:
|
||||
file_size = os.path.getsize(backup_file)
|
||||
os.remove(backup_file)
|
||||
deleted_count += 1
|
||||
total_size_freed += file_size
|
||||
logger.info(f'Deleted old backup: {os.path.basename(backup_file)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting backup {backup_file}: {e}')
|
||||
|
||||
size_mb = total_size_freed / (1024 * 1024)
|
||||
logger.info(f'Cleanup completed: {deleted_count} backups deleted, {size_mb:.2f} MB freed')
|
||||
|
||||
return f'Deleted {deleted_count} old backups, freed {size_mb:.2f} MB'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error cleaning up old backups: {e}', exc_info=True)
|
||||
raise
|
||||
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
"""
|
||||
Административная панель для домашних заданий.
|
||||
"""
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from .models import Homework, HomeworkSubmission, HomeworkFile, HomeworkAssignmentFile, HomeworkAIAgent
|
||||
|
||||
|
||||
class HomeworkAssignmentFileForm(forms.ModelForm):
|
||||
"""Форма файла задания: имя и размер подставляются из загруженного файла."""
|
||||
|
||||
class Meta:
|
||||
model = HomeworkAssignmentFile
|
||||
fields = ['file', 'filename', 'file_size']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['file'].required = False
|
||||
|
||||
def clean_file(self):
|
||||
f = self.cleaned_data.get('file')
|
||||
if f:
|
||||
self.instance.filename = getattr(f, 'name', '') or str(f)
|
||||
self.instance.file_size = getattr(f, 'size', 0) or 0
|
||||
return f
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
f = self.cleaned_data.get('file')
|
||||
if f:
|
||||
self.cleaned_data['filename'] = getattr(f, 'name', '') or str(f)
|
||||
self.cleaned_data['file_size'] = getattr(f, 'size', 0) or 0
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class HomeworkAssignmentFileInline(admin.TabularInline):
|
||||
"""Инлайн: файлы задания (прямая связь Homework → HomeworkAssignmentFile)."""
|
||||
model = HomeworkAssignmentFile
|
||||
form = HomeworkAssignmentFileForm
|
||||
fk_name = 'homework'
|
||||
extra = 3
|
||||
max_num = 20
|
||||
verbose_name = 'Файл задания'
|
||||
verbose_name_plural = 'Файлы задания'
|
||||
fields = ['file', 'filename', 'file_size']
|
||||
readonly_fields = ['filename', 'file_size']
|
||||
|
||||
def save_formset(self, request, form, formset, change):
|
||||
parent = form.instance
|
||||
instances = formset.save(commit=False)
|
||||
for instance in instances:
|
||||
if not instance.file:
|
||||
continue
|
||||
instance.homework = parent
|
||||
if not instance.pk:
|
||||
instance.uploaded_by = request.user
|
||||
instance.filename = getattr(instance.file, 'name', '') or instance.filename or ''
|
||||
instance.file_size = getattr(instance.file, 'size', 0) or instance.file_size or 0
|
||||
instance.save()
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
|
||||
|
||||
@admin.register(Homework)
|
||||
class HomeworkAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для ДЗ."""
|
||||
|
||||
list_display = [
|
||||
'title',
|
||||
'mentor_link',
|
||||
'lesson_link',
|
||||
'status_badge',
|
||||
'deadline',
|
||||
'submissions_info',
|
||||
'average_score',
|
||||
'published_at',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'status',
|
||||
'created_at',
|
||||
'deadline',
|
||||
'published_at',
|
||||
'allow_late_submission',
|
||||
'auto_check_enabled',
|
||||
'ai_check_enabled'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'title',
|
||||
'description',
|
||||
'mentor__email',
|
||||
'mentor__first_name',
|
||||
'mentor__last_name'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'total_submissions',
|
||||
'checked_submissions',
|
||||
'average_score',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'published_at'
|
||||
]
|
||||
|
||||
filter_horizontal = ['assigned_to']
|
||||
inlines = [HomeworkAssignmentFileInline]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': (
|
||||
'title',
|
||||
'description',
|
||||
'mentor',
|
||||
'lesson'
|
||||
)
|
||||
}),
|
||||
('Назначение', {
|
||||
'fields': (
|
||||
'assigned_to',
|
||||
)
|
||||
}),
|
||||
('Файлы и ссылки', {
|
||||
'fields': ('attachment_url',),
|
||||
'description': 'Файлы задания добавляйте в блоке «Файлы задания» ниже. Ссылка на материал — опционально.',
|
||||
}),
|
||||
('Дедлайн и баллы', {
|
||||
'fields': (
|
||||
'deadline',
|
||||
'max_score',
|
||||
'passing_score'
|
||||
)
|
||||
}),
|
||||
('Настройки', {
|
||||
'fields': (
|
||||
'allow_late_submission',
|
||||
'auto_check_enabled',
|
||||
'ai_check_enabled',
|
||||
'requires_file',
|
||||
'allowed_file_types',
|
||||
'max_file_size'
|
||||
)
|
||||
}),
|
||||
('Статус и статистика', {
|
||||
'fields': (
|
||||
'status',
|
||||
'total_submissions',
|
||||
'checked_submissions',
|
||||
'average_score'
|
||||
)
|
||||
}),
|
||||
('Временные метки', {
|
||||
'fields': (
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'published_at'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
actions = ['publish_homework', 'archive_homework']
|
||||
|
||||
def mentor_link(self, obj):
|
||||
"""Ссылка на ментора."""
|
||||
url = reverse('admin:users_user_change', args=[obj.mentor.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.mentor.get_full_name())
|
||||
mentor_link.short_description = 'Ментор'
|
||||
|
||||
def lesson_link(self, obj):
|
||||
"""Ссылка на занятие."""
|
||||
if obj.lesson:
|
||||
url = reverse('admin:schedule_lesson_change', args=[obj.lesson.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.lesson.title)
|
||||
return '-'
|
||||
lesson_link.short_description = 'Занятие'
|
||||
|
||||
def status_badge(self, obj):
|
||||
"""Бейдж статуса."""
|
||||
colors = {
|
||||
'draft': '#6c757d',
|
||||
'published': '#28a745',
|
||||
'archived': '#ffc107'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.status, '#000'),
|
||||
obj.get_status_display()
|
||||
)
|
||||
status_badge.short_description = 'Статус'
|
||||
|
||||
def submissions_info(self, obj):
|
||||
"""Информация о решениях."""
|
||||
return f"{obj.checked_submissions}/{obj.total_submissions}"
|
||||
submissions_info.short_description = 'Проверено/Всего'
|
||||
|
||||
@admin.action(description='Опубликовать задания')
|
||||
def publish_homework(self, request, queryset):
|
||||
"""Опубликовать задания."""
|
||||
for homework in queryset:
|
||||
homework.publish()
|
||||
|
||||
@admin.action(description='Архивировать задания')
|
||||
def archive_homework(self, request, queryset):
|
||||
"""Архивировать задания."""
|
||||
queryset.update(status='archived')
|
||||
|
||||
|
||||
@admin.register(HomeworkSubmission)
|
||||
class HomeworkSubmissionAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для решений ДЗ."""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'homework_link',
|
||||
'student_link',
|
||||
'status_badge',
|
||||
'score_display',
|
||||
'passed',
|
||||
'is_late',
|
||||
'attempt_number',
|
||||
'submitted_at',
|
||||
'checked_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'status',
|
||||
'passed',
|
||||
'is_late',
|
||||
'submitted_at',
|
||||
'checked_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'homework__title',
|
||||
'student__email',
|
||||
'student__first_name',
|
||||
'student__last_name',
|
||||
'content'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'student',
|
||||
'submitted_at',
|
||||
'updated_at',
|
||||
'checked_at',
|
||||
'ai_checked_at',
|
||||
'attempt_number',
|
||||
'is_late'
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': (
|
||||
'homework',
|
||||
'student',
|
||||
'attempt_number'
|
||||
)
|
||||
}),
|
||||
('Решение', {
|
||||
'fields': (
|
||||
'content',
|
||||
'attachment',
|
||||
'attachment_url'
|
||||
)
|
||||
}),
|
||||
('Проверка', {
|
||||
'fields': (
|
||||
'status',
|
||||
'score',
|
||||
'passed',
|
||||
'feedback',
|
||||
'checked_by',
|
||||
'checked_at'
|
||||
)
|
||||
}),
|
||||
('AI проверка', {
|
||||
'fields': (
|
||||
'ai_score',
|
||||
'ai_feedback',
|
||||
'ai_checked_at'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Дополнительно', {
|
||||
'fields': (
|
||||
'is_late',
|
||||
'submitted_at',
|
||||
'updated_at'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
def homework_link(self, obj):
|
||||
"""Ссылка на ДЗ."""
|
||||
url = reverse('admin:homework_homework_change', args=[obj.homework.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.homework.title)
|
||||
homework_link.short_description = 'Задание'
|
||||
|
||||
def student_link(self, obj):
|
||||
"""Ссылка на студента."""
|
||||
url = reverse('admin:users_user_change', args=[obj.student.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.student.get_full_name())
|
||||
student_link.short_description = 'Студент'
|
||||
|
||||
def status_badge(self, obj):
|
||||
"""Бейдж статуса."""
|
||||
colors = {
|
||||
'pending': '#ffc107',
|
||||
'checking': '#17a2b8',
|
||||
'graded': '#28a745',
|
||||
'returned': '#dc3545'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.status, '#000'),
|
||||
obj.get_status_display()
|
||||
)
|
||||
status_badge.short_description = 'Статус'
|
||||
|
||||
def score_display(self, obj):
|
||||
"""Отображение балла."""
|
||||
if obj.score is not None:
|
||||
return f"{obj.score}/{obj.homework.max_score}"
|
||||
return '-'
|
||||
score_display.short_description = 'Балл'
|
||||
|
||||
|
||||
@admin.register(HomeworkFile)
|
||||
class HomeworkFileAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для файлов ДЗ."""
|
||||
|
||||
list_display = [
|
||||
'filename',
|
||||
'file_type_badge',
|
||||
'homework_link',
|
||||
'submission_link',
|
||||
'file_size_display',
|
||||
'uploaded_by_link',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'file_type',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'filename',
|
||||
'homework__title',
|
||||
'uploaded_by__email'
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'filename',
|
||||
'file_size',
|
||||
'uploaded_by',
|
||||
'created_at'
|
||||
]
|
||||
|
||||
def homework_link(self, obj):
|
||||
"""Ссылка на ДЗ."""
|
||||
if obj.homework:
|
||||
url = reverse('admin:homework_homework_change', args=[obj.homework.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.homework.title)
|
||||
return '-'
|
||||
homework_link.short_description = 'Задание'
|
||||
|
||||
def submission_link(self, obj):
|
||||
"""Ссылка на решение."""
|
||||
if obj.submission:
|
||||
url = reverse('admin:homework_homeworksubmission_change', args=[obj.submission.id])
|
||||
return format_html('<a href="{}">Решение #{}</a>', url, obj.submission.id)
|
||||
return '-'
|
||||
submission_link.short_description = 'Решение'
|
||||
|
||||
def file_type_badge(self, obj):
|
||||
"""Бейдж типа файла."""
|
||||
colors = {
|
||||
'assignment': '#007bff',
|
||||
'submission': '#28a745',
|
||||
'feedback': '#ffc107'
|
||||
}
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||||
colors.get(obj.file_type, '#000'),
|
||||
obj.get_file_type_display()
|
||||
)
|
||||
file_type_badge.short_description = 'Тип'
|
||||
|
||||
def file_size_display(self, obj):
|
||||
"""Отображение размера файла."""
|
||||
size_mb = obj.file_size / (1024 * 1024)
|
||||
if size_mb > 1:
|
||||
return f"{size_mb:.2f} MB"
|
||||
size_kb = obj.file_size / 1024
|
||||
return f"{size_kb:.2f} KB"
|
||||
file_size_display.short_description = 'Размер'
|
||||
|
||||
def uploaded_by_link(self, obj):
|
||||
"""Ссылка на загрузившего."""
|
||||
if obj.uploaded_by:
|
||||
url = reverse('admin:users_user_change', args=[obj.uploaded_by.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.uploaded_by.get_full_name())
|
||||
return '-'
|
||||
uploaded_by_link.short_description = 'Загрузил'
|
||||
|
||||
|
||||
@admin.register(HomeworkAIAgent)
|
||||
class HomeworkAIAgentAdmin(admin.ModelAdmin):
|
||||
"""Админ интерфейс для ИИ-агентов проверки ДЗ."""
|
||||
list_display = ['name', 'model_name', 'openai_url_short', 'usage_count', 'tokens_short', 'is_default', 'is_active', 'dev_mode', 'order']
|
||||
list_filter = ['is_default', 'is_active', 'dev_mode']
|
||||
search_fields = ['name', 'model_name']
|
||||
list_editable = ['is_default', 'is_active', 'dev_mode', 'order']
|
||||
ordering = ['order', 'name']
|
||||
readonly_fields = ['usage_count', 'total_prompt_tokens', 'total_completion_tokens']
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('name', 'model_name', 'is_default', 'order', 'is_active')
|
||||
}),
|
||||
('Системный промпт', {
|
||||
'fields': ('system_prompt',),
|
||||
'description': 'Роль и инструкции для модели. Пусто — используется встроенный промпт проверки ДЗ. Перед вашим текстом автоматически добавляется строка «Имя ученика: …» (из отправки ДЗ).'
|
||||
}),
|
||||
('Параметры генерации (влияют на ответ модели)', {
|
||||
'fields': ('temperature', 'top_p', 'max_tokens'),
|
||||
'description': (
|
||||
'temperature — случайность ответа (0–2): 0 = стабильный, 2 = разнообразный; для ДЗ обычно 0.3–0.7. '
|
||||
'top_p — nucleus sampling (0–1): доля вероятных токенов. '
|
||||
'max_tokens — макс. длина ответа в токенах (для развёрнутого комментария 2000–4000). '
|
||||
'Пустые поля — используются значения по умолчанию провайдера.'
|
||||
),
|
||||
}),
|
||||
('Статистика', {
|
||||
'fields': ('usage_count', 'total_prompt_tokens', 'total_completion_tokens'),
|
||||
'description': 'Использований и накопленные токены. Баланс и лимиты — в личном кабинете RouterAI.'
|
||||
}),
|
||||
('Debug / Режим разработки (AI)', {
|
||||
'fields': ('dev_mode',),
|
||||
'description': 'Включить отладочный промпт: ИИ описывает содержимое изображений в комментарии. По умолчанию выключено.'
|
||||
}),
|
||||
('API (OpenAI-совместимый)', {
|
||||
'fields': ('openai_url', 'api_key', 'auth_header'),
|
||||
'description': 'RouterAI: https://routerai.ru/docs/reference — базовый URL https://routerai.ru/api/v1, модели на https://routerai.ru/models, ключ в https://routerai.ru/settings/keys.'
|
||||
}),
|
||||
)
|
||||
|
||||
def openai_url_short(self, obj):
|
||||
if obj.openai_url and len(obj.openai_url) > 50:
|
||||
return obj.openai_url[:47] + '...'
|
||||
return obj.openai_url or '—'
|
||||
openai_url_short.short_description = 'OpenAI URL'
|
||||
|
||||
def tokens_short(self, obj):
|
||||
pt = getattr(obj, 'total_prompt_tokens', 0) or 0
|
||||
pc = getattr(obj, 'total_completion_tokens', 0) or 0
|
||||
if pt or pc:
|
||||
return f'{pt:,} / {pc:,}'
|
||||
return '—'
|
||||
tokens_short.short_description = 'Токены (вход/выход)'
|
||||
|
|
@ -0,0 +1,932 @@
|
|||
"""
|
||||
Сервис для AI проверки домашних заданий.
|
||||
Поддерживает ИИ-агенты из БД (RouterAI и др. OpenAI-совместимые)
|
||||
и fallback на OPENAI_API_KEY из настроек.
|
||||
|
||||
RouterAI: https://routerai.ru/docs/reference
|
||||
OpenAI-совместимый: POST https://routerai.ru/api/v1/chat/completions
|
||||
Модели: openai/gpt-4o-mini, anthropic/claude-3-5-sonnet и др. — см. https://routerai.ru/models
|
||||
Авторизация: Bearer <API-ключ из https://routerai.ru/settings/keys>
|
||||
Поддерживает multimodal (текст + base64 изображения).
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Максимальный размер одного изображения для отправки в AI (байты)
|
||||
MAX_IMAGE_SIZE = 8 * 1024 * 1024 # 8 МБ
|
||||
# Расширения изображений, которые отправляем в запрос
|
||||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
|
||||
# Расширения текстовых файлов: читаем содержимое и подставляем в промпт (задание/решение)
|
||||
TEXT_EXTENSIONS = {'.txt', '.py', '.md', '.json', '.csv', '.xml', '.html', '.css', '.js', '.ts', '.jsx', '.tsx', '.c', '.cpp', '.h', '.java', '.kt', '.rs', '.go', '.rb', '.php', '.sql', '.yaml', '.yml', '.ini', '.cfg', '.log'}
|
||||
# Максимальный размер текстового файла для чтения (байты)
|
||||
MAX_TEXT_FILE_SIZE = 25 * 1024 * 1024 # 2 МБ
|
||||
# Максимум символов из одного файла в промпт (чтобы не перегружать токены)
|
||||
MAX_CHARS_PER_FILE = 800_000
|
||||
|
||||
|
||||
def _read_file_text(file_path: str) -> Optional[str]:
|
||||
"""
|
||||
Читает текстовый файл (.txt, .py, .md и т.д.) и возвращает содержимое.
|
||||
Кодировки: utf-8, затем cp1251, latin-1. Ограничение размера и длины — MAX_TEXT_FILE_SIZE, MAX_CHARS_PER_FILE.
|
||||
"""
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return None
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in TEXT_EXTENSIONS:
|
||||
return None
|
||||
try:
|
||||
size = os.path.getsize(file_path)
|
||||
if size > MAX_TEXT_FILE_SIZE:
|
||||
logger.warning("AI check: пропуск текстового файла (слишком большой %s bytes): %s", size, file_path)
|
||||
return None
|
||||
for encoding in ('utf-8', 'cp1251', 'latin-1'):
|
||||
try:
|
||||
with open(file_path, 'r', encoding=encoding) as f:
|
||||
text = f.read(MAX_CHARS_PER_FILE + 1)
|
||||
if len(text) > MAX_CHARS_PER_FILE:
|
||||
text = text[:MAX_CHARS_PER_FILE] + "\n\n[... файл обрезан ...]"
|
||||
return text
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("AI check: не удалось прочитать текстовый файл %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_pdf_text_pymupdf(data: bytes) -> Optional[str]:
|
||||
"""Fallback: извлечение текста из PDF через PyMuPDF (часто справляется там, где pypdf возвращает пусто)."""
|
||||
if not data or len(data) > MAX_TEXT_FILE_SIZE:
|
||||
return None
|
||||
try:
|
||||
import pymupdf
|
||||
doc = pymupdf.open(stream=data, filetype="pdf")
|
||||
parts = []
|
||||
total = 0
|
||||
for page in doc:
|
||||
if total >= MAX_CHARS_PER_FILE:
|
||||
parts.append("\n[... PDF обрезан ...]")
|
||||
break
|
||||
text = page.get_text() or ""
|
||||
if total + len(text) > MAX_CHARS_PER_FILE:
|
||||
text = text[: MAX_CHARS_PER_FILE - total]
|
||||
parts.append(text)
|
||||
total += len(text)
|
||||
doc.close()
|
||||
return "\n".join(parts).strip() or None
|
||||
except ImportError:
|
||||
logger.debug("AI check: pymupdf не установлен, fallback для PDF недоступен")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("AI check: PyMuPDF не смог извлечь текст из PDF: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_pdf_text_pdfminer(data: bytes) -> Optional[str]:
|
||||
"""Извлечение текста из PDF через pdfminer.six (часто справляется с кодировками и структурой)."""
|
||||
if not data or len(data) > MAX_TEXT_FILE_SIZE:
|
||||
return None
|
||||
try:
|
||||
from pdfminer.high_level import extract_text
|
||||
from pdfminer.layout import LAParams
|
||||
text = extract_text(io.BytesIO(data), laparams=LAParams(line_margin=0.5, word_margin=0.1))
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
if len(text) > MAX_CHARS_PER_FILE:
|
||||
text = text[:MAX_CHARS_PER_FILE] + "\n\n[... PDF обрезан ...]"
|
||||
return text.strip()
|
||||
except ImportError:
|
||||
logger.debug("AI check: pdfminer.six не установлен")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("AI check: pdfminer исключение: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _write_text_to_txt_and_read(text: str) -> Optional[str]:
|
||||
"""Пишем текст во временный .txt и читаем обратно — результат «из файла»."""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
try:
|
||||
fd, path = tempfile.mkstemp(suffix=".txt", prefix="pdf2txt_", text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text[:MAX_CHARS_PER_FILE])
|
||||
if len(text) > MAX_CHARS_PER_FILE:
|
||||
f.write("\n\n[... обрезано ...]")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip() or None
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("AI check: не удалось записать/прочитать .txt: %s", e)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _convert_pdf_to_txt_and_read(pdf_path: Optional[str] = None, pdf_bytes: Optional[bytes] = None) -> Optional[str]:
|
||||
"""
|
||||
Получаем PDF → сразу конвертируем в .txt файл → читаем текст из .txt.
|
||||
Так текст всегда получаем из файла. Порядок: pdftotext (если есть) → pypdf → PyMuPDF → pdfminer.
|
||||
"""
|
||||
if pdf_path and not os.path.isfile(pdf_path):
|
||||
pdf_path = None
|
||||
if not pdf_path and not pdf_bytes:
|
||||
return None
|
||||
if pdf_bytes:
|
||||
if len(pdf_bytes) > MAX_TEXT_FILE_SIZE:
|
||||
return None
|
||||
if len(pdf_bytes) >= 5 and pdf_bytes[:5] != b"%PDF-":
|
||||
logger.warning("AI check: байты не похожи на PDF, len=%s", len(pdf_bytes))
|
||||
return None
|
||||
|
||||
# Если только байты — пишем во временный PDF, дальше работаем с путём
|
||||
temp_pdf_path = None
|
||||
if not pdf_path and pdf_bytes:
|
||||
try:
|
||||
fd, temp_pdf_path = tempfile.mkstemp(suffix=".pdf")
|
||||
os.close(fd)
|
||||
with open(temp_pdf_path, "wb") as f:
|
||||
f.write(pdf_bytes)
|
||||
pdf_path = temp_pdf_path
|
||||
except Exception as e:
|
||||
logger.warning("AI check: не удалось записать PDF во временный файл: %s", e)
|
||||
return None
|
||||
|
||||
txt_path = None
|
||||
try:
|
||||
# 1) pdftotext (poppler) — часто надёжнее библиотек
|
||||
try:
|
||||
fd, txt_path = tempfile.mkstemp(suffix=".txt", prefix="pdf2txt_", text=True)
|
||||
os.close(fd)
|
||||
r = subprocess.run(
|
||||
["pdftotext", "-enc", "UTF-8", pdf_path, txt_path],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
cwd=os.path.dirname(pdf_path) or None,
|
||||
)
|
||||
if r.returncode == 0 and os.path.isfile(txt_path):
|
||||
with open(txt_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read(MAX_CHARS_PER_FILE + 1)
|
||||
if len(text) > MAX_CHARS_PER_FILE:
|
||||
text = text[:MAX_CHARS_PER_FILE] + "\n\n[... обрезано ...]"
|
||||
if text.strip():
|
||||
logger.info("AI check: pdftotext сконвертировал PDF в .txt, прочитано %s символов", len(text))
|
||||
return text.strip()
|
||||
logger.info("AI check: pdftotext вернул код 0, но .txt пустой (возможно скан без текстового слоя)")
|
||||
else:
|
||||
stderr = (r.stderr or b"").decode("utf-8", errors="replace").strip() if r.stderr else ""
|
||||
logger.warning("AI check: pdftotext не сработал returncode=%s stderr=%s", r.returncode, stderr[:500])
|
||||
except FileNotFoundError:
|
||||
logger.warning("AI check: pdftotext не найден в PATH (в Docker установите poppler-utils)")
|
||||
except Exception as e:
|
||||
logger.warning("AI check: pdftotext исключение: %s", e)
|
||||
finally:
|
||||
if txt_path and os.path.isfile(txt_path):
|
||||
try:
|
||||
os.unlink(txt_path)
|
||||
except OSError:
|
||||
pass
|
||||
txt_path = None
|
||||
|
||||
# 2) Извлечение библиотеками и запись в .txt — пробуем все по очереди
|
||||
with open(pdf_path, "rb") as f:
|
||||
data = f.read()
|
||||
logger.info("AI check: PDF прочитан с диска, %s bytes, пробуем извлечь текст", len(data))
|
||||
raw_text = None
|
||||
# pdfminer часто лучше для PDF с кириллицей и сложной вёрсткой
|
||||
raw_text = _extract_pdf_text_pdfminer(data)
|
||||
if raw_text and raw_text.strip():
|
||||
logger.info("AI check: pdfminer извлёк %s символов", len(raw_text))
|
||||
else:
|
||||
logger.info("AI check: pdfminer вернул пустой текст")
|
||||
if not (raw_text and raw_text.strip()):
|
||||
raw_text = _extract_pdf_text_pymupdf(data)
|
||||
if raw_text and raw_text.strip():
|
||||
logger.info("AI check: PyMuPDF извлёк %s символов", len(raw_text))
|
||||
else:
|
||||
logger.info("AI check: PyMuPDF вернул пустой текст")
|
||||
if not (raw_text and raw_text.strip()):
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
parts = []
|
||||
total = 0
|
||||
for page in reader.pages:
|
||||
if total >= MAX_CHARS_PER_FILE:
|
||||
break
|
||||
t = page.extract_text() or ""
|
||||
if total + len(t) > MAX_CHARS_PER_FILE:
|
||||
t = t[: MAX_CHARS_PER_FILE - total]
|
||||
parts.append(t)
|
||||
total += len(t)
|
||||
raw_text = "\n".join(parts).strip() or None
|
||||
if raw_text:
|
||||
logger.info("AI check: pypdf извлёк %s символов", len(raw_text))
|
||||
else:
|
||||
logger.info("AI check: pypdf вернул пустой текст (страниц: %s)", len(reader.pages))
|
||||
except Exception as e:
|
||||
logger.warning("AI check: pypdf исключение: %s", e)
|
||||
if not (raw_text and raw_text.strip()):
|
||||
logger.warning(
|
||||
"AI check: ни один метод не извлек текст. Возможные причины: PDF — скан (нет текстового слоя), защищён, или нестандартная кодировка. Путь: %s",
|
||||
pdf_path,
|
||||
)
|
||||
return None
|
||||
# Конверт: пишем в .txt и читаем из файла
|
||||
return _write_text_to_txt_and_read(raw_text)
|
||||
finally:
|
||||
if temp_pdf_path and os.path.isfile(temp_pdf_path):
|
||||
try:
|
||||
os.unlink(temp_pdf_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_media_path(file_path: str) -> Optional[str]:
|
||||
"""Проверяем путь к файлу; если не найден и путь относительный — пробуем MEDIA_ROOT (для .py, .txt, PDF и т.д.)."""
|
||||
if not file_path:
|
||||
return None
|
||||
if os.path.isfile(file_path):
|
||||
return file_path
|
||||
try:
|
||||
media = getattr(settings, "MEDIA_ROOT", None) or ""
|
||||
if not media or os.path.isabs(file_path):
|
||||
return None
|
||||
base = file_path.lstrip("/")
|
||||
for candidate in (
|
||||
os.path.normpath(os.path.join(media, base)),
|
||||
os.path.normpath(os.path.join(media, os.path.basename(file_path))),
|
||||
):
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_pdf_path(file_path: str) -> Optional[str]:
|
||||
"""Проверяем путь к PDF; если относительный и файл не найден — пробуем MEDIA_ROOT."""
|
||||
return _resolve_media_path(file_path)
|
||||
|
||||
|
||||
def _read_pdf_text(file_path: str) -> Optional[str]:
|
||||
"""PDF по пути → конверт в .txt → читаем текст из .txt. Всегда пробуем извлечь текст."""
|
||||
resolved = _resolve_pdf_path(file_path)
|
||||
if not resolved:
|
||||
logger.warning("AI check: PDF файл не найден: %s", file_path)
|
||||
return None
|
||||
file_path = resolved
|
||||
if os.path.splitext(file_path)[1].lower() != ".pdf":
|
||||
return None
|
||||
try:
|
||||
size = os.path.getsize(file_path)
|
||||
logger.info("AI check: читаем PDF path=%s size=%s", file_path, size)
|
||||
if size > MAX_TEXT_FILE_SIZE:
|
||||
logger.warning("AI check: пропуск PDF (слишком большой): %s", file_path)
|
||||
return None
|
||||
return _convert_pdf_to_txt_and_read(pdf_path=file_path)
|
||||
except Exception as e:
|
||||
logger.warning("AI check: не удалось прочитать PDF %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
|
||||
# Magic bytes для определения PDF по содержимому (если имя файла без расширения)
|
||||
PDF_MAGIC = b"%PDF-"
|
||||
|
||||
|
||||
def _extract_pdf_text_from_bytes(data: bytes) -> Optional[str]:
|
||||
"""PDF из байтов → конверт в .txt → читаем текст из .txt."""
|
||||
if not data or len(data) > MAX_TEXT_FILE_SIZE:
|
||||
return None
|
||||
if len(data) >= 5 and data[:5] != PDF_MAGIC:
|
||||
logger.warning("AI check: байты не похожи на PDF, len=%s", len(data))
|
||||
return None
|
||||
logger.info("AI check: конвертируем PDF в .txt, размер %s bytes", len(data))
|
||||
return _convert_pdf_to_txt_and_read(pdf_bytes=data)
|
||||
|
||||
|
||||
# Подпись для пустых или нечитаемых файлов, чтобы AI видел, что файл приложен, и мог попросить добавить код
|
||||
_EMPTY_OR_UNREADABLE_PLACEHOLDER = "(файл пустой или не удалось прочитать содержимое — попроси студента добавить код/текст в файл)"
|
||||
|
||||
|
||||
def _read_file_content_for_ai(file_path: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Возвращает (имя_файла, содержимое) для вставки в промпт.
|
||||
Если файл пустой или не удалось прочитать — возвращает (имя, подпись), чтобы AI видел приложенный файл.
|
||||
Изображения не возвращаются (идут в multimodal).
|
||||
"""
|
||||
# Разрешаем путь через MEDIA_ROOT при необходимости (PDF, .py, .txt и т.д.)
|
||||
if file_path:
|
||||
file_path = _resolve_media_path(file_path) or file_path
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return None
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
name = os.path.basename(file_path)
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
return None # изображения обрабатываются отдельно
|
||||
if ext == '.pdf':
|
||||
content = _read_pdf_text(file_path)
|
||||
elif ext in TEXT_EXTENSIONS:
|
||||
content = _read_file_text(file_path)
|
||||
else:
|
||||
content = _read_file_text(file_path) # попробовать как текст
|
||||
if content and content.strip():
|
||||
return (name, content.strip())
|
||||
# Файл пустой или не удалось прочитать — всё равно добавляем в промпт с подписью
|
||||
return (name, _EMPTY_OR_UNREADABLE_PLACEHOLDER)
|
||||
|
||||
|
||||
def _read_image_as_data_url(file_path: str) -> Optional[str]:
|
||||
"""
|
||||
Читает файл с диска и возвращает data URL (data:image/...;base64,...) для изображений.
|
||||
Возвращает None, если файл не изображение, слишком большой или ошибка чтения.
|
||||
"""
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return None
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in IMAGE_EXTENSIONS:
|
||||
return None
|
||||
try:
|
||||
size = os.path.getsize(file_path)
|
||||
if size > MAX_IMAGE_SIZE:
|
||||
logger.warning("AI check: пропуск изображения (слишком большой %s bytes): %s", size, file_path)
|
||||
return None
|
||||
with open(file_path, 'rb') as f:
|
||||
data = f.read()
|
||||
b64 = base64.b64encode(data).decode('ascii')
|
||||
mime = 'image/jpeg' if ext in ('.jpg', '.jpeg') else f'image/{ext[1:]}' # png, gif, webp, bmp
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except Exception as e:
|
||||
logger.warning("AI check: не удалось прочитать изображение %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _get_default_agent():
|
||||
"""Получить агент по умолчанию для проверки ДЗ (из БД)."""
|
||||
try:
|
||||
from .models import HomeworkAIAgent
|
||||
return HomeworkAIAgent.objects.filter(is_default=True, is_active=True).first()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_client_and_model():
|
||||
"""
|
||||
Возвращает (client, model_name, agent) для проверки ДЗ.
|
||||
agent может быть None при fallback на OPENAI_*.
|
||||
Лимиты и параметры генерации задаются у провайдера (RouterAI и др.); в запросе передаём только промпт (messages).
|
||||
Приоритет: агент из БД (is_default=True) → настройки OPENAI_*.
|
||||
"""
|
||||
agent = _get_default_agent()
|
||||
if agent:
|
||||
api_key = (agent.api_key or '').strip() or getattr(settings, 'HOMEWORK_AI_API_KEY', None) or getattr(settings, 'OPENAI_API_KEY', None)
|
||||
if not api_key:
|
||||
logger.warning("ИИ-агент '%s' выбран, но API ключ не задан (агент.api_key или HOMEWORK_AI_API_KEY).", agent.name)
|
||||
return None, None, None
|
||||
try:
|
||||
from openai import OpenAI
|
||||
base_url = agent.get_base_url()
|
||||
if not base_url:
|
||||
logger.warning("ИИ-агент '%s': openai_url пустой.", agent.name)
|
||||
return None, None, None
|
||||
auth_header = getattr(agent, 'auth_header', None) or 'Bearer'
|
||||
client_kwargs = {'base_url': base_url, 'api_key': api_key}
|
||||
if auth_header == 'X-API-Key':
|
||||
client_kwargs['default_headers'] = {'X-API-Key': api_key}
|
||||
client = OpenAI(**client_kwargs)
|
||||
return client, agent.model_name, agent
|
||||
except ImportError:
|
||||
logger.error("OpenAI библиотека не установлена. Установите: pip install openai")
|
||||
return None, None, None
|
||||
# Fallback: настройки OpenAI
|
||||
api_key = getattr(settings, 'OPENAI_API_KEY', None)
|
||||
model = getattr(settings, 'OPENAI_MODEL', 'gpt-4o-mini')
|
||||
if not api_key:
|
||||
logger.warning("OPENAI_API_KEY не установлен. AI проверка недоступна.")
|
||||
return None, None, None
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key)
|
||||
return client, model, None
|
||||
except ImportError:
|
||||
logger.error("OpenAI библиотека не установлена. Установите: pip install openai")
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _normalize_usage(data: Any) -> Optional[Dict[str, int]]:
|
||||
"""
|
||||
Извлечь usage (prompt_tokens, completion_tokens, total_tokens) из ответа API.
|
||||
Поддерживает OpenAI-формат (usage.prompt_tokens и т.д.) и варианты (usage.total_tokens, tokens).
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
usage = None
|
||||
if isinstance(data, dict):
|
||||
usage = data.get('usage')
|
||||
elif hasattr(data, 'usage'):
|
||||
usage = getattr(data, 'usage', None)
|
||||
if usage is None:
|
||||
return None
|
||||
if hasattr(usage, 'prompt_tokens'):
|
||||
pt = getattr(usage, 'prompt_tokens', None)
|
||||
ct = getattr(usage, 'completion_tokens', None)
|
||||
tt = getattr(usage, 'total_tokens', None)
|
||||
elif isinstance(usage, dict):
|
||||
pt = usage.get('prompt_tokens')
|
||||
ct = usage.get('completion_tokens')
|
||||
tt = usage.get('total_tokens')
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
prompt_tokens = int(pt) if pt is not None else 0
|
||||
completion_tokens = int(ct) if ct is not None else 0
|
||||
total_tokens = int(tt) if tt is not None else (prompt_tokens + completion_tokens)
|
||||
return {
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'completion_tokens': completion_tokens,
|
||||
'total_tokens': total_tokens or (prompt_tokens + completion_tokens),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _get_agent_for_usage_stats():
|
||||
"""
|
||||
Агент, которому приписать использование: по умолчанию тот, у кого is_default=True;
|
||||
если такого нет — первый активный (чтобы статистика обновлялась в любом случае).
|
||||
"""
|
||||
from .models import HomeworkAIAgent
|
||||
agent = HomeworkAIAgent.objects.filter(is_default=True, is_active=True).first()
|
||||
if agent:
|
||||
return agent
|
||||
return HomeworkAIAgent.objects.filter(is_active=True).order_by('order', 'name').first()
|
||||
|
||||
|
||||
def _increment_agent_usage(agent, usage: Optional[Dict[str, int]] = None):
|
||||
"""Увеличить счётчик использований агента на 1 и при необходимости накопить токены."""
|
||||
if not agent or not getattr(agent, 'pk', None):
|
||||
return
|
||||
try:
|
||||
from .models import HomeworkAIAgent
|
||||
# Сначала только usage_count, чтобы не падать при отсутствии полей токенов (до миграции)
|
||||
updated = HomeworkAIAgent.objects.filter(pk=agent.pk).update(usage_count=F('usage_count') + 1)
|
||||
if not updated:
|
||||
logger.warning("HomeworkAIAgent id=%s update usage_count matched 0 rows", agent.pk)
|
||||
return
|
||||
logger.info("HomeworkAIAgent id=%s usage_count += 1, tokens=%s", agent.pk, usage or 'n/a')
|
||||
# Токены — отдельным update (поля могут отсутствовать до миграции 0015)
|
||||
if usage and (usage.get('prompt_tokens') or usage.get('completion_tokens')):
|
||||
field_names = {f.name for f in HomeworkAIAgent._meta.get_fields()}
|
||||
if 'total_prompt_tokens' in field_names and 'total_completion_tokens' in field_names:
|
||||
HomeworkAIAgent.objects.filter(pk=agent.pk).update(
|
||||
total_prompt_tokens=F('total_prompt_tokens') + usage.get('prompt_tokens', 0),
|
||||
total_completion_tokens=F('total_completion_tokens') + usage.get('completion_tokens', 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка обновления usage_count для агента %s: %s", getattr(agent, 'pk'), e)
|
||||
|
||||
|
||||
class AICheckingService:
|
||||
"""Сервис для автоматической проверки домашних заданий через ИИ (агенты из БД или OpenAI)."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def check_submission(
|
||||
self,
|
||||
homework_title: str,
|
||||
homework_description: str,
|
||||
homework_max_score: int,
|
||||
submission_content: str,
|
||||
submission_files: list = None,
|
||||
homework_files: list = None,
|
||||
homework_file_paths: list = None,
|
||||
submission_file_paths: list = None,
|
||||
homework_file_contents: list = None,
|
||||
submission_file_contents: list = None,
|
||||
student_name: Optional[str] = None,
|
||||
) -> Dict[str, any]:
|
||||
"""
|
||||
Проверка решения домашнего задания через AI.
|
||||
|
||||
Отправляет: задание (текст + прикреплённые файлы/изображения если есть),
|
||||
решение (текст + прикреплённые файлы/изображения если есть).
|
||||
В ответ: комментарий и оценка 1–5.
|
||||
|
||||
Args:
|
||||
homework_file_paths: Пути к файлам задания на диске (для чтения текста и изображений)
|
||||
submission_file_paths: Пути к файлам решения на диске
|
||||
homework_file_contents: [(имя_файла, содержимое_str_or_bytes), ...] — когда path недоступен (S3 и т.д.)
|
||||
submission_file_contents: [(имя_файла, содержимое_str_or_bytes), ...]
|
||||
student_name: Имя ученика — добавляется в начало промпта (например «Имя ученика: Кирилл»).
|
||||
"""
|
||||
agent = _get_default_agent()
|
||||
has_submission_files = bool(submission_file_paths or submission_file_contents or submission_files)
|
||||
if not submission_content and not submission_files and not has_submission_files:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Решение пустое. Нет текста или файлов для проверки.'
|
||||
}
|
||||
|
||||
homework_file_paths = homework_file_paths or []
|
||||
submission_file_paths = submission_file_paths or []
|
||||
homework_file_contents = homework_file_contents or []
|
||||
submission_file_contents = submission_file_contents or []
|
||||
|
||||
# ─── Фаза 1: сначала полностью извлекаем текст из всех файлов, только потом отправляем в AI ───
|
||||
logger.info(
|
||||
"AI check: фаза 1 — извлечение текста из файлов (homework paths=%s, contents=%s, submission paths=%s, contents=%s)",
|
||||
len(homework_file_paths), len(homework_file_contents), len(submission_file_paths), len(submission_file_contents),
|
||||
)
|
||||
|
||||
def _normalize_content(raw: Any, filename: str = "") -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
# .py и код обычно в UTF-8; убираем BOM при наличии
|
||||
data = raw
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
data = data[3:]
|
||||
for enc in ("utf-8", "cp1251", "latin-1"):
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
return str(raw)
|
||||
|
||||
def _content_to_text(filename: str, content: Any) -> str:
|
||||
"""Для файлов из S3/contents: PDF из байтов извлекаем через pypdf+PyMuPDF, остальное (.py, .txt и т.д.) — как текст."""
|
||||
if content is None:
|
||||
return ""
|
||||
is_pdf = (filename or "").lower().endswith(".pdf") or (
|
||||
isinstance(content, bytes) and len(content) >= 5 and content[:5] == PDF_MAGIC
|
||||
)
|
||||
if isinstance(content, bytes) and is_pdf:
|
||||
extracted = _extract_pdf_text_from_bytes(content)
|
||||
return (extracted or "").strip()
|
||||
return _normalize_content(content, filename)
|
||||
|
||||
homework_file_texts: List[Tuple[str, str]] = []
|
||||
for name, content in homework_file_contents:
|
||||
text = _content_to_text(name, content)
|
||||
if text.strip():
|
||||
homework_file_texts.append((name, text.strip()))
|
||||
else:
|
||||
homework_file_texts.append((name, _EMPTY_OR_UNREADABLE_PLACEHOLDER))
|
||||
for path in homework_file_paths:
|
||||
item = _read_file_content_for_ai(path)
|
||||
if item and not any(n == item[0] for n, _ in homework_file_texts):
|
||||
homework_file_texts.append(item)
|
||||
submission_file_texts: List[Tuple[str, str]] = []
|
||||
for name, content in submission_file_contents:
|
||||
text = _content_to_text(name, content)
|
||||
if text.strip():
|
||||
submission_file_texts.append((name, text.strip()))
|
||||
if (name or "").lower().endswith(".py"):
|
||||
logger.info("AI check: файл решения .py из contents «%s» — добавлен, %s символов", name, len(text))
|
||||
else:
|
||||
submission_file_texts.append((name, _EMPTY_OR_UNREADABLE_PLACEHOLDER))
|
||||
for path in submission_file_paths:
|
||||
item = _read_file_content_for_ai(path)
|
||||
if item and not any(n == item[0] for n, _ in submission_file_texts):
|
||||
submission_file_texts.append(item)
|
||||
name, text = item
|
||||
if (name or "").lower().endswith(".py") and text != _EMPTY_OR_UNREADABLE_PLACEHOLDER:
|
||||
logger.info("AI check: файл решения .py по пути «%s» — добавлен, %s символов", name, len(text))
|
||||
|
||||
# Фаза 1 завершена: извлечение из всех файлов выполнено. Дальше только формируем промпт и отправляем.
|
||||
logger.info(
|
||||
"AI check: фаза 1 завершена — задание: %s файлов (текст есть у %s), решение: %s файлов (текст есть у %s). Формируем промпт и отправляем в AI.",
|
||||
len(homework_file_texts),
|
||||
sum(1 for _, t in homework_file_texts if t != _EMPTY_OR_UNREADABLE_PLACEHOLDER),
|
||||
len(submission_file_texts),
|
||||
sum(1 for _, t in submission_file_texts if t != _EMPTY_OR_UNREADABLE_PLACEHOLDER),
|
||||
)
|
||||
|
||||
# ─── Фаза 2: извлечение завершено; проверяем unreadable, затем формируем промпт и только потом отправляем в AI ───
|
||||
# Если не удалось извлечь текст из файлов — не вызываем AI, возвращаем черновик с сообщением
|
||||
homework_has_files = bool(homework_file_texts)
|
||||
homework_all_unreadable = homework_has_files and all(
|
||||
text == _EMPTY_OR_UNREADABLE_PLACEHOLDER for _, text in homework_file_texts
|
||||
)
|
||||
submission_has_files = bool(submission_file_texts)
|
||||
submission_all_unreadable = submission_has_files and all(
|
||||
text == _EMPTY_OR_UNREADABLE_PLACEHOLDER for _, text in submission_file_texts
|
||||
)
|
||||
|
||||
if (
|
||||
homework_has_files and homework_all_unreadable and not (homework_description or "").strip()
|
||||
and submission_has_files and submission_all_unreadable and not (submission_content or "").strip()
|
||||
):
|
||||
logger.info("AI check: не удалось прочитать задание и решение, запрос к AI не отправляем")
|
||||
return {
|
||||
'success': True,
|
||||
'score': None,
|
||||
'feedback': 'Не удалось прочитать задание и решение. Добавьте условие и решение в текстовом виде или в читаемых файлах (.txt, PDF с текстом, не сканы).',
|
||||
'skipped_reason': 'unreadable_both',
|
||||
}
|
||||
if homework_has_files and homework_all_unreadable and not (homework_description or "").strip():
|
||||
logger.info("AI check: не удалось прочитать задание (все файлы пустые/нечитаемые), запрос к AI не отправляем")
|
||||
return {
|
||||
'success': True,
|
||||
'score': None,
|
||||
'feedback': 'Не удалось прочитать задание. Добавьте условие текстом в описание или приложите файл .txt или PDF с извлекаемым текстом (не скан).',
|
||||
'skipped_reason': 'unreadable_assignment',
|
||||
}
|
||||
if submission_has_files and submission_all_unreadable and not (submission_content or "").strip():
|
||||
logger.info("AI check: не удалось прочитать решение (все файлы пустые/нечитаемые), запрос к AI не отправляем")
|
||||
return {
|
||||
'success': True,
|
||||
'score': None,
|
||||
'feedback': 'Не удалось прочитать решение. Попросите студента добавить текст решения в поле «Моё решение» или приложить файл .txt / PDF с извлекаемым текстом.',
|
||||
'skipped_reason': 'unreadable_submission',
|
||||
}
|
||||
|
||||
# Формируем промпт для AI (оценка 1–5). Режим разработки задаётся на агенте, по умолчанию выключен.
|
||||
dev_mode = getattr(agent, 'dev_mode', False) if agent else False
|
||||
# Имя ученика передаётся только в системный промпт (добавляется к вашему промпту из админки)
|
||||
prompt = self._build_prompt(
|
||||
homework_title=homework_title,
|
||||
homework_description=homework_description,
|
||||
homework_max_score=min(homework_max_score, 5),
|
||||
submission_content=submission_content,
|
||||
submission_files=submission_files or [],
|
||||
homework_files=homework_files or [],
|
||||
homework_file_texts=homework_file_texts,
|
||||
submission_file_texts=submission_file_texts,
|
||||
dev_mode=dev_mode,
|
||||
)
|
||||
|
||||
# Собираем изображения для multimodal: при наличии путей используем chat/completions с картинками
|
||||
image_data_urls: List[str] = []
|
||||
for path in homework_file_paths:
|
||||
url = _read_image_as_data_url(path)
|
||||
if url:
|
||||
image_data_urls.append(url)
|
||||
for path in submission_file_paths:
|
||||
url = _read_image_as_data_url(path)
|
||||
if url:
|
||||
image_data_urls.append(url)
|
||||
has_images = bool(image_data_urls)
|
||||
# Вариант 2: всегда отправляем изображения как base64 data URL в chat/completions (multimodal)
|
||||
use_multimodal = has_images
|
||||
if has_images:
|
||||
logger.info(
|
||||
"AI check: загружаем %s изображений в запрос (base64 data URL, multimodal chat/completions)",
|
||||
len(image_data_urls),
|
||||
)
|
||||
|
||||
try:
|
||||
# OpenAI-совместимый API (chat/completions), в т.ч. multimodal (текст + изображения). RouterAI и др.
|
||||
client, model_name, used_agent = _get_client_and_model()
|
||||
if not client or not model_name:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'AI проверка недоступна. Добавьте ИИ-агента в админке (ДЗ → ИИ-агенты) или задайте OPENAI_API_KEY.'
|
||||
}
|
||||
# used_agent — агент, через которого реально пошёл запрос (при fallback на OPENAI_* он None)
|
||||
# Контент пользователя: текст или multimodal (текст + изображения)
|
||||
user_content: Any
|
||||
if use_multimodal and image_data_urls:
|
||||
user_content = [{'type': 'text', 'text': prompt}]
|
||||
for data_url in image_data_urls:
|
||||
user_content.append({'type': 'image_url', 'image_url': {'url': data_url}})
|
||||
logger.info(
|
||||
"AI check (chat/completions multimodal): model=%s prompt_len=%s images=%s",
|
||||
model_name, len(prompt), len(image_data_urls)
|
||||
)
|
||||
else:
|
||||
user_content = prompt
|
||||
logger.info(
|
||||
"AI check (chat/completions): model=%s prompt_len=%s prompt_preview=%s",
|
||||
model_name, len(prompt), (prompt[:400] + '...' if len(prompt) > 400 else prompt)
|
||||
)
|
||||
# Системный промпт: из агента или встроенный по умолчанию
|
||||
default_system = (
|
||||
'Ты опытный преподаватель, который проверяет домашние задания студентов. '
|
||||
'Твоя задача — объективно оценить работу, указать на ошибки и дать конструктивную обратную связь. '
|
||||
'Оценка должна быть справедливой и мотивированной.'
|
||||
)
|
||||
system_content = (getattr(used_agent, 'system_prompt', None) or '').strip() if used_agent else ''
|
||||
if not system_content:
|
||||
system_content = default_system
|
||||
dev_mode = getattr(used_agent, 'dev_mode', False) if used_agent else False
|
||||
if dev_mode:
|
||||
system_content += (
|
||||
' Режим отладки: в комментарии обязательно опиши содержимое каждого приложенного изображения '
|
||||
'(что на фото/скриншоте/рисунке, какой текст виден), затем дай оценку и отзыв по заданию.'
|
||||
)
|
||||
# Имя ученика (кто отправил ДЗ) в начало системного промпта
|
||||
display_name = (str(student_name).strip() if student_name else "") or ""
|
||||
if display_name:
|
||||
system_content = f"Имя ученика: {display_name}\n\n{system_content}"
|
||||
logger.info("AI check: в системный промпт добавлено имя ученика: %r", display_name[:80])
|
||||
|
||||
# Параметры генерации из агента (влияют на ответ модели)
|
||||
extra_kwargs = {}
|
||||
if used_agent:
|
||||
t = getattr(used_agent, 'temperature', None)
|
||||
if t is not None:
|
||||
extra_kwargs['temperature'] = float(t)
|
||||
p = getattr(used_agent, 'top_p', None)
|
||||
if p is not None:
|
||||
extra_kwargs['top_p'] = float(p)
|
||||
mt = getattr(used_agent, 'max_tokens', None)
|
||||
if mt is not None:
|
||||
extra_kwargs['max_tokens'] = int(mt)
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[
|
||||
{'role': 'system', 'content': system_content},
|
||||
{'role': 'user', 'content': user_content}
|
||||
],
|
||||
**extra_kwargs
|
||||
)
|
||||
ai_response = response.choices[0].message.content.strip()
|
||||
usage = _normalize_usage(getattr(response, 'usage', None))
|
||||
logger.info(
|
||||
"AI check (chat/completions) response: status=ok response_len=%s usage=%s",
|
||||
len(ai_response), usage
|
||||
)
|
||||
max_for_parse = min(homework_max_score, 5)
|
||||
score, feedback = self._parse_ai_response(ai_response, max_for_parse)
|
||||
score = max(1, min(5, score))
|
||||
agent_for_stats = _get_agent_for_usage_stats()
|
||||
_increment_agent_usage(agent_for_stats, usage)
|
||||
from .utils import feedback_to_html
|
||||
result = {
|
||||
'success': True,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'feedback_html': feedback_to_html(feedback),
|
||||
'raw_response': ai_response
|
||||
}
|
||||
if usage:
|
||||
result['usage'] = usage
|
||||
return result
|
||||
except requests.RequestException as e:
|
||||
resp = getattr(e, 'response', None)
|
||||
if resp is not None:
|
||||
try:
|
||||
err_body = resp.json()
|
||||
msg = err_body.get('message') or err_body.get('error', {}).get('message') or resp.text[:300]
|
||||
except Exception:
|
||||
msg = resp.text[:300] if getattr(resp, 'text', None) else str(e)
|
||||
if resp.status_code == 403:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'403 Доступ запрещён. RouterAI: проверьте ключ и баланс на routerai.ru/settings. {msg}'
|
||||
}
|
||||
else:
|
||||
msg = str(e)
|
||||
logger.error("Ошибка AI проверки (request): %s", msg, exc_info=True)
|
||||
return {'success': False, 'error': f'Ошибка при проверке через AI: {msg}'}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка AI проверки: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Ошибка при проверке через AI: {str(e)}'
|
||||
}
|
||||
|
||||
def _build_prompt(
|
||||
self,
|
||||
homework_title: str,
|
||||
homework_description: str,
|
||||
homework_max_score: int,
|
||||
submission_content: str,
|
||||
submission_files: list = None,
|
||||
homework_files: list = None,
|
||||
homework_file_texts: list = None,
|
||||
submission_file_texts: list = None,
|
||||
dev_mode: bool = False,
|
||||
) -> str:
|
||||
"""Построение промпта для AI: задание, решение и содержимое приложенных файлов (PDF, .txt, .py и т.д.). Имя ученика добавляется в системный промпт при формировании запроса."""
|
||||
homework_file_texts = homework_file_texts or []
|
||||
submission_file_texts = submission_file_texts or []
|
||||
|
||||
parts = [
|
||||
"ЗАДАНИЕ:",
|
||||
f"Название: {homework_title}",
|
||||
f"Описание: {homework_description or 'Нет текста.'}",
|
||||
]
|
||||
for name, content in homework_file_texts:
|
||||
parts.append(f"\n--- Файл задания «{name}» ---\n{content}")
|
||||
parts.append("\nРЕШЕНИЕ СТУДЕНТА:")
|
||||
parts.append(submission_content or "Нет текста.")
|
||||
for name, content in submission_file_texts:
|
||||
parts.append(f"\n--- Файл решения «{name}» ---\n{content}")
|
||||
parts.append(
|
||||
"\n\n--- ИНСТРУКЦИЯ ДЛЯ ОТВЕТА ---\n"
|
||||
"Ответь строго в два поля.\n"
|
||||
"1) ОЦЕНКА: [целое число от 1 до 5].\n"
|
||||
"2) КОММЕНТАРИЙ: [развёрнутый комментарий для студента на русском]. "
|
||||
"Пиши полноценный отзыв: что сделано хорошо, что улучшить, конкретные замечания по коду или решению. "
|
||||
"Не используй краткие формулировки вроде «Совпало: да», «Верно: да» — только развёрнутый текст в поле КОММЕНТАРИЙ."
|
||||
)
|
||||
if dev_mode:
|
||||
parts.append(
|
||||
"\n\n[РЕЖИМ ОТЛАДКИ] В комментарии обязательно опиши, что изображено на каждом приложенном изображении (фото, скриншот, рисунок): что на них видно, текст если есть, структура. Начни с описания изображений, затем дай оценку и общий комментарий по заданию."
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
def _parse_ai_response(self, response: str, max_score: int) -> Tuple[int, str]:
|
||||
"""
|
||||
Парсинг ответа AI.
|
||||
|
||||
Returns:
|
||||
tuple: (score, feedback)
|
||||
"""
|
||||
try:
|
||||
# Ищем оценку
|
||||
score = None
|
||||
feedback = ""
|
||||
|
||||
lines = response.split('\n')
|
||||
in_comment = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# Ищем оценку
|
||||
if line.startswith('ОЦЕНКА:') or 'ОЦЕНКА:' in line:
|
||||
try:
|
||||
# Извлекаем число
|
||||
parts = line.split('ОЦЕНКА:')
|
||||
if len(parts) > 1:
|
||||
score_str = parts[1].strip().split()[0]
|
||||
score = int(score_str)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Ищем комментарий
|
||||
if line.startswith('КОММЕНТАРИЙ:') or 'КОММЕНТАРИЙ:' in line:
|
||||
in_comment = True
|
||||
# Извлекаем текст после "КОММЕНТАРИЙ:"
|
||||
parts = line.split('КОММЕНТАРИЙ:')
|
||||
if len(parts) > 1:
|
||||
feedback += parts[1].strip() + '\n'
|
||||
continue
|
||||
|
||||
if in_comment:
|
||||
feedback += line + '\n'
|
||||
|
||||
# Если не нашли оценку, пытаемся найти число в начале ответа
|
||||
if score is None:
|
||||
for line in lines[:5]: # Проверяем первые 5 строк
|
||||
try:
|
||||
# Ищем первое число
|
||||
words = line.split()
|
||||
for word in words:
|
||||
if word.isdigit():
|
||||
score = int(word)
|
||||
if 0 <= score <= max_score:
|
||||
break
|
||||
if score is not None:
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# Если все еще не нашли, используем среднее значение (3 по шкале 1–5)
|
||||
if score is None:
|
||||
score = 3
|
||||
logger.warning("Не удалось извлечь оценку из ответа AI. Используется 3.")
|
||||
# Ограничиваем оценку 1–5
|
||||
score = max(1, min(score, min(max_score, 5)))
|
||||
|
||||
# Если не нашли комментарий, используем весь ответ
|
||||
if not feedback.strip():
|
||||
feedback = response
|
||||
|
||||
return score, feedback.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка парсинга ответа AI: {str(e)}")
|
||||
# Возвращаем среднее значение и весь ответ как комментарий
|
||||
return max_score // 2, response
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
_ai_service = None
|
||||
|
||||
|
||||
def get_ai_service() -> AICheckingService:
|
||||
"""Получить экземпляр сервиса AI проверки."""
|
||||
global _ai_service
|
||||
if _ai_service is None:
|
||||
_ai_service = AICheckingService()
|
||||
return _ai_service
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
Конфигурация приложения homework.
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeworkConfig(AppConfig):
|
||||
"""Конфигурация приложения homework."""
|
||||
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.homework'
|
||||
verbose_name = 'Домашние задания'
|
||||
|
||||
def ready(self):
|
||||
"""Инициализация приложения."""
|
||||
import apps.homework.signals # noqa
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
# Generated by Django 4.2.7 on 2025-12-09 21:02
|
||||
|
||||
import apps.homework.models
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Homework",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="Название")),
|
||||
("description", models.TextField(verbose_name="Описание задания")),
|
||||
(
|
||||
"attachment",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
upload_to=apps.homework.models.homework_file_upload_path,
|
||||
verbose_name="Файл задания",
|
||||
),
|
||||
),
|
||||
(
|
||||
"attachment_url",
|
||||
models.URLField(
|
||||
blank=True, max_length=500, verbose_name="Ссылка на материал"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deadline",
|
||||
models.DateTimeField(
|
||||
blank=True, db_index=True, null=True, verbose_name="Дедлайн"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_score",
|
||||
models.IntegerField(
|
||||
default=100,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Максимальный балл",
|
||||
),
|
||||
),
|
||||
(
|
||||
"passing_score",
|
||||
models.IntegerField(
|
||||
default=60,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Проходной балл",
|
||||
),
|
||||
),
|
||||
(
|
||||
"allow_late_submission",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="Разрешить сдачу после дедлайна"
|
||||
),
|
||||
),
|
||||
(
|
||||
"auto_check_enabled",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="Автоматическая проверка"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ai_check_enabled",
|
||||
models.BooleanField(default=False, verbose_name="AI проверка"),
|
||||
),
|
||||
(
|
||||
"requires_file",
|
||||
models.BooleanField(default=True, verbose_name="Требуется файл"),
|
||||
),
|
||||
(
|
||||
"allowed_file_types",
|
||||
models.CharField(
|
||||
default=".pdf,.doc,.docx,.txt,.jpg,.png",
|
||||
max_length=255,
|
||||
verbose_name="Разрешенные типы файлов",
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_file_size",
|
||||
models.IntegerField(
|
||||
default=10485760,
|
||||
verbose_name="Максимальный размер файла (bytes)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("draft", "Черновик"),
|
||||
("published", "Опубликовано"),
|
||||
("archived", "В архиве"),
|
||||
],
|
||||
db_index=True,
|
||||
default="draft",
|
||||
max_length=20,
|
||||
verbose_name="Статус",
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_submissions",
|
||||
models.IntegerField(default=0, verbose_name="Всего решений"),
|
||||
),
|
||||
(
|
||||
"checked_submissions",
|
||||
models.IntegerField(default=0, verbose_name="Проверено решений"),
|
||||
),
|
||||
(
|
||||
"average_score",
|
||||
models.FloatField(default=0.0, verbose_name="Средний балл"),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата создания"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
|
||||
),
|
||||
(
|
||||
"published_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата публикации"
|
||||
),
|
||||
),
|
||||
(
|
||||
"assigned_to",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="assigned_homeworks",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Назначено",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Домашнее задание",
|
||||
"verbose_name_plural": "Домашние задания",
|
||||
"db_table": "homeworks",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="HomeworkSubmission",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("content", models.TextField(blank=True, verbose_name="Текст решения")),
|
||||
(
|
||||
"attachment",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
upload_to=apps.homework.models.submission_file_upload_path,
|
||||
verbose_name="Файл решения",
|
||||
),
|
||||
),
|
||||
(
|
||||
"attachment_url",
|
||||
models.URLField(
|
||||
blank=True, max_length=500, verbose_name="Ссылка на решение"
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Ожидает проверки"),
|
||||
("checking", "На проверке"),
|
||||
("graded", "Проверено"),
|
||||
("returned", "Возвращено на доработку"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
max_length=20,
|
||||
verbose_name="Статус",
|
||||
),
|
||||
),
|
||||
(
|
||||
"score",
|
||||
models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name="Балл",
|
||||
),
|
||||
),
|
||||
("passed", models.BooleanField(default=False, verbose_name="Сдано")),
|
||||
("feedback", models.TextField(blank=True, verbose_name="Отзыв")),
|
||||
(
|
||||
"checked_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата проверки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ai_score",
|
||||
models.IntegerField(blank=True, null=True, verbose_name="AI балл"),
|
||||
),
|
||||
("ai_feedback", models.TextField(blank=True, verbose_name="AI отзыв")),
|
||||
(
|
||||
"ai_checked_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Дата AI проверки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempt_number",
|
||||
models.IntegerField(default=1, verbose_name="Номер попытки"),
|
||||
),
|
||||
(
|
||||
"is_late",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="Сдано с опозданием"
|
||||
),
|
||||
),
|
||||
(
|
||||
"submitted_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата отправки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
|
||||
),
|
||||
(
|
||||
"checked_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="checked_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Проверил",
|
||||
),
|
||||
),
|
||||
(
|
||||
"homework",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="submissions",
|
||||
to="homework.homework",
|
||||
verbose_name="Домашнее задание",
|
||||
),
|
||||
),
|
||||
(
|
||||
"student",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="homework_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Студент",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Решение ДЗ",
|
||||
"verbose_name_plural": "Решения ДЗ",
|
||||
"db_table": "homework_submissions",
|
||||
"ordering": ["-submitted_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="HomeworkFile",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("assignment", "Файл задания"),
|
||||
("submission", "Файл решения"),
|
||||
("feedback", "Файл отзыва"),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name="Тип файла",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
max_length=500, upload_to="homework/files/", verbose_name="Файл"
|
||||
),
|
||||
),
|
||||
(
|
||||
"filename",
|
||||
models.CharField(max_length=255, verbose_name="Название файла"),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.BigIntegerField(verbose_name="Размер файла (bytes)"),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Дата загрузки"
|
||||
),
|
||||
),
|
||||
(
|
||||
"homework",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="homework.homework",
|
||||
verbose_name="Домашнее задание",
|
||||
),
|
||||
),
|
||||
(
|
||||
"submission",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="homework.homeworksubmission",
|
||||
verbose_name="Решение",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="uploaded_homework_files",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Загрузил",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Файл ДЗ",
|
||||
"verbose_name_plural": "Файлы ДЗ",
|
||||
"db_table": "homework_files",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Generated by Django 4.2.7 on 2025-12-09 21:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("schedule", "0001_initial"),
|
||||
("homework", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="homework",
|
||||
name="lesson",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="homeworks",
|
||||
to="schedule.lesson",
|
||||
verbose_name="Занятие",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="homework",
|
||||
name="mentor",
|
||||
field=models.ForeignKey(
|
||||
limit_choices_to={"role": "mentor"},
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="created_homeworks",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Ментор",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homeworksubmission",
|
||||
index=models.Index(
|
||||
fields=["homework", "student"], name="homework_su_homewor_583d50_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homeworksubmission",
|
||||
index=models.Index(
|
||||
fields=["student", "status"], name="homework_su_student_edd8aa_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homeworksubmission",
|
||||
index=models.Index(
|
||||
fields=["status", "submitted_at"], name="homework_su_status_2b19af_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="homeworksubmission",
|
||||
unique_together={("homework", "student", "attempt_number")},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homework",
|
||||
index=models.Index(
|
||||
fields=["mentor", "status"], name="homeworks_mentor__756b56_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homework",
|
||||
index=models.Index(fields=["lesson"], name="homeworks_lesson__a3dbd5_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homework",
|
||||
index=models.Index(
|
||||
fields=["deadline"], name="homeworks_deadlin_031a21_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="homework",
|
||||
index=models.Index(
|
||||
fields=["status", "published_at"], name="homeworks_status_64d19b_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homework',
|
||||
name='returned_submissions',
|
||||
field=models.IntegerField(default=0, verbose_name='Возвращено на доработку'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0003_add_returned_submissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HomeworkAIAgent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='Название модели')),
|
||||
('access_id', models.CharField(blank=True, help_text='Идентификатор агента (например, для Timeweb Cloud AI)', max_length=255, verbose_name='Access ID (agent_access_id)')),
|
||||
('openai_url', models.URLField(help_text='Базовый URL API (OpenAI-совместимый). Пример: https://agent.timeweb.cloud/api/v1/cloud-ai/agents/{access_id}/v1', max_length=500, verbose_name='OpenAI URL')),
|
||||
('model_name', models.CharField(help_text='Имя модели (например: DeepSeek V3.2, gpt-4o-mini)', max_length=255, verbose_name='Название модели')),
|
||||
('api_key', models.CharField(blank=True, help_text='Оставьте пустым, чтобы использовать HOMEWORK_AI_API_KEY из настроек', max_length=500, verbose_name='API ключ')),
|
||||
('is_default', models.BooleanField(default=False, verbose_name='Использовать по умолчанию для проверки ДЗ')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок сортировки')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ИИ-агент для ДЗ',
|
||||
'verbose_name_plural': 'ИИ-агенты для ДЗ',
|
||||
'db_table': 'homework_ai_agents',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0004_add_homework_ai_agent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='auth_header',
|
||||
field=models.CharField(
|
||||
choices=[('Bearer', 'Authorization: Bearer (по умолчанию)'), ('X-API-Key', 'X-API-Key (для Timeweb и др.)')],
|
||||
default='Bearer',
|
||||
help_text='Timeweb часто требует X-API-Key. Если 401 — переключите на X-API-Key.',
|
||||
max_length=32,
|
||||
verbose_name='Заголовок авторизации'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0005_ai_agent_auth_header'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='x_proxy_source',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text='Обязательный заголовок для Timeweb Cloud AI. Оставьте пустым (отправляется пустая строка).',
|
||||
max_length=255,
|
||||
verbose_name='x-proxy-source'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0006_homeworkaiagent_x_proxy_source'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='use_native_call',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='Timeweb: POST .../agents/{id}/call с телом message, parent_message_id, file_ids. Иначе — OpenAI-совместимый .../v1/chat/completions.',
|
||||
verbose_name='Использовать нативный /call'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0007_homeworkaiagent_use_native_call'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='homeworkaiagent',
|
||||
name='api_key',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text='API-токен, полученный у провайдера (Timeweb и т.д.). Пусто — использовать HOMEWORK_AI_API_KEY из .env',
|
||||
max_length=2048,
|
||||
verbose_name='API ключ (токен)'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Generated manually: модель файлов задания (прямая связь Homework → файл)
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('homework', '0008_homeworkaiagent_api_key_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HomeworkAssignmentFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(max_length=500, upload_to='homework/assignment_files/%Y/%m/', verbose_name='Файл')),
|
||||
('filename', models.CharField(max_length=255, verbose_name='Название файла')),
|
||||
('file_size', models.BigIntegerField(verbose_name='Размер файла (bytes)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата загрузки')),
|
||||
('homework', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignment_files', to='homework.homework', verbose_name='Домашнее задание')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_assignment_files', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Файл задания',
|
||||
'verbose_name_plural': 'Файлы задания',
|
||||
'db_table': 'homework_assignment_files',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Добавляем поле graded_by_ai для отображения «Проверено: ИИ» / «Проверено: ментор»
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0009_add_homework_assignment_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworksubmission',
|
||||
name='graded_by_ai',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='True, если оценка опубликована автоматически через ИИ',
|
||||
verbose_name='Оценку выставил ИИ',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Добавляем поле ai_draft_submissions в модель Homework (черновики от ИИ)
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fill_ai_draft_submissions(apps, schema_editor):
|
||||
"""Заполнить ai_draft_submissions для существующих заданий."""
|
||||
Homework = apps.get_model('homework', 'Homework')
|
||||
for hw in Homework.objects.all():
|
||||
count = hw.submissions.filter(status='pending').exclude(ai_checked_at__isnull=True).count()
|
||||
hw.ai_draft_submissions = count
|
||||
hw.save(update_fields=['ai_draft_submissions'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0011_homeworksubmission_graded_by_ai'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homework',
|
||||
name='ai_draft_submissions',
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
help_text='Количество решений со статусом «ожидает проверки» и заполненным черновиком от ИИ',
|
||||
verbose_name='Черновиков от ИИ',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(fill_ai_draft_submissions, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Режим разработки для ИИ-агента (отладочный промпт по умолчанию выключен)
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0012_homework_ai_draft_submissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='dev_mode',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='Включить отладочный промпт: ИИ описывает содержимое изображений в комментарии. Выключено — обычная проверка.',
|
||||
verbose_name='Режим разработки (AI)'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Счётчик использований ИИ-агента для статистики в админке
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0013_homeworkaiagent_dev_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='usage_count',
|
||||
field=models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text='Счётчик успешных проверок ДЗ через этого агента.',
|
||||
verbose_name='Использований'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Накопленная статистика токенов по ИИ-агенту (вход/выход)
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0014_homeworkaiagent_usage_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='total_prompt_tokens',
|
||||
field=models.PositiveBigIntegerField(
|
||||
default=0,
|
||||
help_text='Накоплено входящих токенов за все проверки. Остаток/лимит — в личном кабинете Timeweb.',
|
||||
verbose_name='Всего токенов (вход)'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='total_completion_tokens',
|
||||
field=models.PositiveBigIntegerField(
|
||||
default=0,
|
||||
help_text='Накоплено исходящих токенов за все проверки.',
|
||||
verbose_name='Всего токенов (выход)'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Системный промпт для ИИ-агента (RouterAI и др.)
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0015_homeworkaiagent_total_tokens'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='system_prompt',
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text='Системный промпт для модели (роль и инструкции). Пусто — используется встроенный промпт проверки ДЗ.',
|
||||
verbose_name='Системный промпт'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Удаление полей, не используемых с RouterAI: access_id, x_proxy_source, use_native_call
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0016_homeworkaiagent_system_prompt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='homeworkaiagent',
|
||||
name='access_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='homeworkaiagent',
|
||||
name='x_proxy_source',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='homeworkaiagent',
|
||||
name='use_native_call',
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Параметры генерации ответа модели: temperature, top_p, max_tokens
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0017_remove_homeworkaiagent_access_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='temperature',
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text='Случайность ответа: 0 = детерминированный, 2 = максимально разнообразный. Обычно 0.3–0.7 для проверки ДЗ. Пусто — значение по умолчанию провайдера.',
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(2.0)],
|
||||
verbose_name='Temperature'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='top_p',
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text='Доля наиболее вероятных токенов для выбора (0–1). Меньше — более фокусный ответ. Пусто — по умолчанию провайдера.',
|
||||
null=True,
|
||||
validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)],
|
||||
verbose_name='Top P (nucleus sampling)'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homeworkaiagent',
|
||||
name='max_tokens',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text='Максимальная длина ответа в токенах. Пусто — лимит провайдера. Для развёрнутого комментария можно 2000–4000.',
|
||||
null=True,
|
||||
verbose_name='Max output tokens'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Описание ДЗ не обязательно (можно только файлы)
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0018_homeworkaiagent_temperature_top_p_max_tokens'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='homework',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default='', verbose_name='Описание задания'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0019_homework_description_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homework',
|
||||
name='fill_later',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Заполнить позже'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('homework', '0020_homework_fill_later'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='homework',
|
||||
name='max_score',
|
||||
field=models.IntegerField(
|
||||
default=5,
|
||||
validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)],
|
||||
verbose_name='Максимальный балл',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homework',
|
||||
name='passing_score',
|
||||
field=models.IntegerField(
|
||||
default=1,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
verbose_name='Проходной балл',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,702 @@
|
|||
"""
|
||||
Модели для домашних заданий.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
import uuid
|
||||
import os
|
||||
|
||||
|
||||
def homework_file_upload_path(instance, filename):
|
||||
"""Путь для загрузки файлов заданий."""
|
||||
ext = filename.split('.')[-1]
|
||||
filename = f"{uuid.uuid4()}.{ext}"
|
||||
return os.path.join('homework', 'assignments', str(instance.id), filename)
|
||||
|
||||
|
||||
def submission_file_upload_path(instance, filename):
|
||||
"""Путь для загрузки файлов решений."""
|
||||
ext = filename.split('.')[-1]
|
||||
filename = f"{uuid.uuid4()}.{ext}"
|
||||
return os.path.join('homework', 'submissions', str(instance.id), filename)
|
||||
|
||||
|
||||
class Homework(models.Model):
|
||||
"""
|
||||
Модель домашнего задания.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Черновик'),
|
||||
('published', 'Опубликовано'),
|
||||
('archived', 'В архиве'),
|
||||
]
|
||||
|
||||
# Основная информация
|
||||
title = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Название'
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='Описание задания'
|
||||
)
|
||||
|
||||
# Автор и связи
|
||||
mentor = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='created_homeworks',
|
||||
limit_choices_to={'role': 'mentor'},
|
||||
verbose_name='Ментор'
|
||||
)
|
||||
|
||||
lesson = models.ForeignKey(
|
||||
'schedule.Lesson',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='homeworks',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Занятие'
|
||||
)
|
||||
|
||||
# Кому назначено
|
||||
assigned_to = models.ManyToManyField(
|
||||
'users.User',
|
||||
related_name='assigned_homeworks',
|
||||
blank=True,
|
||||
verbose_name='Назначено'
|
||||
)
|
||||
|
||||
# Файлы задания
|
||||
attachment = models.FileField(
|
||||
upload_to=homework_file_upload_path,
|
||||
blank=True,
|
||||
max_length=500,
|
||||
verbose_name='Файл задания'
|
||||
)
|
||||
|
||||
attachment_url = models.URLField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
verbose_name='Ссылка на материал'
|
||||
)
|
||||
|
||||
# Дедлайн
|
||||
deadline = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дедлайн',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Баллы (по умолчанию шкала 1–5, проходной не учитывается)
|
||||
max_score = models.IntegerField(
|
||||
default=5,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
verbose_name='Максимальный балл'
|
||||
)
|
||||
|
||||
passing_score = models.IntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name='Проходной балл'
|
||||
)
|
||||
|
||||
# Настройки
|
||||
allow_late_submission = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Разрешить сдачу после дедлайна'
|
||||
)
|
||||
|
||||
auto_check_enabled = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Автоматическая проверка'
|
||||
)
|
||||
|
||||
ai_check_enabled = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='AI проверка'
|
||||
)
|
||||
|
||||
requires_file = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Требуется файл'
|
||||
)
|
||||
|
||||
allowed_file_types = models.CharField(
|
||||
max_length=255,
|
||||
default='.pdf,.doc,.docx,.txt,.jpg,.png',
|
||||
verbose_name='Разрешенные типы файлов'
|
||||
)
|
||||
|
||||
max_file_size = models.IntegerField(
|
||||
default=10485760, # 10 MB
|
||||
verbose_name='Максимальный размер файла (bytes)'
|
||||
)
|
||||
|
||||
# Статус
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
verbose_name='Статус',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Черновик «заполнить позже» — создан при завершении урока, ментор должен дописать задание
|
||||
fill_later = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Заполнить позже',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Статистика
|
||||
total_submissions = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Всего решений'
|
||||
)
|
||||
|
||||
checked_submissions = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Проверено решений'
|
||||
)
|
||||
|
||||
returned_submissions = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Возвращено на доработку'
|
||||
)
|
||||
|
||||
ai_draft_submissions = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Черновиков от ИИ',
|
||||
help_text='Количество решений со статусом «ожидает проверки» и заполненным черновиком от ИИ'
|
||||
)
|
||||
|
||||
average_score = models.FloatField(
|
||||
default=0.0,
|
||||
verbose_name='Средний балл'
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата создания'
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name='Дата обновления'
|
||||
)
|
||||
|
||||
published_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата публикации'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'homeworks'
|
||||
verbose_name = 'Домашнее задание'
|
||||
verbose_name_plural = 'Домашние задания'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['mentor', 'status']),
|
||||
models.Index(fields=['lesson']),
|
||||
models.Index(fields=['deadline']),
|
||||
models.Index(fields=['status', 'published_at']),
|
||||
models.Index(fields=['mentor', 'created_at']),
|
||||
models.Index(fields=['status', 'deadline']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def publish(self):
|
||||
"""Опубликовать задание."""
|
||||
if self.status != 'published':
|
||||
self.status = 'published'
|
||||
self.published_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def archive(self):
|
||||
"""Архивировать задание."""
|
||||
self.status = 'archived'
|
||||
self.save()
|
||||
|
||||
def is_overdue(self):
|
||||
"""Проверка просрочено ли задание."""
|
||||
if self.deadline:
|
||||
return timezone.now() > self.deadline
|
||||
return False
|
||||
|
||||
def update_statistics(self):
|
||||
"""Обновить статистику задания."""
|
||||
submissions = self.submissions.all()
|
||||
self.total_submissions = submissions.count()
|
||||
self.checked_submissions = submissions.filter(status='graded').count()
|
||||
self.returned_submissions = submissions.filter(status='returned').count()
|
||||
self.ai_draft_submissions = submissions.filter(
|
||||
status='pending'
|
||||
).exclude(ai_checked_at__isnull=True).count()
|
||||
|
||||
graded = submissions.filter(status='graded')
|
||||
if graded.exists():
|
||||
self.average_score = graded.aggregate(
|
||||
avg=models.Avg('score')
|
||||
)['avg'] or 0.0
|
||||
else:
|
||||
self.average_score = 0.0
|
||||
|
||||
self.save(update_fields=[
|
||||
'total_submissions',
|
||||
'checked_submissions',
|
||||
'returned_submissions',
|
||||
'ai_draft_submissions',
|
||||
'average_score'
|
||||
])
|
||||
|
||||
|
||||
def assignment_file_upload_path(instance, filename):
|
||||
"""Путь для загрузки файлов задания (одно назначение — только задание)."""
|
||||
ext = filename.split('.')[-1] if '.' in filename else ''
|
||||
name = f"{uuid.uuid4()}.{ext}" if ext else str(uuid.uuid4())
|
||||
return os.path.join('homework', 'assignment_files', str(instance.homework_id), name)
|
||||
|
||||
|
||||
class HomeworkAssignmentFile(models.Model):
|
||||
"""
|
||||
Файл задания: прямая связь Homework → файл.
|
||||
Только для файлов, прикреплённых ментором к заданию (без file_type, без submission).
|
||||
"""
|
||||
homework = models.ForeignKey(
|
||||
Homework,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='assignment_files',
|
||||
verbose_name='Домашнее задание',
|
||||
)
|
||||
file = models.FileField(
|
||||
upload_to=assignment_file_upload_path,
|
||||
max_length=500,
|
||||
verbose_name='Файл',
|
||||
)
|
||||
filename = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Название файла',
|
||||
)
|
||||
file_size = models.BigIntegerField(
|
||||
verbose_name='Размер файла (bytes)',
|
||||
)
|
||||
uploaded_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='uploaded_assignment_files',
|
||||
verbose_name='Загрузил',
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата загрузки',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'homework_assignment_files'
|
||||
verbose_name = 'Файл задания'
|
||||
verbose_name_plural = 'Файлы задания'
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.filename
|
||||
|
||||
|
||||
class HomeworkSubmission(models.Model):
|
||||
"""
|
||||
Модель решения домашнего задания.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Ожидает проверки'),
|
||||
('checking', 'На проверке'),
|
||||
('graded', 'Проверено'),
|
||||
('returned', 'Возвращено на доработку'),
|
||||
]
|
||||
|
||||
# Основная информация
|
||||
homework = models.ForeignKey(
|
||||
Homework,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='submissions',
|
||||
verbose_name='Домашнее задание'
|
||||
)
|
||||
|
||||
student = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='homework_submissions',
|
||||
verbose_name='Студент'
|
||||
)
|
||||
|
||||
# Содержимое решения
|
||||
content = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Текст решения'
|
||||
)
|
||||
|
||||
attachment = models.FileField(
|
||||
upload_to=submission_file_upload_path,
|
||||
blank=True,
|
||||
max_length=500,
|
||||
verbose_name='Файл решения'
|
||||
)
|
||||
|
||||
attachment_url = models.URLField(
|
||||
blank=True,
|
||||
max_length=500,
|
||||
verbose_name='Ссылка на решение'
|
||||
)
|
||||
|
||||
# Статус и проверка
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='pending',
|
||||
verbose_name='Статус',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
score = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name='Балл'
|
||||
)
|
||||
|
||||
passed = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Сдано'
|
||||
)
|
||||
|
||||
# Отзыв ментора
|
||||
feedback = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Отзыв'
|
||||
)
|
||||
|
||||
checked_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='checked_submissions',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Проверил'
|
||||
)
|
||||
|
||||
checked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата проверки'
|
||||
)
|
||||
|
||||
# AI проверка
|
||||
ai_score = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='AI балл'
|
||||
)
|
||||
|
||||
ai_feedback = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='AI отзыв'
|
||||
)
|
||||
|
||||
ai_checked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата AI проверки'
|
||||
)
|
||||
|
||||
graded_by_ai = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Оценку выставил ИИ',
|
||||
help_text='True, если оценка опубликована автоматически через ИИ'
|
||||
)
|
||||
|
||||
# Попытки
|
||||
attempt_number = models.IntegerField(
|
||||
default=1,
|
||||
verbose_name='Номер попытки'
|
||||
)
|
||||
|
||||
is_late = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Сдано с опозданием'
|
||||
)
|
||||
|
||||
# Временные метки
|
||||
submitted_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата отправки'
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name='Дата обновления'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'homework_submissions'
|
||||
verbose_name = 'Решение ДЗ'
|
||||
verbose_name_plural = 'Решения ДЗ'
|
||||
ordering = ['-submitted_at']
|
||||
unique_together = ['homework', 'student', 'attempt_number']
|
||||
indexes = [
|
||||
models.Index(fields=['homework', 'student']),
|
||||
models.Index(fields=['student', 'status']),
|
||||
models.Index(fields=['status', 'submitted_at']),
|
||||
models.Index(fields=['homework', 'status']),
|
||||
models.Index(fields=['submitted_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.student.get_full_name()} - {self.homework.title}"
|
||||
|
||||
def grade(self, score, feedback, checked_by):
|
||||
"""Выставить оценку."""
|
||||
self.status = 'graded'
|
||||
self.score = score
|
||||
self.feedback = feedback
|
||||
self.checked_by = checked_by
|
||||
self.checked_at = timezone.now()
|
||||
|
||||
# Проверяем прошло ли
|
||||
if score >= self.homework.passing_score:
|
||||
self.passed = True
|
||||
|
||||
self.save()
|
||||
|
||||
# Обновляем статистику задания
|
||||
self.homework.update_statistics()
|
||||
|
||||
def return_for_revision(self, feedback, checked_by):
|
||||
"""Вернуть на доработку."""
|
||||
self.status = 'returned'
|
||||
self.feedback = feedback
|
||||
self.checked_by = checked_by
|
||||
self.checked_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
# Обновляем статистику задания
|
||||
self.homework.update_statistics()
|
||||
|
||||
def check_if_late(self):
|
||||
"""Проверить сдано ли с опозданием."""
|
||||
if self.homework.deadline:
|
||||
if self.submitted_at > self.homework.deadline:
|
||||
self.is_late = True
|
||||
self.save(update_fields=['is_late'])
|
||||
|
||||
|
||||
class HomeworkFile(models.Model):
|
||||
"""
|
||||
Дополнительные файлы к домашнему заданию или решению.
|
||||
"""
|
||||
|
||||
FILE_TYPE_CHOICES = [
|
||||
('assignment', 'Файл задания'),
|
||||
('submission', 'Файл решения'),
|
||||
('feedback', 'Файл отзыва'),
|
||||
]
|
||||
|
||||
homework = models.ForeignKey(
|
||||
Homework,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='files',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Домашнее задание'
|
||||
)
|
||||
|
||||
submission = models.ForeignKey(
|
||||
HomeworkSubmission,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='files',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Решение'
|
||||
)
|
||||
|
||||
file_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=FILE_TYPE_CHOICES,
|
||||
verbose_name='Тип файла'
|
||||
)
|
||||
|
||||
file = models.FileField(
|
||||
upload_to='homework/files/',
|
||||
max_length=500,
|
||||
verbose_name='Файл'
|
||||
)
|
||||
|
||||
filename = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Название файла'
|
||||
)
|
||||
|
||||
file_size = models.BigIntegerField(
|
||||
verbose_name='Размер файла (bytes)'
|
||||
)
|
||||
|
||||
uploaded_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='uploaded_homework_files',
|
||||
null=True,
|
||||
verbose_name='Загрузил'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата загрузки'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'homework_files'
|
||||
verbose_name = 'Файл ДЗ'
|
||||
verbose_name_plural = 'Файлы ДЗ'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.filename
|
||||
|
||||
|
||||
class HomeworkAIAgent(models.Model):
|
||||
"""
|
||||
ИИ-агент для проверки домашних заданий.
|
||||
OpenAI-совместимый API. Рекомендуется RouterAI: https://routerai.ru/docs/reference
|
||||
Эндпоинт: POST {base_url}/chat/completions (OpenAI-формат).
|
||||
В openai_url храним базовый URL до .../v1 (без /chat/completions).
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Название модели'
|
||||
)
|
||||
openai_url = models.URLField(
|
||||
max_length=500,
|
||||
verbose_name='OpenAI URL (базовый)',
|
||||
help_text='Базовый URL до .../v1. RouterAI: https://routerai.ru/api/v1'
|
||||
)
|
||||
model_name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name='Название модели',
|
||||
help_text='Идентификатор модели RouterAI, например: google/gemini-3-flash-preview, openai/gpt-4o-mini, anthropic/claude-3-5-sonnet. Список: https://routerai.ru/models'
|
||||
)
|
||||
api_key = models.CharField(
|
||||
max_length=2048,
|
||||
blank=True,
|
||||
verbose_name='API ключ (токен)',
|
||||
help_text='API-ключ с https://routerai.ru/settings/keys. Пусто — использовать HOMEWORK_AI_API_KEY из .env'
|
||||
)
|
||||
AUTH_HEADER_BEARER = 'Bearer'
|
||||
AUTH_HEADER_X_API_KEY = 'X-API-Key'
|
||||
AUTH_HEADER_CHOICES = [
|
||||
(AUTH_HEADER_BEARER, 'Authorization: Bearer (по умолчанию, RouterAI)'),
|
||||
(AUTH_HEADER_X_API_KEY, 'X-API-Key'),
|
||||
]
|
||||
auth_header = models.CharField(
|
||||
max_length=32,
|
||||
choices=AUTH_HEADER_CHOICES,
|
||||
default=AUTH_HEADER_BEARER,
|
||||
verbose_name='Заголовок авторизации',
|
||||
help_text='RouterAI использует Bearer. При 401 у другого провайдера попробуйте X-API-Key.'
|
||||
)
|
||||
system_prompt = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='Системный промпт',
|
||||
help_text='Системный промпт для модели (роль и инструкции проверки). Пусто — используется встроенный промпт проверки ДЗ.'
|
||||
)
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Использовать по умолчанию для проверки ДЗ'
|
||||
)
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name='Порядок сортировки'
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Активен'
|
||||
)
|
||||
dev_mode = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Режим разработки (AI)',
|
||||
help_text='Включить отладочный промпт: ИИ описывает содержимое изображений в комментарии. Выключено — обычная проверка.'
|
||||
)
|
||||
# Параметры генерации, влияющие на ответ модели (OpenAI/RouterAI chat completions)
|
||||
temperature = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(0.0), MaxValueValidator(2.0)],
|
||||
verbose_name='Temperature',
|
||||
help_text='Случайность ответа: 0 = детерминированный, 2 = максимально разнообразный. Обычно 0.3–0.7 для проверки ДЗ. Пусто — значение по умолчанию провайдера.'
|
||||
)
|
||||
top_p = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(0.0), MaxValueValidator(1.0)],
|
||||
verbose_name='Top P (nucleus sampling)',
|
||||
help_text='Доля наиболее вероятных токенов для выбора (0–1). Меньше — более фокусный ответ. Пусто — по умолчанию провайдера.'
|
||||
)
|
||||
max_tokens = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Max output tokens',
|
||||
help_text='Максимальная длина ответа в токенах. Пусто — лимит провайдера. Для развёрнутого комментария можно 2000–4000.'
|
||||
)
|
||||
usage_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name='Использований',
|
||||
help_text='Счётчик успешных проверок ДЗ через этого агента.'
|
||||
)
|
||||
total_prompt_tokens = models.PositiveBigIntegerField(
|
||||
default=0,
|
||||
verbose_name='Всего токенов (вход)',
|
||||
help_text='Накоплено входящих токенов за все проверки. Баланс и лимиты — в личном кабинете RouterAI.'
|
||||
)
|
||||
total_completion_tokens = models.PositiveBigIntegerField(
|
||||
default=0,
|
||||
verbose_name='Всего токенов (выход)',
|
||||
help_text='Накоплено исходящих токенов за все проверки.'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создан')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлён')
|
||||
|
||||
class Meta:
|
||||
db_table = 'homework_ai_agents'
|
||||
verbose_name = 'ИИ-агент для ДЗ'
|
||||
verbose_name_plural = 'ИИ-агенты для ДЗ'
|
||||
ordering = ['order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.model_name})'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Нормализация: убираем /chat/completions, если вставили полный URL из документации
|
||||
if self.openai_url:
|
||||
url = self.openai_url.rstrip('/')
|
||||
if url.endswith('/chat/completions'):
|
||||
self.openai_url = url[:-len('/chat/completions')].rstrip('/')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_base_url(self):
|
||||
"""Базовый URL для OpenAI-клиента (без завершающего слэша)."""
|
||||
if not self.openai_url:
|
||||
return ''
|
||||
url = self.openai_url.rstrip('/')
|
||||
if url.endswith('/chat/completions'):
|
||||
url = url[:-len('/chat/completions')].rstrip('/')
|
||||
return url
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"""
|
||||
Permissions для homework модуля.
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class IsHomeworkMentor(permissions.BasePermission):
|
||||
"""
|
||||
Проверка что пользователь - ментор задания.
|
||||
"""
|
||||
|
||||
message = 'Только ментор задания может выполнить это действие.'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
# Для Homework
|
||||
if hasattr(obj, 'mentor'):
|
||||
return obj.mentor == request.user
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsSubmissionOwnerOrMentor(permissions.BasePermission):
|
||||
"""
|
||||
Проверка что пользователь - автор решения или ментор задания.
|
||||
"""
|
||||
|
||||
message = 'У вас нет доступа к этому решению.'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Проверка доступа к объекту."""
|
||||
# Для HomeworkSubmission
|
||||
if hasattr(obj, 'student') and hasattr(obj, 'homework'):
|
||||
# Студент может видеть свои решения
|
||||
if obj.student == request.user:
|
||||
return True
|
||||
|
||||
# Ментор может видеть решения своих заданий
|
||||
if obj.homework.mentor == request.user:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -0,0 +1,613 @@
|
|||
"""
|
||||
Сериализаторы для домашних заданий.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from .models import Homework, HomeworkSubmission, HomeworkFile, HomeworkAssignmentFile, HomeworkAIAgent
|
||||
from apps.users.serializers import UserSerializer
|
||||
from apps.users.mixins import TimezoneAwareSerializerMixin
|
||||
|
||||
|
||||
class HomeworkAIAgentSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор ИИ-агента (без api_key)."""
|
||||
class Meta:
|
||||
model = HomeworkAIAgent
|
||||
fields = ['id', 'name', 'openai_url', 'model_name', 'is_default', 'order', 'is_active']
|
||||
read_only_fields = ['id', 'name', 'openai_url', 'model_name', 'is_default', 'order', 'is_active']
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = frozenset(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'])
|
||||
|
||||
|
||||
def _is_image_filename(filename):
|
||||
"""Определение по расширению, что файл — изображение (для превью и модального просмотра)."""
|
||||
if not filename or '.' not in str(filename).strip():
|
||||
return False
|
||||
ext = str(filename).rsplit('.', 1)[-1].lower()
|
||||
return ext in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
class HomeworkFileSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор файла ДЗ (решения/отзывы)."""
|
||||
|
||||
uploaded_by = UserSerializer(read_only=True)
|
||||
is_image = serializers.SerializerMethodField()
|
||||
|
||||
def get_is_image(self, obj):
|
||||
if _is_image_filename(obj.filename):
|
||||
return True
|
||||
if getattr(obj, 'file', None) and obj.file.name:
|
||||
return _is_image_filename(obj.file.name)
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
model = HomeworkFile
|
||||
fields = [
|
||||
'id',
|
||||
'file_type',
|
||||
'file',
|
||||
'filename',
|
||||
'file_size',
|
||||
'uploaded_by',
|
||||
'created_at',
|
||||
'is_image',
|
||||
]
|
||||
read_only_fields = ['uploaded_by', 'created_at']
|
||||
|
||||
|
||||
class HomeworkAssignmentFileSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор файла задания (прямая связь Homework → файл)."""
|
||||
|
||||
uploaded_by = UserSerializer(read_only=True)
|
||||
file_type = serializers.ReadOnlyField(default='assignment')
|
||||
is_image = serializers.SerializerMethodField()
|
||||
|
||||
def get_is_image(self, obj):
|
||||
if _is_image_filename(obj.filename):
|
||||
return True
|
||||
if getattr(obj, 'file', None) and obj.file.name:
|
||||
return _is_image_filename(obj.file.name)
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
model = HomeworkAssignmentFile
|
||||
fields = [
|
||||
'id',
|
||||
'file_type',
|
||||
'file',
|
||||
'filename',
|
||||
'file_size',
|
||||
'uploaded_by',
|
||||
'created_at',
|
||||
'is_image',
|
||||
]
|
||||
read_only_fields = ['uploaded_by', 'created_at']
|
||||
|
||||
|
||||
class HomeworkSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
|
||||
"""Сериализатор домашнего задания."""
|
||||
|
||||
mentor = UserSerializer(read_only=True)
|
||||
assigned_to = UserSerializer(many=True, read_only=True)
|
||||
files = serializers.SerializerMethodField()
|
||||
is_overdue = serializers.BooleanField(read_only=True)
|
||||
|
||||
def get_files(self, obj):
|
||||
"""Файлы задания — из модели HomeworkAssignmentFile (прямая связь)."""
|
||||
if not hasattr(obj, 'assignment_files'):
|
||||
return []
|
||||
return HomeworkAssignmentFileSerializer(obj.assignment_files.all(), many=True).data
|
||||
|
||||
class Meta:
|
||||
model = Homework
|
||||
fields = [
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
'mentor',
|
||||
'lesson',
|
||||
'assigned_to',
|
||||
'attachment',
|
||||
'attachment_url',
|
||||
'deadline',
|
||||
'max_score',
|
||||
'passing_score',
|
||||
'allow_late_submission',
|
||||
'auto_check_enabled',
|
||||
'ai_check_enabled',
|
||||
'requires_file',
|
||||
'allowed_file_types',
|
||||
'max_file_size',
|
||||
'status',
|
||||
'fill_later',
|
||||
'total_submissions',
|
||||
'checked_submissions',
|
||||
'returned_submissions',
|
||||
'average_score',
|
||||
'is_overdue',
|
||||
'files',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'published_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'mentor',
|
||||
'total_submissions',
|
||||
'checked_submissions',
|
||||
'returned_submissions',
|
||||
'average_score',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'published_at'
|
||||
]
|
||||
timezone_aware_fields = ['deadline', 'created_at', 'updated_at', 'published_at']
|
||||
|
||||
|
||||
class HomeworkListSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор списка ДЗ (упрощенный)."""
|
||||
|
||||
mentor = UserSerializer(read_only=True)
|
||||
is_overdue = serializers.BooleanField(read_only=True)
|
||||
students = serializers.SerializerMethodField()
|
||||
student_score = serializers.SerializerMethodField()
|
||||
lesson_subject = serializers.SerializerMethodField()
|
||||
ai_draft_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Homework
|
||||
fields = [
|
||||
'id',
|
||||
'title',
|
||||
'mentor',
|
||||
'lesson',
|
||||
'lesson_subject',
|
||||
'deadline',
|
||||
'max_score',
|
||||
'passing_score',
|
||||
'status',
|
||||
'fill_later',
|
||||
'total_submissions',
|
||||
'checked_submissions',
|
||||
'returned_submissions',
|
||||
'average_score',
|
||||
'is_overdue',
|
||||
'created_at',
|
||||
'published_at',
|
||||
'students',
|
||||
'student_score',
|
||||
'ai_draft_count',
|
||||
]
|
||||
|
||||
def get_lesson_subject(self, obj):
|
||||
"""Получить название предмета из урока."""
|
||||
if obj.lesson and obj.lesson.subject:
|
||||
return obj.lesson.subject.name
|
||||
if obj.lesson and obj.lesson.mentor_subject:
|
||||
return obj.lesson.mentor_subject.name
|
||||
if obj.lesson and obj.lesson.subject_name:
|
||||
return obj.lesson.subject_name
|
||||
return None
|
||||
|
||||
def get_students(self, obj):
|
||||
"""Получить список студентов для ментора."""
|
||||
request = self.context.get('request')
|
||||
if not request or request.user.role != 'mentor':
|
||||
return None
|
||||
|
||||
# Получаем всех назначенных студентов
|
||||
assigned_students = list(obj.assigned_to.all())
|
||||
|
||||
# Оптимизация: получаем все submissions одним запросом и группируем в Python
|
||||
# Используем prefetch_related из views, но все равно нужно получить последнюю для каждого студента
|
||||
all_submissions = list(obj.submissions.all().order_by('-submitted_at'))
|
||||
|
||||
# Группируем submissions по student_id, берем первую (последнюю по submitted_at)
|
||||
submissions_by_student = {}
|
||||
for submission in all_submissions:
|
||||
student_id = submission.student_id
|
||||
if student_id not in submissions_by_student:
|
||||
submissions_by_student[student_id] = submission
|
||||
|
||||
students = []
|
||||
for student in assigned_students:
|
||||
submission = submissions_by_student.get(student.id)
|
||||
if submission:
|
||||
students.append({
|
||||
'id': student.id,
|
||||
'first_name': student.first_name,
|
||||
'last_name': student.last_name,
|
||||
'score': submission.score,
|
||||
'status': submission.status,
|
||||
})
|
||||
else:
|
||||
# Если решения нет, все равно показываем студента
|
||||
students.append({
|
||||
'id': student.id,
|
||||
'first_name': student.first_name,
|
||||
'last_name': student.last_name,
|
||||
'score': None,
|
||||
'status': None,
|
||||
})
|
||||
|
||||
return students if students else None
|
||||
|
||||
def get_student_score(self, obj):
|
||||
"""Получить оценку студента."""
|
||||
request = self.context.get('request')
|
||||
if not request or request.user.role != 'client':
|
||||
return None
|
||||
|
||||
# Получаем последнюю попытку студента (может быть несколько попыток)
|
||||
submission = obj.submissions.filter(student=request.user).order_by('-submitted_at').first()
|
||||
if submission:
|
||||
return {
|
||||
'score': submission.score,
|
||||
'max_score': obj.max_score,
|
||||
'status': submission.status,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_ai_draft_count(self, obj):
|
||||
"""Количество решений с черновиком от ИИ. Только для ментора. Берём из поля модели ai_draft_submissions."""
|
||||
request = self.context.get('request')
|
||||
if not request or request.user.role != 'mentor':
|
||||
return 0
|
||||
return getattr(obj, 'ai_draft_submissions', 0)
|
||||
|
||||
|
||||
class HomeworkCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания ДЗ."""
|
||||
|
||||
lesson_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
assigned_to_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_empty=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Homework
|
||||
extra_kwargs = {
|
||||
'description': {'required': False, 'allow_blank': True},
|
||||
}
|
||||
fields = [
|
||||
'title',
|
||||
'description',
|
||||
'lesson_id',
|
||||
'assigned_to_ids',
|
||||
'attachment',
|
||||
'attachment_url',
|
||||
'deadline',
|
||||
'max_score',
|
||||
'passing_score',
|
||||
'allow_late_submission',
|
||||
'auto_check_enabled',
|
||||
'ai_check_enabled',
|
||||
'requires_file',
|
||||
'allowed_file_types',
|
||||
'max_file_size',
|
||||
'status',
|
||||
'fill_later'
|
||||
]
|
||||
|
||||
def validate_lesson_id(self, value):
|
||||
"""Проверка занятия."""
|
||||
if value:
|
||||
from apps.schedule.models import Lesson
|
||||
try:
|
||||
lesson = Lesson.objects.get(id=value)
|
||||
# Проверяем что пользователь - ментор занятия
|
||||
user = self.context['request'].user
|
||||
if lesson.mentor != user:
|
||||
raise serializers.ValidationError(
|
||||
'Вы не являетесь ментором этого занятия'
|
||||
)
|
||||
except Lesson.DoesNotExist:
|
||||
raise serializers.ValidationError('Занятие не найдено')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание ДЗ."""
|
||||
from apps.schedule.models import Lesson
|
||||
from apps.users.models import User
|
||||
|
||||
lesson_id = validated_data.pop('lesson_id', None)
|
||||
assigned_to_ids = validated_data.pop('assigned_to_ids', [])
|
||||
user = self.context['request'].user
|
||||
|
||||
homework = Homework.objects.create(
|
||||
mentor=user,
|
||||
lesson_id=lesson_id,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
# Добавляем студентов
|
||||
if assigned_to_ids:
|
||||
students = User.objects.filter(id__in=assigned_to_ids)
|
||||
homework.assigned_to.set(students)
|
||||
|
||||
# Если задание для занятия, автоматически назначаем клиента
|
||||
if lesson_id:
|
||||
lesson = Lesson.objects.get(id=lesson_id)
|
||||
if lesson.client and lesson.client.user:
|
||||
homework.assigned_to.add(lesson.client.user)
|
||||
|
||||
return homework
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Обновление ДЗ."""
|
||||
from apps.schedule.models import Lesson
|
||||
from apps.users.models import User
|
||||
|
||||
lesson_id = validated_data.pop('lesson_id', None)
|
||||
assigned_to_ids = validated_data.pop('assigned_to_ids', None)
|
||||
|
||||
# Обновляем основные поля
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
# Обновляем связь с уроком
|
||||
if lesson_id is not None:
|
||||
instance.lesson_id = lesson_id
|
||||
instance.save()
|
||||
|
||||
# Обновляем назначенных студентов
|
||||
if assigned_to_ids is not None:
|
||||
students = User.objects.filter(id__in=assigned_to_ids)
|
||||
instance.assigned_to.set(students)
|
||||
|
||||
# Если задание для занятия, автоматически добавляем клиента
|
||||
if lesson_id:
|
||||
lesson = Lesson.objects.get(id=lesson_id)
|
||||
if lesson.client and lesson.client.user:
|
||||
instance.assigned_to.add(lesson.client.user)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class HomeworkSubmissionSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
|
||||
"""Сериализатор решения ДЗ."""
|
||||
|
||||
student = UserSerializer(read_only=True)
|
||||
checked_by = UserSerializer(read_only=True)
|
||||
homework = HomeworkListSerializer(read_only=True)
|
||||
files = HomeworkFileSerializer(many=True, read_only=True)
|
||||
ai_feedback_html = serializers.SerializerMethodField()
|
||||
feedback_html = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = HomeworkSubmission
|
||||
fields = [
|
||||
'id',
|
||||
'homework',
|
||||
'student',
|
||||
'content',
|
||||
'attachment',
|
||||
'attachment_url',
|
||||
'status',
|
||||
'score',
|
||||
'passed',
|
||||
'feedback',
|
||||
'feedback_html',
|
||||
'checked_by',
|
||||
'checked_at',
|
||||
'ai_score',
|
||||
'ai_feedback',
|
||||
'ai_feedback_html',
|
||||
'ai_checked_at',
|
||||
'graded_by_ai',
|
||||
'attempt_number',
|
||||
'is_late',
|
||||
'files',
|
||||
'submitted_at',
|
||||
'updated_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'student',
|
||||
'checked_by',
|
||||
'checked_at',
|
||||
'ai_score',
|
||||
'ai_feedback',
|
||||
'ai_checked_at',
|
||||
'graded_by_ai',
|
||||
'attempt_number',
|
||||
'is_late',
|
||||
'submitted_at',
|
||||
'updated_at'
|
||||
]
|
||||
timezone_aware_fields = ['checked_at', 'ai_checked_at', 'submitted_at', 'updated_at']
|
||||
|
||||
def get_ai_feedback_html(self, obj):
|
||||
"""HTML для отображения ai_feedback: markdown + LaTeX ($a$, $$...$$) → HTML/MathML."""
|
||||
from .utils import feedback_to_html
|
||||
return feedback_to_html(obj.ai_feedback) if obj.ai_feedback else ''
|
||||
|
||||
def get_feedback_html(self, obj):
|
||||
"""HTML для отображения комментария проверки: markdown + LaTeX → HTML/MathML."""
|
||||
from .utils import feedback_to_html
|
||||
return feedback_to_html(obj.feedback) if obj.feedback else ''
|
||||
|
||||
|
||||
class HomeworkSubmissionCreateSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор создания решения ДЗ."""
|
||||
|
||||
homework_id = serializers.IntegerField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = HomeworkSubmission
|
||||
fields = [
|
||||
'homework_id',
|
||||
'content',
|
||||
'attachment',
|
||||
'attachment_url'
|
||||
]
|
||||
|
||||
def validate_homework_id(self, value):
|
||||
"""Проверка ДЗ."""
|
||||
try:
|
||||
homework = Homework.objects.get(id=value)
|
||||
except Homework.DoesNotExist:
|
||||
raise serializers.ValidationError('Домашнее задание не найдено')
|
||||
|
||||
# Проверяем что задание опубликовано
|
||||
if homework.status != 'published':
|
||||
raise serializers.ValidationError('Задание еще не опубликовано')
|
||||
|
||||
# Проверяем дедлайн если не разрешена поздняя сдача
|
||||
if not homework.allow_late_submission and homework.is_overdue():
|
||||
raise serializers.ValidationError('Дедлайн прошел')
|
||||
|
||||
# Проверяем что пользователь назначен на это задание
|
||||
user = self.context['request'].user
|
||||
if not homework.assigned_to.filter(id=user.id).exists():
|
||||
raise serializers.ValidationError('Вам не назначено это задание')
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Общая валидация."""
|
||||
homework = Homework.objects.get(id=attrs['homework_id'])
|
||||
|
||||
# Проверяем, что есть либо текст, либо файл/ссылка
|
||||
has_content = bool(attrs.get('content', '').strip())
|
||||
has_attachment = bool(attrs.get('attachment')) or bool(attrs.get('attachment_url', '').strip())
|
||||
|
||||
if not has_content and not has_attachment:
|
||||
raise serializers.ValidationError({
|
||||
'content': 'Необходимо указать текст ответа или прикрепить файл'
|
||||
})
|
||||
|
||||
# Если требуется файл, проверяем его наличие
|
||||
# Но если есть текстовый ответ, файл не обязателен
|
||||
if homework.requires_file and not has_attachment and not has_content:
|
||||
raise serializers.ValidationError({
|
||||
'attachment': 'Требуется прикрепить файл или указать текстовый ответ'
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создание или обновление решения."""
|
||||
homework_id = validated_data.pop('homework_id')
|
||||
homework = Homework.objects.get(id=homework_id)
|
||||
user = self.context['request'].user
|
||||
|
||||
# Проверяем, есть ли submission со статусом 'returned' для этого студента и задания
|
||||
returned_submission = HomeworkSubmission.objects.filter(
|
||||
homework=homework,
|
||||
student=user,
|
||||
status='returned'
|
||||
).order_by('-submitted_at').first()
|
||||
|
||||
if returned_submission:
|
||||
# Обновляем существующее submission, которое было возвращено на доработку
|
||||
# Перезаписываем содержимое, но сохраняем тот же ID и attempt_number
|
||||
if 'content' in validated_data:
|
||||
returned_submission.content = validated_data['content']
|
||||
if 'attachment' in validated_data and validated_data['attachment']:
|
||||
returned_submission.attachment = validated_data['attachment']
|
||||
if 'attachment_url' in validated_data:
|
||||
returned_submission.attachment_url = validated_data['attachment_url']
|
||||
returned_submission.status = 'pending' # Меняем статус на pending
|
||||
returned_submission.feedback = '' # Очищаем feedback
|
||||
returned_submission.checked_by = None # Очищаем checked_by
|
||||
returned_submission.checked_at = None # Очищаем checked_at
|
||||
returned_submission.score = None # Очищаем оценку
|
||||
returned_submission.passed = False # Сбрасываем passed
|
||||
returned_submission.ai_score = None # Очищаем AI оценку
|
||||
returned_submission.ai_feedback = '' # Очищаем AI feedback
|
||||
returned_submission.ai_checked_at = None # Очищаем AI checked_at
|
||||
returned_submission.save()
|
||||
|
||||
submission = returned_submission
|
||||
else:
|
||||
# Создаем новое submission
|
||||
# Определяем номер попытки
|
||||
last_submission = HomeworkSubmission.objects.filter(
|
||||
homework=homework,
|
||||
student=user
|
||||
).order_by('-attempt_number').first()
|
||||
|
||||
attempt_number = 1
|
||||
if last_submission:
|
||||
attempt_number = last_submission.attempt_number + 1
|
||||
|
||||
submission = HomeworkSubmission.objects.create(
|
||||
homework=homework,
|
||||
student=user,
|
||||
attempt_number=attempt_number,
|
||||
**validated_data
|
||||
)
|
||||
|
||||
# Проверяем опоздание
|
||||
submission.check_if_late()
|
||||
|
||||
# Обновляем статистику задания
|
||||
homework.update_statistics()
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
# Шкала оценки ментором: от 1 до 5; зачёт при 3 и выше
|
||||
MENTOR_GRADE_MIN = 1
|
||||
MENTOR_GRADE_MAX = 5
|
||||
MENTOR_PASS_THRESHOLD = 3
|
||||
|
||||
|
||||
class HomeworkGradeSerializer(serializers.Serializer):
|
||||
"""Сериализатор выставления оценки (ментор оценивает от 1 до 5)."""
|
||||
|
||||
score = serializers.IntegerField(
|
||||
min_value=MENTOR_GRADE_MIN,
|
||||
max_value=MENTOR_GRADE_MAX,
|
||||
required=True
|
||||
)
|
||||
|
||||
feedback = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True
|
||||
)
|
||||
|
||||
def validate_score(self, value):
|
||||
"""Проверка балла: только 1–5."""
|
||||
if value < MENTOR_GRADE_MIN or value > MENTOR_GRADE_MAX:
|
||||
raise serializers.ValidationError(
|
||||
f'Оценка должна быть от {MENTOR_GRADE_MIN} до {MENTOR_GRADE_MAX}'
|
||||
)
|
||||
return value
|
||||
|
||||
def save(self):
|
||||
"""Выставить оценку."""
|
||||
submission = self.instance
|
||||
user = self.context['request'].user
|
||||
score = self.validated_data['score']
|
||||
|
||||
submission.grade(
|
||||
score=score,
|
||||
feedback=self.validated_data.get('feedback', ''),
|
||||
checked_by=user
|
||||
)
|
||||
# Зачёт при 3 и выше по шкале 1–5
|
||||
submission.passed = score >= MENTOR_PASS_THRESHOLD
|
||||
submission.save(update_fields=['passed'])
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
class HomeworkReturnSerializer(serializers.Serializer):
|
||||
"""Сериализатор возврата на доработку."""
|
||||
|
||||
feedback = serializers.CharField(required=True)
|
||||
|
||||
def save(self):
|
||||
"""Вернуть на доработку."""
|
||||
submission = self.instance
|
||||
user = self.context['request'].user
|
||||
|
||||
submission.return_for_revision(
|
||||
feedback=self.validated_data['feedback'],
|
||||
checked_by=user
|
||||
)
|
||||
|
||||
return submission
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Сигналы для домашних заданий.
|
||||
"""
|
||||
from django.db.models.signals import post_save, pre_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from .models import Homework, HomeworkSubmission
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Homework)
|
||||
def homework_post_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Обработчик после сохранения домашнего задания.
|
||||
"""
|
||||
if created:
|
||||
# При создании нового задания обновляем статистику
|
||||
instance.update_statistics()
|
||||
logger.info(f"Создано новое домашнее задание: {instance.id} - {instance.title}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=HomeworkSubmission)
|
||||
def homework_submission_post_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Обработчик после сохранения решения домашнего задания.
|
||||
"""
|
||||
if created:
|
||||
# При создании нового решения проверяем опоздание
|
||||
instance.check_if_late()
|
||||
|
||||
# Обновляем статистику задания
|
||||
instance.homework.update_statistics()
|
||||
|
||||
logger.info(f"Создано новое решение ДЗ: {instance.id} для задания {instance.homework.id}")
|
||||
|
||||
# При изменении статуса или черновика от ИИ — обновляем статистику задания (в т.ч. ai_draft_submissions)
|
||||
if not created:
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if update_fields is not None:
|
||||
if 'status' in update_fields or 'ai_checked_at' in update_fields:
|
||||
instance.homework.update_statistics()
|
||||
|
||||
|
||||
@receiver(pre_save, sender=HomeworkSubmission)
|
||||
def homework_submission_pre_save(sender, instance, **kwargs):
|
||||
"""
|
||||
Обработчик перед сохранением решения домашнего задания.
|
||||
|
||||
Автоматически переводит в статус 'checking' если включена автоматическая проверка.
|
||||
"""
|
||||
# Если решение создается впервые и у задания включена автоматическая проверка
|
||||
if not instance.pk and instance.homework.auto_check_enabled:
|
||||
instance.status = 'checking'
|
||||
|
||||
|
||||
@receiver(post_delete, sender=HomeworkSubmission)
|
||||
def homework_submission_post_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
После удаления решения — обновить статистику задания,
|
||||
чтобы колонки канбана (Ожидают / На проверке и т.д.) отображались верно.
|
||||
"""
|
||||
try:
|
||||
homework = Homework.objects.get(id=instance.homework_id)
|
||||
homework.update_statistics()
|
||||
except Homework.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
"""
|
||||
Celery задачи для домашних заданий.
|
||||
"""
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _student_display_name(submission):
|
||||
"""Имя ученика (только имя, без фамилии) — тот, кто отправил ДЗ."""
|
||||
if not submission or not getattr(submission, 'student', None):
|
||||
return None
|
||||
s = submission.student
|
||||
name = (s.first_name or (s.get_short_name() if getattr(s, 'email', None) else '') or '').strip()
|
||||
return name or f"Студент (id: {s.pk})"
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_homework_deadline_reminders():
|
||||
"""
|
||||
Отправка напоминаний о приближающихся дедлайнах домашних заданий.
|
||||
|
||||
Запускается каждый день в 09:00.
|
||||
Отправляет напоминания за 1 день, 3 дня и 1 неделю до дедлайна.
|
||||
"""
|
||||
from .models import Homework
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# Находим задания с дедлайнами в ближайшие периоды
|
||||
deadlines_to_check = [
|
||||
(now + timedelta(days=1), 'Через 1 день'), # Завтра
|
||||
(now + timedelta(days=3), 'Через 3 дня'), # Через 3 дня
|
||||
(now + timedelta(days=7), 'Через неделю'), # Через неделю
|
||||
]
|
||||
|
||||
sent_count = 0
|
||||
|
||||
for deadline_date, deadline_text in deadlines_to_check:
|
||||
# Находим задания с дедлайном в этот день (в пределах дня)
|
||||
start_of_day = deadline_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end_of_day = deadline_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
homeworks = Homework.objects.filter(
|
||||
status='published',
|
||||
deadline__gte=start_of_day,
|
||||
deadline__lte=end_of_day
|
||||
).select_related('mentor', 'lesson').prefetch_related('assigned_to', 'submissions')
|
||||
|
||||
for homework in homeworks:
|
||||
# Оптимизация: используем prefetch_related для submissions и assigned_to
|
||||
# Преобразуем в list для использования предзагруженных данных
|
||||
submissions_list = list(homework.submissions.all())
|
||||
assigned_students_list = list(homework.assigned_to.all())
|
||||
submitted_student_ids = {sub.student_id for sub in submissions_list}
|
||||
students_without_submission = [
|
||||
student for student in assigned_students_list
|
||||
if student.id not in submitted_student_ids
|
||||
]
|
||||
|
||||
for student in students_without_submission:
|
||||
try:
|
||||
NotificationService.create_notification_with_telegram(
|
||||
recipient=student,
|
||||
notification_type='homework_deadline_reminder',
|
||||
title='⏰ Напоминание о дедлайне',
|
||||
message=f'Домашнее задание "{homework.title}" нужно сдать {deadline_text}',
|
||||
priority='normal',
|
||||
action_url=f'/homework/{homework.id}/',
|
||||
content_object=homework
|
||||
)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки напоминания о дедлайне: {str(e)}")
|
||||
|
||||
logger.info(f"Отправлено {sent_count} напоминаний о дедлайнах домашних заданий")
|
||||
return f"Отправлено {sent_count} напоминаний о дедлайнах"
|
||||
|
||||
|
||||
@shared_task
|
||||
def auto_check_homework_submissions():
|
||||
"""
|
||||
Автоматическая проверка домашних заданий через AI (если включена).
|
||||
|
||||
Запускается каждые 30 минут.
|
||||
Проверяет решения, у которых включена автоматическая проверка.
|
||||
"""
|
||||
from .models import HomeworkSubmission
|
||||
from .ai_service import get_ai_service
|
||||
|
||||
# Находим решения с включенной автоматической проверкой
|
||||
submissions = HomeworkSubmission.objects.filter(
|
||||
homework__auto_check_enabled=True,
|
||||
homework__ai_check_enabled=True,
|
||||
status='pending',
|
||||
ai_checked_at__isnull=True
|
||||
).select_related('homework', 'student')[:10] # Ограничиваем количество за раз
|
||||
|
||||
checked_count = 0
|
||||
|
||||
for submission in submissions:
|
||||
try:
|
||||
# Получаем файлы решения
|
||||
submission_files = []
|
||||
if submission.attachment:
|
||||
submission_files.append(submission.attachment.name)
|
||||
|
||||
from .models import HomeworkFile
|
||||
# Оптимизация: используем values_list для получения только имен файлов
|
||||
additional_file_names = HomeworkFile.objects.filter(
|
||||
submission=submission,
|
||||
file_type='submission'
|
||||
).exclude(file='').values_list('file', flat=True)
|
||||
submission_files.extend([name for name in additional_file_names if name])
|
||||
|
||||
# Вызываем AI проверку
|
||||
ai_service = get_ai_service()
|
||||
result = ai_service.check_submission(
|
||||
homework_title=submission.homework.title,
|
||||
homework_description=submission.homework.description,
|
||||
homework_max_score=submission.homework.max_score,
|
||||
submission_content=submission.content or '',
|
||||
submission_files=submission_files,
|
||||
student_name=_student_display_name(submission),
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
# Сохраняем результат AI проверки
|
||||
submission.ai_score = result.get('score')
|
||||
submission.ai_feedback = result.get('feedback')
|
||||
submission.ai_checked_at = timezone.now()
|
||||
submission.status = 'checking' # Переводим в статус "на проверке"
|
||||
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at', 'status'])
|
||||
checked_count += 1
|
||||
|
||||
logger.info(f"Автоматически проверено решение ДЗ {submission.id} через AI. Оценка: {submission.ai_score}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка автоматической проверки решения ДЗ {submission.id}: {str(e)}", exc_info=True)
|
||||
|
||||
if checked_count > 0:
|
||||
logger.info(f"Автоматически проверено {checked_count} решений домашних заданий")
|
||||
|
||||
return f"Автоматически проверено {checked_count} решений"
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def run_mentor_ai_check_submission(self, submission_id, publish):
|
||||
"""
|
||||
Проверка решения ДЗ через AI по настройкам ментора (доверять AI).
|
||||
Вызывается после загрузки ДЗ студентом, если у ментора включено ai_trust_draft или ai_trust_publish.
|
||||
publish: True — выставить оценку и опубликовать (status=graded, уведомление студенту);
|
||||
False — сохранить только как черновик (ai_score, ai_feedback, ai_checked_at).
|
||||
"""
|
||||
import traceback
|
||||
from .models import HomeworkSubmission, HomeworkFile
|
||||
from .ai_service import get_ai_service
|
||||
from django.core.files.storage import default_storage
|
||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
logger.info("run_mentor_ai_check_submission: старт submission_id=%s publish=%s", submission_id, publish)
|
||||
try:
|
||||
submission = HomeworkSubmission.objects.select_related(
|
||||
'homework', 'homework__mentor', 'student'
|
||||
).prefetch_related(
|
||||
'homework__assignment_files'
|
||||
).get(pk=submission_id)
|
||||
except HomeworkSubmission.DoesNotExist:
|
||||
logger.warning("run_mentor_ai_check_submission: submission %s не найден", submission_id)
|
||||
return
|
||||
|
||||
try:
|
||||
mentor = submission.homework.mentor
|
||||
homework_files = []
|
||||
homework_file_paths = []
|
||||
homework_file_contents = []
|
||||
def _add_homework_file(name):
|
||||
homework_files.append(name)
|
||||
try:
|
||||
p = default_storage.path(name)
|
||||
if p:
|
||||
homework_file_paths.append(p)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with default_storage.open(name, 'rb') as f:
|
||||
data = f.read(2 * 1024 * 1024 + 1)
|
||||
fname = name.split('/')[-1] if '/' in name else name
|
||||
homework_file_contents.append((fname, data[: 2 * 1024 * 1024]))
|
||||
except Exception:
|
||||
pass
|
||||
if submission.homework.attachment:
|
||||
_add_homework_file(submission.homework.attachment.name)
|
||||
for f in submission.homework.assignment_files.all():
|
||||
if f.file:
|
||||
_add_homework_file(f.file.name)
|
||||
submission_files = []
|
||||
submission_file_paths = []
|
||||
submission_file_contents = []
|
||||
def _add_submission_file(name):
|
||||
submission_files.append(name)
|
||||
try:
|
||||
p = default_storage.path(name)
|
||||
if p:
|
||||
submission_file_paths.append(p)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with default_storage.open(name, 'rb') as f:
|
||||
data = f.read(2 * 1024 * 1024 + 1)
|
||||
fname = name.split('/')[-1] if '/' in name else name
|
||||
submission_file_contents.append((fname, data[: 2 * 1024 * 1024]))
|
||||
except Exception:
|
||||
pass
|
||||
if submission.attachment:
|
||||
_add_submission_file(submission.attachment.name)
|
||||
for file_obj in HomeworkFile.objects.filter(submission=submission, file_type='submission'):
|
||||
if file_obj.file:
|
||||
_add_submission_file(file_obj.file.name)
|
||||
|
||||
ai_service = get_ai_service()
|
||||
result = ai_service.check_submission(
|
||||
homework_title=submission.homework.title,
|
||||
homework_description=submission.homework.description or '',
|
||||
homework_max_score=5,
|
||||
submission_content=submission.content or '',
|
||||
submission_files=submission_files,
|
||||
homework_files=homework_files,
|
||||
homework_file_paths=homework_file_paths,
|
||||
submission_file_paths=submission_file_paths,
|
||||
homework_file_contents=homework_file_contents,
|
||||
submission_file_contents=submission_file_contents,
|
||||
student_name=_student_display_name(submission),
|
||||
)
|
||||
|
||||
if not result.get('success'):
|
||||
logger.warning(
|
||||
"run_mentor_ai_check_submission: AI проверка не удалась для submission %s: %s",
|
||||
submission_id, result.get('error', '')
|
||||
)
|
||||
return
|
||||
|
||||
student_name = _student_display_name(submission)
|
||||
homework_title = submission.homework.title
|
||||
|
||||
skipped_reason = result.get('skipped_reason')
|
||||
if skipped_reason:
|
||||
# Текст не удалось извлечь — сохраняем черновик, запрос к AI не отправляли
|
||||
submission.ai_score = None
|
||||
submission.ai_feedback = result.get('feedback', '')
|
||||
submission.ai_checked_at = timezone.now()
|
||||
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
|
||||
invalidate_dashboard_cache(mentor.id, 'mentor')
|
||||
NotificationService.create_notification_with_telegram(
|
||||
recipient=mentor,
|
||||
notification_type='homework_submitted',
|
||||
title='⚠️ ИИ не смог прочитать задание или решение',
|
||||
message=f'{student_name} — ДЗ «{homework_title}»: не удалось извлечь текст из файлов. Проверьте вручную или добавьте текст/.txt.',
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
)
|
||||
logger.info("run_mentor_ai_check_submission: submission %s — пропуск AI (skipped_reason=%s)", submission_id, skipped_reason)
|
||||
return
|
||||
|
||||
score = result.get('score')
|
||||
feedback = result.get('feedback', '')
|
||||
score = max(1, min(5, score)) if score is not None else None
|
||||
if score is None:
|
||||
logger.warning("run_mentor_ai_check_submission: submission %s — AI вернул пустую оценку", submission_id)
|
||||
return
|
||||
|
||||
if publish:
|
||||
submission.grade(score, feedback, checked_by=mentor)
|
||||
submission.graded_by_ai = True
|
||||
submission.save(update_fields=['graded_by_ai'])
|
||||
invalidate_dashboard_cache(submission.student.id, 'client')
|
||||
invalidate_dashboard_cache(mentor.id, 'mentor')
|
||||
NotificationService.create_notification_with_telegram(
|
||||
recipient=submission.student,
|
||||
notification_type='homework_reviewed',
|
||||
title='✅ ДЗ проверено',
|
||||
message=f'Проверено ДЗ "{homework_title}". Оценка: {score}/5',
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
)
|
||||
NotificationService.create_notification_with_telegram(
|
||||
recipient=mentor,
|
||||
notification_type='homework_reviewed',
|
||||
title='🤖 ИИ проверил ДЗ и выставил оценку',
|
||||
message=f'{student_name} — ДЗ «{homework_title}»: оценка {score}/5. ИИ поставил оценку и опубликовал результат.',
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
)
|
||||
logger.info("run_mentor_ai_check_submission: submission %s опубликована AI, оценка %s", submission_id, score)
|
||||
else:
|
||||
submission.ai_score = score
|
||||
submission.ai_feedback = feedback
|
||||
submission.ai_checked_at = timezone.now()
|
||||
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
|
||||
invalidate_dashboard_cache(mentor.id, 'mentor')
|
||||
NotificationService.create_notification_with_telegram(
|
||||
recipient=mentor,
|
||||
notification_type='homework_submitted',
|
||||
title='🤖 ИИ проверил ДЗ, статус: черновик',
|
||||
message=f'{student_name} — ДЗ «{homework_title}»: предварительная оценка {score}/5. ИИ сохранил как черновик — можете отредактировать и опубликовать.',
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
)
|
||||
logger.info("run_mentor_ai_check_submission: submission %s сохранена как черновик AI, оценка %s", submission_id, score)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"run_mentor_ai_check_submission: ошибка для submission_id=%s: %s\n%s",
|
||||
submission_id, e, traceback.format_exc()
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_old_homework_data():
|
||||
"""
|
||||
Очистка старых данных домашних заданий.
|
||||
|
||||
Запускается каждый месяц 1-го числа в 05:00.
|
||||
Архивирует старые задания и удаляет старые файлы.
|
||||
"""
|
||||
from .models import Homework
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
# Архивируем задания старше 1 года без активности
|
||||
one_year_ago = timezone.now() - timedelta(days=365)
|
||||
|
||||
old_homeworks = Homework.objects.filter(
|
||||
status='published',
|
||||
updated_at__lt=one_year_ago,
|
||||
submissions__isnull=True # Без решений
|
||||
)
|
||||
|
||||
archived_count = old_homeworks.update(status='archived')
|
||||
|
||||
logger.info(f"Архивировано {archived_count} старых домашних заданий")
|
||||
return f"Архивировано {archived_count} старых домашних заданий"
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_homework_statistics():
|
||||
"""
|
||||
Обновление статистики домашних заданий.
|
||||
|
||||
Запускается каждый день в 02:00.
|
||||
Пересчитывает статистику для всех активных заданий.
|
||||
"""
|
||||
from .models import Homework
|
||||
|
||||
# Находим все опубликованные задания
|
||||
homeworks = Homework.objects.filter(status='published')
|
||||
|
||||
updated_count = 0
|
||||
for homework in homeworks:
|
||||
try:
|
||||
homework.update_statistics()
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления статистики для ДЗ {homework.id}: {str(e)}")
|
||||
|
||||
logger.info(f"Обновлена статистика для {updated_count} домашних заданий")
|
||||
return f"Обновлена статистика для {updated_count} домашних заданий"
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_overdue_homeworks():
|
||||
"""
|
||||
Проверка просроченных домашних заданий и отправка уведомлений.
|
||||
|
||||
Запускается каждый день в 08:00.
|
||||
Отправляет уведомления студентам о просроченных заданиях.
|
||||
"""
|
||||
from .models import Homework
|
||||
from apps.notifications.services import NotificationService
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# Находим просроченные опубликованные задания
|
||||
overdue_homeworks = Homework.objects.filter(
|
||||
status='published',
|
||||
deadline__lt=now
|
||||
).select_related('mentor', 'lesson').prefetch_related('assigned_to', 'submissions')
|
||||
|
||||
sent_count = 0
|
||||
|
||||
for homework in overdue_homeworks:
|
||||
# Оптимизация: используем prefetch_related для submissions
|
||||
submitted_student_ids = {sub.student_id for sub in homework.submissions.all()}
|
||||
students_without_submission = [
|
||||
student for student in homework.assigned_to.all()
|
||||
if student.id not in submitted_student_ids
|
||||
]
|
||||
|
||||
for student in students_without_submission:
|
||||
try:
|
||||
NotificationService.create_notification_with_telegram(
|
||||
recipient=student,
|
||||
notification_type='homework_overdue',
|
||||
title='⚠️ Просрочено домашнее задание',
|
||||
message=f'Просрочено домашнее задание "{homework.title}". Дедлайн: {homework.deadline.strftime("%d.%m.%Y %H:%M")}',
|
||||
priority='high',
|
||||
action_url=f'/homework/{homework.id}/',
|
||||
content_object=homework
|
||||
)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о просроченном ДЗ: {str(e)}")
|
||||
|
||||
logger.info(f"Отправлено {sent_count} уведомлений о просроченных домашних заданиях")
|
||||
return f"Отправлено {sent_count} уведомлений о просроченных заданиях"
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_old_files():
|
||||
"""
|
||||
Очистка старых неиспользуемых файлов домашних заданий.
|
||||
|
||||
Запускается каждую неделю в воскресенье в 02:00.
|
||||
Удаляет файлы из архивированных заданий старше 2 лет.
|
||||
"""
|
||||
from .models import HomeworkFile
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import os
|
||||
|
||||
two_years_ago = timezone.now() - timedelta(days=730)
|
||||
|
||||
# Находим файлы из архивированных заданий старше 2 лет
|
||||
old_files = HomeworkFile.objects.filter(
|
||||
homework__status='archived',
|
||||
homework__updated_at__lt=two_years_ago,
|
||||
created_at__lt=two_years_ago
|
||||
)
|
||||
|
||||
deleted_count = 0
|
||||
# Оптимизация: сначала удаляем физические файлы, затем удаляем записи одним запросом
|
||||
old_files_list = list(old_files)
|
||||
file_ids_to_delete = []
|
||||
for file_obj in old_files_list:
|
||||
try:
|
||||
# Удаляем физический файл
|
||||
if file_obj.file and os.path.isfile(file_obj.file.path):
|
||||
os.remove(file_obj.file.path)
|
||||
file_ids_to_delete.append(file_obj.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления физического файла ДЗ {file_obj.id}: {str(e)}")
|
||||
|
||||
# Удаляем записи из базы данных одним запросом
|
||||
if file_ids_to_delete:
|
||||
deleted_count = HomeworkFile.objects.filter(id__in=file_ids_to_delete).delete()[0]
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых файлов домашних заданий")
|
||||
return f"Удалено {deleted_count} старых файлов"
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
Тесты для приложения homework.
|
||||
"""
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
API тесты для домашних заданий.
|
||||
"""
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from apps.homework.models import Homework, HomeworkSubmission
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.api
|
||||
class TestHomeworkAPI:
|
||||
"""Тесты API домашних заданий."""
|
||||
|
||||
def test_list_homeworks(self, authenticated_client, mentor_user):
|
||||
"""Тест получения списка домашних заданий."""
|
||||
Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Задание 1',
|
||||
description='Описание',
|
||||
status='published'
|
||||
)
|
||||
|
||||
response = authenticated_client.get('/api/homework/homeworks/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data['results']) > 0
|
||||
|
||||
def test_create_homework(self, authenticated_client, mentor_user):
|
||||
"""Тест создания домашнего задания."""
|
||||
data = {
|
||||
'title': 'Новое задание',
|
||||
'description': 'Описание задания',
|
||||
'max_score': 100
|
||||
}
|
||||
|
||||
response = authenticated_client.post('/api/homework/homeworks/', data)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data['title'] == 'Новое задание'
|
||||
|
||||
def test_submit_homework(self, authenticated_client_user, mentor_user, client_user):
|
||||
"""Тест отправки решения домашнего задания."""
|
||||
homework = Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Задание',
|
||||
description='Описание',
|
||||
status='published'
|
||||
)
|
||||
|
||||
data = {
|
||||
'homework': homework.id,
|
||||
'text': 'Мое решение'
|
||||
}
|
||||
|
||||
response = authenticated_client_user.post('/api/homework/submissions/', data)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data['text'] == 'Мое решение'
|
||||
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
"""
|
||||
Unit тесты для моделей домашних заданий.
|
||||
"""
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from apps.homework.models import Homework, HomeworkSubmission, HomeworkFile
|
||||
from apps.schedule.models import Lesson, Subject
|
||||
from apps.users.models import User, Client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.unit
|
||||
class TestHomeworkModel:
|
||||
"""Тесты модели Homework."""
|
||||
|
||||
def test_create_homework(self, mentor_user):
|
||||
"""Тест создания домашнего задания."""
|
||||
homework = Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Решить задачи по алгебре',
|
||||
description='Учебник, страница 45, задачи 1-10',
|
||||
max_score=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
assert homework.mentor == mentor_user
|
||||
assert homework.title == 'Решить задачи по алгебре'
|
||||
assert homework.status == 'draft'
|
||||
assert homework.max_score == 100
|
||||
|
||||
def test_homework_publish(self, mentor_user):
|
||||
"""Тест публикации домашнего задания."""
|
||||
homework = Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Тест',
|
||||
description='Описание',
|
||||
status='draft'
|
||||
)
|
||||
|
||||
homework.publish()
|
||||
|
||||
assert homework.status == 'published'
|
||||
assert homework.published_at is not None
|
||||
|
||||
def test_homework_archive(self, mentor_user):
|
||||
"""Тест архивирования домашнего задания."""
|
||||
homework = Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Тест',
|
||||
description='Описание',
|
||||
status='published'
|
||||
)
|
||||
|
||||
homework.archive()
|
||||
|
||||
assert homework.status == 'archived'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.unit
|
||||
class TestHomeworkSubmissionModel:
|
||||
"""Тесты модели HomeworkSubmission."""
|
||||
|
||||
def test_create_submission(self, mentor_user, client_user):
|
||||
"""Тест создания решения домашнего задания."""
|
||||
homework = Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Тест',
|
||||
description='Описание',
|
||||
status='published'
|
||||
)
|
||||
|
||||
submission = HomeworkSubmission.objects.create(
|
||||
homework=homework,
|
||||
student=client_user,
|
||||
text='Мое решение задачи',
|
||||
status='pending'
|
||||
)
|
||||
|
||||
assert submission.homework == homework
|
||||
assert submission.student == client_user
|
||||
assert submission.status == 'pending'
|
||||
assert submission.text == 'Мое решение задачи'
|
||||
|
||||
def test_submission_grade(self, mentor_user, client_user):
|
||||
"""Тест оценивания решения."""
|
||||
homework = Homework.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Тест',
|
||||
description='Описание',
|
||||
status='published',
|
||||
max_score=100
|
||||
)
|
||||
|
||||
submission = HomeworkSubmission.objects.create(
|
||||
homework=homework,
|
||||
student=client_user,
|
||||
text='Решение',
|
||||
status='pending'
|
||||
)
|
||||
|
||||
submission.grade(score=85, feedback='Хорошая работа!')
|
||||
|
||||
assert submission.status == 'graded'
|
||||
assert submission.score == 85
|
||||
assert submission.feedback == 'Хорошая работа!'
|
||||
assert submission.graded_at is not None
|
||||
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
"""
|
||||
Тесты производительности для API домашних заданий.
|
||||
Измеряют время ответа и количество SQL запросов.
|
||||
"""
|
||||
import time
|
||||
import pytest
|
||||
from django.test.utils import override_settings
|
||||
from django.db import connection, reset_queries
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.performance
|
||||
class TestHomeworkPerformance:
|
||||
"""Тесты производительности API домашних заданий."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_data(self, mentor_user, client_user):
|
||||
"""Создает тестовые данные для проверки производительности."""
|
||||
from apps.schedule.models import Lesson
|
||||
from apps.homework.models import Homework, HomeworkSubmission
|
||||
|
||||
# Создаем несколько студентов
|
||||
students = []
|
||||
for i in range(10):
|
||||
student = User.objects.create_user(
|
||||
email=f'student{i}@test.com',
|
||||
password='TestPass123!',
|
||||
first_name=f'Студент{i}',
|
||||
last_name=f'Тестовый{i}',
|
||||
role='client',
|
||||
is_email_verified=True,
|
||||
is_active=True
|
||||
)
|
||||
students.append(student)
|
||||
|
||||
# Создаем занятие
|
||||
lesson = Lesson.objects.create(
|
||||
mentor=mentor_user,
|
||||
title='Тестовое занятие',
|
||||
status='scheduled'
|
||||
)
|
||||
|
||||
# Создаем домашние задания
|
||||
homeworks = []
|
||||
for i in range(5):
|
||||
homework = Homework.objects.create(
|
||||
title=f'ДЗ {i+1}',
|
||||
description=f'Описание ДЗ {i+1}',
|
||||
mentor=mentor_user,
|
||||
lesson=lesson,
|
||||
status='published',
|
||||
max_score=100
|
||||
)
|
||||
# Назначаем студентов
|
||||
homework.assigned_to.set(students)
|
||||
|
||||
# Создаем решения для некоторых студентов
|
||||
for j, student in enumerate(students[:5]): # 5 студентов сдали
|
||||
HomeworkSubmission.objects.create(
|
||||
homework=homework,
|
||||
student=student,
|
||||
content=f'Решение от студента {j}',
|
||||
status='graded' if j % 2 == 0 else 'pending',
|
||||
score=80 + j if j % 2 == 0 else None
|
||||
)
|
||||
|
||||
homeworks.append(homework)
|
||||
|
||||
return {
|
||||
'mentor_user': mentor_user,
|
||||
'students': students,
|
||||
'homeworks': homeworks
|
||||
}
|
||||
|
||||
def test_homework_list_performance_mentor(self, authenticated_client):
|
||||
"""Тест производительности списка ДЗ для ментора."""
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client.get('/api/homework/homeworks/')
|
||||
|
||||
end_time = time.time()
|
||||
elapsed_time = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed_time < 0.5, f"Время ответа слишком долгое: {elapsed_time:.3f}с"
|
||||
assert query_count < 20, f"Слишком много SQL запросов: {query_count}"
|
||||
|
||||
print(f"\n📊 Список ДЗ (ментор):")
|
||||
print(f" Время: {elapsed_time:.3f}с")
|
||||
print(f" SQL запросов: {query_count}")
|
||||
print(f" ДЗ в ответе: {len(response.json().get('results', response.json()))}")
|
||||
|
||||
def test_homework_list_performance_student(self, authenticated_client_user):
|
||||
"""Тест производительности списка ДЗ для студента."""
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client_user.get('/api/homework/homeworks/')
|
||||
|
||||
end_time = time.time()
|
||||
elapsed_time = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed_time < 0.3, f"Время ответа слишком долгое: {elapsed_time:.3f}с"
|
||||
assert query_count < 15, f"Слишком много SQL запросов: {query_count}"
|
||||
|
||||
print(f"\n📊 Список ДЗ (студент):")
|
||||
print(f" Время: {elapsed_time:.3f}с")
|
||||
print(f" SQL запросов: {query_count}")
|
||||
|
||||
def test_homework_statistics_performance(self, authenticated_client, setup_data):
|
||||
"""Тест производительности статистики ДЗ."""
|
||||
homework = setup_data['homeworks'][0]
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client.get(f'/api/homework/homeworks/{homework.id}/statistics/')
|
||||
|
||||
end_time = time.time()
|
||||
elapsed_time = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed_time < 0.2, f"Время ответа слишком долгое: {elapsed_time:.3f}с"
|
||||
assert query_count < 5, f"Слишком много SQL запросов: {query_count}"
|
||||
|
||||
print(f"\n📊 Статистика ДЗ:")
|
||||
print(f" Время: {elapsed_time:.3f}с")
|
||||
print(f" SQL запросов: {query_count}")
|
||||
print(f" Данные: {response.json()}")
|
||||
|
||||
def test_homework_submissions_list_performance(self, authenticated_client, setup_data):
|
||||
"""Тест производительности списка решений ДЗ."""
|
||||
homework = setup_data['homeworks'][0]
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client.get(
|
||||
f'/api/homework/submissions/?homework_id={homework.id}'
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
elapsed_time = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed_time < 0.3, f"Время ответа слишком долгое: {elapsed_time:.3f}с"
|
||||
assert query_count < 10, f"Слишком много SQL запросов: {query_count}"
|
||||
|
||||
print(f"\n📊 Список решений ДЗ:")
|
||||
print(f" Время: {elapsed_time:.3f}с")
|
||||
print(f" SQL запросов: {query_count}")
|
||||
print(f" Решений в ответе: {len(response.json().get('results', response.json()))}")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.performance
|
||||
class TestChatPerformance:
|
||||
"""Тесты производительности API чата."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_chat_data(self, mentor_user, client_user):
|
||||
"""Создает тестовые данные для чата."""
|
||||
from apps.chat.models import Chat, ChatParticipant, Message, MessageRead
|
||||
|
||||
# Создаем несколько пользователей
|
||||
users = [mentor_user, client_user]
|
||||
for i in range(5):
|
||||
user = User.objects.create_user(
|
||||
email=f'user{i}@test.com',
|
||||
password='TestPass123!',
|
||||
first_name=f'Пользователь{i}',
|
||||
last_name=f'Тестовый{i}',
|
||||
role='client',
|
||||
is_email_verified=True,
|
||||
is_active=True
|
||||
)
|
||||
users.append(user)
|
||||
|
||||
# Создаем чаты с участниками и сообщениями
|
||||
chats = []
|
||||
for i in range(3):
|
||||
chat = Chat.objects.create(
|
||||
chat_type='group',
|
||||
name=f'Чат {i+1}',
|
||||
created_by=mentor_user
|
||||
)
|
||||
|
||||
# Добавляем участников
|
||||
for user in users[:3]:
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=user,
|
||||
role='member'
|
||||
)
|
||||
|
||||
# Создаем сообщения
|
||||
for j in range(10):
|
||||
message = Message.objects.create(
|
||||
chat=chat,
|
||||
sender=users[j % len(users)],
|
||||
content=f'Сообщение {j+1} в чате {i+1}'
|
||||
)
|
||||
|
||||
# Помечаем некоторые сообщения как прочитанные
|
||||
if j % 2 == 0:
|
||||
MessageRead.objects.create(
|
||||
message=message,
|
||||
user=mentor_user
|
||||
)
|
||||
|
||||
chats.append(chat)
|
||||
|
||||
return {
|
||||
'mentor_user': mentor_user,
|
||||
'chats': chats
|
||||
}
|
||||
|
||||
def test_chat_list_performance(self, authenticated_client):
|
||||
"""Тест производительности списка чатов."""
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client.get('/api/chat/chats/')
|
||||
|
||||
end_time = time.time()
|
||||
elapsed_time = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed_time < 0.4, f"Время ответа слишком долгое: {elapsed_time:.3f}с"
|
||||
assert query_count < 15, f"Слишком много SQL запросов: {query_count}"
|
||||
|
||||
print(f"\n📊 Список чатов:")
|
||||
print(f" Время: {elapsed_time:.3f}с")
|
||||
print(f" SQL запросов: {query_count}")
|
||||
print(f" Чатов в ответе: {len(response.json().get('results', response.json()))}")
|
||||
|
||||
def test_chat_messages_performance(self, authenticated_client, setup_chat_data):
|
||||
"""Тест производительности сообщений чата."""
|
||||
chat = setup_chat_data['chats'][0]
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client.get(f'/api/chat/chats/{chat.uuid}/messages/')
|
||||
|
||||
end_time = time.time()
|
||||
elapsed_time = end_time - start_time
|
||||
query_count = len(connection.queries)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed_time < 0.3, f"Время ответа слишком долгое: {elapsed_time:.3f}с"
|
||||
assert query_count < 10, f"Слишком много SQL запросов: {query_count}"
|
||||
|
||||
print(f"\n📊 Сообщения чата:")
|
||||
print(f" Время: {elapsed_time:.3f}с")
|
||||
print(f" SQL запросов: {query_count}")
|
||||
messages_data = response.json().get('results', response.json())
|
||||
print(f" Сообщений в ответе: {len(messages_data)}")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.performance
|
||||
class TestGeneralPerformance:
|
||||
"""Общие тесты производительности."""
|
||||
|
||||
def test_multiple_requests_performance(self, authenticated_client):
|
||||
"""Тест производительности при множественных запросах."""
|
||||
times = []
|
||||
query_counts = []
|
||||
|
||||
for i in range(5):
|
||||
reset_queries()
|
||||
start_time = time.time()
|
||||
|
||||
response = authenticated_client.get('/api/homework/homeworks/')
|
||||
|
||||
end_time = time.time()
|
||||
times.append(end_time - start_time)
|
||||
query_counts.append(len(connection.queries))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
avg_time = sum(times) / len(times)
|
||||
avg_queries = sum(query_counts) / len(query_counts)
|
||||
max_time = max(times)
|
||||
max_queries = max(query_counts)
|
||||
|
||||
print(f"\n📊 Множественные запросы (5 раз):")
|
||||
print(f" Среднее время: {avg_time:.3f}с")
|
||||
print(f" Максимальное время: {max_time:.3f}с")
|
||||
print(f" Среднее SQL запросов: {avg_queries:.1f}")
|
||||
print(f" Максимум SQL запросов: {max_queries}")
|
||||
|
||||
assert avg_time < 0.5, f"Среднее время слишком долгое: {avg_time:.3f}с"
|
||||
assert avg_queries < 20, f"Среднее количество запросов слишком большое: {avg_queries:.1f}"
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""
|
||||
URL routing для homework API.
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
HomeworkViewSet,
|
||||
HomeworkSubmissionViewSet,
|
||||
HomeworkFileViewSet,
|
||||
HomeworkAIAgentViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'homeworks', HomeworkViewSet, basename='homework')
|
||||
router.register(r'submissions', HomeworkSubmissionViewSet, basename='homeworksubmission')
|
||||
router.register(r'files', HomeworkFileViewSet, basename='homeworkfile')
|
||||
router.register(r'ai-agents', HomeworkAIAgentViewSet, basename='homeworkaiagent')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
"""
|
||||
Утилиты для работы с домашними заданиями.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Теги, разрешённые после конвертации Markdown + MathML (для санитизации)
|
||||
ALLOWED_FEEDBACK_HTML_TAGS = [
|
||||
'p', 'br', 'div', 'span', 'strong', 'em', 'b', 'i', 'u', 's',
|
||||
'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4',
|
||||
'a', 'sub', 'sup',
|
||||
# MathML (для формул из LaTeX)
|
||||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'munder', 'mover',
|
||||
'mfrac', 'mtable', 'mtr', 'mtd', 'mstyle', 'mtext', 'annotation', 'semantics',
|
||||
]
|
||||
|
||||
|
||||
def feedback_to_html(raw: str) -> str:
|
||||
"""
|
||||
Переводит ответ ИИ (markdown + LaTeX вроде $a$, $$x^2$$) в безопасный HTML.
|
||||
Используется для отображения ai_feedback и комментариев проверки без «сырых» символов $.
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return ''
|
||||
try:
|
||||
import markdown
|
||||
import bleach
|
||||
except ImportError:
|
||||
logger.warning("markdown/bleach не установлены — возвращаем экранированный текст")
|
||||
return _escape_html(raw)
|
||||
|
||||
# 1) Выносим LaTeX в placeholder'ы, чтобы markdown их не трогал
|
||||
block_maths = []
|
||||
inline_maths = []
|
||||
|
||||
def block_replacer(m):
|
||||
block_maths.append(m.group(1).strip())
|
||||
return f'\x00MATH_BLOCK_{len(block_maths) - 1}\x00'
|
||||
|
||||
def inline_replacer(m):
|
||||
inline_maths.append(m.group(1).strip())
|
||||
return f'\x00MATH_INLINE_{len(inline_maths) - 1}\x00'
|
||||
|
||||
text = raw
|
||||
# Сначала блочные $$ ... $$
|
||||
text = re.sub(r'\$\$([^$]*?)\$\$', block_replacer, text, flags=re.DOTALL)
|
||||
# Потом инлайновые $ ... $ (не захватываем переносы в инлайне)
|
||||
text = re.sub(r'\$([^$\n]+?)\$', inline_replacer, text)
|
||||
|
||||
# 2) Markdown → HTML
|
||||
html = markdown.markdown(
|
||||
text,
|
||||
extensions=['nl2br'],
|
||||
output_format='html5',
|
||||
)
|
||||
|
||||
# 3) LaTeX → MathML и подставляем обратно
|
||||
try:
|
||||
from latex2mathml.converter import convert as latex_to_mathml
|
||||
except ImportError:
|
||||
latex_to_mathml = None
|
||||
|
||||
def replace_math(placeholder_prefix, maths_list):
|
||||
for i, latex in enumerate(maths_list):
|
||||
token = f'\x00{placeholder_prefix}_{i}\x00'
|
||||
if latex_to_mathml and latex:
|
||||
try:
|
||||
mathml = latex_to_mathml(latex)
|
||||
# latex2mathml возвращает полный <math>...</math>
|
||||
html_replacement = f'<span class="math-wrap">{mathml}</span>'
|
||||
except Exception as e:
|
||||
logger.debug("LaTeX → MathML failed for %r: %s", latex[:50], e)
|
||||
html_replacement = _escape_html(f'${latex}$')
|
||||
else:
|
||||
html_replacement = _escape_html(f'${latex}$')
|
||||
nonlocal html
|
||||
html = html.replace(token, html_replacement)
|
||||
|
||||
replace_math('MATH_BLOCK', block_maths)
|
||||
replace_math('MATH_INLINE', inline_maths)
|
||||
|
||||
# 4) Санитизация (разрешаем class, xmlns для формул и ссылок href)
|
||||
allowed_attrs = {'*': ['class'], 'a': ['href', 'title', 'rel'], 'math': ['xmlns'], 'span': ['class']}
|
||||
html = bleach.clean(html, tags=ALLOWED_FEEDBACK_HTML_TAGS, attributes=allowed_attrs, strip=True)
|
||||
return html.strip()
|
||||
|
||||
|
||||
def _escape_html(s: str) -> str:
|
||||
"""Экранирует HTML-сущности."""
|
||||
return (
|
||||
s.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
)
|
||||
|
||||
|
||||
def sanitize_filename(filename):
|
||||
"""
|
||||
Очистка имени файла от опасных символов.
|
||||
|
||||
Args:
|
||||
filename: Исходное имя файла
|
||||
|
||||
Returns:
|
||||
str: Безопасное имя файла
|
||||
"""
|
||||
# Удаляем путь, оставляем только имя файла
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Удаляем опасные символы
|
||||
filename = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '', filename)
|
||||
|
||||
# Удаляем ведущие/завершающие точки и пробелы
|
||||
filename = filename.strip('. ')
|
||||
|
||||
# Ограничиваем длину
|
||||
if len(filename) > 255:
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename = name[:250] + ext
|
||||
|
||||
# Если имя пустое, возвращаем дефолтное
|
||||
if not filename:
|
||||
filename = 'file'
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def validate_file_type(filename, allowed_types):
|
||||
"""
|
||||
Проверка типа файла.
|
||||
|
||||
Args:
|
||||
filename: Имя файла
|
||||
allowed_types: Строка с разрешенными расширениями (например, ".pdf,.doc,.docx")
|
||||
|
||||
Returns:
|
||||
bool: True если тип разрешен
|
||||
"""
|
||||
if not allowed_types:
|
||||
return True
|
||||
|
||||
# Получаем расширение файла
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
# Нормализуем allowed_types
|
||||
allowed_list = [t.strip().lower() for t in allowed_types.split(',') if t.strip()]
|
||||
|
||||
return ext in allowed_list
|
||||
|
||||
|
||||
def validate_file_size(file_size, max_size):
|
||||
"""
|
||||
Проверка размера файла.
|
||||
|
||||
Args:
|
||||
file_size: Размер файла в байтах
|
||||
max_size: Максимальный размер в байтах
|
||||
|
||||
Returns:
|
||||
bool: True если размер допустим
|
||||
"""
|
||||
if max_size <= 0:
|
||||
return True
|
||||
|
||||
return file_size <= max_size
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue