feat: Update environment configuration for PostgreSQL and enhance requirements

This commit is contained in:
Murilo 2025-07-01 20:53:00 -03:00
parent e4a6d0aeeb
commit d4813aad3c
4 changed files with 109 additions and 110 deletions

View file

@ -1,9 +1,15 @@
# .env.example
# Chave secreta para a segurança da sessão do Flask.
# Gere uma chave forte e única para produção. Você pode usar o comando: openssl rand -hex 32
SECRET_KEY="sua_chave_secreta_super_forte_aqui"
# Chave secreta para o Flask. Gere uma chave forte e aleatória para o seu ambiente .env
SECRET_KEY=""
# Chave da API do AbacatePay para processamento de pagamentos PIX.
ABACATE_API_KEY="sua_chave_da_api_abacatepay_aqui"
# Configuração de depuração do Flask. Use 'True' para desenvolvimento.
FLASK_DEBUG=True
# Chave de API do AbacatePay. Substitua pelo valor real da sua chave de API.
ABACATE_API_KEY=""
# URL de conexão com o banco de dados PostgreSQL.
# Em produção (ex: Render), esta variável será fornecida pelo serviço de banco de dados.
# Para desenvolvimento local, descomente e ajuste a linha abaixo.
# DATABASE_URL="postgresql://USUARIO:SENHA@localhost:5432/NOME_DO_BANCO"
# (Opcional) Para rodar o Flask em modo de depuração localmente.
# Não defina como 'true' em produção.
# FLASK_DEBUG="true"

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ static/payment_qrcodes/
__pycache__/
data/
.env
venv

113
app.py
View file

