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

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