This commit is contained in:
2026-04-09 19:28:41 +03:00
commit 9fa723bb4c
43 changed files with 2804 additions and 0 deletions

45
app/api/deps.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'),
)

View 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
View 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
}

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]

View 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"

View 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"

View 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
}

View 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