@danhachuel/prodmaker 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/app/api/contas.py +45 -0
- package/app/api/invest.py +37 -0
- package/app/main.py +6 -2
- package/app/services/database.py +219 -200
- package/app/services/dbengine.py +195 -0
- package/app/services/invest.py +52 -0
- package/app/services/oauth_google.py +108 -0
- package/frontend/dist/assets/index-C8sX5Mkf.js +74 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/requirements.txt +3 -0
- package/scripts/cli.mjs +1 -1
- package/frontend/dist/assets/index-DGhGACYG.js +0 -74
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Camada de abstração de banco — SQLite (padrão) + PostgreSQL/Supabase (DATABASE_URL).
|
|
2
|
+
|
|
3
|
+
Objetivo (radar Supabase): o sistema aceita `DATABASE_URL` apontando para o PostgreSQL
|
|
4
|
+
do Supabase SEM quebrar a compatibilidade local com SQLite. Todos os serviços continuam
|
|
5
|
+
usando `get_db()` com a mesma API (execute/fetchall/fetchone, context manager, rows
|
|
6
|
+
acessíveis por nome de coluna). Para PostgreSQL os placeholders `?` são traduzidos para
|
|
7
|
+
`%s` e as linhas viram dicts (mesmo acesso por nome).
|
|
8
|
+
|
|
9
|
+
Multi-tenant (preparado, NÃO implementado): todas as tabelas têm `user_id TEXT DEFAULT ''`.
|
|
10
|
+
Quando o Supabase Auth entrar, basta filtrar por `current_user_id()` (hoje retorna '').
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .store import STATE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def now_str() -> str:
|
|
22
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def row_to_dict(row) -> dict:
|
|
26
|
+
if row is None:
|
|
27
|
+
return {}
|
|
28
|
+
if isinstance(row, dict):
|
|
29
|
+
return dict(row)
|
|
30
|
+
return {key: row[key] for key in row.keys()}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dsn() -> str | None:
|
|
34
|
+
return (os.environ.get("DATABASE_URL") or "").strip() or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_postgres() -> bool:
|
|
38
|
+
value = dsn()
|
|
39
|
+
return bool(value and value.startswith(("postgres://", "postgresql://")))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def engine_name() -> str:
|
|
43
|
+
return "postgres" if is_postgres() else "sqlite"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def current_user_id() -> str:
|
|
47
|
+
"""Placeholder multi-tenant: vazio enquanto não houver Supabase Auth."""
|
|
48
|
+
return (os.environ.get("PRODMAKER_USER_ID") or "").strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def new_user_id() -> str:
|
|
52
|
+
return str(uuid.uuid4())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# PostgreSQL — wrapper mínimo (imports preguiçosos de psycopg)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _translate(sql: str) -> str:
|
|
61
|
+
return sql.replace("?", "%s")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _PgRow(dict):
|
|
65
|
+
"""Acesso por nome (row["col"]) como sqlite3.Row."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _PgResult:
|
|
69
|
+
def __init__(self, cursor):
|
|
70
|
+
self._cursor = cursor
|
|
71
|
+
self._description = cursor.description
|
|
72
|
+
self._rows = None
|
|
73
|
+
|
|
74
|
+
def _materialize(self):
|
|
75
|
+
if self._rows is None:
|
|
76
|
+
columns = [col.name for col in self._description] if self._description else []
|
|
77
|
+
self._rows = [_PgRow(zip(columns, values)) for values in (self._cursor.fetchall() if self._description else [])]
|
|
78
|
+
|
|
79
|
+
def fetchall(self):
|
|
80
|
+
self._materialize()
|
|
81
|
+
return list(self._rows)
|
|
82
|
+
|
|
83
|
+
def fetchone(self):
|
|
84
|
+
self._materialize()
|
|
85
|
+
return self._rows[0] if self._rows else None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def lastrowid(self):
|
|
89
|
+
# PostgreSQL: capturamos o id via RETURNING nas inserções específicas
|
|
90
|
+
self._materialize()
|
|
91
|
+
row = self._rows[0] if self._rows else None
|
|
92
|
+
return row.get("id") if row else None
|
|
93
|
+
|
|
94
|
+
def __iter__(self):
|
|
95
|
+
self._materialize()
|
|
96
|
+
return iter(self._rows)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _PgConn:
|
|
100
|
+
def __init__(self, connection):
|
|
101
|
+
self._conn = connection
|
|
102
|
+
|
|
103
|
+
def execute(self, sql: str, params=()):
|
|
104
|
+
cursor = self._conn.cursor()
|
|
105
|
+
cursor.execute(_translate(sql), tuple(params or ()))
|
|
106
|
+
return _PgResult(cursor)
|
|
107
|
+
|
|
108
|
+
def executescript(self, sql: str):
|
|
109
|
+
cursor = self._conn.cursor()
|
|
110
|
+
cursor.execute(sql)
|
|
111
|
+
return _PgResult(cursor)
|
|
112
|
+
|
|
113
|
+
def commit(self):
|
|
114
|
+
self._conn.commit()
|
|
115
|
+
|
|
116
|
+
def close(self):
|
|
117
|
+
try:
|
|
118
|
+
self._conn.rollback()
|
|
119
|
+
except Exception: # noqa: BLE001
|
|
120
|
+
pass
|
|
121
|
+
self._conn.close()
|
|
122
|
+
|
|
123
|
+
def __enter__(self):
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, exc_type, exc, tb):
|
|
127
|
+
if exc_type is None:
|
|
128
|
+
self._conn.commit()
|
|
129
|
+
else:
|
|
130
|
+
self._conn.rollback()
|
|
131
|
+
self.close()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Conexão
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _sqlite_path():
|
|
140
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
return STATE / "prodmaker.db"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def connect():
|
|
145
|
+
"""Conexão do engine ativo (sqlite padrão; postgres quando DATABASE_URL definido)."""
|
|
146
|
+
if is_postgres():
|
|
147
|
+
try:
|
|
148
|
+
import psycopg
|
|
149
|
+
except ImportError as exc:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
"DATABASE_URL definido, mas o driver psycopg não está instalado — "
|
|
152
|
+
"rode 'pip install psycopg[binary]' (ou 'prodmaker install' com o requirements atualizado)."
|
|
153
|
+
) from exc
|
|
154
|
+
try:
|
|
155
|
+
connection = psycopg.connect(dsn(), connect_timeout=10)
|
|
156
|
+
except Exception as exc: # noqa: BLE001
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
f"Falha ao conectar no PostgreSQL (DATABASE_URL): {exc}. "
|
|
159
|
+
"Confira a connection string do Supabase ou desdefina DATABASE_URL para voltar ao SQLite local."
|
|
160
|
+
) from exc
|
|
161
|
+
return _PgConn(connection)
|
|
162
|
+
conn = sqlite3.connect(_sqlite_path())
|
|
163
|
+
conn.row_factory = sqlite3.Row
|
|
164
|
+
return conn
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Compatibilidade com o nome antigo usado pelos serviços
|
|
168
|
+
def get_db():
|
|
169
|
+
return connect()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Introspecção / migração (dialect-aware)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def table_columns(conn, table: str) -> list[str]:
|
|
178
|
+
if is_postgres():
|
|
179
|
+
rows = (
|
|
180
|
+
conn.execute(
|
|
181
|
+
"SELECT column_name AS name FROM information_schema.columns WHERE table_name = %s",
|
|
182
|
+
(table,),
|
|
183
|
+
)
|
|
184
|
+
.fetchall()
|
|
185
|
+
)
|
|
186
|
+
return [row["name"] for row in rows]
|
|
187
|
+
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
188
|
+
return [row["name"] for row in rows]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def add_column_if_missing(conn, table: str, column_ddl: str, column_name: str) -> bool:
|
|
192
|
+
if column_name not in table_columns(conn, table):
|
|
193
|
+
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column_ddl}")
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Investimento em tráfego — base do ROAS real e do Lucro Líquido."""
|
|
2
|
+
from .database import get_db, now_str, row_to_dict
|
|
3
|
+
from .llm import LLMError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def registrar(valor: float, data: str = "", observacao: str = "") -> dict:
|
|
7
|
+
if float(valor) <= 0:
|
|
8
|
+
raise LLMError("Informe um valor de investimento maior que zero.")
|
|
9
|
+
data = (data or "").strip() or now_str()[:10]
|
|
10
|
+
timestamp = now_str()
|
|
11
|
+
with get_db() as conn:
|
|
12
|
+
cursor = conn.execute(
|
|
13
|
+
"INSERT INTO investimentos (data, valor, fonte, observacao, created_at) VALUES (?, ?, 'manual', ?, ?)",
|
|
14
|
+
(data, float(valor), observacao.strip(), timestamp),
|
|
15
|
+
)
|
|
16
|
+
row = conn.execute("SELECT * FROM investimentos WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
17
|
+
return row_to_dict(row)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def listar(limit: int = 60) -> list[dict]:
|
|
21
|
+
with get_db() as conn:
|
|
22
|
+
rows = conn.execute("SELECT * FROM investimentos ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
23
|
+
return [row_to_dict(row) for row in rows]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def remover(investimento_id: int) -> None:
|
|
27
|
+
with get_db() as conn:
|
|
28
|
+
cursor = conn.execute("DELETE FROM investimentos WHERE id = ?", (investimento_id,))
|
|
29
|
+
if cursor.rowcount == 0:
|
|
30
|
+
raise LLMError("Registro não encontrado.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resumo_financeiro() -> dict:
|
|
34
|
+
"""ROAS e Lucro Líquido reais (faturamento = receita das vendas aprovadas)."""
|
|
35
|
+
with get_db() as conn:
|
|
36
|
+
receita = conn.execute(
|
|
37
|
+
"SELECT COALESCE(SUM(valor),0) AS r, COALESCE(SUM(comissao),0) AS c FROM vendas WHERE status = 'aprovada'"
|
|
38
|
+
).fetchone()
|
|
39
|
+
investido = conn.execute("SELECT COALESCE(SUM(valor),0) AS t FROM investimentos").fetchone()
|
|
40
|
+
receita_valor = float(receita["r"] or 0)
|
|
41
|
+
comissao_valor = float(receita["c"] or 0)
|
|
42
|
+
investido_valor = float(investido["t"] or 0)
|
|
43
|
+
roas = round(receita_valor / investido_valor, 2) if investido_valor > 0 else None
|
|
44
|
+
lucro = round(receita_valor - investido_valor, 2)
|
|
45
|
+
return {
|
|
46
|
+
"faturamento": round(receita_valor, 2),
|
|
47
|
+
"comissao": round(comissao_valor, 2),
|
|
48
|
+
"investimento": round(investido_valor, 2),
|
|
49
|
+
"lucro_liquido": lucro,
|
|
50
|
+
"roas": roas,
|
|
51
|
+
"tem_investimento": investido_valor > 0,
|
|
52
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Google OAuth 2.0 — conexão de conta com 1 clique (gera o Refresh Token automaticmente).
|
|
2
|
+
|
|
3
|
+
Fluxo:
|
|
4
|
+
1. `GET /api/google/oauth/url` monta a URL de consentimento (scopes do Google Ads,
|
|
5
|
+
access_type=offline + prompt=consent para GARANTIR o refresh_token).
|
|
6
|
+
2. O usuário autoriza no popup; o Google redireciona para `redirect_uri`
|
|
7
|
+
(http://localhost:8188/api/google/callback?code=...).
|
|
8
|
+
3. `GET /api/google/callback` troca o code por tokens e salva o refresh_token em
|
|
9
|
+
settings.api_keys.google-ads (status "conectado").
|
|
10
|
+
|
|
11
|
+
Observação: o usuário precisa criar um OAuth Client ID (tipo "Desktop app" ou "Web")
|
|
12
|
+
no Google Cloud, com a URL de redirect autorizada, e colar client_id/secret uma vez
|
|
13
|
+
em Chaves de API. A autorização da conta passa a ser 1 clique.
|
|
14
|
+
"""
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .store import get_settings, save_settings
|
|
20
|
+
|
|
21
|
+
OAUTH_SCOPE = "https://www.googleapis.com/auth/adwords"
|
|
22
|
+
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
23
|
+
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def callback_url(request=None) -> str:
|
|
27
|
+
base = "http://localhost:8188"
|
|
28
|
+
if request is not None:
|
|
29
|
+
host = request.headers.get("host")
|
|
30
|
+
base = f"{request.url.scheme}://{host}"
|
|
31
|
+
return f"{base}/api/google/callback"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_auth_url(request=None) -> dict:
|
|
35
|
+
keys = (get_settings().get("api_keys") or {}).get("google-ads") or {}
|
|
36
|
+
client_id = (keys.get("client_id") or "").strip()
|
|
37
|
+
if not client_id:
|
|
38
|
+
from .llm import LLMError
|
|
39
|
+
|
|
40
|
+
raise LLMError(
|
|
41
|
+
"Para conectar com 1 clique, primeiro salve o 'OAuth Client ID' do Google Cloud em Chaves de API (card Google Ads)."
|
|
42
|
+
)
|
|
43
|
+
redirect = callback_url(request)
|
|
44
|
+
import urllib.parse
|
|
45
|
+
|
|
46
|
+
query = urllib.parse.urlencode(
|
|
47
|
+
{
|
|
48
|
+
"client_id": client_id,
|
|
49
|
+
"redirect_uri": redirect,
|
|
50
|
+
"response_type": "code",
|
|
51
|
+
"scope": OAUTH_SCOPE,
|
|
52
|
+
"access_type": "offline",
|
|
53
|
+
"prompt": "consent", # força a devolução do refresh_token
|
|
54
|
+
"include_granted_scopes": "true",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
return {"url": f"{AUTH_URL}?{query}", "redirect_uri": redirect}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def exchange_code(code: str, request=None) -> dict:
|
|
61
|
+
keys = (get_settings().get("api_keys") or {}).get("google-ads") or {}
|
|
62
|
+
client_id = (keys.get("client_id") or "").strip()
|
|
63
|
+
client_secret = (keys.get("client_secret") or "").strip()
|
|
64
|
+
if not client_id or not client_secret:
|
|
65
|
+
from .llm import LLMError
|
|
66
|
+
|
|
67
|
+
raise LLMError("client_id e client_secret precisam estar salvos em Chaves de API antes de concluir o OAuth.")
|
|
68
|
+
redirect = callback_url(request)
|
|
69
|
+
try:
|
|
70
|
+
response = httpx.post(
|
|
71
|
+
TOKEN_URL,
|
|
72
|
+
data={
|
|
73
|
+
"client_id": client_id,
|
|
74
|
+
"client_secret": client_secret,
|
|
75
|
+
"code": code.strip(),
|
|
76
|
+
"grant_type": "authorization_code",
|
|
77
|
+
"redirect_uri": redirect,
|
|
78
|
+
},
|
|
79
|
+
timeout=30.0,
|
|
80
|
+
)
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
from .llm import LLMError
|
|
83
|
+
|
|
84
|
+
raise LLMError(f"Falha de conexão com o OAuth do Google: {exc}", provider="google-ads", transient=True) from exc
|
|
85
|
+
if response.status_code != 200:
|
|
86
|
+
from .llm import LLMError
|
|
87
|
+
|
|
88
|
+
raise LLMError(
|
|
89
|
+
f"Google rejeitou a troca do código ({response.status_code}): {response.text[:200]}. "
|
|
90
|
+
"Confira redirect_uri autorizado e tente novamente.",
|
|
91
|
+
provider="google-ads",
|
|
92
|
+
)
|
|
93
|
+
tokens = response.json() or {}
|
|
94
|
+
refresh_token = tokens.get("refresh_token")
|
|
95
|
+
if not refresh_token:
|
|
96
|
+
from .llm import LLMError
|
|
97
|
+
|
|
98
|
+
raise LLMError(
|
|
99
|
+
"O Google não devolveu refresh_token — apague o app nas configurações da conta Google "
|
|
100
|
+
"(apps de terceiros conectados) e tente novamente, ou marque prompt=consent."
|
|
101
|
+
)
|
|
102
|
+
settings = get_settings()
|
|
103
|
+
api_keys = settings.setdefault("api_keys", {})
|
|
104
|
+
google = api_keys.setdefault("google-ads", {})
|
|
105
|
+
google["refresh_token"] = refresh_token
|
|
106
|
+
google["connected_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
107
|
+
save_settings(settings)
|
|
108
|
+
return {"ok": True, "connected_at": google["connected_at"], "scope": tokens.get("scope", OAUTH_SCOPE)}
|