@danhachuel/prodmaker 0.1.10 → 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 +40 -0
- package/app/api/contas.py +45 -0
- package/app/api/guias.py +34 -0
- package/app/api/invest.py +37 -0
- package/app/api/logs.py +42 -0
- package/app/api/pins.py +80 -0
- package/app/main.py +12 -2
- package/app/services/database.py +220 -183
- package/app/services/dbengine.py +195 -0
- package/app/services/invest.py +52 -0
- package/app/services/oauth_google.py +108 -0
- package/app/services/pinterest.py +174 -0
- package/frontend/dist/assets/index-B4wxnTCs.css +1 -0
- package/frontend/dist/assets/index-C8sX5Mkf.js +74 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/requirements.txt +7 -0
- package/scripts/cli.mjs +1 -1
- package/seed/references/pixel-e-tuneis.md +39 -0
- package/seed/references/webhooks-de-venda.md +38 -0
- package/frontend/dist/assets/index-B88UVmZI.css +0 -1
- package/frontend/dist/assets/index-EOri3RlA.js +0 -74
package/app/services/database.py
CHANGED
|
@@ -1,200 +1,237 @@
|
|
|
1
|
-
"""Banco
|
|
2
|
-
import sqlite3
|
|
3
|
-
import time
|
|
4
|
-
from pathlib import Path
|
|
1
|
+
"""Banco de dados — façade sobre dbengine (SQLite padrão, PostgreSQL/Supabase via DATABASE_URL).
|
|
5
2
|
|
|
6
|
-
|
|
3
|
+
Esquema único para os dois engines. Todas as tabelas carregam `user_id` (preparação
|
|
4
|
+
multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migração.
|
|
5
|
+
"""
|
|
6
|
+
from .dbengine import (
|
|
7
|
+
add_column_if_missing,
|
|
8
|
+
is_postgres,
|
|
9
|
+
now_str, # noqa: F401 (reexportado para os serviços)
|
|
10
|
+
row_to_dict, # noqa: F401 (reexportado para os serviços)
|
|
11
|
+
)
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Esquema — SQLite DDL; para PostgreSQL as mesmas definições são traduzidas.
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
plataforma TEXT NOT NULL DEFAULT 'Outro',
|
|
26
|
-
link_afiliado TEXT NOT NULL DEFAULT '',
|
|
27
|
-
comissao REAL NOT NULL DEFAULT 0,
|
|
28
|
-
preco REAL NOT NULL DEFAULT 0,
|
|
29
|
-
nicho TEXT NOT NULL DEFAULT '',
|
|
30
|
-
status TEXT NOT NULL DEFAULT 'em_analise',
|
|
31
|
-
created_at TEXT NOT NULL,
|
|
32
|
-
updated_at TEXT NOT NULL
|
|
33
|
-
)
|
|
34
|
-
"""
|
|
17
|
+
TABLES = {
|
|
18
|
+
"produtos": """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS produtos (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
22
|
+
nome TEXT NOT NULL,
|
|
23
|
+
plataforma TEXT NOT NULL DEFAULT 'Outro',
|
|
24
|
+
link_afiliado TEXT NOT NULL DEFAULT '',
|
|
25
|
+
comissao REAL NOT NULL DEFAULT 0,
|
|
26
|
+
preco REAL NOT NULL DEFAULT 0,
|
|
27
|
+
nicho TEXT NOT NULL DEFAULT '',
|
|
28
|
+
status TEXT NOT NULL DEFAULT 'em_analise',
|
|
29
|
+
analysis TEXT NOT NULL DEFAULT '',
|
|
30
|
+
created_at TEXT NOT NULL,
|
|
31
|
+
updated_at TEXT NOT NULL
|
|
35
32
|
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"""
|
|
33
|
+
""",
|
|
34
|
+
"infoprodutos": """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS infoprodutos (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
38
|
+
titulo TEXT NOT NULL,
|
|
39
|
+
formato TEXT NOT NULL DEFAULT 'E-book',
|
|
40
|
+
nicho TEXT NOT NULL DEFAULT '',
|
|
41
|
+
modo TEXT NOT NULL DEFAULT 'rapido',
|
|
42
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
43
|
+
pdf_path TEXT NOT NULL DEFAULT '',
|
|
44
|
+
md_path TEXT NOT NULL DEFAULT '',
|
|
45
|
+
created_at TEXT NOT NULL
|
|
50
46
|
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"""
|
|
47
|
+
""",
|
|
48
|
+
"paginas": """
|
|
49
|
+
CREATE TABLE IF NOT EXISTS paginas (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
52
|
+
titulo TEXT NOT NULL,
|
|
53
|
+
tipo TEXT NOT NULL,
|
|
54
|
+
produto_id INTEGER,
|
|
55
|
+
idioma TEXT NOT NULL DEFAULT 'Portugues',
|
|
56
|
+
html_path TEXT NOT NULL DEFAULT '',
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'rascunho',
|
|
58
|
+
published_url TEXT NOT NULL DEFAULT '',
|
|
59
|
+
created_at TEXT NOT NULL,
|
|
60
|
+
updated_at TEXT NOT NULL
|
|
66
61
|
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"""
|
|
62
|
+
""",
|
|
63
|
+
"dominios": """
|
|
64
|
+
CREATE TABLE IF NOT EXISTS dominios (
|
|
65
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
67
|
+
nome TEXT NOT NULL,
|
|
68
|
+
status TEXT NOT NULL DEFAULT 'pendente',
|
|
69
|
+
cf_zone_id TEXT NOT NULL DEFAULT '',
|
|
70
|
+
ns_data TEXT NOT NULL DEFAULT '',
|
|
71
|
+
a_data TEXT NOT NULL DEFAULT '',
|
|
72
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
73
|
+
created_at TEXT NOT NULL,
|
|
74
|
+
updated_at TEXT NOT NULL
|
|
81
75
|
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"""
|
|
76
|
+
""",
|
|
77
|
+
"visitas": """
|
|
78
|
+
CREATE TABLE IF NOT EXISTS visitas (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
81
|
+
event TEXT NOT NULL DEFAULT '',
|
|
82
|
+
page TEXT NOT NULL DEFAULT '',
|
|
83
|
+
gclid TEXT NOT NULL DEFAULT '',
|
|
84
|
+
utm_source TEXT NOT NULL DEFAULT '',
|
|
85
|
+
utm_medium TEXT NOT NULL DEFAULT '',
|
|
86
|
+
utm_campaign TEXT NOT NULL DEFAULT '',
|
|
87
|
+
ref TEXT NOT NULL DEFAULT '',
|
|
88
|
+
ip TEXT NOT NULL DEFAULT '',
|
|
89
|
+
user_agent TEXT NOT NULL DEFAULT '',
|
|
90
|
+
extra TEXT NOT NULL DEFAULT '',
|
|
91
|
+
created_at TEXT NOT NULL
|
|
99
92
|
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"""
|
|
93
|
+
""",
|
|
94
|
+
"vendas": """
|
|
95
|
+
CREATE TABLE IF NOT EXISTS vendas (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
98
|
+
fonte TEXT NOT NULL DEFAULT 'manual',
|
|
99
|
+
produto_id INTEGER,
|
|
100
|
+
produto_nome TEXT NOT NULL DEFAULT '',
|
|
101
|
+
transacao_id TEXT NOT NULL DEFAULT '',
|
|
102
|
+
comprador_email TEXT NOT NULL DEFAULT '',
|
|
103
|
+
valor REAL NOT NULL DEFAULT 0,
|
|
104
|
+
comissao REAL NOT NULL DEFAULT 0,
|
|
105
|
+
gclid TEXT NOT NULL DEFAULT '',
|
|
106
|
+
utm_source TEXT NOT NULL DEFAULT '',
|
|
107
|
+
utm_medium TEXT NOT NULL DEFAULT '',
|
|
108
|
+
utm_campaign TEXT NOT NULL DEFAULT '',
|
|
109
|
+
status TEXT NOT NULL DEFAULT 'aprovada',
|
|
110
|
+
conversao_status TEXT NOT NULL DEFAULT 'pendente',
|
|
111
|
+
conversao_erro TEXT NOT NULL DEFAULT '',
|
|
112
|
+
gclid_match TEXT NOT NULL DEFAULT '',
|
|
113
|
+
raw TEXT NOT NULL DEFAULT '',
|
|
114
|
+
created_at TEXT NOT NULL
|
|
123
115
|
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
116
|
+
""",
|
|
117
|
+
"palavras": """
|
|
118
|
+
CREATE TABLE IF NOT EXISTS palavras (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
121
|
+
produto_id INTEGER,
|
|
122
|
+
produto_nome TEXT NOT NULL DEFAULT '',
|
|
123
|
+
tipo TEXT NOT NULL DEFAULT 'positiva',
|
|
124
|
+
texto TEXT NOT NULL,
|
|
125
|
+
correspondencia TEXT NOT NULL DEFAULT 'frase',
|
|
126
|
+
origem TEXT NOT NULL DEFAULT 'ia',
|
|
127
|
+
created_at TEXT NOT NULL
|
|
128
|
+
)
|
|
129
|
+
""",
|
|
130
|
+
"campanhas": """
|
|
131
|
+
CREATE TABLE IF NOT EXISTS campanhas (
|
|
132
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
133
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
134
|
+
produto_id INTEGER,
|
|
135
|
+
plataforma TEXT NOT NULL DEFAULT 'google',
|
|
136
|
+
nome TEXT NOT NULL,
|
|
137
|
+
objetivo TEXT NOT NULL DEFAULT '',
|
|
138
|
+
cpa_alvo REAL NOT NULL DEFAULT 0,
|
|
139
|
+
orcamento_diario REAL NOT NULL DEFAULT 0,
|
|
140
|
+
keywords_json TEXT NOT NULL DEFAULT '[]',
|
|
141
|
+
negativas_json TEXT NOT NULL DEFAULT '[]',
|
|
142
|
+
criativos_json TEXT NOT NULL DEFAULT '[]',
|
|
143
|
+
plano_json TEXT NOT NULL DEFAULT '',
|
|
144
|
+
status TEXT NOT NULL DEFAULT 'rascunho',
|
|
145
|
+
resultado_json TEXT NOT NULL DEFAULT '',
|
|
146
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
147
|
+
created_at TEXT NOT NULL,
|
|
148
|
+
updated_at TEXT NOT NULL
|
|
140
149
|
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
negativas_json TEXT NOT NULL DEFAULT '[]',
|
|
153
|
-
criativos_json TEXT NOT NULL DEFAULT '[]',
|
|
154
|
-
plano_json TEXT NOT NULL DEFAULT '',
|
|
155
|
-
status TEXT NOT NULL DEFAULT 'rascunho',
|
|
156
|
-
resultado_json TEXT NOT NULL DEFAULT '',
|
|
157
|
-
erro TEXT NOT NULL DEFAULT '',
|
|
158
|
-
created_at TEXT NOT NULL,
|
|
159
|
-
updated_at TEXT NOT NULL
|
|
160
|
-
)
|
|
161
|
-
"""
|
|
150
|
+
""",
|
|
151
|
+
"lancamentos": """
|
|
152
|
+
CREATE TABLE IF NOT EXISTS lancamentos (
|
|
153
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
155
|
+
produto_id INTEGER,
|
|
156
|
+
etapa_atual INTEGER NOT NULL DEFAULT 1,
|
|
157
|
+
status TEXT NOT NULL DEFAULT 'em_andamento',
|
|
158
|
+
dados_json TEXT NOT NULL DEFAULT '{}',
|
|
159
|
+
created_at TEXT NOT NULL,
|
|
160
|
+
updated_at TEXT NOT NULL
|
|
162
161
|
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
updated_at TEXT NOT NULL
|
|
176
|
-
)
|
|
177
|
-
"""
|
|
162
|
+
""",
|
|
163
|
+
"notificacoes": """
|
|
164
|
+
CREATE TABLE IF NOT EXISTS notificacoes (
|
|
165
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
166
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
167
|
+
event TEXT NOT NULL DEFAULT '',
|
|
168
|
+
titulo TEXT NOT NULL DEFAULT '',
|
|
169
|
+
mensagem TEXT NOT NULL DEFAULT '',
|
|
170
|
+
canal TEXT NOT NULL DEFAULT '',
|
|
171
|
+
status TEXT NOT NULL DEFAULT 'pendente',
|
|
172
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
173
|
+
created_at TEXT NOT NULL
|
|
178
174
|
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
175
|
+
""",
|
|
176
|
+
"pins": """
|
|
177
|
+
CREATE TABLE IF NOT EXISTS pins (
|
|
178
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
179
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
180
|
+
produto_id INTEGER,
|
|
181
|
+
titulo TEXT NOT NULL,
|
|
182
|
+
descricao TEXT NOT NULL DEFAULT '',
|
|
183
|
+
texto_imagem TEXT NOT NULL DEFAULT '',
|
|
184
|
+
sugestao_imagem TEXT NOT NULL DEFAULT '',
|
|
185
|
+
board TEXT NOT NULL DEFAULT '',
|
|
186
|
+
link TEXT NOT NULL DEFAULT '',
|
|
187
|
+
status TEXT NOT NULL DEFAULT 'ideia',
|
|
188
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
189
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
190
|
+
created_at TEXT NOT NULL
|
|
192
191
|
)
|
|
192
|
+
""",
|
|
193
|
+
"investimentos": """
|
|
194
|
+
CREATE TABLE IF NOT EXISTS investimentos (
|
|
195
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
196
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
197
|
+
data TEXT NOT NULL DEFAULT '',
|
|
198
|
+
valor REAL NOT NULL DEFAULT 0,
|
|
199
|
+
fonte TEXT NOT NULL DEFAULT 'manual',
|
|
200
|
+
observacao TEXT NOT NULL DEFAULT '',
|
|
201
|
+
created_at TEXT NOT NULL
|
|
202
|
+
)
|
|
203
|
+
""",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
ALL_TABLES = list(TABLES.keys())
|
|
193
207
|
|
|
194
208
|
|
|
195
|
-
def
|
|
196
|
-
|
|
209
|
+
def _pg_ddl(sqlite_ddl: str) -> str:
|
|
210
|
+
"""Traduz o DDL SQLite para PostgreSQL."""
|
|
211
|
+
return sqlite_ddl.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_db():
|
|
215
|
+
from .dbengine import connect
|
|
216
|
+
|
|
217
|
+
return connect()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ensure_db() -> None:
|
|
221
|
+
with get_db() as conn:
|
|
222
|
+
if is_postgres():
|
|
223
|
+
for ddl in TABLES.values():
|
|
224
|
+
conn.execute(_pg_ddl(ddl))
|
|
225
|
+
else:
|
|
226
|
+
for ddl in TABLES.values():
|
|
227
|
+
conn.execute(ddl)
|
|
228
|
+
# Migração: bancos criados antes da coluna user_id / da tabela investimentos
|
|
229
|
+
for table in ALL_TABLES:
|
|
230
|
+
add_column_if_missing(conn, table, "user_id TEXT NOT NULL DEFAULT ''", "user_id")
|
|
231
|
+
if "investimentos" not in [row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]:
|
|
232
|
+
conn.execute(TABLES["investimentos"])
|
|
233
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
|
|
234
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
|
|
197
235
|
|
|
198
236
|
|
|
199
|
-
|
|
200
|
-
return {key: row[key] for key in row.keys()}
|
|
237
|
+
ensure_db()
|
|
@@ -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
|