Adiciona novos templates de e-mail para verificação, redefinição de senha e boas-vindas, além de atualizar a estrutura do projeto para incluir um novo esquema de registro de cliente e usuário. Implementa a lógica de envio de e-mails utilizando Jinja2 para renderização de templates. Atualiza o serviço de autenticação para suportar a criação de clientes com usuários associados.

This commit is contained in:
Davidson Gomes 2025-04-28 16:14:11 -03:00
parent 84ea77c3f7
commit 09b0219e77
33 changed files with 793 additions and 715 deletions

View File

@ -41,6 +41,10 @@ src/
│ ├── auth_service.py # JWT authentication logic │ ├── auth_service.py # JWT authentication logic
│ ├── email_service.py # Email sending service │ ├── email_service.py # Email sending service
│ └── audit_service.py # Audit logs logic │ └── audit_service.py # Audit logs logic
├── templates/
│ ├── emails/
│ │ ├── verification_email.html
│ │ └── password_reset.html
└── utils/ └── utils/
└── security.py # Security utilities (JWT, hash) └── security.py # Security utilities (JWT, hash)
``` ```

10
.env
View File

@ -24,14 +24,14 @@ REDIS_PASSWORD=""
TOOLS_CACHE_TTL=3600 TOOLS_CACHE_TTL=3600
# Configurações JWT # Configurações JWT
JWT_SECRET_KEY="sua-chave-secreta-jwt" JWT_SECRET_KEY="f6884ef5be4c279686ff90f0ed9d4656685eef9807245019ac94a3fbe32b0938"
JWT_ALGORITHM="HS256" JWT_ALGORITHM="HS256"
JWT_EXPIRATION_TIME=30 # Em minutos JWT_EXPIRATION_TIME=3600 # Em minutos
# SendGrid # SendGrid
SENDGRID_API_KEY="sua-sendgrid-api-key" SENDGRID_API_KEY="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"
EMAIL_FROM="noreply@yourdomain.com" EMAIL_FROM="noreply@evolution-api.com"
APP_URL="https://yourdomain.com" APP_URL="https://evoai.evoapicloud.com"
# Configurações do Servidor # Configurações do Servidor
HOST="0.0.0.0" HOST="0.0.0.0"

View File

