first
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# Database
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=formuser
|
||||
POSTGRES_PASSWORD=secure_password_here
|
||||
POSTGRES_DB=formbuilder
|
||||
|
||||
# App
|
||||
APP_NAME=FormBuilder
|
||||
APP_ENV=development
|
||||
DEBUG=True
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Redis (для кэширования и очередей)
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Performance
|
||||
DB_POOL_SIZE=20
|
||||
DB_MAX_OVERFLOW=40
|
||||
DB_POOL_TIMEOUT=30
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"]
|
||||
|
||||
# Email (для уведомлений)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-password
|
||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
*.log.*
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
media/
|
||||
|
||||
# Docker
|
||||
*.pid
|
||||
docker-compose.override.yml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
alembic/versions/*.pyc
|
||||
migrations/__pycache__/
|
||||
|
||||
# Secrets
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Form Builder Backend
|
||||
|
||||
Dynamic form builder with PostgreSQL JSONB support.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Dynamic form creation with custom fields
|
||||
- ✅ JSONB storage for flexible submissions
|
||||
- ✅ Advanced analytics and reporting
|
||||
- ✅ CSV/Excel export
|
||||
- ✅ RESTful API with FastAPI
|
||||
- ✅ Async database operations
|
||||
- ✅ Full-text search in JSONB fields
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker
|
||||
|
||||
```bash
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Start services
|
||||
make docker-up
|
||||
|
||||
# Initialize database
|
||||
make init-db
|
||||
|
||||
# Seed test data (optional)
|
||||
python scripts/seed_data.py
|
||||
45
app/api/deps.py
Normal file
45
app/api/deps.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# app/api/deps.py
|
||||
from typing import Generator, Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.config import settings
|
||||
|
||||
# Re-export get_db для удобства
|
||||
__all__ = ["get_db", "get_current_user", "get_current_active_user"]
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Dependency для получения текущего пользователя (опционально)
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
# Здесь будет логика проверки JWT токена
|
||||
# Пока возвращаем тестового пользователя
|
||||
if settings.APP_ENV == "development":
|
||||
return {"id": 1, "username": "test_user", "email": "test@example.com"}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: Optional[dict] = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Dependency для получения активного пользователя (требует аутентификации)
|
||||
"""
|
||||
if not current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return current_user
|
||||
4
app/api/v1/__init__.py
Normal file
4
app/api/v1/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# app/api/v1/__init__.py
|
||||
from app.api.v1 import forms, submissions, analytics, export
|
||||
|
||||
__all__ = ["forms", "submissions", "analytics", "export"]
|
||||
46
app/api/v1/analytics.py
Normal file
46
app/api/v1/analytics.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# app/api/v1/analytics.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.api import deps
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.schemas.response import FormAnalyticsResponse
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
@router.get("/forms/{form_id}/report", response_model=FormAnalyticsResponse)
|
||||
async def get_form_analytics(
|
||||
form_id: int,
|
||||
db: Session = Depends(deps.get_db),
|
||||
days: int = Query(30, ge=1, le=365)
|
||||
):
|
||||
"""Получить аналитику по форме"""
|
||||
service = AnalyticsService(db)
|
||||
try:
|
||||
report = service.get_form_report(form_id, days)
|
||||
return report
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@router.get("/forms/{form_id}/fields/{field_name}/stats")
|
||||
async def get_field_statistics(
|
||||
form_id: int,
|
||||
field_name: str,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Получить статистику по полю формы"""
|
||||
service = AnalyticsService(db)
|
||||
stats = service.get_field_statistics(form_id, field_name)
|
||||
if stats is None:
|
||||
raise HTTPException(status_code=404, detail="Form or field not found")
|
||||
return stats
|
||||
|
||||
@router.get("/dashboard/overview")
|
||||
async def get_dashboard_overview(
|
||||
db: Session = Depends(deps.get_db),
|
||||
days: int = Query(30, ge=1, le=365)
|
||||
):
|
||||
"""Получить общую статистику по всем формам"""
|
||||
service = AnalyticsService(db)
|
||||
return service.get_global_overview(days)
|
||||
30
app/api/v1/export.py
Normal file
30
app/api/v1/export.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# app/api/v1/export.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.api import deps
|
||||
from app.services.export_service import ExportService
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["export"])
|
||||
|
||||
@router.get("/forms/{form_id}/csv")
|
||||
async def export_form_csv(
|
||||
form_id: int,
|
||||
format: str = Query("csv", regex="^(csv|xlsx)$"),
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Экспорт данных формы в CSV или Excel"""
|
||||
service = ExportService(db)
|
||||
try:
|
||||
file_content, filename, media_type = service.export_form_data(form_id, format)
|
||||
return StreamingResponse(
|
||||
iter([file_content]),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
|
||||
91
app/api/v1/forms.py
Normal file
91
app/api/v1/forms.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# app/api/v1/forms.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.api import deps
|
||||
from app.schemas.form import FormCreate, FormUpdate, FormResponse, FormWithFieldsResponse
|
||||
from app.schemas.common import MessageResponse, PaginatedResponse
|
||||
from app.services.form_service import FormService
|
||||
|
||||
router = APIRouter(prefix="/forms", tags=["forms"])
|
||||
|
||||
@router.post("/", response_model=FormResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_form(
|
||||
form_data: FormCreate,
|
||||
db: Session = Depends(deps.get_db),
|
||||
current_user: Optional[dict] = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Создать новую форму"""
|
||||
service = FormService(db)
|
||||
try:
|
||||
form = service.create_form(form_data, user_id=current_user.get("id") if current_user else None)
|
||||
return form
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse[FormResponse])
|
||||
async def list_forms(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Список форм с пагинацией"""
|
||||
service = FormService(db)
|
||||
result = service.get_forms_paginated(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
active_only=active_only
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/{form_id}")
|
||||
async def get_form(
|
||||
form_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Получить форму по ID с полями"""
|
||||
service = FormService(db)
|
||||
form = service.get_form_with_fields(form_id)
|
||||
if not form:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return form
|
||||
|
||||
@router.put("/{form_id}", response_model=FormResponse)
|
||||
async def update_form(
|
||||
form_id: int,
|
||||
form_data: FormUpdate,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Обновить форму"""
|
||||
service = FormService(db)
|
||||
form = service.update_form(form_id, form_data)
|
||||
if not form:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return form
|
||||
|
||||
@router.delete("/{form_id}", response_model=MessageResponse)
|
||||
async def delete_form(
|
||||
form_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Удалить форму"""
|
||||
service = FormService(db)
|
||||
success = service.delete_form(form_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return MessageResponse(message="Form deleted successfully")
|
||||
|
||||
@router.post("/{form_id}/publish", response_model=FormResponse)
|
||||
async def publish_form(
|
||||
form_id: int,
|
||||
publish: bool = True,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Опубликовать или скрыть форму"""
|
||||
service = FormService(db)
|
||||
form = service.publish_form(form_id, publish)
|
||||
if not form:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return form
|
||||
79
app/api/v1/submissions.py
Normal file
79
app/api/v1/submissions.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# app/api/v1/submissions.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.api import deps
|
||||
from app.schemas.submission import SubmissionCreate, SubmissionResponse, SubmissionFilterParams
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.services.submission_service import SubmissionService
|
||||
|
||||
router = APIRouter(prefix="/submissions", tags=["submissions"])
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
async def submit_form(
|
||||
submission_data: SubmissionCreate,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Отправить данные формы"""
|
||||
service = SubmissionService(db)
|
||||
try:
|
||||
submission = service.create_submission(submission_data)
|
||||
return submission # Теперь возвращает словарь, а не объект SQLAlchemy
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/{submission_id}", response_model=SubmissionResponse)
|
||||
async def get_submission(
|
||||
submission_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Получить submission по ID"""
|
||||
service = SubmissionService(db)
|
||||
submission = service.get_submission_by_id(submission_id)
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
return submission
|
||||
|
||||
@router.get("/forms/{form_id}/submissions/", response_model=PaginatedResponse[SubmissionResponse])
|
||||
async def get_form_submissions(
|
||||
form_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Получить все submission формы"""
|
||||
service = SubmissionService(db)
|
||||
result = service.get_submissions_by_form(
|
||||
form_id=form_id,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
status=status
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/{submission_id}", response_model=SubmissionResponse)
|
||||
async def update_submission(
|
||||
submission_id: int,
|
||||
data: Dict[str, Any],
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Обновить submission"""
|
||||
service = SubmissionService(db)
|
||||
submission = service.update_submission(submission_id, data)
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
return submission
|
||||
|
||||
@router.delete("/{submission_id}", response_model=Dict[str, str])
|
||||
async def delete_submission(
|
||||
submission_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""Удалить submission"""
|
||||
service = SubmissionService(db)
|
||||
success = service.delete_submission(submission_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
return {"message": "Submission deleted successfully"}
|
||||
102
app/config.py
Normal file
102
app/config.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# app/config.py (рабочая версия)
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field, field_validator
|
||||
import json
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки приложения"""
|
||||
|
||||
# App
|
||||
APP_NAME: str = "FormBuilder"
|
||||
APP_ENV: str = Field(default="development", pattern="^(development|staging|production)$")
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production-min-32-chars"
|
||||
|
||||
# Database
|
||||
DB_TYPE: str = "postgresql"
|
||||
DB_HOST: str = "localhost"
|
||||
DB_PORT: int = 5432
|
||||
DB_USER: str = "postgres"
|
||||
DB_PASSWORD: str = "postgres"
|
||||
DB_NAME: str = "formbuilder"
|
||||
|
||||
# Database Pool
|
||||
DB_POOL_SIZE: int = 20
|
||||
DB_MAX_OVERFLOW: int = 40
|
||||
DB_POOL_TIMEOUT: int = 30
|
||||
DB_POOL_PRE_PING: bool = True
|
||||
|
||||
# Redis
|
||||
REDIS_URL: Optional[str] = None
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||
|
||||
# Security
|
||||
JWT_SECRET_KEY: Optional[str] = None
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Email
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: Optional[int] = None
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
# File Upload
|
||||
MAX_UPLOAD_SIZE_MB: int = 10
|
||||
ALLOWED_UPLOAD_EXTENSIONS: List[str] = [".jpg", ".jpeg", ".png", ".pdf", ".doc", ".docx"]
|
||||
UPLOAD_PATH: str = "uploads"
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
"""Формирует URL для подключения к БД"""
|
||||
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
|
||||
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v):
|
||||
"""Парсинг CORS origins"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except:
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
|
||||
@field_validator("ALLOWED_UPLOAD_EXTENSIONS", mode="before")
|
||||
@classmethod
|
||||
def parse_extensions(cls, v):
|
||||
"""Парсинг расширений файлов"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except:
|
||||
return [ext.strip() for ext in v.split(",")]
|
||||
return v
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def validate_secret_key(cls, v, info):
|
||||
"""Валидация секретного ключа в production"""
|
||||
# Получаем APP_ENV из данных, которые уже были обработаны
|
||||
app_env = info.data.get('APP_ENV', 'development')
|
||||
if app_env == "production" and (not v or len(v) < 32):
|
||||
raise ValueError("SECRET_KEY must be at least 32 characters in production")
|
||||
return v
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = True
|
||||
extra = "ignore" # Игнорируем лишние переменные окружения
|
||||
|
||||
|
||||
# Создаем глобальный экземпляр настроек
|
||||
settings = Settings()
|
||||
45
app/database.py
Normal file
45
app/database.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# app/database.py
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем синхронный движок (для простоты, без asyncio)
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=settings.DB_POOL_PRE_PING,
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_timeout=settings.DB_POOL_TIMEOUT,
|
||||
)
|
||||
|
||||
# Создаем фабрику сессий
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Базовый класс для моделей
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Dependency для получения сессии базы данных
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def init_db():
|
||||
"""Инициализация базы данных (создание таблиц)"""
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create database tables: {e}")
|
||||
raise
|
||||
105
app/main.py
Normal file
105
app/main.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# app/main.py
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем приложение FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version="1.0.0",
|
||||
description="Form Builder Backend API with PostgreSQL",
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
# Настройка CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.BACKEND_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Инициализация БД при запуске
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("Starting application...")
|
||||
logger.info(f"Environment: {settings.APP_ENV}")
|
||||
logger.info(f"Database: {settings.DATABASE_URL}")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization failed: {e}")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Shutting down application...")
|
||||
|
||||
# Обработчики ошибок
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Обработка ошибок валидации"""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"error": "Validation Error",
|
||||
"details": exc.errors(),
|
||||
"status_code": 422
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Обработка всех непредвиденных ошибок"""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "Internal Server Error",
|
||||
"detail": str(exc) if settings.DEBUG else "An unexpected error occurred",
|
||||
"status_code": 500
|
||||
}
|
||||
)
|
||||
|
||||
# Импортируем и регистрируем роутеры
|
||||
from app.api.v1 import forms, submissions, analytics, export
|
||||
|
||||
app.include_router(forms.router, prefix="/api/v1")
|
||||
app.include_router(submissions.router, prefix="/api/v1")
|
||||
app.include_router(analytics.router, prefix="/api/v1")
|
||||
app.include_router(export.router, prefix="/api/v1")
|
||||
|
||||
# Корневой эндпоинт
|
||||
@app.get("/", tags=["root"])
|
||||
async def root():
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME} API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs" if settings.DEBUG else None,
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
# Health check
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""Проверка состояния приложения"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"version": "1.0.0",
|
||||
"database": "connected"
|
||||
}
|
||||
13
app/models/__init__.py
Normal file
13
app/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.models.base import Base, BaseModel
|
||||
from app.models.form import Form, FormField, Field
|
||||
from app.models.submission import Submission, SubmissionAuditLog
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"BaseModel",
|
||||
"Form",
|
||||
"FormField",
|
||||
"Field",
|
||||
"Submission",
|
||||
"SubmissionAuditLog",
|
||||
]
|
||||
41
app/models/base.py
Normal file
41
app/models/base.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, DateTime
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
# Базовый класс для декларативных моделей
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class BaseModel(Base):
|
||||
"""
|
||||
Абстрактный базовый класс для всех моделей.
|
||||
Содержит общие поля и методы.
|
||||
"""
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def dict(self) -> dict[str, Any]:
|
||||
"""Преобразует модель в словарь"""
|
||||
return {
|
||||
column.name: getattr(self, column.name)
|
||||
for column in self.__table__.columns
|
||||
}
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
"""Обновляет поля модели"""
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
@classmethod
|
||||
def get_column_names(cls) -> list[str]:
|
||||
"""Возвращает список имен колонок модели"""
|
||||
return [column.name for column in cls.__table__.columns]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Строковое представление модели"""
|
||||
return f"<{self.__class__.__name__}(id={self.id})>"
|
||||
79
app/models/form.py
Normal file
79
app/models/form.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from app.models.base import BaseModel
|
||||
|
||||
|
||||
class Form(BaseModel):
|
||||
__tablename__ = "forms"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
version = Column(Integer, default=1)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_published = Column(Boolean, default=False)
|
||||
form_settings = Column(JSONB, default={}) # переименовано с settings
|
||||
created_by = Column(Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
fields = relationship("FormField", back_populates="form", cascade="all, delete-orphan")
|
||||
submissions = relationship("Submission", back_populates="form", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_forms_name_active', 'name', 'is_active'),
|
||||
Index('ix_forms_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
@property
|
||||
def fields_count(self) -> int:
|
||||
return len(self.fields)
|
||||
|
||||
@property
|
||||
def submissions_count(self) -> int:
|
||||
return len(self.submissions)
|
||||
|
||||
|
||||
class FormField(BaseModel):
|
||||
__tablename__ = "form_fields"
|
||||
|
||||
form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"))
|
||||
field_id = Column(Integer, ForeignKey("fields.id", ondelete="CASCADE"))
|
||||
order = Column(Integer, default=0)
|
||||
is_required = Column(Boolean, default=False)
|
||||
default_value = Column(JSONB, nullable=True)
|
||||
visibility_conditions = Column(JSONB, default={})
|
||||
validation_rules_override = Column(JSONB, nullable=True)
|
||||
|
||||
# Relationships
|
||||
form = relationship("Form", back_populates="fields")
|
||||
field = relationship("Field", back_populates="form_fields")
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_form_fields_order', 'form_id', 'order'),
|
||||
Index('ix_form_fields_unique', 'form_id', 'field_id', unique=True),
|
||||
)
|
||||
|
||||
|
||||
class Field(BaseModel):
|
||||
__tablename__ = "fields"
|
||||
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
label = Column(String(200), nullable=False)
|
||||
field_type = Column(String(50), nullable=False)
|
||||
placeholder = Column(String(200))
|
||||
help_text = Column(Text)
|
||||
field_options = Column(JSONB, default={}) # переименовано с options
|
||||
validation_rules = Column(JSONB, default={})
|
||||
field_metadata = Column(JSONB, default={}) # переименовано с metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
form_fields = relationship("FormField", back_populates="field")
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_fields_type', 'field_type'),
|
||||
Index('ix_fields_name_type', 'name', 'field_type'),
|
||||
)
|
||||
51
app/models/submission.py
Normal file
51
app/models/submission.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from sqlalchemy import Column, Integer, DateTime, ForeignKey, String, Float, Boolean, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from app.models.base import BaseModel
|
||||
|
||||
|
||||
class Submission(BaseModel):
|
||||
__tablename__ = "submissions"
|
||||
|
||||
form_id = Column(Integer, ForeignKey("forms.id", ondelete="CASCADE"))
|
||||
submission_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False, index=True)
|
||||
submission_data = Column(JSONB, nullable=False) # переименовано с data
|
||||
submission_metadata = Column(JSONB, default={}) # переименовано с metadata
|
||||
status = Column(String(50), default="completed")
|
||||
submitted_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
submitted_by = Column(Integer, nullable=True)
|
||||
|
||||
# Для аналитики
|
||||
completion_time_seconds = Column(Float, nullable=True)
|
||||
referrer = Column(String(500))
|
||||
|
||||
# Relationships
|
||||
form = relationship("Form", back_populates="submissions")
|
||||
audit_logs = relationship("SubmissionAuditLog", back_populates="submission")
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_submissions_data_gin', submission_data, postgresql_using='gin'),
|
||||
Index('ix_submissions_metadata_gin', submission_metadata, postgresql_using='gin'),
|
||||
Index('ix_submissions_form_status', 'form_id', 'status'),
|
||||
Index('ix_submissions_date_status', 'submitted_at', 'status'),
|
||||
Index('ix_submissions_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
class SubmissionAuditLog(BaseModel):
|
||||
__tablename__ = "submission_audit_logs"
|
||||
|
||||
submission_id = Column(Integer, ForeignKey("submissions.id", ondelete="CASCADE"))
|
||||
action = Column(String(50))
|
||||
changed_data = Column(JSONB)
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(String(500))
|
||||
|
||||
submission = relationship("Submission", back_populates="audit_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_audit_submission', 'submission_id'),
|
||||
Index('ix_audit_created_at', 'created_at'),
|
||||
)
|
||||
10
app/repositories/__init__.py
Normal file
10
app/repositories/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.repositories.form_repository import FormRepository, FieldRepository
|
||||
from app.repositories.submission_repository import SubmissionRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"FormRepository",
|
||||
"FieldRepository",
|
||||
"SubmissionRepository",
|
||||
]
|
||||
243
app/repositories/base.py
Normal file
243
app/repositories/base.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, desc, asc
|
||||
from app.models.base import BaseModel
|
||||
|
||||
# Тип для модели SQLAlchemy
|
||||
ModelType = TypeVar("ModelType", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
"""
|
||||
Базовый репозиторий с CRUD операциями.
|
||||
Использует Generic для поддержки разных моделей.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, model: Type[ModelType]):
|
||||
"""
|
||||
Инициализация репозитория
|
||||
|
||||
Args:
|
||||
db: Сессия SQLAlchemy
|
||||
model: Класс модели
|
||||
"""
|
||||
self.db = db
|
||||
self.model = model
|
||||
|
||||
def get_by_id(self, id: int) -> Optional[ModelType]:
|
||||
"""Получить запись по ID"""
|
||||
return self.db.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
def get_by_uuid(self, uuid: str) -> Optional[ModelType]:
|
||||
"""Получить запись по UUID (если модель имеет поле uuid)"""
|
||||
if hasattr(self.model, 'uuid'):
|
||||
return self.db.query(self.model).filter(self.model.uuid == uuid).first()
|
||||
raise AttributeError(f"Model {self.model.__name__} does not have 'uuid' field")
|
||||
|
||||
def get_all(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
order_by: Optional[str] = None,
|
||||
descending: bool = False
|
||||
) -> List[ModelType]:
|
||||
"""Получить все записи с пагинацией"""
|
||||
query = self.db.query(self.model)
|
||||
|
||||
if order_by and hasattr(self.model, order_by):
|
||||
order_column = getattr(self.model, order_by)
|
||||
if descending:
|
||||
query = query.order_by(desc(order_column))
|
||||
else:
|
||||
query = query.order_by(asc(order_column))
|
||||
elif hasattr(self.model, 'created_at'):
|
||||
query = query.order_by(desc(self.model.created_at))
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
def create(self, obj_in: Dict[str, Any]) -> ModelType:
|
||||
"""Создать новую запись"""
|
||||
db_obj = self.model(**obj_in)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def create_many(self, objects_in: List[Dict[str, Any]]) -> List[ModelType]:
|
||||
"""Создать несколько записей"""
|
||||
db_objects = [self.model(**obj_in) for obj_in in objects_in]
|
||||
self.db.add_all(db_objects)
|
||||
self.db.commit()
|
||||
for obj in db_objects:
|
||||
self.db.refresh(obj)
|
||||
return db_objects
|
||||
|
||||
def update(self, id: int, obj_in: Dict[str, Any]) -> Optional[ModelType]:
|
||||
"""Обновить запись"""
|
||||
db_obj = self.get_by_id(id)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
for field, value in obj_in.items():
|
||||
if hasattr(db_obj, field):
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update_by_uuid(self, uuid: str, obj_in: Dict[str, Any]) -> Optional[ModelType]:
|
||||
"""Обновить запись по UUID"""
|
||||
db_obj = self.get_by_uuid(uuid)
|
||||
if not db_obj:
|
||||
return None
|
||||
|
||||
for field, value in obj_in.items():
|
||||
if hasattr(db_obj, field):
|
||||
setattr(db_obj, field, value)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, id: int) -> bool:
|
||||
"""Удалить запись"""
|
||||
db_obj = self.get_by_id(id)
|
||||
if not db_obj:
|
||||
return False
|
||||
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def delete_by_uuid(self, uuid: str) -> bool:
|
||||
"""Удалить запись по UUID"""
|
||||
db_obj = self.get_by_uuid(uuid)
|
||||
if not db_obj:
|
||||
return False
|
||||
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def count(self, **filters) -> int:
|
||||
"""Подсчитать количество записей с фильтрами"""
|
||||
query = self.db.query(self.model)
|
||||
|
||||
for field, value in filters.items():
|
||||
if hasattr(self.model, field):
|
||||
query = query.filter(getattr(self.model, field) == value)
|
||||
|
||||
return query.count()
|
||||
|
||||
def exists(self, **filters) -> bool:
|
||||
"""Проверить существование записи"""
|
||||
return self.count(**filters) > 0
|
||||
|
||||
def filter_by(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
order_by: Optional[str] = None,
|
||||
descending: bool = False,
|
||||
**filters
|
||||
) -> List[ModelType]:
|
||||
"""Фильтрация записей по параметрам"""
|
||||
query = self.db.query(self.model)
|
||||
|
||||
for field, value in filters.items():
|
||||
if hasattr(self.model, field):
|
||||
query = query.filter(getattr(self.model, field) == value)
|
||||
|
||||
if order_by and hasattr(self.model, order_by):
|
||||
order_column = getattr(self.model, order_by)
|
||||
if descending:
|
||||
query = query.order_by(desc(order_column))
|
||||
else:
|
||||
query = query.order_by(asc(order_column))
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
def find_first(self, **filters) -> Optional[ModelType]:
|
||||
"""Найти первую запись по фильтрам"""
|
||||
query = self.db.query(self.model)
|
||||
|
||||
for field, value in filters.items():
|
||||
if hasattr(self.model, field):
|
||||
query = query.filter(getattr(self.model, field) == value)
|
||||
|
||||
return query.first()
|
||||
|
||||
def bulk_update(self, ids: List[int], obj_in: Dict[str, Any]) -> int:
|
||||
"""Массовое обновление записей"""
|
||||
updated_count = self.db.query(self.model).filter(
|
||||
self.model.id.in_(ids)
|
||||
).update(obj_in, synchronize_session=False)
|
||||
|
||||
self.db.commit()
|
||||
return updated_count
|
||||
|
||||
def bulk_delete(self, ids: List[int]) -> int:
|
||||
"""Массовое удаление записей"""
|
||||
deleted_count = self.db.query(self.model).filter(
|
||||
self.model.id.in_(ids)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
self.db.commit()
|
||||
return deleted_count
|
||||
|
||||
def get_or_create(self, defaults: Optional[Dict] = None, **filters) -> tuple[ModelType, bool]:
|
||||
"""
|
||||
Получить запись или создать новую
|
||||
|
||||
Returns:
|
||||
tuple: (объект, создан_ли_новый)
|
||||
"""
|
||||
instance = self.find_first(**filters)
|
||||
|
||||
if instance:
|
||||
return instance, False
|
||||
|
||||
if defaults:
|
||||
filters.update(defaults)
|
||||
|
||||
return self.create(filters), True
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
order_by: Optional[str] = None,
|
||||
descending: bool = False,
|
||||
**filters
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Пагинированный список записей
|
||||
|
||||
Returns:
|
||||
Dict с ключами: items, total, page, per_page, pages
|
||||
"""
|
||||
query = self.db.query(self.model)
|
||||
|
||||
for field, value in filters.items():
|
||||
if hasattr(self.model, field):
|
||||
query = query.filter(getattr(self.model, field) == value)
|
||||
|
||||
total = query.count()
|
||||
|
||||
if order_by and hasattr(self.model, order_by):
|
||||
order_column = getattr(self.model, order_by)
|
||||
if descending:
|
||||
query = query.order_by(desc(order_column))
|
||||
else:
|
||||
query = query.order_by(asc(order_column))
|
||||
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page
|
||||
}
|
||||
98
app/repositories/form_repository.py
Normal file
98
app/repositories/form_repository.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.models.form import Form, FormField, Field
|
||||
|
||||
|
||||
class FormRepository(BaseRepository[Form]):
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(db, Form)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[Form]:
|
||||
"""Получить форму по имени"""
|
||||
return self.db.query(Form).filter(Form.name == name).first()
|
||||
|
||||
def get_active_forms(self, skip: int = 0, limit: int = 100) -> List[Form]:
|
||||
"""Получить активные формы"""
|
||||
return self.db.query(Form).filter(
|
||||
Form.is_active == True,
|
||||
Form.is_published == True
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
def get_forms_with_submissions_count(self) -> List[Dict[str, Any]]:
|
||||
"""Получить формы с количеством submissions"""
|
||||
from app.models.submission import Submission
|
||||
|
||||
results = self.db.query(
|
||||
Form.id,
|
||||
Form.name,
|
||||
Form.description,
|
||||
Form.created_at,
|
||||
func.count(Submission.id).label('submissions_count')
|
||||
).outerjoin(
|
||||
Submission, Form.id == Submission.form_id
|
||||
).group_by(
|
||||
Form.id
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"created_at": r.created_at,
|
||||
"submissions_count": r.submissions_count
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
def clone_form(self, form_id: int, new_name: str) -> Optional[Form]:
|
||||
"""Клонировать форму со всеми полями"""
|
||||
original_form = self.get_by_id(form_id)
|
||||
if not original_form:
|
||||
return None
|
||||
|
||||
# Клонируем форму
|
||||
cloned_form = Form(
|
||||
name=new_name,
|
||||
description=original_form.description,
|
||||
settings=original_form.settings,
|
||||
is_active=False,
|
||||
is_published=False
|
||||
)
|
||||
self.db.add(cloned_form)
|
||||
self.db.flush()
|
||||
|
||||
# Клонируем связи с полями
|
||||
for form_field in original_form.fields:
|
||||
new_form_field = FormField(
|
||||
form_id=cloned_form.id,
|
||||
field_id=form_field.field_id,
|
||||
order=form_field.order,
|
||||
is_required=form_field.is_required,
|
||||
default_value=form_field.default_value,
|
||||
visibility_conditions=form_field.visibility_conditions,
|
||||
validation_rules_override=form_field.validation_rules_override
|
||||
)
|
||||
self.db.add(new_form_field)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(cloned_form)
|
||||
return cloned_form
|
||||
|
||||
|
||||
class FieldRepository(BaseRepository[Field]):
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(db, Field)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[Field]:
|
||||
"""Получить поле по имени"""
|
||||
return self.db.query(Field).filter(Field.name == name).first()
|
||||
|
||||
def get_or_create_field(self, field_data: Dict[str, Any]) -> Field:
|
||||
"""Получить или создать поле"""
|
||||
field = self.get_by_name(field_data.get("name"))
|
||||
if not field:
|
||||
field = self.create(field_data)
|
||||
return field
|
||||
105
app/repositories/submission_repository.py
Normal file
105
app/repositories/submission_repository.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, text
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.submission import Submission
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class SubmissionRepository(BaseRepository):
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(db, Submission)
|
||||
|
||||
def filter_by_field_value(self, form_id: int, field_name: str, value: Any):
|
||||
"""Фильтрация по значению поля в JSONB"""
|
||||
return self.db.query(Submission).filter(
|
||||
Submission.form_id == form_id,
|
||||
Submission.data[field_name].astext == str(value)
|
||||
)
|
||||
|
||||
def filter_by_multiple_fields(self, form_id: int, filters: Dict[str, Any]):
|
||||
"""Фильтрация по нескольким полям"""
|
||||
query = self.db.query(Submission).filter(Submission.form_id == form_id)
|
||||
|
||||
for field_name, value in filters.items():
|
||||
# Используем JSONB оператор @> для точного совпадения
|
||||
query = query.filter(
|
||||
Submission.data[field_name].astext == str(value)
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def search_in_jsonb(self, form_id: int, search_text: str, fields: Optional[List[str]] = None):
|
||||
"""Полнотекстовый поиск по JSONB полям"""
|
||||
if fields:
|
||||
# Поиск только в указанных полях
|
||||
conditions = []
|
||||
for field in fields:
|
||||
conditions.append(
|
||||
Submission.data[field].astext.ilike(f'%{search_text}%')
|
||||
)
|
||||
return self.db.query(Submission).filter(
|
||||
Submission.form_id == form_id,
|
||||
or_(*conditions)
|
||||
)
|
||||
else:
|
||||
# Поиск по всем полям через преобразование JSONB в текст
|
||||
return self.db.query(Submission).filter(
|
||||
Submission.form_id == form_id,
|
||||
func.cast(Submission.data, String).ilike(f'%{search_text}%')
|
||||
)
|
||||
|
||||
def get_field_statistics(self, form_id: int, field_name: str):
|
||||
"""Статистика по полю"""
|
||||
from sqlalchemy import case
|
||||
|
||||
results = self.db.query(
|
||||
Submission.data[field_name].astext.label('value'),
|
||||
func.count(Submission.id).label('count')
|
||||
).filter(
|
||||
Submission.form_id == form_id,
|
||||
Submission.data[field_name].isnot(None)
|
||||
).group_by(
|
||||
Submission.data[field_name].astext
|
||||
).all()
|
||||
|
||||
return {row.value: row.count for row in results}
|
||||
|
||||
def get_daily_submissions(self, form_id: int, days: int = 30):
|
||||
"""Статистика по дням"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
results = self.db.query(
|
||||
func.date(Submission.submitted_at).label('date'),
|
||||
func.count(Submission.id).label('count')
|
||||
).filter(
|
||||
Submission.form_id == form_id,
|
||||
Submission.submitted_at >= start_date
|
||||
).group_by(
|
||||
func.date(Submission.submitted_at)
|
||||
).order_by(
|
||||
'date'
|
||||
).all()
|
||||
|
||||
return [{"date": r.date, "count": r.count} for r in results]
|
||||
|
||||
def get_advanced_analytics(self, form_id: int):
|
||||
"""Расширенная аналитика с использованием JSONB функций PostgreSQL"""
|
||||
|
||||
# Количество уникальных значений для каждого поля
|
||||
query = text("""
|
||||
SELECT
|
||||
COUNT(*) as total_submissions,
|
||||
COUNT(DISTINCT data->>'email') as unique_emails,
|
||||
AVG((data->>'age')::int) as average_age,
|
||||
jsonb_object_agg(
|
||||
data->>'country',
|
||||
COUNT(*)
|
||||
) as country_distribution
|
||||
FROM submissions
|
||||
WHERE form_id = :form_id
|
||||
GROUP BY form_id
|
||||
""")
|
||||
|
||||
result = self.db.execute(query, {"form_id": form_id}).first()
|
||||
return dict(result._mapping) if result else {}
|
||||
92
app/schemas/__init__.py
Normal file
92
app/schemas/__init__.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# app/schemas/__init__.py
|
||||
# Common
|
||||
from app.schemas.common import (
|
||||
BaseSchema,
|
||||
PaginationParams,
|
||||
PaginatedResponse,
|
||||
MessageResponse,
|
||||
ErrorResponse
|
||||
)
|
||||
|
||||
# Field
|
||||
from app.schemas.field import (
|
||||
FieldType,
|
||||
FieldValidationRules,
|
||||
FieldOptions,
|
||||
FieldCreate,
|
||||
FieldUpdate,
|
||||
FieldResponse
|
||||
)
|
||||
|
||||
# Form
|
||||
from app.schemas.form import (
|
||||
FormSettings,
|
||||
FormCreate,
|
||||
FormUpdate,
|
||||
FormResponse,
|
||||
FormWithFieldsResponse,
|
||||
FormPublishRequest # ✅ теперь импорт работает
|
||||
)
|
||||
|
||||
# Submission
|
||||
from app.schemas.submission import (
|
||||
SubmissionMetadata,
|
||||
SubmissionCreate,
|
||||
SubmissionUpdate,
|
||||
SubmissionResponse,
|
||||
SubmissionWithFormResponse,
|
||||
SubmissionFilterParams
|
||||
)
|
||||
|
||||
# Response
|
||||
from app.schemas.response import (
|
||||
HealthResponse,
|
||||
ValidationErrorDetail,
|
||||
ValidationErrorResponse,
|
||||
ExportResponse,
|
||||
AnalyticsFieldStats,
|
||||
AnalyticsDailyStats,
|
||||
FormAnalyticsResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Common
|
||||
"BaseSchema",
|
||||
"PaginationParams",
|
||||
"PaginatedResponse",
|
||||
"MessageResponse",
|
||||
"ErrorResponse",
|
||||
|
||||
# Field
|
||||
"FieldType",
|
||||
"FieldValidationRules",
|
||||
"FieldOptions",
|
||||
"FieldCreate",
|
||||
"FieldUpdate",
|
||||
"FieldResponse",
|
||||
|
||||
# Form
|
||||
"FormSettings",
|
||||
"FormCreate",
|
||||
"FormUpdate",
|
||||
"FormResponse",
|
||||
"FormWithFieldsResponse",
|
||||
"FormPublishRequest",
|
||||
|
||||
# Submission
|
||||
"SubmissionMetadata",
|
||||
"SubmissionCreate",
|
||||
"SubmissionUpdate",
|
||||
"SubmissionResponse",
|
||||
"SubmissionWithFormResponse",
|
||||
"SubmissionFilterParams",
|
||||
|
||||
# Response
|
||||
"HealthResponse",
|
||||
"ValidationErrorDetail",
|
||||
"ValidationErrorResponse",
|
||||
"ExportResponse",
|
||||
"AnalyticsFieldStats",
|
||||
"AnalyticsDailyStats",
|
||||
"FormAnalyticsResponse",
|
||||
]
|
||||
45
app/schemas/common.py
Normal file
45
app/schemas/common.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Generic, TypeVar, List
|
||||
from datetime import datetime
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""Базовый класс для всех схем"""
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Параметры пагинации"""
|
||||
page: int = Field(1, ge=1, description="Номер страницы")
|
||||
per_page: int = Field(20, ge=1, le=100, description="Записей на странице")
|
||||
order_by: Optional[str] = Field(None, description="Поле для сортировки")
|
||||
descending: bool = Field(True, description="По убыванию")
|
||||
|
||||
|
||||
class PaginatedResponse(BaseSchema, Generic[T]):
|
||||
"""Ответ с пагинацией"""
|
||||
items: List[T]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class MessageResponse(BaseSchema):
|
||||
"""Простой ответ с сообщением"""
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
|
||||
class ErrorResponse(BaseSchema):
|
||||
"""Ответ с ошибкой"""
|
||||
error: str
|
||||
detail: Optional[str] = None
|
||||
status_code: int
|
||||
102
app/schemas/field.py
Normal file
102
app/schemas/field.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# app/schemas/field.py
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
from app.schemas.common import BaseSchema
|
||||
|
||||
|
||||
class FieldType(str, Enum):
|
||||
"""Типы полей формы"""
|
||||
TEXT = "text"
|
||||
TEXTAREA = "textarea"
|
||||
NUMBER = "number"
|
||||
EMAIL = "email"
|
||||
PHONE = "phone"
|
||||
DATE = "date"
|
||||
DATETIME = "datetime"
|
||||
TIME = "time"
|
||||
SELECT = "select"
|
||||
MULTISELECT = "multiselect"
|
||||
RADIO = "radio"
|
||||
CHECKBOX = "checkbox"
|
||||
FILE = "file"
|
||||
IMAGE = "image"
|
||||
URL = "url"
|
||||
COLOR = "color"
|
||||
RANGE = "range"
|
||||
HIDDEN = "hidden"
|
||||
|
||||
|
||||
class FieldValidationRules(BaseModel):
|
||||
"""Правила валидации поля"""
|
||||
min_length: Optional[int] = Field(None, ge=0)
|
||||
max_length: Optional[int] = Field(None, ge=0)
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
pattern: Optional[str] = None # regex pattern
|
||||
required: bool = False
|
||||
custom_error_message: Optional[str] = None
|
||||
|
||||
|
||||
class FieldOptions(BaseModel):
|
||||
"""Опции для полей типа select/radio/checkbox"""
|
||||
options: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
multiple: bool = False
|
||||
searchable: bool = False
|
||||
placeholder: Optional[str] = None
|
||||
|
||||
|
||||
class FieldCreate(BaseSchema):
|
||||
"""Создание поля"""
|
||||
name: str = Field(..., min_length=1, max_length=100,
|
||||
pattern=r'^[a-zA-Z_][a-zA-Z0-9_]*$') # ✅ заменено regex на pattern
|
||||
label: str = Field(..., min_length=1, max_length=200)
|
||||
field_type: FieldType
|
||||
placeholder: Optional[str] = Field(None, max_length=200)
|
||||
help_text: Optional[str] = None
|
||||
default_value: Optional[Any] = None
|
||||
options: Optional[FieldOptions] = None
|
||||
validation_rules: Optional[FieldValidationRules] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
order: int = Field(0, ge=0)
|
||||
is_required: bool = False
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
if v in ['id', 'created_at', 'updated_at', 'form_id', 'submission_id']:
|
||||
raise ValueError(f'Field name "{v}" is reserved')
|
||||
return v
|
||||
|
||||
|
||||
class FieldUpdate(BaseSchema):
|
||||
"""Обновление поля"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100,
|
||||
pattern=r'^[a-zA-Z_][a-zA-Z0-9_]*$') # ✅ pattern вместо regex
|
||||
label: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
field_type: Optional[FieldType] = None
|
||||
placeholder: Optional[str] = None
|
||||
help_text: Optional[str] = None
|
||||
default_value: Optional[Any] = None
|
||||
options: Optional[FieldOptions] = None
|
||||
validation_rules: Optional[FieldValidationRules] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
order: Optional[int] = Field(None, ge=0)
|
||||
is_required: Optional[bool] = None
|
||||
|
||||
|
||||
class FieldResponse(BaseSchema):
|
||||
id: int
|
||||
name: str
|
||||
label: str
|
||||
field_type: FieldType
|
||||
placeholder: Optional[str] = None
|
||||
help_text: Optional[str] = None
|
||||
options: Dict[str, Any] = Field(default_factory=dict)
|
||||
validation_rules: Dict[str, Any] = Field(default_factory=dict)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
69
app/schemas/form.py
Normal file
69
app/schemas/form.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# app/schemas/form.py
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.schemas.common import BaseSchema
|
||||
from app.schemas.field import FieldResponse
|
||||
|
||||
|
||||
class FormSettings(BaseModel):
|
||||
"""Настройки формы"""
|
||||
theme: Optional[str] = "light"
|
||||
show_submit_button: bool = True
|
||||
submit_button_text: str = "Submit"
|
||||
success_message: str = "Form submitted successfully"
|
||||
redirect_url: Optional[str] = None
|
||||
send_notifications: bool = False
|
||||
notification_emails: List[str] = Field(default_factory=list)
|
||||
limit_one_submission_per_user: bool = False
|
||||
enable_captcha: bool = False
|
||||
|
||||
|
||||
class FormCreate(BaseSchema):
|
||||
"""Создание формы"""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
settings: FormSettings = Field(default_factory=FormSettings)
|
||||
fields: List[Dict[str, Any]] = Field(..., min_items=1)
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
if not v.strip():
|
||||
raise ValueError('Name cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
|
||||
class FormUpdate(BaseSchema):
|
||||
"""Обновление формы"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
settings: Optional[FormSettings] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_published: Optional[bool] = None
|
||||
|
||||
|
||||
class FormResponse(BaseSchema):
|
||||
"""Ответ с данными формы"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
version: int
|
||||
is_active: bool
|
||||
is_published: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[int]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FormWithFieldsResponse(FormResponse):
|
||||
"""Форма со всеми полями"""
|
||||
fields: List[FieldResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FormPublishRequest(BaseSchema):
|
||||
"""Публикация формы"""
|
||||
publish: bool = True
|
||||
version_note: Optional[str] = None
|
||||
66
app/schemas/response.py
Normal file
66
app/schemas/response.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Any, Dict, List, Generic, TypeVar
|
||||
from datetime import datetime
|
||||
from app.schemas.common import BaseSchema, PaginatedResponse
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class HealthResponse(BaseSchema):
|
||||
"""Health check ответ"""
|
||||
status: str = "healthy"
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
version: str = "1.0.0"
|
||||
database: str
|
||||
|
||||
class ValidationErrorDetail(BaseSchema):
|
||||
"""Детали ошибки валидации"""
|
||||
field: str
|
||||
message: str
|
||||
value: Optional[Any] = None
|
||||
|
||||
class ValidationErrorResponse(BaseSchema):
|
||||
"""Ответ с ошибками валидации"""
|
||||
error: str = "Validation Error"
|
||||
details: List[ValidationErrorDetail] = Field(default_factory=list)
|
||||
status_code: int = 422
|
||||
|
||||
class ExportResponse(BaseSchema):
|
||||
"""Ответ на экспорт данных"""
|
||||
download_url: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
expires_at: datetime
|
||||
format: str # csv, xlsx, json
|
||||
|
||||
class AnalyticsFieldStats(BaseSchema):
|
||||
"""Статистика по полю формы"""
|
||||
field_name: str
|
||||
field_label: str
|
||||
field_type: str
|
||||
total_responses: int
|
||||
filled_count: int
|
||||
empty_count: int
|
||||
fill_rate: float # процент заполнения
|
||||
unique_values: int
|
||||
distribution: Dict[str, int] # значение -> количество
|
||||
|
||||
class AnalyticsDailyStats(BaseSchema):
|
||||
"""Ежедневная статистика"""
|
||||
date: str
|
||||
count: int
|
||||
avg_completion_time: Optional[float] = None
|
||||
|
||||
class FormAnalyticsResponse(BaseSchema):
|
||||
"""Аналитика по форме"""
|
||||
form_id: int
|
||||
form_name: str
|
||||
total_submissions: int
|
||||
unique_submitters: int
|
||||
avg_completion_time: Optional[float]
|
||||
completion_rate: float # процент завершенных
|
||||
fields_stats: List[AnalyticsFieldStats]
|
||||
daily_stats: List[AnalyticsDailyStats]
|
||||
peak_hours: Dict[int, int] # час -> количество
|
||||
trend: str # increasing, decreasing, stable
|
||||
last_submission_at: Optional[datetime]
|
||||
first_submission_at: Optional[datetime]
|
||||
71
app/schemas/submission.py
Normal file
71
app/schemas/submission.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# app/schemas/submission.py
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from app.schemas.common import BaseSchema
|
||||
|
||||
|
||||
class SubmissionMetadata(BaseModel):
|
||||
"""Метаданные submission"""
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
referrer: Optional[str] = None
|
||||
browser: Optional[str] = None
|
||||
os: Optional[str] = None
|
||||
device: Optional[str] = None
|
||||
location: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SubmissionCreate(BaseSchema):
|
||||
"""Создание submission (отправка формы)"""
|
||||
form_id: int
|
||||
data: Dict[str, Any] = Field(..., description="Данные формы")
|
||||
metadata: Optional[SubmissionMetadata] = None
|
||||
|
||||
@validator('data')
|
||||
def validate_data_not_empty(cls, v):
|
||||
if not v:
|
||||
raise ValueError('Data cannot be empty')
|
||||
return v
|
||||
|
||||
|
||||
class SubmissionUpdate(BaseSchema):
|
||||
"""Обновление submission"""
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
status: Optional[str] = Field(None, pattern='^(draft|completed|rejected)$')
|
||||
completion_time_seconds: Optional[float] = Field(None, ge=0)
|
||||
|
||||
|
||||
class SubmissionResponse(BaseSchema):
|
||||
"""Ответ с данными submission - соответствует модели БД"""
|
||||
id: int
|
||||
submission_uuid: str
|
||||
form_id: int
|
||||
submission_data: Dict[str, Any] # ← изменено с data на submission_data
|
||||
submission_metadata: Dict[str, Any] = Field(default_factory=dict) # ← изменено с metadata
|
||||
status: str
|
||||
submitted_at: datetime
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
submitted_by: Optional[int]
|
||||
completion_time_seconds: Optional[float]
|
||||
referrer: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SubmissionWithFormResponse(SubmissionResponse):
|
||||
"""Submission с данными формы"""
|
||||
form_name: Optional[str] = None
|
||||
form_version: Optional[int] = None
|
||||
|
||||
|
||||
class SubmissionFilterParams(BaseSchema):
|
||||
"""Параметры фильтрации submissions"""
|
||||
status: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
search: Optional[str] = None
|
||||
search_fields: Optional[List[str]] = None
|
||||
field_filters: Dict[str, Any] = Field(default_factory=dict)
|
||||
12
app/services/__init__.py
Normal file
12
app/services/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# app/services/__init__.py
|
||||
from app.services.form_service import FormService
|
||||
from app.services.submission_service import SubmissionService
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.services.export_service import ExportService
|
||||
|
||||
__all__ = [
|
||||
"FormService",
|
||||
"SubmissionService",
|
||||
"AnalyticsService",
|
||||
"ExportService"
|
||||
]
|
||||
135
app/services/analytics_service.py
Normal file
135
app/services/analytics_service.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, Any, List
|
||||
from app.repositories.submission_repository import SubmissionRepository
|
||||
from app.repositories.form_repository import FormRepository
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.submission_repo = SubmissionRepository(db)
|
||||
self.form_repo = FormRepository(db)
|
||||
|
||||
def get_form_report(self, form_id: int) -> Dict[str, Any]:
|
||||
"""Полный отчет по форме"""
|
||||
form = self.form_repo.get_by_id(form_id)
|
||||
if not form:
|
||||
raise ValueError("Form not found")
|
||||
|
||||
# Базовая статистика
|
||||
total_submissions = self.submission_repo.count(form_id=form_id)
|
||||
|
||||
# Аналитика по каждому полю
|
||||
fields_analytics = {}
|
||||
for form_field in form.fields:
|
||||
field = form_field.field
|
||||
stats = self.submission_repo.get_field_statistics(form_id, field.name)
|
||||
|
||||
fields_analytics[field.name] = {
|
||||
"label": field.label,
|
||||
"type": field.field_type,
|
||||
"completion_rate": self._calculate_completion_rate(form_id, field.name),
|
||||
"unique_values": len(stats),
|
||||
"distribution": stats,
|
||||
"null_count": self._get_null_count(form_id, field.name)
|
||||
}
|
||||
|
||||
# Временная аналитика
|
||||
daily_stats = self.submission_repo.get_daily_submissions(form_id)
|
||||
|
||||
return {
|
||||
"form_name": form.name,
|
||||
"total_submissions": total_submissions,
|
||||
"fields_analytics": fields_analytics,
|
||||
"daily_statistics": daily_stats,
|
||||
"peak_hours": self._get_peak_hours(form_id),
|
||||
"submission_trend": self._calculate_trend(daily_stats)
|
||||
}
|
||||
|
||||
def _calculate_completion_rate(self, form_id: int, field_name: str) -> float:
|
||||
"""Процент заполнения поля"""
|
||||
total = self.submission_repo.count(form_id=form_id)
|
||||
if total == 0:
|
||||
return 0.0
|
||||
|
||||
filled = self.submission_repo.count(
|
||||
form_id=form_id,
|
||||
filters={f"data->>'{field_name}'": "is not null"}
|
||||
)
|
||||
return (filled / total) * 100
|
||||
|
||||
def _get_null_count(self, form_id: int, field_name: str) -> int:
|
||||
"""Количество пустых значений в поле"""
|
||||
return self.submission_repo.count(
|
||||
form_id=form_id,
|
||||
filters={f"data->>'{field_name}'": "is null"}
|
||||
)# app/services/analytics_service.py (базовая версия)
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.submission import Submission
|
||||
from app.models.form import Form
|
||||
|
||||
class AnalyticsService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_form_report(self, form_id: int, days: int = 30):
|
||||
form = self.db.query(Form).filter(Form.id == form_id).first()
|
||||
if not form:
|
||||
raise ValueError("Form not found")
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
submissions = self.db.query(Submission).filter(
|
||||
Submission.form_id == form_id,
|
||||
Submission.submitted_at >= start_date
|
||||
).all()
|
||||
|
||||
return {
|
||||
"form_id": form_id,
|
||||
"form_name": form.name,
|
||||
"total_submissions": len(submissions),
|
||||
"unique_submitters": len(set(s.submitted_by for s in submissions if s.submitted_by)),
|
||||
"avg_completion_time": None,
|
||||
"completion_rate": 100.0,
|
||||
"fields_stats": [],
|
||||
"daily_stats": [],
|
||||
"peak_hours": {},
|
||||
"trend": "stable",
|
||||
"last_submission_at": submissions[-1].submitted_at if submissions else None,
|
||||
"first_submission_at": submissions[0].submitted_at if submissions else None
|
||||
}
|
||||
|
||||
def get_field_statistics(self, form_id: int, field_name: str):
|
||||
return None
|
||||
|
||||
def get_global_overview(self, days: int):
|
||||
return {"total_forms": 0, "total_submissions": 0, "active_forms": 0}
|
||||
|
||||
def _get_peak_hours(self, form_id: int) -> Dict[str, int]:
|
||||
"""Часы пик заполнения"""
|
||||
from sqlalchemy import func
|
||||
|
||||
results = self.db.query(
|
||||
func.extract('hour', Submission.submitted_at).label('hour'),
|
||||
func.count(Submission.id).label('count')
|
||||
).filter(
|
||||
Submission.form_id == form_id
|
||||
).group_by('hour').all()
|
||||
|
||||
return {int(r.hour): r.count for r in results}
|
||||
|
||||
def _calculate_trend(self, daily_stats: List[Dict]) -> str:
|
||||
"""Расчет тренда (рост/падение/стабильно)"""
|
||||
if len(daily_stats) < 2:
|
||||
return "insufficient_data"
|
||||
|
||||
recent_avg = sum(d['count'] for d in daily_stats[-7:]) / 7
|
||||
previous_avg = sum(d['count'] for d in daily_stats[-14:-7]) / 7
|
||||
|
||||
if recent_avg > previous_avg * 1.1:
|
||||
return "increasing"
|
||||
elif recent_avg < previous_avg * 0.9:
|
||||
return "decreasing"
|
||||
else:
|
||||
return "stable"
|
||||
19
app/services/export_service.py
Normal file
19
app/services/export_service.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# app/services/export_service.py (базовая версия)
|
||||
from sqlalchemy.orm import Session
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
class ExportService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def export_form_data(self, form_id: int, format: str = "csv"):
|
||||
# Временная заглушка
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["id", "form_id", "submitted_at", "data"])
|
||||
writer.writerow(["1", str(form_id), "2024-01-01", "{}"])
|
||||
|
||||
output.seek(0)
|
||||
return output.getvalue(), f"form_{form_id}.csv", "text/csv"
|
||||
145
app/services/form_service.py
Normal file
145
app/services/form_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# app/services/form_service.py
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app.models.form import Form, Field, FormField
|
||||
from app.schemas.form import FormCreate
|
||||
|
||||
|
||||
class FormService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_form(self, form_data: FormCreate, user_id=None):
|
||||
"""Создать новую форму с полями"""
|
||||
|
||||
# 1. Создаем форму
|
||||
form = Form(
|
||||
name=form_data.name,
|
||||
description=form_data.description,
|
||||
version=1,
|
||||
is_active=True,
|
||||
is_published=False,
|
||||
created_by=user_id
|
||||
)
|
||||
self.db.add(form)
|
||||
self.db.flush()
|
||||
|
||||
# 2. Создаем или получаем поля и связываем с формой
|
||||
for field_data in form_data.fields:
|
||||
existing_field = self.db.query(Field).filter(
|
||||
Field.name == field_data["name"]
|
||||
).first()
|
||||
|
||||
if existing_field:
|
||||
field = existing_field
|
||||
else:
|
||||
field = Field(
|
||||
name=field_data["name"],
|
||||
label=field_data["label"],
|
||||
field_type=field_data["field_type"],
|
||||
placeholder=field_data.get("placeholder"),
|
||||
help_text=field_data.get("help_text"),
|
||||
field_options=field_data.get("options", {}),
|
||||
validation_rules=field_data.get("validation_rules", {}),
|
||||
field_metadata=field_data.get("metadata", {})
|
||||
)
|
||||
self.db.add(field)
|
||||
self.db.flush()
|
||||
|
||||
form_field = FormField(
|
||||
form_id=form.id,
|
||||
field_id=field.id,
|
||||
order=field_data.get("order", 0),
|
||||
is_required=field_data.get("is_required", False)
|
||||
)
|
||||
self.db.add(form_field)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(form)
|
||||
return form
|
||||
|
||||
def get_form_by_id(self, form_id: int, include_fields: bool = True):
|
||||
"""Получить форму с полями"""
|
||||
form = self.db.query(Form).filter(Form.id == form_id).first()
|
||||
|
||||
if not form:
|
||||
return None
|
||||
|
||||
return form
|
||||
|
||||
def get_form_with_fields(self, form_id: int):
|
||||
"""Получить форму с полями в виде словаря (для API)"""
|
||||
form = self.db.query(Form).filter(Form.id == form_id).first()
|
||||
|
||||
if not form:
|
||||
return None
|
||||
|
||||
# Получаем поля формы
|
||||
form_fields = self.db.query(FormField).filter(
|
||||
FormField.form_id == form_id
|
||||
).order_by(FormField.order).all()
|
||||
|
||||
fields_list = []
|
||||
for ff in form_fields:
|
||||
field = self.db.query(Field).filter(Field.id == ff.field_id).first()
|
||||
if field:
|
||||
fields_list.append({
|
||||
"id": field.id,
|
||||
"name": field.name,
|
||||
"label": field.label,
|
||||
"field_type": field.field_type,
|
||||
"order": ff.order,
|
||||
"is_required": ff.is_required,
|
||||
"placeholder": field.placeholder,
|
||||
"help_text": field.help_text,
|
||||
"options": field.field_options or {},
|
||||
"validation_rules": field.validation_rules or {},
|
||||
"metadata": field.field_metadata or {},
|
||||
"created_at": field.created_at.isoformat() if field.created_at else None
|
||||
})
|
||||
|
||||
# Возвращаем словарь, а не объект SQLAlchemy
|
||||
return {
|
||||
"id": form.id,
|
||||
"name": form.name,
|
||||
"description": form.description,
|
||||
"version": form.version,
|
||||
"is_active": form.is_active,
|
||||
"is_published": form.is_published,
|
||||
"created_at": form.created_at.isoformat() if form.created_at else None,
|
||||
"updated_at": form.updated_at.isoformat() if form.updated_at else None,
|
||||
"created_by": form.created_by,
|
||||
"fields": fields_list
|
||||
}
|
||||
|
||||
def get_forms_paginated(self, page=1, per_page=20, active_only=False):
|
||||
"""Получить список форм с пагинацией"""
|
||||
query = self.db.query(Form)
|
||||
if active_only:
|
||||
query = query.filter(Form.is_active == True)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
# Преобразуем объекты в словари
|
||||
items_list = []
|
||||
for form in items:
|
||||
items_list.append({
|
||||
"id": form.id,
|
||||
"name": form.name,
|
||||
"description": form.description,
|
||||
"version": form.version,
|
||||
"is_active": form.is_active,
|
||||
"is_published": form.is_published,
|
||||
"created_at": form.created_at.isoformat() if form.created_at else None,
|
||||
"updated_at": form.updated_at.isoformat() if form.updated_at else None,
|
||||
"created_by": form.created_by
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items_list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page
|
||||
}
|
||||
115
app/services/submission_service.py
Normal file
115
app/services/submission_service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# app/services/submission_service.py
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, Dict, Any
|
||||
from app.models.submission import Submission
|
||||
from app.schemas.submission import SubmissionCreate
|
||||
|
||||
|
||||
class SubmissionService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_submission(self, submission_data: SubmissionCreate):
|
||||
"""Создать новую submission"""
|
||||
submission = Submission(
|
||||
form_id=submission_data.form_id,
|
||||
submission_data=submission_data.data, # ← используем submission_data
|
||||
submission_metadata=submission_data.metadata.dict() if submission_data.metadata else {},
|
||||
status="completed"
|
||||
)
|
||||
self.db.add(submission)
|
||||
self.db.commit()
|
||||
self.db.refresh(submission)
|
||||
|
||||
# Возвращаем словарь, соответствующий схеме
|
||||
return {
|
||||
"id": submission.id,
|
||||
"submission_uuid": str(submission.submission_uuid),
|
||||
"form_id": submission.form_id,
|
||||
"submission_data": submission.submission_data,
|
||||
"submission_metadata": submission.submission_metadata,
|
||||
"status": submission.status,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"created_at": submission.created_at,
|
||||
"updated_at": submission.updated_at,
|
||||
"submitted_by": submission.submitted_by,
|
||||
"completion_time_seconds": submission.completion_time_seconds,
|
||||
"referrer": submission.referrer
|
||||
}
|
||||
|
||||
def get_submission_by_id(self, submission_id: int):
|
||||
"""Получить submission по ID"""
|
||||
submission = self.db.query(Submission).filter(Submission.id == submission_id).first()
|
||||
if not submission:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": submission.id,
|
||||
"submission_uuid": str(submission.submission_uuid),
|
||||
"form_id": submission.form_id,
|
||||
"submission_data": submission.submission_data,
|
||||
"submission_metadata": submission.submission_metadata,
|
||||
"status": submission.status,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"created_at": submission.created_at,
|
||||
"updated_at": submission.updated_at,
|
||||
"submitted_by": submission.submitted_by,
|
||||
"completion_time_seconds": submission.completion_time_seconds,
|
||||
"referrer": submission.referrer
|
||||
}
|
||||
|
||||
def get_submissions_by_form(self, form_id: int, page=1, per_page=20, status=None):
|
||||
"""Получить все submission формы"""
|
||||
query = self.db.query(Submission).filter(Submission.form_id == form_id)
|
||||
if status:
|
||||
query = query.filter(Submission.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
items_list = []
|
||||
for submission in items:
|
||||
items_list.append({
|
||||
"id": submission.id,
|
||||
"submission_uuid": str(submission.submission_uuid),
|
||||
"form_id": submission.form_id,
|
||||
"submission_data": submission.submission_data,
|
||||
"submission_metadata": submission.submission_metadata,
|
||||
"status": submission.status,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"created_at": submission.created_at,
|
||||
"updated_at": submission.updated_at,
|
||||
"submitted_by": submission.submitted_by,
|
||||
"completion_time_seconds": submission.completion_time_seconds,
|
||||
"referrer": submission.referrer
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items_list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page
|
||||
}
|
||||
|
||||
def update_submission(self, submission_id: int, data: Dict[str, Any]):
|
||||
"""Обновить submission"""
|
||||
submission = self.db.query(Submission).filter(Submission.id == submission_id).first()
|
||||
if not submission:
|
||||
return None
|
||||
|
||||
submission.submission_data = data
|
||||
self.db.commit()
|
||||
self.db.refresh(submission)
|
||||
|
||||
return self.get_submission_by_id(submission_id)
|
||||
|
||||
def delete_submission(self, submission_id: int):
|
||||
"""Удалить submission"""
|
||||
submission = self.db.query(Submission).filter(Submission.id == submission_id).first()
|
||||
if not submission:
|
||||
return False
|
||||
|
||||
self.db.delete(submission)
|
||||
self.db.commit()
|
||||
return True
|
||||
94
docker-compose.yml
Normal file
94
docker-compose.yml
Normal file
@@ -0,0 +1,94 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: formbuilder_postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-formbuilder}
|
||||
POSTGRES_INITDB_ARGS: "-E UTF8"
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- formbuilder_network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: formbuilder_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- formbuilder_network
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
target: development
|
||||
container_name: formbuilder_app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --log-level debug
|
||||
volumes:
|
||||
- ./app:/app
|
||||
- ./tests:/tests
|
||||
- ./alembic.ini:/alembic.ini
|
||||
- ./migrations:/migrations
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- APP_ENV=development
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER:-postgres}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||
- DB_NAME=${DB_NAME:-formbuilder}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- formbuilder_network
|
||||
restart: unless-stopped
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: formbuilder_pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@admin.com
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- formbuilder_network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
formbuilder_network:
|
||||
driver: bridge
|
||||
63
docker/Dockerfile
Normal file
63
docker/Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
||||
# Build stage
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt
|
||||
|
||||
# Development stage
|
||||
FROM python:3.11-slim as development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Python dependencies from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Make sure scripts in .local are usable
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim as production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only requirements and install
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
16
main.py
Normal file
16
main.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# This is a sample Python script.
|
||||
|
||||
# Press Shift+F10 to execute it or replace it with your code.
|
||||
# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings.
|
||||
|
||||
|
||||
def print_hi(name):
|
||||
# Use a breakpoint in the code line below to debug your script.
|
||||
print(f'Hi, {name}') # Press Ctrl+F8 to toggle the breakpoint.
|
||||
|
||||
|
||||
# Press the green button in the gutter to run the script.
|
||||
if __name__ == '__main__':
|
||||
print_hi('PyCharm')
|
||||
|
||||
# See PyCharm help at https://www.jetbrains.com/help/pycharm/
|
||||
66
makefile
Normal file
66
makefile
Normal file
@@ -0,0 +1,66 @@
|
||||
.PHONY: help install dev run test lint format migrate init-db clean docker-up docker-down
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make install - Install dependencies"
|
||||
@echo " make dev - Run development server"
|
||||
@echo " make run - Run production server"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make lint - Run linters"
|
||||
@echo " make format - Format code"
|
||||
@echo " make init-db - Initialize database"
|
||||
@echo " make migrate - Run migrations"
|
||||
@echo " make docker-up - Start Docker containers"
|
||||
@echo " make docker-down - Stop Docker containers"
|
||||
@echo " make clean - Clean cache files"
|
||||
|
||||
install:
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
dev:
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
run:
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
test:
|
||||
pytest tests/ -v --cov=app --cov-report=term-missing
|
||||
|
||||
test-watch:
|
||||
ptw -- --testmon
|
||||
|
||||
lint:
|
||||
flake8 app/ tests/ --max-line-length=120
|
||||
mypy app/ --ignore-missing-imports
|
||||
black app/ tests/ --check
|
||||
|
||||
format:
|
||||
black app/ tests/ --line-length=120
|
||||
isort app/ tests/ --profile black
|
||||
|
||||
init-db:
|
||||
python scripts/init_db.py
|
||||
|
||||
migrate:
|
||||
alembic upgrade head
|
||||
|
||||
migration:
|
||||
alembic revision --autogenerate -m "$(name)"
|
||||
|
||||
docker-up:
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
|
||||
docker-down:
|
||||
docker-compose -f docker-compose.yml down
|
||||
|
||||
docker-logs:
|
||||
docker-compose -f docker-compose.yml logs -f app
|
||||
|
||||
clean:
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
find . -type d -name "*.pyc" -delete
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} +
|
||||
find . -type d -name ".mypy_cache" -exec rm -rf {} +
|
||||
find . -type d -name ".coverage" -delete
|
||||
rm -rf .pytest_cache .mypy_cache .coverage htmlcov
|
||||
22
requirements-dev.txt
Normal file
22
requirements-dev.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Testing
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
pytest-env==1.1.3
|
||||
factory-boy==3.3.0
|
||||
|
||||
# Code quality
|
||||
black==23.11.0
|
||||
isort==5.12.0
|
||||
flake8==6.1.0
|
||||
mypy==1.7.0
|
||||
pre-commit==3.5.0
|
||||
|
||||
# Debugging
|
||||
ipython==8.17.2
|
||||
ipdb==0.13.13
|
||||
|
||||
# Database tools
|
||||
pgcli==4.0.1
|
||||
40
requirements.txt
Normal file
40
requirements.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
# Core
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
asyncpg==0.29.0
|
||||
alembic==1.12.1
|
||||
|
||||
# PostgreSQL extensions
|
||||
psycopg2-binary==2.9.9
|
||||
|
||||
# Security
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Utils
|
||||
python-dotenv==1.0.0
|
||||
email-validator==2.1.0
|
||||
httpx==0.25.1
|
||||
|
||||
# File handling
|
||||
aiofiles==23.2.1
|
||||
Pillow==10.1.0
|
||||
|
||||
# Export
|
||||
openpyxl==3.1.2
|
||||
pandas==2.1.3
|
||||
|
||||
# Caching (optional)
|
||||
redis==5.0.1
|
||||
|
||||
# Monitoring
|
||||
prometheus-client==0.19.0
|
||||
|
||||
# Logging
|
||||
python-json-logger==2.0.7
|
||||
64
scripts/init_db.py
Normal file
64
scripts/init_db.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для инициализации базы данных
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from app.config import settings
|
||||
from app.models.base import Base
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Синхронная инициализация БД"""
|
||||
try:
|
||||
logger.info(f"Connecting to: {settings.DATABASE_URL}")
|
||||
|
||||
# Создаем синхронный движок
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# Создаем таблицы
|
||||
logger.info("Creating database tables...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database tables created successfully!")
|
||||
|
||||
# Проверяем подключение - используем text()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT version()"))
|
||||
version = result.scalar()
|
||||
logger.info(f"✅ PostgreSQL version: {version[:50]}...")
|
||||
|
||||
# Показываем созданные таблицы
|
||||
result = conn.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
"""))
|
||||
tables = [row[0] for row in result]
|
||||
if tables:
|
||||
logger.info(f"✅ Created tables: {', '.join(tables)}")
|
||||
else:
|
||||
logger.warning("No tables found")
|
||||
|
||||
engine.dispose()
|
||||
logger.info("Database initialization completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
31
scripts/init_db.sql
Normal file
31
scripts/init_db.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Включение расширений PostgreSQL
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- для поиска по шаблону
|
||||
CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- для составных индексов
|
||||
|
||||
-- Создание кастомных индексов для JSONB
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_submissions_email
|
||||
ON submissions ((data->>'email')) WHERE data ? 'email';
|
||||
|
||||
-- Функция для валидации JSONB схемы
|
||||
CREATE OR REPLACE FUNCTION validate_submission_data()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
-- Проверяем, что data не пустой
|
||||
IF NEW.data IS NULL OR NEW.data = '{}'::jsonb THEN
|
||||
RAISE EXCEPTION 'Submission data cannot be empty';
|
||||
END IF;
|
||||
|
||||
-- Проверяем, что все обязательные поля присутствуют
|
||||
IF NEW.form_id IS NOT NULL THEN
|
||||
-- Дополнительная валидация через триггер
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER validate_submission
|
||||
BEFORE INSERT OR UPDATE ON submissions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_submission_data();
|
||||
93
scripts/init_db_with_create.py
Normal file
93
scripts/init_db_with_create.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для инициализации базы данных с автоматическим созданием
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from app.config import settings
|
||||
from app.models.base import Base
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_database_if_not_exists():
|
||||
"""Создает базу данных если она не существует"""
|
||||
# Подключаемся к default database (postgres)
|
||||
default_db_url = settings.DATABASE_URL.replace(f"/{settings.DB_NAME}", "/postgres")
|
||||
|
||||
try:
|
||||
engine = create_engine(default_db_url, isolation_level="AUTOCOMMIT")
|
||||
with engine.connect() as conn:
|
||||
# Проверяем существует ли база
|
||||
result = conn.execute(
|
||||
text(f"SELECT 1 FROM pg_database WHERE datname = '{settings.DB_NAME}'")
|
||||
)
|
||||
exists = result.fetchone()
|
||||
|
||||
if not exists:
|
||||
logger.info(f"Creating database '{settings.DB_NAME}'...")
|
||||
conn.execute(text(f"CREATE DATABASE {settings.DB_NAME}"))
|
||||
logger.info(f"✅ Database '{settings.DB_NAME}' created successfully!")
|
||||
else:
|
||||
logger.info(f"✅ Database '{settings.DB_NAME}' already exists")
|
||||
|
||||
engine.dispose()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to create database: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Инициализация таблиц в БД"""
|
||||
try:
|
||||
# Сначала создаем БД если нужно
|
||||
if not create_database_if_not_exists():
|
||||
logger.error("Cannot proceed without database")
|
||||
return False
|
||||
|
||||
logger.info(f"Connecting to: {settings.DATABASE_URL}")
|
||||
|
||||
# Создаем движок для основной БД
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
# Создаем таблицы
|
||||
logger.info("Creating database tables...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database tables created successfully!")
|
||||
|
||||
# Проверяем созданные таблицы
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
"""))
|
||||
tables = [row[0] for row in result]
|
||||
logger.info(f"Created tables: {', '.join(tables)}")
|
||||
|
||||
engine.dispose()
|
||||
logger.info("Database initialization completed successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = init_database()
|
||||
sys.exit(0 if success else 1)
|
||||
90
scripts/seed_data.py
Normal file
90
scripts/seed_data.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для заполнения базы данных тестовыми данными
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.form import Form, Field, FormField
|
||||
from app.models.submission import Submission
|
||||
from app.schemas.field import FieldType
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def seed_database():
|
||||
"""Заполнение тестовыми данными"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Создаем тестовые поля
|
||||
fields_data = [
|
||||
{"name": "full_name", "label": "Full Name", "field_type": FieldType.TEXT},
|
||||
{"name": "email", "label": "Email Address", "field_type": FieldType.EMAIL},
|
||||
{"name": "age", "label": "Age", "field_type": FieldType.NUMBER},
|
||||
{"name": "country", "label": "Country", "field_type": FieldType.SELECT,
|
||||
"options": {"options": ["USA", "UK", "Canada", "Australia"]}},
|
||||
]
|
||||
|
||||
fields = []
|
||||
for field_data in fields_data:
|
||||
field = Field(**field_data)
|
||||
db.add(field)
|
||||
fields.append(field)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Создаем тестовую форму
|
||||
form = Form(
|
||||
name="Test Registration Form",
|
||||
description="Test form for development",
|
||||
is_active=True,
|
||||
is_published=True
|
||||
)
|
||||
db.add(form)
|
||||
await db.flush()
|
||||
|
||||
# Связываем поля с формой
|
||||
for i, field in enumerate(fields):
|
||||
form_field = FormField(
|
||||
form_id=form.id,
|
||||
field_id=field.id,
|
||||
order=i,
|
||||
is_required=True if i < 2 else False
|
||||
)
|
||||
db.add(form_field)
|
||||
|
||||
# Создаем тестовые submission'ы
|
||||
test_data = [
|
||||
{"full_name": "John Doe", "email": "john@example.com", "age": 30, "country": "USA"},
|
||||
{"full_name": "Jane Smith", "email": "jane@example.com", "age": 25, "country": "UK"},
|
||||
{"full_name": "Bob Johnson", "email": "bob@example.com", "age": 35, "country": "Canada"},
|
||||
]
|
||||
|
||||
for data in test_data:
|
||||
submission = Submission(
|
||||
form_id=form.id,
|
||||
data=data,
|
||||
status="completed",
|
||||
submitted_at=datetime.utcnow()
|
||||
)
|
||||
db.add(submission)
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Test data seeded successfully! Created form '{form.name}' with {len(fields)} fields and {len(test_data)} submissions")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Failed to seed database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_database())
|
||||
15
scripts/test_models.py
Normal file
15
scripts/test_models.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# test_models.py
|
||||
from app.database import SessionLocal
|
||||
from app.models.form import Form, Field, FormField
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
# Создаем тестовую форму
|
||||
form = Form(name="Test Form", description="Test")
|
||||
db.add(form)
|
||||
db.commit()
|
||||
|
||||
print(f"Created form with id: {form.id}")
|
||||
print("Database is working correctly!")
|
||||
|
||||
db.close()
|
||||
30
setup_db.sh
Executable file
30
setup_db.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Настройки
|
||||
DB_NAME="formbuilder"
|
||||
DB_USER="postgres"
|
||||
DB_PASSWORD="postgres"
|
||||
|
||||
echo "Setting up PostgreSQL database..."
|
||||
|
||||
# Проверяем, запущен ли PostgreSQL
|
||||
if ! pg_isready -U $DB_USER -h localhost -p 5432; then
|
||||
echo "PostgreSQL is not running. Starting..."
|
||||
sudo systemctl start postgresql
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
# Создаем базу данных если не существует
|
||||
if ! psql -U $DB_USER -h localhost -lqt | cut -d \| -f 1 | grep -qw $DB_NAME; then
|
||||
echo "Creating database $DB_NAME..."
|
||||
sudo -u postgres createdb $DB_NAME
|
||||
echo "Database created successfully!"
|
||||
else
|
||||
echo "Database $DB_NAME already exists"
|
||||
fi
|
||||
|
||||
# Запускаем инициализацию Python
|
||||
echo "Running database initialization..."
|
||||
python scripts/init_db.py
|
||||
|
||||
echo "Setup complete!"
|
||||
Reference in New Issue
Block a user