feat: Update environment configuration for PostgreSQL and enhance requirements
This commit is contained in:
parent
e4a6d0aeeb
commit
d4813aad3c
4 changed files with 109 additions and 110 deletions
20
.env.example
20
.env.example
|
|
@ -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
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ static/payment_qrcodes/
|
|||
__pycache__/
|
||||
data/
|
||||
.env
|
||||
venv
|
||||
113
app.py
113
app.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue