@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.
@@ -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)}