commit 9fa723bb4cf6a3d6a1202196cda271a7abc8baee Author: Kavalar Date: Thu Apr 9 19:28:41 2026 +0300 first diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d7dc1c7 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7483117 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..759968c --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..43b9612 --- /dev/null +++ b/app/api/deps.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..b90fa47 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,4 @@ +# app/api/v1/__init__.py +from app.api.v1 import forms, submissions, analytics, export + +__all__ = ["forms", "submissions", "analytics", "export"] \ No newline at end of file diff --git a/app/api/v1/analytics.py b/app/api/v1/analytics.py new file mode 100644 index 0000000..ce5e113 --- /dev/null +++ b/app/api/v1/analytics.py @@ -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) \ No newline at end of file diff --git a/app/api/v1/export.py b/app/api/v1/export.py new file mode 100644 index 0000000..82e5ccc --- /dev/null +++ b/app/api/v1/export.py @@ -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)}") \ No newline at end of file diff --git a/app/api/v1/forms.py b/app/api/v1/forms.py new file mode 100644 index 0000000..ff14dfc --- /dev/null +++ b/app/api/v1/forms.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/submissions.py b/app/api/v1/submissions.py new file mode 100644 index 0000000..ca995a9 --- /dev/null +++ b/app/api/v1/submissions.py @@ -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"} \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ae8779f --- /dev/null +++ b/app/config.py @@ -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() \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..c61202a --- /dev/null +++ b/app/database.py @@ -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 \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0858853 --- /dev/null +++ b/app/main.py @@ -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" + } \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ba40b49 --- /dev/null +++ b/app/models/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..9aaa6d2 --- /dev/null +++ b/app/models/base.py @@ -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})>" \ No newline at end of file diff --git a/app/models/form.py b/app/models/form.py new file mode 100644 index 0000000..23e1108 --- /dev/null +++ b/app/models/form.py @@ -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'), + ) \ No newline at end of file diff --git a/app/models/submission.py b/app/models/submission.py new file mode 100644 index 0000000..3c5073d --- /dev/null +++ b/app/models/submission.py @@ -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'), + ) \ No newline at end of file diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..8af0bc0 --- /dev/null +++ b/app/repositories/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/repositories/base.py b/app/repositories/base.py new file mode 100644 index 0000000..41838af --- /dev/null +++ b/app/repositories/base.py @@ -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 + } \ No newline at end of file diff --git a/app/repositories/form_repository.py b/app/repositories/form_repository.py new file mode 100644 index 0000000..34e782e --- /dev/null +++ b/app/repositories/form_repository.py @@ -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 \ No newline at end of file diff --git a/app/repositories/submission_repository.py b/app/repositories/submission_repository.py new file mode 100644 index 0000000..1a588a2 --- /dev/null +++ b/app/repositories/submission_repository.py @@ -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 {} \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..8603e1a --- /dev/null +++ b/app/schemas/__init__.py @@ -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", +] \ No newline at end of file diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..3513a68 --- /dev/null +++ b/app/schemas/common.py @@ -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 \ No newline at end of file diff --git a/app/schemas/field.py b/app/schemas/field.py new file mode 100644 index 0000000..ff147e9 --- /dev/null +++ b/app/schemas/field.py @@ -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 \ No newline at end of file diff --git a/app/schemas/form.py b/app/schemas/form.py new file mode 100644 index 0000000..b0cbe28 --- /dev/null +++ b/app/schemas/form.py @@ -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 \ No newline at end of file diff --git a/app/schemas/response.py b/app/schemas/response.py new file mode 100644 index 0000000..026e5c0 --- /dev/null +++ b/app/schemas/response.py @@ -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] \ No newline at end of file diff --git a/app/schemas/submission.py b/app/schemas/submission.py new file mode 100644 index 0000000..cd98bc0 --- /dev/null +++ b/app/schemas/submission.py @@ -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) \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..b673d11 --- /dev/null +++ b/app/services/__init__.py @@ -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" +] \ No newline at end of file diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py new file mode 100644 index 0000000..67950f2 --- /dev/null +++ b/app/services/analytics_service.py @@ -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" \ No newline at end of file diff --git a/app/services/export_service.py b/app/services/export_service.py new file mode 100644 index 0000000..a9098e1 --- /dev/null +++ b/app/services/export_service.py @@ -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" \ No newline at end of file diff --git a/app/services/form_service.py b/app/services/form_service.py new file mode 100644 index 0000000..e7675eb --- /dev/null +++ b/app/services/form_service.py @@ -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 + } \ No newline at end of file diff --git a/app/services/submission_service.py b/app/services/submission_service.py new file mode 100644 index 0000000..b0a4545 --- /dev/null +++ b/app/services/submission_service.py @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..98469e8 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..cf44509 --- /dev/null +++ b/docker/Dockerfile @@ -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"] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..5596b44 --- /dev/null +++ b/main.py @@ -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/ diff --git a/makefile b/makefile new file mode 100644 index 0000000..f8a0659 --- /dev/null +++ b/makefile @@ -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 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..31b5992 --- /dev/null +++ b/requirements-dev.txt @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..774eaa0 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..0530aa6 --- /dev/null +++ b/scripts/init_db.py @@ -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() \ No newline at end of file diff --git a/scripts/init_db.sql b/scripts/init_db.sql new file mode 100644 index 0000000..ce8747b --- /dev/null +++ b/scripts/init_db.sql @@ -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(); \ No newline at end of file diff --git a/scripts/init_db_with_create.py b/scripts/init_db_with_create.py new file mode 100644 index 0000000..fecb169 --- /dev/null +++ b/scripts/init_db_with_create.py @@ -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) \ No newline at end of file diff --git a/scripts/seed_data.py b/scripts/seed_data.py new file mode 100644 index 0000000..95d670c --- /dev/null +++ b/scripts/seed_data.py @@ -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()) \ No newline at end of file diff --git a/scripts/test_models.py b/scripts/test_models.py new file mode 100644 index 0000000..73fc999 --- /dev/null +++ b/scripts/test_models.py @@ -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() \ No newline at end of file diff --git a/setup_db.sh b/setup_db.sh new file mode 100755 index 0000000..93672fc --- /dev/null +++ b/setup_db.sh @@ -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!" \ No newline at end of file