@ -1,90 +0,0 @@
"""allow_null_model_and_api_key
Revision ID: 4a61703e9b7e
Revises: 9d819594ac9b
Create Date: 2025-04-28 12:04:33.607371
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '4a61703e9b7e'
down_revision: Union[str, None] = '9d819594ac9b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('events')
op.drop_table('user_states')
op.drop_table('app_states')
op.drop_table('sessions')
op.alter_column('agents', 'model',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('agents', 'api_key',
existing_type=sa.VARCHAR(),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('agents', 'api_key',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('agents', 'model',
existing_type=sa.VARCHAR(),
nullable=False)
op.create_table('sessions',
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('state', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('create_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('update_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('app_name', 'user_id', 'id', name='sessions_pkey'),
postgresql_ignore_search_path=False
)
op.create_table('app_states',
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('state', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('update_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('app_name', name='app_states_pkey')
)
op.create_table('user_states',
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('state', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('update_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('app_name', 'user_id', name='user_states_pkey')
)
op.create_table('events',
sa.Column('id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('session_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('invocation_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('author', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('branch', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('timestamp', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('actions', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.Column('long_running_tool_ids_json', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('grounding_metadata', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('partial', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('turn_complete', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('error_code', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('error_message', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('interrupted', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['app_name', 'user_id', 'session_id'], ['sessions.app_name', 'sessions.user_id', 'sessions.id'], name='events_app_name_user_id_session_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', 'app_name', 'user_id', 'session_id', name='events_pkey')
)
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""add_email_field_on_clients_table
Revision ID: 6cd898ec9f7c
Revises: ab6f3a31f3e8
Create Date: 2025-04-28 15:52:26.406846
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6cd898ec9f7c'
down_revision: Union[str, None] = 'ab6f3a31f3e8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('clients', sa.Column('email', sa.String(), nullable=False))
op.create_index(op.f('ix_clients_email'), 'clients', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_clients_email'), table_name='clients')
op.drop_column('clients', 'email')
# ### end Alembic commands ###

View File

@ -1,44 +0,0 @@
"""add_audit_table
Revision ID: 98780d4fb293
Revises: f11fb4060739
Create Date: 2025-04-28 15:17:10.491183
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '98780d4fb293'
down_revision: Union[str, None] = 'f11fb4060739'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('action', sa.String(), nullable=False),
sa.Column('resource_type', sa.String(), nullable=False),
sa.Column('resource_id', sa.String(), nullable=True),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(), nullable=True),
sa.Column('user_agent', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('audit_logs')
# ### end Alembic commands ###

View File

@ -1,91 +0,0 @@
"""init migration
Revision ID: 9d819594ac9b
Revises:
Create Date: 2025-04-28 11:53:49.375964
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9d819594ac9b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('clients',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('mcp_servers',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('config_json', sa.JSON(), nullable=False),
sa.Column('environments', sa.JSON(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("type IN ('official', 'community')", name='check_mcp_server_type'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tools',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('config_json', sa.JSON(), nullable=False),
sa.Column('environments', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('agents',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('client_id', sa.UUID(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('type', sa.String(), nullable=False),
sa.Column('model', sa.String(), nullable=False),
sa.Column('api_key', sa.String(), nullable=False),
sa.Column('instruction', sa.Text(), nullable=True),
sa.Column('config', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("type IN ('llm', 'sequential', 'parallel', 'loop')", name='check_agent_type'),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('contacts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('client_id', sa.UUID(), nullable=True),
sa.Column('ext_id', sa.String(), nullable=True),
sa.Column('name', sa.String(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('contacts')
op.drop_table('agents')
op.drop_table('tools')
op.drop_table('mcp_servers')
op.drop_table('clients')
# ### end Alembic commands ###

View File

@ -1,19 +1,19 @@
"""add_tools_field_to_mcp_servers """init migration
Revision ID: 2d612b95d0ea Revision ID: ab6f3a31f3e8
Revises: da8e7fb4da5d Revises:
Create Date: 2025-04-28 12:39:21.430144 Create Date: 2025-04-28 15:37:40.885065
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '2d612b95d0ea' revision: str = 'ab6f3a31f3e8'
down_revision: Union[str, None] = 'da8e7fb4da5d' down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -21,12 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('mcp_servers', sa.Column('tools', sa.JSON(), nullable=False, server_default='[]')) pass
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mcp_servers', 'tools') pass
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -1,78 +0,0 @@
"""fix_agent_table
Revision ID: da8e7fb4da5d
Revises: 4a61703e9b7e
Create Date: 2025-04-28 12:29:31.292844
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'da8e7fb4da5d'
down_revision: Union[str, None] = '4a61703e9b7e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_states')
op.drop_table('app_states')
op.drop_table('events')
op.drop_table('sessions')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sessions',
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('state', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('create_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('update_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('app_name', 'user_id', 'id', name='sessions_pkey'),
postgresql_ignore_search_path=False
)
op.create_table('events',
sa.Column('id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('session_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('invocation_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('author', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('branch', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('timestamp', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('actions', postgresql.BYTEA(), autoincrement=False, nullable=False),
sa.Column('long_running_tool_ids_json', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('grounding_metadata', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('partial', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('turn_complete', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('error_code', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('error_message', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('interrupted', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['app_name', 'user_id', 'session_id'], ['sessions.app_name', 'sessions.user_id', 'sessions.id'], name='events_app_name_user_id_session_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', 'app_name', 'user_id', 'session_id', name='events_pkey')
)
op.create_table('app_states',
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('state', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('update_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('app_name', name='app_states_pkey')
)
op.create_table('user_states',
sa.Column('app_name', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('state', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('update_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('app_name', 'user_id', name='user_states_pkey')
)
# ### end Alembic commands ###

View File

@ -1,50 +0,0 @@
"""add_user_table
Revision ID: f11fb4060739
Revises: 2d612b95d0ea
Create Date: 2025-04-28 15:01:34.432588
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f11fb4060739'
down_revision: Union[str, None] = '2d612b95d0ea'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('client_id', sa.UUID(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.Column('email_verified', sa.Boolean(), nullable=True),
sa.Column('verification_token', sa.String(), nullable=True),
sa.Column('verification_token_expiry', sa.DateTime(timezone=True), nullable=True),
sa.Column('password_reset_token', sa.String(), nullable=True),
sa.Column('password_reset_expiry', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@ -16,3 +16,7 @@ python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
sendgrid sendgrid
pydantic[email] pydantic[email]
pydantic-settings
fastapi_utils
bcrypt
jinja2

View File

@ -17,9 +17,12 @@ from dotenv import load_dotenv
from src.models.models import MCPServer from src.models.models import MCPServer
# Configurar logging # Configurar logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_mcp_servers(): def create_mcp_servers():
"""Cria servidores MCP padrão no sistema""" """Cria servidores MCP padrão no sistema"""
try: try:
@ -41,79 +44,140 @@ def create_mcp_servers():
# Verificar se já existem servidores MCP # Verificar se já existem servidores MCP
existing_servers = session.query(MCPServer).all() existing_servers = session.query(MCPServer).all()
if existing_servers: if existing_servers:
logger.info(f"Já existem {len(existing_servers)} servidores MCP cadastrados") logger.info(
f"Já existem {len(existing_servers)} servidores MCP cadastrados"
)
return True return True
# Definições dos servidores MCP # Definições dos servidores MCP
mcp_servers = [ mcp_servers = [
{ {
"name": "Anthropic Claude", "name": "Sequential Thinking",
"description": "Servidor para modelos Claude da Anthropic", "description": "Sequential Thinking helps users organize their thoughts and break down complex problems through a structured workflow. By guiding users through defined cognitive stages like Problem Definition, Research, Analysis, Synthesis, and Conclusion, it provides a framework for progressive thinking. The server tracks the progression of your thinking process, identifies connections between similar thoughts, monitors progress, and generates summaries, making it easier to approach challenges methodically and reach well-reasoned conclusions.",
"config_json": { "config_json": {
"provider": "anthropic", "command": "npx",
"models": ["claude-3-sonnet-20240229", "claude-3-opus-20240229", "claude-3-haiku-20240307"], "args": [
"api_base": "https://api.anthropic.com/v1", "-y",
"api_key_env": "ANTHROPIC_API_KEY" "@modelcontextprotocol/server-sequential-thinking",
],
}, },
"environments": { "environments": {},
"production": True, "tools": ["sequential_thinking"],
"development": True, "type": "community",
"staging": True "id": "4519dd69-9343-4792-af51-dc4d322fb0c9",
}, "created_at": "2025-04-28T15:14:16.901236Z",
"tools": ["function_calling", "web_search"], "updated_at": "2025-04-28T15:43:42.755205Z",
"type": "official"
}, },
{ {
"name": "OpenAI GPT", "name": "CloudFlare",
"description": "Servidor para modelos GPT da OpenAI", "description": "Model Context Protocol (MCP) is a new, standardized protocol for managing context between large language models (LLMs) and external systems. In this repository, we provide an installer as well as an MCP Server for Cloudflare's API.\r\n\r\nThis lets you use Claude Desktop, or any MCP Client, to use natural language to accomplish things on your Cloudflare account, e.g.:\r\n\r\nList all the Cloudflare workers on my <some-email>@gmail.com account.\r\nCan you tell me about any potential issues on this particular worker '...'?",
"config_json": { "config_json": {
"provider": "openai", "url": "https://observability.mcp.cloudflare.com/sse"
"models": ["gpt-4", "gpt-4-turbo", "gpt-3.5-turbo"],
"api_base": "https://api.openai.com/v1",
"api_key_env": "OPENAI_API_KEY"
}, },
"environments": { "environments": {},
"production": True, "tools": [
"development": True, "worker_list",
"staging": True "worker_get",
}, "worker_put",
"tools": ["function_calling", "web_search", "image_generation"], "worker_delete",
"type": "official" "worker_get_worker",
"worker_logs_by_worker_name",
"worker_logs_by_ray_id",
"worker_logs_keys",
"get_kvs",
"kv_get",
"kv_put",
"kv_list",
"kv_delete",
"r2_list_buckets",
"r2_create_bucket",
"r2_delete_bucket",
"r2_list_objects",
"r2_get_object",
"r2_put_object",
"r2_delete_object",
"d1_list_databases",
"d1_create_database",
"d1_delete_database",
"d1_query",
"durable_objects_list",
"durable_objects_create",
"durable_objects_delete",
"durable_objects_list_instances",
"durable_objects_get_instance",
"durable_objects_delete_instance",
"queues_list",
"queues_create",
"queues_delete",
"queues_get",
"queues_send_message",
"queues_get_messages",
"queues_update_consumer",
"workers_ai_list_models",
"workers_ai_get_model",
"workers_ai_run_inference",
"workers_ai_list_tasks",
"workflows_list",
"workflows_create",
"workflows_delete",
"workflows_get",
"workflows_update",
"workflows_execute",
"templates_list",
"templates_get",
"templates_create_from_template",
"w4p_list_dispatchers",
"w4p_create_dispatcher",
"w4p_delete_dispatcher",
"w4p_get_dispatcher",
"w4p_update_dispatcher",
"bindings_list",
"bindings_create",
"bindings_update",
"bindings_delete",
"routing_list_routes",
"routing_create_route",
"routing_update_route",
"routing_delete_route",
"cron_list",
"cron_create",
"cron_update",
"cron_delete",
"zones_list",
"zones_create",
"zones_delete",
"zones_get",
"zones_check_activation",
"secrets_list",
"secrets_put",
"secrets_delete",
"versions_list",
"versions_get",
"versions_rollback",
"wrangler_get_config",
"wrangler_update_config",
"analytics_get",
],
"type": "official",
"id": "9138d1a2-24e6-4a75-87b0-bfa4932273e8",
"created_at": "2025-04-28T15:16:53.350824Z",
"updated_at": "2025-04-28T15:48:04.821766Z",
}, },
{ {
"name": "Google Gemini", "name": "Brave Search",
"description": "Servidor para modelos Gemini do Google", "description": "Brave Search allows you to seamlessly integrate Brave Search functionality into AI assistants like Claude. By implementing a Model Context Protocol (MCP) server, it enables the AI to leverage Brave Search's web search and local business search capabilities. It provides tools for both general web searches and specific local searches, enhancing the AI assistant's ability to provide relevant and up-to-date information.",
"config_json": { "config_json": {
"provider": "google", "command": "npx",
"models": ["gemini-pro", "gemini-ultra"], "args": ["-y", "@modelcontextprotocol/server-brave-search"],
"api_base": "https://generativelanguage.googleapis.com/v1", "env": {"BRAVE_API_KEY": "env@@BRAVE_API_KEY"},
"api_key_env": "GOOGLE_API_KEY"
}, },
"environments": { "environments": {"BRAVE_API_KEY": "env@@BRAVE_API_KEY"},
"production": True, "tools": ["brave_web_search", "brave_local_search"],
"development": True, "type": "official",
"staging": True "id": "416c94d7-77f5-43f4-8181-aeb87934ecbf",
"created_at": "2025-04-28T15:20:07.647225Z",
"updated_at": "2025-04-28T15:49:17.434428Z",
}, },
"tools": ["function_calling", "web_search"],
"type": "official"
},
{
"name": "Ollama Local",
"description": "Servidor para modelos locais via Ollama",
"config_json": {
"provider": "ollama",
"models": ["llama3", "mistral", "mixtral"],
"api_base": "http://localhost:11434",
"api_key_env": None
},
"environments": {
"production": False,
"development": True,
"staging": False
},
"tools": [],
"type": "community"
}
] ]
# Criar os servidores MCP # Criar os servidores MCP
@ -124,7 +188,7 @@ def create_mcp_servers():
config_json=server_data["config_json"], config_json=server_data["config_json"],
environments=server_data["environments"], environments=server_data["environments"],
tools=server_data["tools"], tools=server_data["tools"],
type=server_data["type"] type=server_data["type"],
) )
session.add(server) session.add(server)
@ -145,6 +209,7 @@ def create_mcp_servers():
finally: finally:
session.close() session.close()
if __name__ == "__main__": if __name__ == "__main__":
success = create_mcp_servers() success = create_mcp_servers()
sys.exit(0 if success else 1) sys.exit(0 if success else 1)

View File

@ -45,113 +45,10 @@ def create_tools():
return True return True
# Definições das ferramentas # Definições das ferramentas
tools = [ tools = []
{
"name": "web_search",
"description": "Pesquisa na web para obter informações atualizadas",
"config_json": {
"provider": "brave",
"api_base": "https://api.search.brave.com/res/v1/web/search",
"api_key_env": "BRAVE_API_KEY",
"max_results": 5,
"safe_search": "moderate"
},
"environments": {
"production": True,
"development": True,
"staging": True
}
},
{
"name": "document_query",
"description": "Consulta documentos internos para obter informações específicas",
"config_json": {
"provider": "internal",
"api_base": "${KNOWLEDGE_API_URL}/documents",
"api_key_env": "KNOWLEDGE_API_KEY",
"embeddings_model": "text-embedding-3-small",
"max_chunks": 10,
"similarity_threshold": 0.75
},
"environments": {
"production": True,
"development": True,
"staging": True
}
},
{
"name": "knowledge_base",
"description": "Consulta base de conhecimento para solução de problemas",
"config_json": {
"provider": "internal",
"api_base": "${KNOWLEDGE_API_URL}/kb",
"api_key_env": "KNOWLEDGE_API_KEY",
"max_results": 3,
"categories": ["support", "faq", "troubleshooting"]
},
"environments": {
"production": True,
"development": True,
"staging": True
}
},
{
"name": "whatsapp_integration",
"description": "Integração com WhatsApp para envio e recebimento de mensagens",
"config_json": {
"provider": "meta",
"api_base": "https://graph.facebook.com/v17.0",
"api_key_env": "WHATSAPP_API_KEY",
"phone_number_id": "${WHATSAPP_PHONE_ID}",
"webhook_verify_token": "${WHATSAPP_VERIFY_TOKEN}",
"templates_enabled": True
},
"environments": {
"production": True,
"development": False,
"staging": True
}
},
{
"name": "telegram_integration",
"description": "Integração com Telegram para envio e recebimento de mensagens",
"config_json": {
"provider": "telegram",
"api_base": "https://api.telegram.org",
"api_key_env": "TELEGRAM_BOT_TOKEN",
"webhook_url": "${APP_URL}/webhook/telegram",
"allowed_updates": ["message", "callback_query"]
},
"environments": {
"production": True,
"development": False,
"staging": True
}
}
]
# Criar as ferramentas # Criar as ferramentas
for tool_data in tools: for tool_data in tools:
# Substituir placeholders por variáveis de ambiente quando disponíveis
if "api_base" in tool_data["config_json"]:
if "${KNOWLEDGE_API_URL}" in tool_data["config_json"]["api_base"]:
tool_data["config_json"]["api_base"] = tool_data["config_json"]["api_base"].replace(
"${KNOWLEDGE_API_URL}", os.getenv("KNOWLEDGE_API_URL", "http://localhost:5540")
)
if "webhook_url" in tool_data["config_json"]:
if "${APP_URL}" in tool_data["config_json"]["webhook_url"]:
tool_data["config_json"]["webhook_url"] = tool_data["config_json"]["webhook_url"].replace(
"${APP_URL}", os.getenv("APP_URL", "http://localhost:8000")
)
if "phone_number_id" in tool_data["config_json"]:
if "${WHATSAPP_PHONE_ID}" in tool_data["config_json"]["phone_number_id"]:
tool_data["config_json"]["phone_number_id"] = os.getenv("WHATSAPP_PHONE_ID", "")
if "webhook_verify_token" in tool_data["config_json"]:
if "${WHATSAPP_VERIFY_TOKEN}" in tool_data["config_json"]["webhook_verify_token"]:
tool_data["config_json"]["webhook_verify_token"] = os.getenv("WHATSAPP_VERIFY_TOKEN", "")
tool = Tool( tool = Tool(
name=tool_data["name"], name=tool_data["name"],

Binary file not shown.

View File

@ -1,11 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Any from typing import List, Dict, Any
import uuid import uuid
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, EmailStr
from src.config.database import get_db from src.config.database import get_db
from src.core.jwt_middleware import get_jwt_token, verify_user_client, verify_admin, get_current_user_client_id from src.core.jwt_middleware import (
get_jwt_token,
verify_user_client,
verify_admin,
get_current_user_client_id,
)
from src.schemas.schemas import ( from src.schemas.schemas import (
Client, Client,
ClientCreate, ClientCreate,
@ -18,6 +24,7 @@ from src.schemas.schemas import (
Tool, Tool,
ToolCreate, ToolCreate,
) )
from src.schemas.user import UserCreate
from src.services import ( from src.services import (
client_service, client_service,
contact_service, contact_service,
@ -52,6 +59,12 @@ session_service = DatabaseSessionService(db_url=POSTGRES_CONNECTION_STRING)
artifacts_service = InMemoryArtifactService() artifacts_service = InMemoryArtifactService()
memory_service = InMemoryMemoryService() memory_service = InMemoryMemoryService()
# Definindo um novo schema para registro combinado de cliente e usuário
class ClientRegistration(BaseModel):
name: str
email: EmailStr
password: str
@router.post( @router.post(
"/chat", "/chat",
@ -71,8 +84,7 @@ async def chat(
agent = agent_service.get_agent(db, request.agent_id) agent = agent_service.get_agent(db, request.agent_id)
if not agent: if not agent:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Agente não encontrado"
detail="Agente não encontrado"
) )
# Verificar se o usuário tem acesso ao agente (via cliente) # Verificar se o usuário tem acesso ao agente (via cliente)
@ -127,8 +139,7 @@ async def get_agent_sessions(
agent = agent_service.get_agent(db, agent_id) agent = agent_service.get_agent(db, agent_id)
if not agent: if not agent:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Agente não encontrado"
detail="Agente não encontrado"
) )
# Verificar se o usuário tem acesso ao agente (via cliente) # Verificar se o usuário tem acesso ao agente (via cliente)
@ -147,8 +158,7 @@ async def get_session(
session = get_session_by_id(session_service, session_id) session = get_session_by_id(session_service, session_id)
if not session: if not session:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Sessão não encontrada"
detail="Sessão não encontrada"
) )
# Verificar se o agente da sessão pertence ao cliente do usuário # Verificar se o agente da sessão pertence ao cliente do usuário
@ -174,8 +184,7 @@ async def get_agent_messages(
session = get_session_by_id(session_service, session_id) session = get_session_by_id(session_service, session_id)
if not session: if not session:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Sessão não encontrada"
detail="Sessão não encontrada"
) )
# Verificar se o agente da sessão pertence ao cliente do usuário # Verificar se o agente da sessão pertence ao cliente do usuário
@ -201,8 +210,7 @@ async def remove_session(
session = get_session_by_id(session_service, session_id) session = get_session_by_id(session_service, session_id)
if not session: if not session:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Sessão não encontrada"
detail="Sessão não encontrada"
) )
# Verificar se o agente da sessão pertence ao cliente do usuário # Verificar se o agente da sessão pertence ao cliente do usuário
@ -216,15 +224,38 @@ async def remove_session(
# Rotas para Clientes # Rotas para Clientes
@router.post("/clients/", response_model=Client, status_code=status.HTTP_201_CREATED) @router.post("/clients/", response_model=Client, status_code=status.HTTP_201_CREATED)
async def create_client( async def create_user(
client: ClientCreate, registration: ClientRegistration,
db: Session = Depends(get_db), db: Session = Depends(get_db),
payload: dict = Depends(get_jwt_token), payload: dict = Depends(get_jwt_token),
): ):
"""
Cria um cliente e um usuário associado a ele
Args:
registration: Dados do cliente e usuário a serem criados
db: Sessão do banco de dados
payload: Payload do token JWT
Returns:
Client: Cliente criado
"""
# Apenas administradores podem criar clientes # Apenas administradores podem criar clientes
await verify_admin(payload) await verify_admin(payload)
return client_service.create_client(db, client)
# Criar objetos ClientCreate e UserCreate a partir do ClientRegistration
client = ClientCreate(name=registration.name, email=registration.email)
user = UserCreate(email=registration.email, password=registration.password, name=registration.name)
# Criar cliente com usuário
client_obj, message = client_service.create_client_with_user(db, client, user)
if not client_obj:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
return client_obj
@router.get("/clients/", response_model=List[Client]) @router.get("/clients/", response_model=List[Client])
@ -457,8 +488,8 @@ async def update_agent(
await verify_user_client(payload, db, db_agent.client_id) await verify_user_client(payload, db, db_agent.client_id)
# Se estiver tentando mudar o client_id, verificar permissão para o novo cliente também # Se estiver tentando mudar o client_id, verificar permissão para o novo cliente também
if 'client_id' in agent_data and agent_data['client_id'] != str(db_agent.client_id): if "client_id" in agent_data and agent_data["client_id"] != str(db_agent.client_id):
new_client_id = uuid.UUID(agent_data['client_id']) new_client_id = uuid.UUID(agent_data["client_id"])
await verify_user_client(payload, db, new_client_id) await verify_user_client(payload, db, new_client_id)
return await agent_service.update_agent(db, agent_id, agent_data) return await agent_service.update_agent(db, agent_id, agent_data)

View File

@ -9,6 +9,7 @@ class Client(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, nullable=False) name = Column(String, nullable=False)
email = Column(String, unique=True, index=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator, EmailStr
from typing import Optional, Dict, Any, Union, List from typing import Optional, Dict, Any, Union, List
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -8,6 +8,7 @@ from .agent_config import LLMConfig, SequentialConfig, ParallelConfig, LoopConfi
class ClientBase(BaseModel): class ClientBase(BaseModel):
name: str name: str
email: Optional[EmailStr] = None
class ClientCreate(ClientBase): class ClientCreate(ClientBase):
pass pass

View File

@ -10,6 +10,10 @@ class UserCreate(UserBase):
password: str password: str
name: str # Para criação do cliente associado name: str # Para criação do cliente associado
class AdminUserCreate(UserBase):
password: str
name: str
class UserLogin(BaseModel): class UserLogin(BaseModel):
email: EmailStr email: EmailStr
password: str password: str

View File

@ -7,6 +7,7 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from src.config.settings import settings from src.config.settings import settings
from src.config.database import get_db
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Optional from typing import Optional
@ -147,6 +148,3 @@ def create_access_token(user: User) -> str:
# Criar token # Criar token
return create_jwt_token(token_data) return create_jwt_token(token_data)
# Dependência para obter a sessão do banco de dados
from src.config.database import get_db

View File

@ -3,7 +3,9 @@ from sqlalchemy.exc import SQLAlchemyError
from fastapi import HTTPException, status from fastapi import HTTPException, status
from src.models.models import Client from src.models.models import Client
from src.schemas.schemas import ClientCreate from src.schemas.schemas import ClientCreate
from typing import List, Optional from src.schemas.user import UserCreate
from src.services.user_service import create_user
from typing import List, Optional, Tuple
import uuid import uuid
import logging import logging
@ -92,3 +94,45 @@ def delete_client(db: Session, client_id: uuid.UUID) -> bool:
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Erro ao remover cliente" detail="Erro ao remover cliente"
) )
def create_client_with_user(db: Session, client_data: ClientCreate, user_data: UserCreate) -> Tuple[Optional[Client], str]:
"""
Cria um novo cliente com um usuário associado
Args:
db: Sessão do banco de dados
client_data: Dados do cliente a ser criado
user_data: Dados do usuário a ser criado
Returns:
Tuple[Optional[Client], str]: Tupla com o cliente criado (ou None em caso de erro) e mensagem de status
"""
try:
# Iniciar transação - primeiro cria o cliente
client = Client(**client_data.model_dump())
db.add(client)
db.flush() # Obter o ID do cliente sem confirmar a transação
# Usar o ID do cliente para criar o usuário associado
user, message = create_user(db, user_data, is_admin=False, client_id=client.id)
if not user:
# Se houve erro na criação do usuário, fazer rollback
db.rollback()
logger.error(f"Erro ao criar usuário para o cliente: {message}")
return None, f"Erro ao criar usuário: {message}"
# Se tudo correu bem, confirmar a transação
db.commit()
logger.info(f"Cliente e usuário criados com sucesso: {client.id}")
return client, "Cliente e usuário criados com sucesso"
except SQLAlchemyError as e:
db.rollback()
logger.error(f"Erro ao criar cliente com usuário: {str(e)}")
return None, f"Erro ao criar cliente com usuário: {str(e)}"
except Exception as e:
db.rollback()
logger.error(f"Erro inesperado ao criar cliente com usuário: {str(e)}")
return None, f"Erro inesperado: {str(e)}"

View File

@ -3,157 +3,204 @@ from sendgrid.helpers.mail import Mail, Email, To, Content
from src.config.settings import settings from src.config.settings import settings
import logging import logging
from datetime import datetime from datetime import datetime
from jinja2 import Environment, FileSystemLoader, select_autoescape
import os
from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def send_verification_email(email: str, token: str) -> bool: # Configure Jinja2 to load templates
templates_dir = Path(__file__).parent.parent / "templates" / "emails"
os.makedirs(templates_dir, exist_ok=True)
# Configure Jinja2 with the templates directory
env = Environment(
loader=FileSystemLoader(templates_dir),
autoescape=select_autoescape(['html', 'xml'])
)
def _render_template(template_name: str, context: dict) -> str:
""" """
Envia um email de verificação para o usuário Render a template with the provided data
Args: Args:
email: Email do destinatário template_name: Template file name
token: Token de verificação de email context: Data to render in the template
Returns: Returns:
bool: True se o email foi enviado com sucesso, False caso contrário str: Rendered HTML
"""
try:
template = env.get_template(f"{template_name}.html")
return template.render(**context)
except Exception as e:
logger.error(f"Error rendering template '{template_name}': {str(e)}")
return f"<p>Could not display email content. Please access {context.get('verification_link', '') or context.get('reset_link', '')}</p>"
def send_verification_email(email: str, token: str) -> bool:
"""
Send a verification email to the user
Args:
email: Recipient's email
token: Email verification token
Returns:
bool: True if the email was sent successfully, False otherwise
""" """
try: try:
sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY) sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY)
from_email = Email(settings.EMAIL_FROM) from_email = Email(settings.EMAIL_FROM)
to_email = To(email) to_email = To(email)
subject = "Verificação de Email - Evo AI" subject = "Email Verification - Evo AI"
verification_link = f"{settings.APP_URL}/auth/verify-email/{token}" verification_link = f"{settings.APP_URL}/auth/verify-email/{token}"
content = Content( html_content = _render_template('verification_email', {
"text/html", 'verification_link': verification_link,
f""" 'user_name': email.split('@')[0], # Use part of the email as temporary name
<html> 'current_year': datetime.now().year
<head> })
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }} content = Content("text/html", html_content)
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #4A90E2; color: white; padding: 10px; text-align: center; }}
.content {{ padding: 20px; }}
.button {{ background-color: #4A90E2; color: white; padding: 10px 20px;
text-decoration: none; border-radius: 4px; display: inline-block; }}
.footer {{ font-size: 12px; text-align: center; margin-top: 30px; color: #888; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Evo AI</h1>
</div>
<div class="content">
<h2>Bem-vindo à Plataforma Evo AI!</h2>
<p>Obrigado por se cadastrar. Para verificar sua conta e começar a usar nossos serviços,
por favor clique no botão abaixo:</p>
<p style="text-align: center;">
<a href="{verification_link}" class="button">Verificar meu Email</a>
</p>
<p>Ou copie e cole o link abaixo no seu navegador:</p>
<p>{verification_link}</p>
<p>Este link é válido por 24 horas.</p>
<p>Se você não solicitou este email, por favor ignore-o.</p>
</div>
<div class="footer">
<p>Este é um email automático, por favor não responda.</p>
<p>&copy; {datetime.now().year} Evo AI. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
"""
)
mail = Mail(from_email, to_email, subject, content) mail = Mail(from_email, to_email, subject, content)
response = sg.client.mail.send.post(request_body=mail.get()) response = sg.client.mail.send.post(request_body=mail.get())
if response.status_code >= 200 and response.status_code < 300: if response.status_code >= 200 and response.status_code < 300:
logger.info(f"Email de verificação enviado para {email}") logger.info(f"Verification email sent to {email}")
return True return True
else: else:
logger.error(f"Falha ao enviar email de verificação para {email}. Status: {response.status_code}") logger.error(f"Failed to send verification email to {email}. Status: {response.status_code}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Erro ao enviar email de verificação para {email}: {str(e)}") logger.error(f"Error sending verification email to {email}: {str(e)}")
return False return False
def send_password_reset_email(email: str, token: str) -> bool: def send_password_reset_email(email: str, token: str) -> bool:
""" """
Envia um email de redefinição de senha para o usuário Send a password reset email to the user
Args: Args:
email: Email do destinatário email: Recipient's email
token: Token de redefinição de senha token: Password reset token
Returns: Returns:
bool: True se o email foi enviado com sucesso, False caso contrário bool: True if the email was sent successfully, False otherwise
""" """
try: try:
sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY) sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY)
from_email = Email(settings.EMAIL_FROM) from_email = Email(settings.EMAIL_FROM)
to_email = To(email) to_email = To(email)
subject = "Redefinição de Senha - Evo AI" subject = "Password Reset - Evo AI"
reset_link = f"{settings.APP_URL}/reset-password?token={token}" reset_link = f"{settings.APP_URL}/reset-password?token={token}"
content = Content( html_content = _render_template('password_reset', {
"text/html", 'reset_link': reset_link,
f""" 'user_name': email.split('@')[0], # Use part of the email as temporary name
<html> 'current_year': datetime.now().year
<head> })
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }} content = Content("text/html", html_content)
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #4A90E2; color: white; padding: 10px; text-align: center; }}
.content {{ padding: 20px; }}
.button {{ background-color: #4A90E2; color: white; padding: 10px 20px;
text-decoration: none; border-radius: 4px; display: inline-block; }}
.footer {{ font-size: 12px; text-align: center; margin-top: 30px; color: #888; }}
.warning {{ color: #E74C3C; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Evo AI</h1>
</div>
<div class="content">
<h2>Redefinição de Senha</h2>
<p>Recebemos uma solicitação para redefinir sua senha. Clique no botão abaixo
para criar uma nova senha:</p>
<p style="text-align: center;">
<a href="{reset_link}" class="button">Redefinir minha Senha</a>
</p>
<p>Ou copie e cole o link abaixo no seu navegador:</p>
<p>{reset_link}</p>
<p>Este link é válido por 1 hora.</p>
<p class="warning">Se você não solicitou esta alteração, por favor ignore este email
e entre em contato com o suporte imediatamente.</p>
</div>
<div class="footer">
<p>Este é um email automático, por favor não responda.</p>
<p>&copy; {datetime.now().year} Evo AI. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
"""
)
mail = Mail(from_email, to_email, subject, content) mail = Mail(from_email, to_email, subject, content)
response = sg.client.mail.send.post(request_body=mail.get()) response = sg.client.mail.send.post(request_body=mail.get())
if response.status_code >= 200 and response.status_code < 300: if response.status_code >= 200 and response.status_code < 300:
logger.info(f"Email de redefinição de senha enviado para {email}") logger.info(f"Password reset email sent to {email}")
return True return True
else: else:
logger.error(f"Falha ao enviar email de redefinição de senha para {email}. Status: {response.status_code}") logger.error(f"Failed to send password reset email to {email}. Status: {response.status_code}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Erro ao enviar email de redefinição de senha para {email}: {str(e)}") logger.error(f"Error sending password reset email to {email}: {str(e)}")
return False
def send_welcome_email(email: str, user_name: str = None) -> bool:
"""
Send a welcome email to the user after verification
Args:
email: Recipient's email
user_name: User's name (optional)
Returns:
bool: True if the email was sent successfully, False otherwise
"""
try:
sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY)
from_email = Email(settings.EMAIL_FROM)
to_email = To(email)
subject = "Welcome to Evo AI"
dashboard_link = f"{settings.APP_URL}/dashboard"
html_content = _render_template('welcome_email', {
'dashboard_link': dashboard_link,
'user_name': user_name or email.split('@')[0],
'current_year': datetime.now().year
})
content = Content("text/html", html_content)
mail = Mail(from_email, to_email, subject, content)
response = sg.client.mail.send.post(request_body=mail.get())
if response.status_code >= 200 and response.status_code < 300:
logger.info(f"Welcome email sent to {email}")
return True
else:
logger.error(f"Failed to send welcome email to {email}. Status: {response.status_code}")
return False
except Exception as e:
logger.error(f"Error sending welcome email to {email}: {str(e)}")
return False
def send_account_locked_email(email: str, reset_token: str, failed_attempts: int, time_period: str) -> bool:
"""
Send an email informing that the account has been locked after login attempts
Args:
email: Recipient's email
reset_token: Token to reset the password
failed_attempts: Number of failed attempts
time_period: Time period of the attempts
Returns:
bool: True if the email was sent successfully, False otherwise
"""
try:
sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY)
from_email = Email(settings.EMAIL_FROM)
to_email = To(email)
subject = "Security Alert - Account Locked"
reset_link = f"{settings.APP_URL}/reset-password?token={reset_token}"
html_content = _render_template('account_locked', {
'reset_link': reset_link,
'user_name': email.split('@')[0],
'failed_attempts': failed_attempts,
'time_period': time_period,
'current_year': datetime.now().year
})
content = Content("text/html", html_content)
mail = Mail(from_email, to_email, subject, content)
response = sg.client.mail.send.post(request_body=mail.get())
if response.status_code >= 200 and response.status_code < 300:
logger.info(f"Account locked email sent to {email}")
return True
else:
logger.error(f"Failed to send account locked email to {email}. Status: {response.status_code}")
return False
except Exception as e:
logger.error(f"Error sending account locked email to {email}: {str(e)}")
return False return False

View File

@ -11,7 +11,7 @@ from typing import Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_user(db: Session, user_data: UserCreate, is_admin: bool = False) -> Tuple[Optional[User], str]: def create_user(db: Session, user_data: UserCreate, is_admin: bool = False, client_id: Optional[uuid.UUID] = None) -> Tuple[Optional[User], str]:
""" """
Cria um novo usuário no sistema Cria um novo usuário no sistema
@ -19,6 +19,7 @@ def create_user(db: Session, user_data: UserCreate, is_admin: bool = False) -> T
db: Sessão do banco de dados db: Sessão do banco de dados
user_data: Dados do usuário a ser criado user_data: Dados do usuário a ser criado
is_admin: Se o usuário é um administrador is_admin: Se o usuário é um administrador
client_id: ID do cliente associado (opcional, será criado um novo se não fornecido)
Returns: Returns:
Tuple[Optional[User], str]: Tupla com o usuário criado (ou None em caso de erro) e mensagem de status Tuple[Optional[User], str]: Tupla com o usuário criado (ou None em caso de erro) e mensagem de status
@ -36,21 +37,21 @@ def create_user(db: Session, user_data: UserCreate, is_admin: bool = False) -> T
# Iniciar transação # Iniciar transação
user = None user = None
client_id = None local_client_id = client_id
try: try:
# Se não for admin, criar um cliente associado # Se não for admin e não tiver client_id, criar um cliente associado
if not is_admin: if not is_admin and local_client_id is None:
client = Client(name=user_data.name) client = Client(name=user_data.name)
db.add(client) db.add(client)
db.flush() # Obter o ID do cliente db.flush() # Obter o ID do cliente
client_id = client.id local_client_id = client.id
# Criar usuário # Criar usuário
user = User( user = User(
email=user_data.email, email=user_data.email,
password_hash=get_password_hash(user_data.password), password_hash=get_password_hash(user_data.password),
client_id=client_id, client_id=local_client_id,
is_admin=is_admin, is_admin=is_admin,
is_active=False, # Inativo até verificar email is_active=False, # Inativo até verificar email
email_verified=False, email_verified=False,
@ -80,14 +81,14 @@ def create_user(db: Session, user_data: UserCreate, is_admin: bool = False) -> T
def verify_email(db: Session, token: str) -> Tuple[bool, str]: def verify_email(db: Session, token: str) -> Tuple[bool, str]:
""" """
Verifica o email de um usuário usando o token fornecido Verifica o email do usuário usando o token fornecido
Args: Args:
db: Sessão do banco de dados db: Sessão do banco de dados
token: Token de verificação token: Token de verificação
Returns: Returns:
Tuple[bool, str]: Tupla com status da operação e mensagem Tuple[bool, str]: Tupla com status da verificação e mensagem
""" """
try: try:
# Buscar usuário pelo token # Buscar usuário pelo token
@ -98,7 +99,18 @@ def verify_email(db: Session, token: str) -> Tuple[bool, str]:
return False, "Token de verificação inválido" return False, "Token de verificação inválido"
# Verificar se o token expirou # Verificar se o token expirou
if user.verification_token_expiry < datetime.utcnow(): now = datetime.utcnow()
expiry = user.verification_token_expiry
# Garantir que ambas as datas sejam do mesmo tipo (aware ou naive)
if expiry.tzinfo is not None and now.tzinfo is None:
# Se expiry tem fuso e now não, converter now para ter fuso
now = now.replace(tzinfo=expiry.tzinfo)
elif now.tzinfo is not None and expiry.tzinfo is None:
# Se now tem fuso e expiry não, converter expiry para ter fuso
expiry = expiry.replace(tzinfo=now.tzinfo)
if expiry < now:
logger.warning(f"Tentativa de verificação com token expirado para usuário: {user.email}") logger.warning(f"Tentativa de verificação com token expirado para usuário: {user.email}")
return False, "Token de verificação expirado" return False, "Token de verificação expirado"
@ -300,3 +312,76 @@ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
if not user.is_active: if not user.is_active:
return None return None
return user return user
def get_admin_users(db: Session, skip: int = 0, limit: int = 100):
"""
Lista os usuários administradores
Args:
db: Sessão do banco de dados
skip: Número de registros para pular
limit: Número máximo de registros para retornar
Returns:
List[User]: Lista de usuários administradores
"""
try:
users = db.query(User).filter(User.is_admin == True).offset(skip).limit(limit).all()
logger.info(f"Listagem de administradores: {len(users)} encontrados")
return users
except SQLAlchemyError as e:
logger.error(f"Erro ao listar administradores: {str(e)}")
return []
except Exception as e:
logger.error(f"Erro inesperado ao listar administradores: {str(e)}")
return []
def create_admin_user(db: Session, user_data: UserCreate) -> Tuple[Optional[User], str]:
"""
Cria um novo usuário administrador
Args:
db: Sessão do banco de dados
user_data: Dados do usuário a ser criado
Returns:
Tuple[Optional[User], str]: Tupla com o usuário criado (ou None em caso de erro) e mensagem de status
"""
return create_user(db, user_data, is_admin=True)
def deactivate_user(db: Session, user_id: uuid.UUID) -> Tuple[bool, str]:
"""
Desativa um usuário (não exclui, apenas marca como inativo)
Args:
db: Sessão do banco de dados
user_id: ID do usuário a ser desativado
Returns:
Tuple[bool, str]: Tupla com status da operação e mensagem
"""
try:
# Buscar usuário pelo ID
user = db.query(User).filter(User.id == user_id).first()
if not user:
logger.warning(f"Tentativa de desativação de usuário inexistente: {user_id}")
return False, "Usuário não encontrado"
# Desativar usuário
user.is_active = False
db.commit()
logger.info(f"Usuário desativado com sucesso: {user.email}")
return True, "Usuário desativado com sucesso"
except SQLAlchemyError as e:
db.rollback()
logger.error(f"Erro ao desativar usuário: {str(e)}")
return False, f"Erro ao desativar usuário: {str(e)}"
except Exception as e:
logger.error(f"Erro inesperado ao desativar usuário: {str(e)}")
return False, f"Erro inesperado: {str(e)}"

View File

@ -0,0 +1,32 @@
{% extends "base_email.html" %}
{% block title %}Account Locked - Evo AI{% endblock %}
{% block header %}Evo AI - Security{% endblock %}
{% block content %}
<h2>Security Alert: Your Account Has Been Locked</h2>
<p>Hello {{ user_name }},</p>
<p>We detected multiple failed login attempts to your account on the Evo AI platform. To protect your information, we have temporarily locked access to your account.</p>
<h3>What happened?</h3>
<p>Our security system detected {{ failed_attempts }} failed login attempts with incorrect passwords in the last {{ time_period }}. This may indicate an unauthorized access attempt to your account.</p>
<h3>What to do now?</h3>
<p>To unlock your account, you need to reset your password:</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{{ reset_link }}" class="button">Reset My Password</a>
</p>
<p>The link above is valid for <strong>24 hours</strong>. If you don't reset your password within this period, you will need to request a new reset link.</p>
<p><strong>Important:</strong> If you haven't tried to log in recently, we recommend that you reset your password immediately and consider enabling two-factor authentication for greater security.</p>
<p>If you need help, please contact our support team.</p>
<p>Best regards,<br>
Evo AI Security Team</p>
{% endblock %}
{% block footer_message %}If you don't recognize this activity, please contact support immediately.{% endblock %}

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Evo AI{% endblock %}</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f7f7f7;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
background-color: #4A90E2;
color: white;
padding: 15px;
text-align: center;
border-radius: 6px 6px 0 0;
}
.content {
padding: 20px;
}
.button {
background-color: #4A90E2;
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 4px;
display: inline-block;
font-weight: bold;
text-align: center;
transition: background-color 0.3s;
}
.button:hover {
background-color: #3a7bc8;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #888;
border-top: 1px solid #eee;
padding-top: 20px;
}
.link {
word-break: break-all;
color: #4A90E2;
}
.warning {
color: #E74C3C;
padding: 10px;
background-color: #FADBD8;
border-radius: 4px;
margin-top: 20px;
}
</style>
{% block additional_styles %}{% endblock %}
</head>
<body>
<div class="container">
<div class="header">
<h1>{% block header %}Evo AI{% endblock %}</h1>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
<div class="footer">
<p>{% block footer_message %}This is an automated email, please do not reply.{% endblock %}</p>
<p>&copy; {{ current_year }} Evo AI. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,29 @@
{% extends "base_email.html" %}
{% block title %}Password Reset - Evo AI{% endblock %}
{% block header %}Evo AI{% endblock %}
{% block content %}
<h2>Password Reset</h2>
<p>Hello {{ user_name }},</p>
<p>We received a request to reset the password for your account on the Evo AI platform. If you didn't request this change, please ignore this email or contact our support team if you have any questions.</p>
<p>To reset your password, click the button below:</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{{ reset_link }}" class="button">Reset My Password</a>
</p>
<p>This reset link is valid for <strong>24 hours</strong>. After this period, you will need to request a new password reset.</p>
<p>For security reasons, after resetting your password, you will be logged out of all active sessions and will need to log in again on all devices.</p>
<p>If you can't click the button above, copy and paste the following URL into your browser:</p>
<p style="word-break: break-all; font-size: 12px;">{{ reset_link }}</p>
<p>Best regards,<br>
Evo AI Team</p>
{% endblock %}
{% block footer_message %}This is an automated email. Please do not reply to this message.{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "base_email.html" %}
{% block title %}Email Verification - Evo AI{% endblock %}
{% block header %}Evo AI{% endblock %}
{% block content %}
<h2>Email Verification</h2>
<p>Hello {{ user_name }},</p>
<p>Thank you for registering on the Evo AI platform. To complete your registration and ensure the security of your account, we need to verify your email address.</p>
<p>Please click the button below to confirm your email:</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{{ verification_link }}" class="button">Verify My Email</a>
</p>
<p>This verification link is valid for <strong>48 hours</strong>. If it expires, you can request a new verification email through our platform.</p>
<p>If you can't click the button above, copy and paste the following URL into your browser:</p>
<p style="word-break: break-all; font-size: 12px;">{{ verification_link }}</p>
<p>If you didn't create an account on Evo AI, please ignore this email or contact our support team.</p>
<p>We're excited to have you as part of our community!</p>
<p>Best regards,<br>
Evo AI Team</p>
{% endblock %}
{% block footer_message %}This is an automated email. Please do not reply to this message.{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "base_email.html" %}
{% block title %}Welcome to Evo AI{% endblock %}
{% block header %}Evo AI{% endblock %}
{% block content %}
<h2>Welcome to the Evo AI Platform!</h2>
<p>Hello {{ user_name }},</p>
<p>We're thrilled to have you as part of our community. Your account has been successfully verified and you can now start using all the features of our platform.</p>
<h3>Next steps:</h3>
<ul>
<li>Set up your complete profile</li>
<li>Explore our AI capabilities</li>
<li>Create your first intelligent agent</li>
</ul>
<p style="text-align: center; margin-top: 30px;">
<a href="{{ dashboard_link }}" class="button">Access My Dashboard</a>
</p>
<p>If you have any questions or need assistance, our support team is available to help you.</p>
<p>Make the most of the power of Evo AI!</p>
<p>Best regards,<br>
Evo AI Team</p>
{% endblock %}
{% block footer_message %}This is an automated email. Please do not reply to this message. For support, use our help center.{% endblock %}

View File

@ -5,9 +5,19 @@ import string
from jose import jwt from jose import jwt
from src.config.settings import settings from src.config.settings import settings
import logging import logging
import bcrypt
from dataclasses import dataclass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Corrigir erro do bcrypt com passlib
if not hasattr(bcrypt, '__about__'):
@dataclass
class BcryptAbout:
__version__: str = getattr(bcrypt, "__version__")
setattr(bcrypt, "__about__", BcryptAbout())
# Contexto para hash de senhas usando bcrypt # Contexto para hash de senhas usando bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")