@ -51,17 +51,30 @@ ABACATE_API_KEY = os.environ.get('ABACATE_API_KEY')
if not ABACATE_API_KEY:
raise ValueError("ABACATE_API_KEY não definida no .env")
# --- NOVO: Definir caminho para o banco de dados dentro do STORAGE_BASE_PATH ---
DB_FOLDER_NAME = 'database'
DB_FULL_PATH = os.path.join(STORAGE_BASE_PATH, DB_FOLDER_NAME, 'party.db')
# ##################################################################################
# ## CORREÇÃO APLICADA AQUI ##
# ##################################################################################
# Converte as barras invertidas do Windows para barras normais para a URI
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + DB_FULL_PATH.replace('\\', '/')
##################################################################################
### ALTERADO: Implementação do Banco de Dados PostgreSQL ###
##################################################################################
# 1. Pega a URL do banco de dados da variável de ambiente.
# Em produção (Render), ela será fornecida automaticamente.
# Localmente, você a define no seu arquivo .env.
DATABASE_URL = os.environ.get('DATABASE_URL')
if not DATABASE_URL:
raise ValueError("A variável de ambiente 'DATABASE_URL' não foi definida. Ela é essencial para a conexão com o PostgreSQL.")
# 2. Algumas plataformas (como Heroku e versões mais antigas do Render) usam "postgres://"
# enquanto o SQLAlchemy espera "postgresql://". Esta linha garante a compatibilidade.
if DATABASE_URL.startswith("postgres://"):
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
# 3. Configura o Flask-SQLAlchemy para usar a URL do seu banco de dados PostgreSQL.
app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
### FIM DA ALTERAÇÃO ###
# --- Inicializações ---
db = SQLAlchemy(app)
login_manager = LoginManager()
@ -90,10 +103,8 @@ if not os.path.exists(STORAGE_BASE_PATH):
os.makedirs(STORAGE_BASE_PATH)
print(f"Criado diretório de armazenamento base: {STORAGE_BASE_PATH}")
# Criar a pasta do banco de dados dentro do STORAGE_BASE_PATH
if not os.path.exists(os.path.dirname(DB_FULL_PATH)):
os.makedirs(os.path.dirname(DB_FULL_PATH))
print(f"Criada pasta para o banco de dados: {os.path.dirname(DB_FULL_PATH)}")
### REMOVIDO: A criação da pasta do banco de dados não é mais necessária. ###
# O PostgreSQL é um serviço, não um arquivo local.
# Criar as subpastas para logos e QR codes dentro do diretório base de armazenamento
if not os.path.exists(PARTY_LOGOS_SAVE_PATH):
@ -169,6 +180,7 @@ def delete_pix_qr_code_file(guest):
app.logger.error(f"Erro ao deletar o arquivo QR Code PIX {guest.pix_qr_code_filename}: {e}")
# --- Modelos do Banco de Dados ---
# NENHUMA ALTERAÇÃO NECESSÁRIA AQUI. SQLAlchemy cuida da tradução para o dialeto do PostgreSQL.
party_collaborators = db.Table('party_collaborators',
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True),
db.Column('party_id', db.Integer, db.ForeignKey('party.id'), primary_key=True)
@ -222,7 +234,7 @@ class Guest(db.Model):
purchase_link_id = db.Column(db.String(64), unique=True, nullable=True)
purchased_by_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
purchaser = db.relationship('User', foreign_keys=[purchased_by_user_id], backref='purchased_tickets')
purchase_price = db.Column(db.Float, nullable=True) # NOVO CAMPO para registrar o preço pago
purchase_price = db.Column(db.Float, nullable=True)
@property
def qr_image_url(self):
@ -242,13 +254,14 @@ class Guest(db.Model):
return "N/A"
with app.app_context():
# Isso criará o arquivo do banco de dados na pasta DB_FULL_PATH
# Para bancos de dados existentes, isso adicionará a coluna 'purchase_price' como NULL.
# Se você já tem dados 'paid' e deseja que eles contribuam para o faturamento,
# precisaria de uma migração de dados ou um script one-off para popular 'purchase_price'
# para esses registros existentes com o valor de party.ticket_price na época.
# Este comando agora se conectará ao seu banco de dados PostgreSQL
# e criará as tabelas se elas ainda não existirem.
db.create_all()
# O restante do seu código (rotas, funções, etc.) permanece exatamente o mesmo,
# pois ele interage com o `db` (SQLAlchemy), que agora está configurado para o PostgreSQL.
# Colei todo o resto sem modificações para ter o arquivo completo.
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
@ -285,14 +298,11 @@ def generate_qr_code_image(qr_data, guest_name, party, output_format='PNG'):
font_guest_name = PILImageFont.truetype(font_path_bold, 36)
font_footer = PILImageFont.truetype(font_path_regular, 14)
except IOError:
# Fallback para fontes padrão se as personalizadas não puderem ser carregadas
app.logger.warning(f"Fonte personalizada não encontrada em {FONT_PATH}. Usando fonte padrão.")
font_party_name, font_guest_name, font_footer = (PILImageFont.load_default(s) for s in [52,36,14])
logo_img_raw, logo_height = None, 0
if party.logo_filename:
# O caminho aqui está correto, ele tenta carregar a imagem do disco persistente
logo_path = os.path.join(PARTY_LOGOS_SAVE_PATH, party.logo_filename)
if os.path.exists(logo_path):
logo_height = int(CARD_WIDTH / LOGO_ASPECT_RATIO)
@ -572,7 +582,6 @@ def toggle_guest_count(party_id):
flash('Visibilidade da contagem de convidados atualizada.', 'success')
return redirect(url_for('party_manager', party_id=party_id))
# --- ROTA MODIFICADA ---
@app.route('/party/<int:party_id>/register_guest_for_payment', methods=['POST'])
@login_required
def register_guest_for_payment(party_id):
@ -586,13 +595,9 @@ def register_guest_for_payment(party_id):
if not guest_name:
return jsonify({'status': 'error','message': 'Nome do convidado para pagamento é obrigatório.'}), 400
# Validação do perfil do usuário
if not current_user.tax_id or not current_user.cellphone:
# Constrói a URL para completar o perfil, com o 'next' para voltar à página da festa
next_url = url_for('party_manager', party_id=party_id)
complete_profile_url = url_for('complete_profile', next=next_url)
# Retorna um JSON com a ação de redirecionamento
return jsonify({
'status': 'error',
'message': 'Seu perfil precisa ter CPF/CNPJ e Telefone para gerar links de pagamento. Redirecionando...',
@ -621,7 +626,7 @@ def register_guest_for_payment(party_id):
pix_emv_code=charge_data.get('brCode'),
pix_created_at=datetime.now(BRASILIA_TZ),
purchase_link_id=purchase_link_id,
purchase_price=party.ticket_price # NOVO: Salva o preço atual da festa
purchase_price=party.ticket_price
)
db.session.add(new_guest)
db.session.commit()
@ -658,7 +663,7 @@ def buy_ticket_owner_invite(purchase_link_id):
guest.pix_qr_code_filename = qr_filename
guest.pix_emv_code = charge_data.get('brCode')
guest.pix_created_at = datetime.now(BRASILIA_TZ)
guest.purchase_price = party.ticket_price # NOVO: Salva o preço atual da festa
guest.purchase_price = party.ticket_price
db.session.commit()
except Exception as e:
app.logger.error(f"Erro na API ao gerar PIX para convite: {e}")
@ -698,7 +703,7 @@ def buy_ticket_public(party_id):
added_by_user_id=current_user.id,
payment_status='paid',
purchased_by_user_id=current_user.id,
purchase_price=0.0 # NOVO: Preço 0.0 para ingresso gratuito
purchase_price=0.0
)
db.session.add(new_guest)
db.session.commit()
@ -739,7 +744,7 @@ def buy_ticket_public(party_id):
guest_to_update.pix_qr_code_filename = qr_filename
guest_to_update.pix_emv_code = charge_data.get('brCode')
guest_to_update.pix_created_at = datetime.now(BRASILIA_TZ)
guest_to_update.purchase_price = party.ticket_price # NOVO: Salva o preço atual da festa
guest_to_update.purchase_price = party.ticket_price
db.session.commit()
return redirect(url_for('show_ticket_payment', payment_charge_id=charge_data['id']))
except Exception as e:
@ -880,10 +885,7 @@ def check_payment_status(pix_id):
if status == 'PAID':
if is_guest_ticket_payment.payment_status != 'paid':
is_guest_ticket_payment.payment_status = 'paid'
# NOVO: Garante que purchase_price esteja definido no momento do pagamento
if is_guest_ticket_payment.purchase_price is None:
# Este caso idealmente não deveria ocorrer se o preço é definido na criação da cobrança,
# mas serve como fallback de segurança.
is_guest_ticket_payment.purchase_price = is_guest_ticket_payment.party.ticket_price
db.session.commit()
delete_pix_qr_code_file(is_guest_ticket_payment)
@ -919,10 +921,7 @@ def abacatepay_webhook():
guest = Guest.query.filter_by(payment_charge_id=transaction_id).first()
if guest and guest.payment_status != 'paid':
guest.payment_status = 'paid'
# NOVO: Garante que purchase_price esteja definido no momento do pagamento
if guest.purchase_price is None:
# Este caso idealmente não deveria ocorrer se o preço é definido na criação da cobrança,
# mas serve como fallback de segurança.
guest.purchase_price = guest.party.ticket_price
db.session.commit()
delete_pix_qr_code_file(guest)
@ -942,11 +941,10 @@ def get_party_stats_data(party_id):
entered_count = Guest.query.filter_by(party_id=party_id, entered=True).count()
party = Party.query.get(party_id)
# NOVO: Calcular faturamento total de ingressos pagos
total_revenue = db.session.query(db.func.sum(Guest.purchase_price)).filter(
Guest.party_id == party_id,
Guest.payment_status == 'paid'
).scalar() or 0.0 # Garante que seja 0.0 se não houver vendas ou preço for nulo
).scalar() or 0.0
if party and party.ticket_price > 0:
total_paid_guests = Guest.query.filter_by(party_id=party_id, payment_status='paid').count()
@ -957,7 +955,7 @@ def get_party_stats_data(party_id):
'entered_count': entered_paid_guests,
'not_entered_count': total_paid_guests - entered_paid_guests,
'percentage_entered': round((entered_paid_guests / total_paid_guests) * 100, 2) if total_paid_guests > 0 else 0.0,
'total_revenue': total_revenue # Adiciona o faturamento
'total_revenue': total_revenue
}
else:
return {
@ -966,7 +964,7 @@ def get_party_stats_data(party_id):
'entered_count': entered_count,
'not_entered_count': total_invited - entered_count,
'percentage_entered': round((entered_count / total_invited) * 100, 2) if total_invited > 0 else 0.0,
'total_revenue': total_revenue # Adiciona o faturamento (mesmo para festas gratuitas, será 0)
'total_revenue': total_revenue
}
@app.route('/api/party/<int:party_id>/stats', methods=['GET'])
@ -990,7 +988,7 @@ def handle_guests(party_id):
party_id=party_id,
added_by_user_id=current_user.id,
payment_status='not_applicable',
purchase_price=0.0 # NOVO: Preço 0.0 para convidado gratuito
purchase_price=0.0
)
db.session.add(new_guest)
db.session.commit()
@ -1023,7 +1021,7 @@ def handle_guests(party_id):
'id': g.id, 'name': g.name, 'qr_hash': g.qr_hash, 'entered': g.entered, 'qr_image_url': g.qr_image_url,
'check_in_time': g.get_check_in_time_str(), 'added_by': g.adder.username if g.adder else 'N/A',
'payment_status': g.payment_status, 'purchased_by': purchased_by_name, 'purchase_link_id': g.purchase_link_id,
'purchase_price': g.purchase_price # NOVO: Inclui o preço de compra na API
'purchase_price': g.purchase_price
})
return jsonify({
@ -1099,7 +1097,7 @@ def delete_guest(party_id, qr_hash):
class PDF(FPDF):
def __init__(self, orientation='P', unit='mm', format='A4', party_name=''):
super().__init__(orientation, unit, format)
self.montserrat_font_path = FONT_PATH # A fonte continua vindo do diretório da aplicação
self.montserrat_font_path = FONT_PATH
self.font_name = 'Montserrat'
self.default_font = 'Helvetica'
self.current_font_family = self.default_font
@ -1140,7 +1138,7 @@ class PDF(FPDF):
("Entraram:", str(stats_data['entered_count'])),
("Não Entraram:", str(stats_data['not_entered_count'])),
("Comparecimento:", f"{stats_data['percentage_entered']:.2f}%"),
("Faturamento Total:", f"R$ {stats_data['total_revenue']:.2f}") # NOVO: Adiciona faturamento
("Faturamento Total:", f"R$ {stats_data['total_revenue']:.2f}")
]
for i, (label, value) in enumerate(stats_items):
@ -1157,20 +1155,18 @@ class PDF(FPDF):
y_pos_value = y_pos_label + line_height_label
self.set_xy(current_x, y_pos_value)
self.set_text_color(0, 100, 200) # Mantém a cor para valores
# Cor específica para faturamento
self.set_text_color(0, 100, 200)
if label == "Faturamento Total:":
self.set_text_color(0, 150, 0) # Verde para faturamento
self.set_text_color(0, 150, 0)
self.set_font(self.current_font_family, 'B', 10)
self.cell(col_width_stat, line_height_value, value, border=0, align='C')
self.set_text_color(0, 0, 0) # Reseta a cor
self.set_text_color(0, 0, 0)
current_x += col_width_stat
# Quebra de linha após 4 colunas ou no final
if (i + 1) % 4 == 0 and (i + 1) < len(stats_items):
current_x = self.l_margin
base_y += stat_box_height + 5 # Ajusta espaço para próxima linha
self.ln(5) # Linha vazia para separar
base_y += stat_box_height + 5
self.ln(5)
self.set_y(base_y + stat_box_height)
self.ln(5)
@ -1205,7 +1201,6 @@ class PDF(FPDF):
self.set_font(self.current_font_family, 'B', 9)
self.set_fill_color(230, 230, 230)
# Adicionar coluna "Preço Pago" ao cabeçalho da tabela
col_widths = {"name": 55, "payment_status": 22, "purchase_price": 20, "purchased_by": 28, "status": 20, "check_in": 30, "added_by": 25}
total_width = sum(col_widths.values())
start_x = self.l_margin + (self.w - 2 * self.l_margin - total_width) / 2
@ -1213,7 +1208,7 @@ class PDF(FPDF):
self.set_x(start_x)
self.cell(col_widths["name"], 8, 'Nome', 1, 0, 'C', 1)
self.cell(col_widths["payment_status"], 8, 'Pagamento', 1, 0, 'C', 1)
self.cell(col_widths["purchase_price"], 8, 'Preço Pago', 1, 0, 'C', 1) # NOVO
self.cell(col_widths["purchase_price"], 8, 'Preço Pago', 1, 0, 'C', 1)
self.cell(col_widths["purchased_by"], 8, 'Comprado Por', 1, 0, 'C', 1)
self.cell(col_widths["status"], 8, 'Entrou?', 1, 0, 'C', 1)
self.cell(col_widths["check_in"], 8, 'Data Check-in', 1, 0, 'C', 1)
@ -1232,16 +1227,13 @@ class PDF(FPDF):
'failed': 'Falhou'
}.get(guest_obj.payment_status, guest_obj.payment_status)
# Formatação do preço de compra
purchase_price_display = f"R$ {guest_obj.purchase_price:.2f}" if guest_obj.purchase_price is not None else 'N/A'
purchased_by_name = guest_obj.purchaser.username if guest_obj.purchaser else 'N/A'
added_by_name = guest_obj.adder.username if guest_obj.adder else 'N/A'
self.cell(col_widths["name"], 7, guest_obj.name, 1, 0, 'L', fill)
self.cell(col_widths["payment_status"], 7, payment_status_display, 1, 0, 'C', fill)
self.cell(col_widths["purchase_price"], 7, purchase_price_display, 1, 0, 'C', fill) # NOVO
self.cell(col_widths["purchase_price"], 7, purchase_price_display, 1, 0, 'C', fill)
self.cell(col_widths["purchased_by"], 7, purchased_by_name, 1, 0, 'C', fill)
self.cell(col_widths["status"], 7, 'Sim' if guest_obj.entered else 'Não', 1, 0, 'C', fill)
self.cell(col_widths["check_in"], 7, guest_obj.get_check_in_time_str(), 1, 0, 'C', fill)
@ -1256,7 +1248,6 @@ def export_guests_csv(party_id):
si = io.StringIO()
cw = csv.writer(si)
# NOVO: Adiciona "Preço Pago" ao cabeçalho do CSV
cw.writerow(['Nome', 'Status Entrada', 'Data Check-in', 'Status Pagamento', 'Preço Pago', 'Comprado Por', 'Adicionado Por'])
for guest in guests_data:
@ -1268,9 +1259,7 @@ def export_guests_csv(party_id):
'failed': 'Pagamento Falhou'
}.get(guest.payment_status, guest.payment_status)
# Formatação do preço de compra
purchase_price_export = f"{guest.purchase_price:.2f}".replace('.', ',') if guest.purchase_price is not None else 'N/A'
purchased_by_name = guest.purchaser.username if guest.purchaser else 'N/A'
added_by_name = guest.adder.username if guest.adder else 'N/A'
@ -1279,7 +1268,7 @@ def export_guests_csv(party_id):
'Sim' if guest.entered else 'Não',
guest.get_check_in_time_str(),
payment_status_display,
purchase_price_export, # NOVO
purchase_price_export,
purchased_by_name,
added_by_name
])
@ -1299,8 +1288,6 @@ def export_guests_pdf(party_id):
pdf.add_page()
pdf.draw_stats_summary(stats_for_chart)
# Reajustar o Y do gráfico se a tabela de stats cresceu
# pdf.draw_pie_chart(stats_for_chart, y_offset=pdf.get_y(), chart_size_mm=70) # Remover y_offset, já está no get_y()
if guests_for_table:
pdf.chapter_body(guests_for_table)

