api-admim/app/database/RelationalTableRepository.py

593 lines
27 KiB
Python

# Importações de bibliotecas padrão
from typing import Generic, TypeVar, Dict, Type, Union, Any, List
from uuid import UUID
import copy
# Importações de bibliotecas de terceiros
from sqlalchemy import BinaryExpression, literal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.inspection import inspect
from sqlalchemy.future import select
from sqlalchemy import delete
from pydantic import BaseModel
from fastapi import HTTPException
from app.database.audit_log import audit_log
# Importações do seu próprio projeto
from app.database.RepositoryBase import (
RepositoryBase, IntegrityError, IntegrityConflictException,
SnippetException, NotFoundException
)
from app.database import models
Model = TypeVar("Model", bound=models.Base)
Schema = TypeVar("Schema", bound=BaseModel)
class RelationalTableRepository(RepositoryBase[Model], Generic[Model]):
def __init__(self, model: type[Model], session: AsyncSession) -> None:
super().__init__(model, session)
async def filter(
self,
*expressions: BinaryExpression,
) -> list[Model]:
query = select(self.model)
if expressions:
query = query.where(*expressions)
return list(await self.session.scalars(query))
async def create(self, data_one: Schema, *args: Any, **kwargs: Any) -> Model:
try:
if kwargs.get("related_info_extra_columns"):
db_model = await self.process_related_items_with_extra_columns(data_one, **kwargs)
else:
db_model = await self.process_related_items(data_one, **kwargs)
# Commit e refresh do modelo
await self.session.commit()
await self.session.refresh(db_model)
return db_model
except HTTPException as e:
# Repassa a HTTPException específica
raise e
except Exception as e:
raise HTTPException(status_code=500, detail="Erro ao criar relação com entidades relacionadas") from e
async def create_many(self, data: List[Schema], return_models: bool = False, *args: Any, **kwargs: Any) -> (
list[Model] | bool):
db_models = []
if kwargs.get("related_info_extra_columns"):
for single_data in data:
db_model = await self.process_related_items_with_extra_columns(single_data, **kwargs)
db_models.append(db_model)
else:
for single_data in data:
db_model = await self.process_related_items(single_data, **kwargs)
db_models.append(db_model)
try:
self.session.add_all(db_models)
await self.session.commit()
except IntegrityError:
raise IntegrityConflictException(
f"Na tabela {self.model.__tablename__} existe conflito com dados já cadastrados em campo único.",
)
except Exception as e:
raise SnippetException(f"Unknown error occurred: {e}") from e
if not return_models:
return True
for m in db_models:
await self.session.refresh(m)
return db_models
# async def update_by_id(self, update: Schema, coluna: str, *args: Any, **kwargs: Any) -> Model:
# related_info_append = kwargs.get('related_info_append', [])
# related_info_add = kwargs.get('related_info_add', [])
#
# # Prepara as variáveis para os relacionamentos simples e muitos para muitos
# simple_relationship_fields = {
# info["key"]: {"related_model": info["related_model"], "exclude_field": info["foreign_key"]}
# for info in related_info_add
# }
# many_to_many_relationship_fields = {
# info["key"]: info["related_model"] for info in related_info_append
# }
#
# uuid = str(update.uuid)
# db_model = await self.get_one_by_id(uuid, coluna, with_for_update=True)
#
# if not db_model:
# raise NotFoundException(
# f"{self.model.__tablename__} {coluna}={uuid} não encontrada."
# )
#
# values = update.model_dump(exclude_unset=True)
#
# # Atualiza campos simples
# for k, v in values.items():
# if k not in simple_relationship_fields and k not in many_to_many_relationship_fields:
# setattr(db_model, k, v)
#
# # Atualiza relacionamentos simples
# for field, details in simple_relationship_fields.items():
# related_model = details["related_model"]
# foreign_key = details["exclude_field"] # Isso é o "fk_pessoa_uuid"
#
# # Se a chave não estiver presente ou estiver com uma lista vazia, remove os itens relacionados
# if field not in values or values.get(field) == []:
# foreign_key_column = getattr(related_model, foreign_key)
#
# # Garantimos que a comparação é feita entre expressões SQL válidas
# await self.session.execute(
# delete(related_model).where(foreign_key_column == literal(db_model.uuid))
# )
# await self.session.flush()
# else:
# # Faz a Atualização de valores da lista e exclusão do que não estão na lista
# related_items = values.get(field)
# await self.update_simple_relationship(db_model=db_model, field=field, related_items=related_items,
# related_model=related_model, session=self.session,
# foreign_key=foreign_key)
#
# # Atualiza relacionamentos muitos para muitos
# for field, related_model in many_to_many_relationship_fields.items():
# # Se a chave não estiver presente ou estiver com uma lista vazia, remove os itens relacionados
# if field not in values or values.get(field) == []:
# setattr(db_model, field, [])
# else:
# related_item_ids = values.get(field)
# await self.update_many_to_many_relationship(db_model, field, related_item_ids, related_model,
# coluna=coluna)
#
# try:
# await self.session.commit()
# await self.session.refresh(db_model)
# return db_model
# except IntegrityError:
# raise IntegrityConflictException(
# f"{self.model.__tablename__} {coluna}={uuid} conflito com dados existentes."
# )
# @audit_log
async def update_by_id(self, update: Schema, coluna: str, *args: Any, **kwargs: Any) -> Model:
# Obtém as configurações dos relacionamentos
related_info_append = kwargs.get('related_info_append', [])
related_info_add = kwargs.get('related_info_add', [])
related_info_extra = kwargs.get('related_info_extra_columns', [])
# Preparação dos relacionamentos simples (one-to-many) e muitos-para-muitos simples
simple_relationship_fields = {
info["key"]: {"related_model": info["related_model"], "exclude_field": info["foreign_key"]}
for info in related_info_add
}
many_to_many_relationship_fields = {
info["key"]: info["related_model"] for info in related_info_append
}
# Para relacionamentos com extra columns, armazenamos a configuração completa
extra_relationship_fields = {info["key"]: info for info in related_info_extra}
# Busca o objeto base a ser atualizado
uuid = str(update.uuid)
db_model = await self.get_one_by_id(uuid, coluna, with_for_update=True)
if not db_model:
raise NotFoundException(f"{self.model.__tablename__} {coluna}={uuid} não encontrada.")
# Guardar o estado atual antes da modificação (cópia profunda para evitar problemas com lazy attributes)
original_model = copy.deepcopy(db_model)
values = update.model_dump(exclude_unset=True)
# Atualiza os campos simples (excluindo os campos que representam relacionamentos)
all_relationship_keys = set(simple_relationship_fields.keys()) | set(
many_to_many_relationship_fields.keys()) | set(extra_relationship_fields.keys())
for k, v in values.items():
if k not in all_relationship_keys:
setattr(db_model, k, v)
# Atualiza relacionamentos simples (one-to-many)
for field, details in simple_relationship_fields.items():
related_model = details["related_model"]
foreign_key = details["exclude_field"] # por exemplo, "fk_pessoa_uuid"
if field not in values or values.get(field) == []:
foreign_key_column = getattr(related_model, foreign_key)
await self.session.execute(
delete(related_model).where(foreign_key_column == literal(db_model.uuid))
)
await self.session.flush()
else:
related_items = values.get(field)
await self.update_simple_relationship(
db_model=db_model,
field=field,
related_items=related_items,
related_model=related_model,
session=self.session,
foreign_key=foreign_key
)
# Atualiza relacionamentos muitos-para-muitos simples
for field, related_model in many_to_many_relationship_fields.items():
if field not in values or values.get(field) == []:
setattr(db_model, field, [])
else:
related_item_ids = values.get(field)
await self.update_many_to_many_relationship(
db_model=db_model,
field=field,
related_item_ids=related_item_ids,
related_model=related_model,
coluna=coluna)
# Atualiza relacionamentos muitos-para-muitos com campos extras
for field, config in extra_relationship_fields.items():
# Se o campo não foi enviado no update, ignore a atualização desse relacionamento.
if field not in values:
continue
new_items = values.get(field) # Espera-se que seja uma lista (pode ser vazia)
await self.update_many_to_many_extra_relationship(
db_model=db_model,
field=field,
new_items=new_items,
association_model=config["association_model"],
base_foreign_key=config["base_foreign_key"],
related_foreign_key=config["related_foreign_key"],
extra_fields=config.get("extra_fields", [])
)
try:
await self.session.commit()
await self.session.refresh(db_model)
return db_model
except IntegrityError:
raise IntegrityConflictException(
f"{self.model.__tablename__} {coluna}={uuid} conflito com dados existentes."
)
async def update_many_by_ids(self, *args, **kwargs):
raise NotImplementedError("Update many não implementado para relacionamentos.")
@staticmethod
def _create_base_model(data: Schema, db_data,
related_info_append: List[Dict[str, Any]],
related_info_add: List[Dict[str, Any]],
related_info_extra_columns: List[Dict[str, Any]]) -> Model:
# Inicia com as chaves dos relacionamentos simples (append e add)
exclude_keys = {info["key"] for info in related_info_append + related_info_add + related_info_extra_columns}
return db_data(**data.model_dump(exclude=exclude_keys))
async def _collect_related_items(self, data: Schema, related_info_append: List[Dict[str, Any]]) -> (
Dict[str, List[Any]]):
related_items_to_append = {}
for info in related_info_append:
key = info["key"]
related_model = info["related_model"]
foreign_key_field = info["foreign_key_field"]
related_items_to_append[key] = []
related_data = getattr(data, key, [])
if not related_data:
continue # Pula para a próxima iteração se não houver dados para este campo
for related_item in related_data:
related_item_id = related_item[foreign_key_field] if isinstance(related_item, dict) else getattr(
related_item, foreign_key_field)
related_item_instance = await self.session.get(related_model, related_item_id)
if not related_item_instance:
raise HTTPException(status_code=400, detail=f"ID {related_item_id} inválido")
related_items_to_append[key].append(related_item_instance)
return related_items_to_append
async def process_related_items(self, data: Schema, **kwargs: Any) -> Model:
# Obtendo argumentos adicionais
db_data = kwargs.get('db_data')
related_info_append = kwargs.get('related_info_append', [])
related_info_add = kwargs.get('related_info_add', [])
related_info_extra_columns = []
# Criação do modelo base
db_model = self._create_base_model(data, db_data, related_info_append, related_info_add,
related_info_extra_columns)
# Processamento de related_info_append, se presente
if related_info_append:
related_items_to_append = await self._collect_related_items(data, related_info_append)
if related_items_to_append: # Verifica se há itens para serem relacionados
self._append_related_items(db_model, related_items_to_append)
# Adiciona o modelo à sessão
self.session.add(db_model)
await self.session.flush()
# Processamento de related_info_add, se presente
if related_info_add:
await self._add_related_items(data, db_model, related_info_add)
return db_model
async def process_related_items_with_extra_columns(self, data: Schema, **kwargs: Any) -> Model:
"""
Processa os relacionamentos onde a tabela de associação possui colunas extras.
Espera-se que em kwargs seja informado:
- db_data: a classe/modelo base a ser instanciado com os dados.
- related_info_extra_columns: uma lista de dicionários com as seguintes chaves:
* key: nome do atributo no schema contendo os dados do relacionamento.
* association_model: o modelo da tabela de associação (com colunas extras).
* base_foreign_key: nome da coluna que referencia o modelo base.
* related_foreign_key: nome da coluna que referencia a entidade relacionada.
* related_model: modelo da entidade relacionada (para validação, por exemplo).
* extra_fields: (opcional) lista de nomes dos campos extras que deverão ser extraídos.
"""
# Cria o modelo base usando os dados enviados no schema
db_data = kwargs.get('db_data')
related_info_append = []
related_info_extra_columns = kwargs.get('related_info_extra_columns', [])
related_info_add = kwargs.get('related_info_add', [])
db_model = self._create_base_model(data, db_data, related_info_append, related_info_add,
related_info_extra_columns,
)
self.session.add(db_model)
await self.session.flush() # Garante que db_model possua sua PK definida (ex.: uuid)
# Processa os relacionamentos com colunas extras
related_info_extra = kwargs.get('related_info_extra_columns', [])
for info in related_info_extra:
key = info.get("key")
association_model = info.get("association_model")
base_foreign_key = info.get("base_foreign_key")
related_foreign_key = info.get("related_foreign_key")
related_model = info.get("related_model") # Para validação do item relacionado
extra_fields = info.get("extra_fields", [])
# Obtém os dados do relacionamento a partir do schema
related_items = getattr(data, key, [])
if not related_items:
continue
for item in related_items:
# Verifica se o identificador da entidade relacionada está presente no item
if not hasattr(item, related_foreign_key):
raise HTTPException(
status_code=400,
detail=f"O campo '{related_foreign_key}' é obrigatório em '{key}'."
)
related_item_id = item[related_foreign_key] if isinstance(item, dict) else getattr(item,
related_foreign_key)
# Valida se o item relacionado existe (usando o modelo relacionado)
if not related_model:
raise HTTPException(
status_code=400,
detail="Não foi definido o modelo relacionado para o relacionamento."
)
related_instance = await self.session.get(related_model, related_item_id)
if not related_instance:
raise HTTPException(
status_code=400,
detail=f"ID {related_item_id} inválido para {related_model.__tablename__}"
)
# Extrai os valores dos campos extras, se existirem
extra_data = {field: getattr(item, field) for field in extra_fields if hasattr(item, field)}
# Cria a instância da tabela de associação, populando as FKs e os campos extras
association_instance = association_model(**{
base_foreign_key: db_model.uuid,
related_foreign_key: related_item_id,
**extra_data
})
self.session.add(association_instance)
# Realiza um flush para persistir os itens da associação antes de continuar
await self.session.flush()
# Se houver relacionamentos do tipo um-para-muitos (add), processa-os também
related_info_add = kwargs.get('related_info_add', [])
if related_info_add:
await self._add_related_items(data, db_model, related_info_add)
return db_model
@staticmethod
def _append_related_items(db_model: Model, related_items_to_append: Dict[str, List[Model]]) -> None:
for key, items in related_items_to_append.items():
getattr(db_model, key).extend(items)
async def _add_related_items(self, data: Schema, db_model: Model, related_info_add: List[Dict[str, Any]]) -> None:
for info in related_info_add:
key = info["key"]
foreign_key = info["foreign_key"]
related_model = info["related_model"]
relations = info.get("relations", []) # Pode ser uma lista de relações
related_items = getattr(data, key, [])
if not related_items:
continue # Pula para a próxima iteração se não houver dados para este campo
for related_item in related_items:
for relation in relations:
related_model_fk = relation.get("related_model_fk")
foreign_key_fk = relation.get("foreign_key_fk")
if related_model_fk and foreign_key_fk:
fk = getattr(related_item, foreign_key_fk)
relacao = await self.session.get(related_model_fk, fk)
if not relacao:
raise HTTPException(status_code=404,
detail=f"{related_model_fk.__name__} com UUID {fk} não encontrado")
new_item = related_model(**related_item.model_dump(), **{foreign_key: db_model.uuid})
self.session.add(new_item)
@staticmethod
async def update_simple_relationship(
db_model: Model,
field: str,
related_items: List[Dict[str, Any]],
related_model: Type[Model],
session: AsyncSession,
foreign_key: str
) -> None:
current_items = getattr(db_model, field, [])
# Lista de UUIDs dos itens enviados no JSON
new_item_uuids = {str(item['uuid']) for item in related_items if 'uuid' in item}
# Remover itens que não estão mais presentes no JSON
for item in current_items:
if str(item.uuid) not in new_item_uuids:
foreign_key_column = getattr(related_model, foreign_key)
await session.execute(
delete(related_model).where(foreign_key_column == literal(db_model.uuid)).where(
related_model.uuid == literal(item.uuid)
)
)
await session.flush()
# Atualizar ou adicionar novos itens
for related_item in related_items:
if 'uuid' in related_item:
item_uuid = str(related_item['uuid'])
db_item = next((item for item in current_items if str(item.uuid) == item_uuid), None)
if db_item:
for k, v in related_item.items():
setattr(db_item, k, v)
else:
raise NotFoundException(f"Related item {field} with UUID {item_uuid} not found.")
else:
new_item = related_model(**related_item)
setattr(new_item, db_model.__class__.__name__.lower() + "_uuid", db_model.uuid)
session.add(new_item)
current_items.append(new_item)
setattr(db_model, field, current_items)
await session.flush()
async def update_many_to_many_relationship(
self,
db_model: Model,
field: str,
related_item_ids: List[Union[str, UUID, Dict[str, Any]]], # Aceita "strings", UUIDs ou dicionários
related_model: Type[Model], # Adicionando o modelo relacionado
coluna: str = "uuid" # Adicionando a coluna para uso dinâmico
) -> None:
current_items = getattr(db_model, field, [])
current_item_ids = {str(getattr(item, coluna)) for item in current_items}
# Extrair "IDs" dos dicionários, se necessário
new_item_ids = set()
for item in related_item_ids:
if isinstance(item, dict) and coluna in item:
new_item_ids.add(str(item[coluna]))
else:
new_item_ids.add(str(item))
# Remover itens que não estão mais relacionados
items_to_remove = current_item_ids - new_item_ids
for item in current_items:
if str(getattr(item, coluna)) in items_to_remove:
current_items.remove(item)
# Adicionar novos itens
items_to_add = new_item_ids - current_item_ids
for item_id in items_to_add:
related_item = await self.session.get(related_model, item_id)
if related_item:
current_items.append(related_item)
else:
raise NotFoundException(f"Related item {field} with UUID {item_id} not found.")
setattr(db_model, field, current_items)
async def update_many_to_many_extra_relationship(
self,
db_model: Model,
field: str,
new_items: List[Union[Dict[str, Any], Any]], # Aceita dicionários ou objetos com .dict()
association_model: Type[Model],
base_foreign_key: str,
related_foreign_key: str,
extra_fields: List[str] = [],
) -> None:
"""
Atualiza um relacionamento muitos-para-muitos com colunas extras.
Parâmetros:
- db_model: objeto base que possui o relacionamento.
- field: nome do atributo em db_model que contém a lista de associações.
- new_items: lista de novos itens para o relacionamento (pode ser dicionário ou objeto com .dict()).
- association_model: classe mapeada da tabela de associação.
- base_foreign_key: nome do atributo da associação que referencia db_model (ex: 'fk_manutencao_uuid').
- related_foreign_key: nome do atributo da associação que referencia o objeto relacionado (ex: 'fk_itens_equipamentos_uuid').
- extra_fields: lista de campos extras que também devem ser atualizados ou definidos.
"""
current_assocs = getattr(db_model, field, [])
if not new_items:
# Se o payload enviar uma lista vazia, remova todas as associações
for assoc in current_assocs:
await self.session.delete(assoc)
setattr(db_model, field, [])
await self.session.flush()
return
new_assoc_instances = []
for new_item in new_items:
# Suporta tanto dicionários quanto objetos com método .dict()
new_item_data = new_item if isinstance(new_item, dict) else new_item.dict()
if related_foreign_key not in new_item_data:
raise HTTPException(
status_code=400,
detail=f"O campo '{related_foreign_key}' é obrigatório em '{field}'."
)
new_related_id = new_item_data[related_foreign_key]
# Procura uma associação existente que corresponda ao valor do foreign key
existing_assoc = next(
(assoc for assoc in current_assocs if str(getattr(assoc, related_foreign_key)) == str(new_related_id)),
None
)
if existing_assoc:
# Atualiza os campos extras se houver valores informados
for ef in extra_fields:
if ef in new_item_data:
setattr(existing_assoc, ef, new_item_data[ef])
new_assoc_instances.append(existing_assoc)
else:
# Cria uma nova associação, incluindo os campos extras
extra_data = {ef: new_item_data.get(ef) for ef in extra_fields if ef in new_item_data}
new_assoc = association_model(**{
base_foreign_key: db_model.uuid,
related_foreign_key: new_related_id,
**extra_data
})
self.session.add(new_assoc)
await self.session.flush()
new_assoc_instances.append(new_assoc)
# Mescla as associações atuais com as novas (ou atualizadas)
merged_assocs = list({*current_assocs, *new_assoc_instances})
setattr(db_model, field, merged_assocs)