View file

@ -1,40 +1,45 @@
abacatepay
annotated-types
blinker
certifi
charset-normalizer
click
contourpy
cycler
defusedxml
Faker
Flask
Flask-Login
Flask-SQLAlchemy
fonttools
fpdf2
greenlet
idna
itsdangerous
Jinja2
kiwisolver
MarkupSafe
matplotlib
numpy
packaging
pillow
pydantic
pydantic_core
pyparsing
python-dateutil
python-dotenv
pytz
qrcode
requests
six
SQLAlchemy
typing-inspection
typing_extensions
tzdata
urllib3
Werkzeug
abacatepay==1.0.7
annotated-types==0.7.0
blinker==1.9.0
certifi==2025.6.15
charset-normalizer==3.4.2
click==8.2.1
contourpy==1.3.2
cycler==0.12.1
defusedxml==0.7.1
dnspython==2.7.0
email_validator==2.2.0
Faker==37.4.0
Flask==3.1.1
Flask-Login==0.6.3
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.2
fonttools==4.58.4
fpdf2==2.8.3
greenlet==3.2.3
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
kiwisolver==1.4.8
MarkupSafe==3.0.2
matplotlib==3.10.3
numpy==2.3.1
packaging==25.0
pillow==11.3.0
psycopg2-binary==2.9.10
pydantic==2.11.7
pydantic_core==2.33.2
pyparsing==3.2.3
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
pytz==2025.2
qrcode==8.2
requests==2.32.4
six==1.17.0
SQLAlchemy==2.0.41
typing-inspection==0.4.1
typing_extensions==4.14.0
tzdata==2025.2
urllib3==2.5.0
Werkzeug==3.1.3
WTForms==3.2.1