Refactoring to MVC clean [Security Fix]

This commit is contained in:
Murilo Scolari 2026-01-30 23:15:26 -03:00
parent 44b32f3222
commit de1b93c796
46 changed files with 2225 additions and 1836 deletions

2
.gitignore vendored
View file

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

1458
app.py

File diff suppressed because it is too large Load diff

50
app/__init__.py Normal file
View file

@ -0,0 +1,50 @@
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
from flask import Flask
from app.config import Config
from app.extensions import db, login_manager
from app.utils import generate_google_maps_url
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize Extensions
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = "Por favor, faça login para acessar esta página."
login_manager.login_message_category = "info"
# Context Processors
@app.context_processor
def inject_current_year():
return {'current_year': datetime.now(Config.BRASILIA_TZ).year, 'generate_google_maps_url': generate_google_maps_url}
# Register Blueprints
from app.views import auth, main, party, payment, public
app.register_blueprint(auth.bp, url_prefix='/auth')
app.register_blueprint(main.bp) # Main routes like /dashboard on root
app.register_blueprint(party.bp, url_prefix='/party')
app.register_blueprint(payment.bp) # Payment routes often need root access or specific paths
app.register_blueprint(public.bp) # Public routes like /p/
# Ensure storage directories exist
import os
if not os.path.exists(app.config['STORAGE_BASE_PATH']):
os.makedirs(app.config['STORAGE_BASE_PATH'])
if not os.path.exists(app.config['PARTY_LOGOS_SAVE_PATH']):
os.makedirs(app.config['PARTY_LOGOS_SAVE_PATH'])
if not os.path.exists(app.config['PAYMENT_QRCODES_SAVE_PATH']):
os.makedirs(app.config['PAYMENT_QRCODES_SAVE_PATH'])
# Instance folder
if not os.path.exists(os.path.join(app.instance_path)):
try:
os.makedirs(app.instance_path)
except OSError:
pass
return app

29
app/config.py Normal file
View file

@ -0,0 +1,29 @@
import os
import pytz
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY')
ABACATE_API_KEY = os.environ.get('ABACATE_API_KEY')
# --- Configuração do Caminho de Armazenamento Persistente ---
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
STORAGE_BASE_PATH = os.environ.get("STORAGE_BASE_PATH") or os.path.join(os.getcwd(), "persistent_storage")
if not STORAGE_BASE_PATH:
STORAGE_BASE_PATH = os.path.join(basedir, 'data')
PARTY_LOGOS_FOLDER_NAME = 'party_logos'
PAYMENT_QRCODES_FOLDER_NAME = 'payment_qrcodes'
PARTY_LOGOS_SAVE_PATH = os.path.join(STORAGE_BASE_PATH, PARTY_LOGOS_FOLDER_NAME)
PAYMENT_QRCODES_SAVE_PATH = os.path.join(STORAGE_BASE_PATH, PAYMENT_QRCODES_FOLDER_NAME)
FONT_PATH = os.path.join(basedir, "app", "static", "fonts", "Montserrat-Regular.ttf")
BRASILIA_TZ = pytz.timezone('America/Sao_Paulo')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# Database
DATABASE_URL = os.environ.get('DATABASE_URL')
if DATABASE_URL and DATABASE_URL.startswith("postgres://"):
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
SQLALCHEMY_DATABASE_URI = DATABASE_URL
SQLALCHEMY_TRACK_MODIFICATIONS = False

5
app/extensions.py Normal file
View file

@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
db = SQLAlchemy()
login_manager = LoginManager()

98
app/models.py Normal file
View file

@ -0,0 +1,98 @@
from datetime import datetime
from flask import url_for
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.extensions import db, login_manager
from app.config import Config
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)
)
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
tax_id = db.Column(db.String(20), unique=True, nullable=True)
cellphone = db.Column(db.String(20), nullable=True)
parties = db.relationship('Party', backref='owner', lazy=True, cascade="all, delete-orphan")
collaborations = db.relationship('Party', secondary=party_collaborators, lazy='subquery',
backref=db.backref('collaborators', lazy=True))
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Party(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
party_code = db.Column(db.String(6), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(Config.BRASILIA_TZ))
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
guests = db.relationship('Guest', backref='party', lazy=True, cascade="all, delete-orphan")
logo_filename = db.Column(db.String(255), nullable=True)
shareable_link_id = db.Column(db.String(16), unique=True, nullable=False)
public_description = db.Column(db.Text, nullable=True)
show_guest_count = db.Column(db.Boolean, nullable=False, default=True)
share_code = db.Column(db.String(8), unique=True, nullable=False)
ticket_price = db.Column(db.Float, default=0.0, nullable=False)
allow_public_purchase = db.Column(db.Boolean, default=False, nullable=False)
location = db.Column(db.String(255), nullable=True)
event_date = db.Column(db.Date, nullable=True)
event_time = db.Column(db.Time, nullable=True)
invite_font = db.Column(db.String(100), nullable=False, default='Montserrat-Regular')
@property
def formatted_date(self):
if self.event_date:
return self.event_date.strftime('%d/%m/%Y')
return None
@property
def formatted_time(self):
if self.event_time:
return self.event_time.strftime('%H:%M')
return None
class Guest(db.Model):
__tablename__ = 'guest'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
qr_hash = db.Column(db.String(64), unique=True, nullable=False)
entered = db.Column(db.Boolean, default=False, nullable=False)
check_in_time = db.Column(db.DateTime, nullable=True)
party_id = db.Column(db.Integer, db.ForeignKey('party.id'), nullable=False)
added_by_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
adder = db.relationship('User', foreign_keys=[added_by_user_id], backref='added_guests')
payment_status = db.Column(db.String(30), default='not_applicable', nullable=False)
payment_charge_id = db.Column(db.String(120), unique=True, nullable=True)
pix_qr_code_filename = db.Column(db.String(255), nullable=True)
pix_emv_code = db.Column(db.Text, nullable=True)
pix_created_at = db.Column(db.DateTime, nullable=True)
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)
@property
def qr_image_url(self):
# Adjusted for Blueprint 'party'
return url_for('party.serve_qr_code', qr_hash=self.qr_hash)
@property
def pix_qr_code_url(self):
if self.pix_qr_code_filename:
# Adjusted for Blueprint 'main'
return url_for('main.serve_persistent_file', filename=f"{Config.PAYMENT_QRCODES_FOLDER_NAME}/{self.pix_qr_code_filename}")
return None
def get_check_in_time_str(self):
if self.check_in_time:
return self.check_in_time.astimezone(Config.BRASILIA_TZ).strftime('%d/%m/%Y %H:%M:%S')
return "N/A"
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))

View file

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View file

@ -2,8 +2,8 @@
{% block title %}Sobre o QRPass - Como Funciona{% endblock %}
{% block header_actions %}
<a href="{{ url_for('login') }}" class="glass-effect text-primary dark:text-white px-4 py-2 sm:px-6 sm:py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale text-sm sm:text-base">Entrar</a>
<a href="{{ url_for('signup') }}" class="bg-gradient-to-r from-primary to-secondary text-white px-4 py-2 sm:px-6 sm:py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale text-sm sm:text-base">Criar Conta</a>
<a href="{{ url_for('auth.login') }}" class="glass-effect text-primary dark:text-white px-4 py-2 sm:px-6 sm:py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale text-sm sm:text-base">Entrar</a>
<a href="{{ url_for('auth.signup') }}" class="bg-gradient-to-r from-primary to-secondary text-white px-4 py-2 sm:px-6 sm:py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale text-sm sm:text-base">Criar Conta</a>
{% endblock %}
{% block content %}

View file

@ -32,8 +32,8 @@
<div class="mt-6 text-sm text-gray-600 dark:text-gray-400">
<p>Você será redirecionado para a página de pagamento (se aplicável) ou para o seu ingresso.</p>
<p class="mt-2">Já tem uma conta? <a href="{{ url_for('login', next=request.url) }}" class="text-primary hover:underline font-semibold">Faça Login</a></p>
<p>Não tem uma conta? <a href="{{ url_for('signup', next=request.url) }}" class="text-primary hover:underline font-semibold">Cadastre-se</a></p>
<p class="mt-2">Já tem uma conta? <a href="{{ url_for('auth.login', next=request.url) }}" class="text-primary hover:underline font-semibold">Faça Login</a></p>
<p>Não tem uma conta? <a href="{{ url_for('auth.signup', next=request.url) }}" class="text-primary hover:underline font-semibold">Cadastre-se</a></p>
</div>
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
<div class="flex justify-center items-center py-12">
<div class="w-full max-w-md">
{# ATENÇÃO AQUI: next=request.args.get('next') foi adicionado ao action #}
<form method="POST" action="{{ url_for('complete_profile', next=request.args.get('next')) }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-4">
<form method="POST" action="{{ url_for('main.complete_profile', next=request.args.get('next')) }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-4">
<h2 class="text-center text-3xl font-bold text-gray-900 dark:text-white">Complete seu Perfil</h2>
<p class="text-center text-gray-600 dark:text-gray-400 !mt-2 !mb-6">
Precisamos de mais algumas informações para processar seu pagamento com segurança.
@ -35,7 +35,7 @@
</button>
</div>
<p class="text-center text-sm">
<a href="{{ url_for('dashboard') }}" class="text-gray-500 hover:text-primary">Voltar</a>
<a href="{{ url_for('main.dashboard') }}" class="text-gray-500 hover:text-primary">Voltar</a>
</p>
</form>
</div>

View file

@ -2,7 +2,7 @@
{% block title %}Dashboard - QRPass{% endblock %}
{% block header_actions %}
<span class="text-gray-700 dark:text-gray-300 hidden sm:inline">Olá, {{ current_user.username }}!</span>
<a href="{{ url_for('logout') }}" class="glass-effect text-danger px-4 py-2 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center space-x-2"><i class="fas fa-sign-out-alt"></i><span>Sair</span></a>
<a href="{{ url_for('auth.logout') }}" class="glass-effect text-danger px-4 py-2 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center space-x-2"><i class="fas fa-sign-out-alt"></i><span>Sair</span></a>
{% endblock %}
{% block content %}
<div class="fade-in">
@ -13,7 +13,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div class="glass-card rounded-2xl shadow-2xl p-6 dashboard-card">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Criar Nova Festa</h2>
<form action="{{ url_for('create_party') }}" method="POST" class="flex flex-col sm:flex-row gap-4">
<form action="{{ url_for('party.create_party') }}" method="POST" class="flex flex-col sm:flex-row gap-4">
<input type="text" name="party_name" placeholder="Nome da sua nova festa" required class="flex-grow w-full px-4 py-3 glass-effect rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-200 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400">
<button type="submit" class="bg-gradient-to-r from-primary to-secondary text-white px-6 py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center justify-center space-x-2"><i class="fas fa-plus"></i><span>Criar</span></button>
</form>
@ -21,7 +21,7 @@
<div class="glass-card rounded-2xl shadow-2xl p-6 dashboard-card">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Colaborar em Festa</h2>
<form action="{{ url_for('add_collaboration') }}" method="POST" class="flex flex-col sm:flex-row gap-4">
<form action="{{ url_for('party.add_collaboration') }}" method="POST" class="flex flex-col sm:flex-row gap-4">
<input type="text" name="share_code" placeholder="Código de Compartilhamento" required class="flex-grow w-full px-4 py-3 glass-effect rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400">
<button type="submit" class="bg-gradient-to-r from-green-500 to-emerald-600 text-white px-6 py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center justify-center space-x-2"><i class="fas fa-user-plus"></i><span>Entrar</span></button>
</form>
@ -42,8 +42,8 @@
<div class="mt-2"><span class="text-sm text-gray-500 dark:text-gray-400">Código do Scanner:</span><span class="ml-2 font-mono text-lg bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">{{ party.party_code }}</span></div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">{{ party.guests|length }} convidados</p>
</div>
<div class="mt-6 grid grid-cols-2 gap-2"><a href="{{ url_for('party_manager', party_id=party.id) }}" class="text-center w-full bg-primary text-white px-4 py-2 rounded-lg font-semibold hover:bg-secondary transition-colors text-sm"><i class="fas fa-cog mr-1"></i>Gerenciar</a><a href="{{ url_for('public_scanner') }}" class="text-center w-full bg-success text-white px-4 py-2 rounded-lg font-semibold hover:bg-green-600 transition-colors text-sm"><i class="fas fa-qrcode mr-1"></i>Scanner</a><a href="{{ url_for('public_party_page', shareable_link_id=party.shareable_link_id) }}" target="_blank" class="col-span-2 text-center w-full bg-purple-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-purple-700 transition-colors text-sm"><i class="fas fa-eye mr-1"></i>Ver Página Pública</a></div>
<div class="mt-4 text-right"><form action="{{ url_for('delete_party', party_id=party.id) }}" method="POST" onsubmit="return confirm('Tem certeza que deseja apagar a festa \'{{ party.name }}\' e todos os seus dados? Esta ação não pode ser desfeita.');"><button type="submit" class="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 font-semibold"><i class="fas fa-trash-alt mr-1"></i>Apagar Festa</button></form></div>
<div class="mt-6 grid grid-cols-2 gap-2"><a href="{{ url_for('party.party_manager', party_id=party.id) }}" class="text-center w-full bg-primary text-white px-4 py-2 rounded-lg font-semibold hover:bg-secondary transition-colors text-sm"><i class="fas fa-cog mr-1"></i>Gerenciar</a><a href="{{ url_for('public.public_scanner') }}" class="text-center w-full bg-success text-white px-4 py-2 rounded-lg font-semibold hover:bg-green-600 transition-colors text-sm"><i class="fas fa-qrcode mr-1"></i>Scanner</a><a href="{{ url_for('public.public_party_page', shareable_link_id=party.shareable_link_id) }}" target="_blank" class="col-span-2 text-center w-full bg-purple-600 text-white px-4 py-2 rounded-lg font-semibold hover:bg-purple-700 transition-colors text-sm"><i class="fas fa-eye mr-1"></i>Ver Página Pública</a></div>
<div class="mt-4 text-right"><form action="{{ url_for('party.delete_party', party_id=party.id) }}" method="POST" onsubmit="return confirm('Tem certeza que deseja apagar a festa \'{{ party.name }}\' e todos os seus dados? Esta ação não pode ser desfeita.');"><button type="submit" class="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 font-semibold"><i class="fas fa-trash-alt mr-1"></i>Apagar Festa</button></form></div>
</div>
{% endfor %}
{% for party in collaborated_parties %}
@ -57,7 +57,7 @@
<div class="mt-2"><span class="text-sm text-gray-500 dark:text-gray-400">Código do Scanner:</span><span class="ml-2 font-mono text-lg bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">{{ party.party_code }}</span></div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">{{ party.guests|length }} convidados</p>
</div>
<div class="mt-6 grid grid-cols-2 gap-2"><a href="{{ url_for('party_manager', party_id=party.id) }}" class="text-center w-full bg-primary text-white px-4 py-2 rounded-lg font-semibold hover:bg-secondary transition-colors text-sm"><i class="fas fa-cog mr-1"></i>Gerenciar</a><a href="{{ url_for('public_scanner') }}" class="text-center w-full bg-success text-white px-4 py-2 rounded-lg font-semibold hover:bg-green-600 transition-colors text-sm"><i class="fas fa-qrcode mr-1"></i>Scanner</a></div>
<div class="mt-6 grid grid-cols-2 gap-2"><a href="{{ url_for('party.party_manager', party_id=party.id) }}" class="text-center w-full bg-primary text-white px-4 py-2 rounded-lg font-semibold hover:bg-secondary transition-colors text-sm"><i class="fas fa-cog mr-1"></i>Gerenciar</a><a href="{{ url_for('public.public_scanner') }}" class="text-center w-full bg-success text-white px-4 py-2 rounded-lg font-semibold hover:bg-green-600 transition-colors text-sm"><i class="fas fa-qrcode mr-1"></i>Scanner</a></div>
</div>
{% endfor %}
</div>

View file

@ -1,14 +1,14 @@
{% extends "layout.html" %}
{% block title %}QRPass - Bem-vindo{% endblock %}
{% block header_actions %}
<a href="{{ url_for('public_scanner') }}" data-cursor-target class="glass-effect text-primary dark:text-white rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center justify-center px-3 py-2 sm:px-4 text-xs sm:text-sm space-x-2">
<a href="{{ url_for('public.public_scanner') }}" data-cursor-target class="glass-effect text-primary dark:text-white rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center justify-center px-3 py-2 sm:px-4 text-xs sm:text-sm space-x-2">
<i class="fas fa-qrcode text-lg"></i>
<span>
<span class="sm:hidden">Escanear</span>
<span class="hidden sm:inline">Escanear Ingresso</span>
</span>
</a>
<a href="{{ url_for('login') }}" data-cursor-target class="bg-gradient-to-r from-primary to-secondary text-white rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center justify-center w-10 h-10 p-2 sm:w-auto sm:px-4 sm:py-2 sm:space-x-2">
<a href="{{ url_for('auth.login') }}" data-cursor-target class="bg-gradient-to-r from-primary to-secondary text-white rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center justify-center w-10 h-10 p-2 sm:w-auto sm:px-4 sm:py-2 sm:space-x-2">
<i class="fas fa-sign-in-alt text-lg"></i>
<span class="hidden sm:inline text-sm">Entrar</span>
</a>
@ -98,10 +98,10 @@
<!-- Container dos botões de ação -->
<div class="mt-10 flex flex-col items-center justify-center gap-4 px-4 sm:px-0 sm:flex-row">
<a href="{{ url_for('signup') }}" data-cursor-target class="bloom-button bg-gradient-to-r from-primary to-secondary text-white px-8 py-4 rounded-xl font-bold text-lg hover:shadow-2xl transition-all duration-200 hover-scale transform hover:-translate-y-1 w-full sm:w-auto">
<a href="{{ url_for('auth.signup') }}" data-cursor-target class="bloom-button bg-gradient-to-r from-primary to-secondary text-white px-8 py-4 rounded-xl font-bold text-lg hover:shadow-2xl transition-all duration-200 hover-scale transform hover:-translate-y-1 w-full sm:w-auto">
Comece Agora - É Grátis!
</a>
<a href="{{ url_for('my_invitations') }}" data-cursor-target class="glass-effect text-primary dark:text-white px-8 py-4 rounded-xl font-bold text-lg hover:shadow-2xl transition-all duration-200 hover-scale transform hover:-translate-y-1 w-full sm:w-auto">
<a href="{{ url_for('main.my_invitations') }}" data-cursor-target class="glass-effect text-primary dark:text-white px-8 py-4 rounded-xl font-bold text-lg hover:shadow-2xl transition-all duration-200 hover-scale transform hover:-translate-y-1 w-full sm:w-auto">
Ver Meus Ingressos
</a>
</div>

View file

@ -102,7 +102,7 @@
<header class="glass-header sticky top-0 z-50 shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex items-center justify-between">
<a href="{{ url_for('landing') }}" class="flex items-center space-x-3">
<a href="{{ url_for('main.landing') }}" class="flex items-center space-x-3">
<img src="{{ url_for('static', filename='logo.png') }}" alt="QRPass Logo" class="logo-image">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">QRPass</h1>
@ -110,9 +110,9 @@
</a>
<div class="flex items-center space-x-2 sm:space-x-4">
{% if current_user.is_authenticated %}
<a href="{{ url_for('dashboard') }}" class="hidden sm:inline-block text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light font-semibold transition-colors">Minhas Festas</a>
<a href="{{ url_for('my_invitations') }}" class="hidden sm:inline-block text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light font-semibold transition-colors">Meus Ingressos</a>
<a href="{{ url_for('logout') }}" class="hidden sm:inline-block text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light font-semibold transition-colors">Sair</a>
<a href="{{ url_for('main.dashboard') }}" class="hidden sm:inline-block text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light font-semibold transition-colors">Minhas Festas</a>
<a href="{{ url_for('main.my_invitations') }}" class="hidden sm:inline-block text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light font-semibold transition-colors">Meus Ingressos</a>
<a href="{{ url_for('auth.logout') }}" class="hidden sm:inline-block text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light font-semibold transition-colors">Sair</a>
{% endif %}
{% block header_actions %}
{% endblock %}
@ -146,9 +146,9 @@
<footer class="text-center py-8 px-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
<a href="{{ url_for('landing') }}" class="hover:text-primary">Início</a>
<a href="{{ url_for('main.landing') }}" class="hover:text-primary">Início</a>
<span class="mx-2">·</span>
<a href="{{ url_for('about') }}" class="hover:text-primary">Sobre</a>
<a href="{{ url_for('main.about') }}" class="hover:text-primary">Sobre</a>
<span class="mx-2">·</span>
<span>© {{ current_year }} QRPass. Todos os direitos reservados.</span>
</div>

View file

@ -3,7 +3,7 @@
{% block content %}
<div class="flex justify-center items-center py-12">
<div class="w-full max-w-md">
<form method="POST" action="{{ url_for('login', next=request.args.get('next')) }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-6">
<form method="POST" action="{{ url_for('auth.login', next=request.args.get('next')) }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-6">
<h2 class="text-center text-3xl font-bold text-gray-900 dark:text-white">Login</h2>
<div>
@ -24,7 +24,7 @@
</button>
</div>
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
Não tem uma conta? <a href="{{ url_for('signup', next=request.args.get('next')) }}" class="font-medium text-primary hover:text-secondary">Crie uma agora</a>
Não tem uma conta? <a href="{{ url_for('auth.signup', next=request.args.get('next')) }}" class="font-medium text-primary hover:text-secondary">Crie uma agora</a>
</p>
</form>
</div>

View file

@ -49,7 +49,7 @@
<div class="invitation-card glass-card rounded-2xl shadow-lg overflow-hidden flex flex-col justify-between">
{% if invitation.party.logo_filename %}
<div class="w-full h-40 bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
<img src="{{ url_for('serve_persistent_file', filename='party_logos/' + invitation.party.logo_filename) }}" alt="Logo da Festa {{ invitation.party.name }}" class="w-full h-full object-cover">
<img src="{{ url_for('main.serve_persistent_file', filename='party_logos/' + invitation.party.logo_filename) }}" alt="Logo da Festa {{ invitation.party.name }}" class="w-full h-full object-cover">
</div>
{% endif %}
<div class="p-6 flex-grow">
@ -85,7 +85,7 @@
<i class="fas fa-eye mr-2"></i>Mostrar Ingresso
</button>
{% elif invitation.payment_charge_id %}
<a href="{{ url_for('show_ticket_payment', payment_charge_id=invitation.payment_charge_id) }}"
<a href="{{ url_for('payment.show_ticket_payment', payment_charge_id=invitation.payment_charge_id) }}"
class="w-full text-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-lg font-bold text-white bg-gradient-to-r from-yellow-500 to-orange-500 hover:shadow-lg transition-all duration-200 hover-scale">
<i class="fas fa-money-check-alt mr-2"></i>Ver Pagamento
</a>

View file

@ -9,9 +9,9 @@
<meta property="og:title" content="{{ party.name }}">
<meta property="og:description" content="{{ party.public_description or 'Detalhes do evento em QRPass.' }}">
{% if party.logo_filename %}
<meta property="og:image" content="{{ url_for('serve_persistent_file', filename='party_logos/' + party.logo_filename, _external=True) }}">
<meta property="og:image" content="{{ url_for('main.serve_persistent_file', filename='party_logos/' + party.logo_filename, _external=True) }}">
{% endif %}
<meta property="og:url" content="{{ url_for('public_party_page', shareable_link_id=party.shareable_link_id, _external=True) }}">
<meta property="og:url" content="{{ url_for('public.public_party_page', shareable_link_id=party.shareable_link_id, _external=True) }}">
<meta property="og:type" content="website">
<!-- Scripts e Estilos -->
@ -101,7 +101,7 @@
{% if party.logo_filename %}
<div id="image-container" class="w-full h-48 bg-gray-200 dark:bg-gray-800">
<img id="party-logo-image" src="{{ url_for('serve_persistent_file', filename='party_logos/' + party.logo_filename) }}"
<img id="party-logo-image" src="{{ url_for('main.serve_persistent_file', filename='party_logos/' + party.logo_filename) }}"
alt="Logo do Evento"
class="w-full h-full object-cover"
crossorigin="anonymous">
@ -166,7 +166,7 @@
<!-- CORREÇÃO FINAL: Adicionado 'text-base' para aumentar o tamanho da fonte e melhorar o alinhamento vertical -->
{% if party.ticket_price > 0 and party.allow_public_purchase %}
<div class="mt-8 text-center">
<a href="{{ url_for('buy_ticket_public', party_id=party.id) }}"
<a href="{{ url_for('payment.buy_ticket_public', party_id=party.id) }}"
class="btn-action bg-gradient-to-r from-green-500 to-teal-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300 inline-flex items-center justify-center space-x-2 text-base">
<i class="fas fa-ticket-alt"></i>
<span>Comprar Ingresso (R$ {{ '%.2f' | format(party.ticket_price) }})</span>
@ -174,7 +174,7 @@
</div>
{% elif party.ticket_price == 0 and party.allow_public_purchase %}
<div class="mt-8 text-center">
<a href="{{ url_for('buy_ticket_public', party_id=party.id) }}"
<a href="{{ url_for('payment.buy_ticket_public', party_id=party.id) }}"
class="btn-action bg-gradient-to-r from-green-500 to-teal-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-1 transition-all duration-300 inline-flex items-center justify-center space-x-2 text-base">
<i class="fas fa-ticket-alt"></i>
<span>Obter Ingresso Gratuito</span>
@ -199,7 +199,7 @@
</div>
<footer class="text-center mt-6">
<a id="footer-link" href="{{ url_for('landing') }}" class="text-sm text-gray-500 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
<a id="footer-link" href="{{ url_for('main.landing') }}" class="text-sm text-gray-500 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
Criado com <strong>QRPass</strong>
</a>
</footer>

View file

@ -2,7 +2,7 @@
{% block title %}Check-in: {{ party.name }}{% endblock %}
{% block header_actions %}
<!-- MUDANÇA: Botão para esquecer o scanner -->
<a href="{{ url_for('forget_scanner') }}" class="glass-effect text-yellow-600 dark:text-yellow-400 px-4 py-2 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center space-x-2">
<a href="{{ url_for('public.forget_scanner') }}" class="glass-effect text-yellow-600 dark:text-yellow-400 px-4 py-2 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center space-x-2">
<i class="fas fa-sign-out-alt"></i>
<span>Trocar Festa</span>
</a>

View file

@ -5,7 +5,7 @@
{% block content %}
<div class="flex justify-center items-center py-12">
<div class="w-full max-w-md">
<form method="POST" action="{{ url_for('public_scanner') }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-6">
<form method="POST" action="{{ url_for('public.public_scanner') }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-6">
<h2 class="text-center text-3xl font-bold text-gray-900 dark:text-white">Acessar Scanner da Festa</h2>
<p class="text-center text-gray-600 dark:text-gray-400 !mt-2">
Digite o código de 6 dígitos da festa fornecido pelo organizador.
@ -22,7 +22,7 @@
</button>
</div>
<p class="text-center text-sm">
<a href="{{ url_for('landing') }}" class="text-gray-500 hover:text-primary">Voltar para a Página Inicial</a>
<a href="{{ url_for('main.landing') }}" class="text-gray-500 hover:text-primary">Voltar para a Página Inicial</a>
</p>
</form>
</div>

View file

@ -28,7 +28,7 @@
</div>
<div class="mt-4">
<a href="{{ url_for('dashboard') }}" class="text-primary hover:underline">Voltar ao Dashboard</a>
<a href="{{ url_for('main.dashboard') }}" class="text-primary hover:underline">Voltar ao Dashboard</a>
</div>
</div>
{% endblock %}
@ -72,7 +72,7 @@
info.innerHTML = '<i class="fas fa-check-circle text-success mr-2"></i> Sucesso!';
setTimeout(() => {
window.location.href = "{{ url_for('dashboard') }}";
window.location.href = "{{ url_for('main.dashboard') }}";
}, 3000); // Redireciona após 3 segundos
}
})

View file

@ -7,7 +7,7 @@
class="max-w-xl mx-auto glass-card rounded-2xl shadow-2xl p-8 hover-scale fade-in text-center"
data-payment-pending="{{ payment_pending | tojson }}"
data-pix-id="{{ guest.payment_charge_id or '' }}"
data-expired-redirect-url="{% if guest.purchase_link_id %}{{ url_for('buy_ticket_owner_invite', purchase_link_id=guest.purchase_link_id) }}{% elif guest.purchased_by_user_id %}{{ url_for('buy_ticket_public', party_id=guest.party.id) }}{% else %}{{ url_for('public_party_page', shareable_link_id=guest.party.shareable_link_id) }}{% endif %}"
data-expired-redirect-url="{% if guest.purchase_link_id %}{{ url_for('payment.buy_ticket_owner_invite', purchase_link_id=guest.purchase_link_id) }}{% elif guest.purchased_by_user_id %}{{ url_for('payment.buy_ticket_public', party_id=guest.party.id) }}{% else %}{{ url_for('public.public_party_page', shareable_link_id=guest.party.shareable_link_id) }}{% endif %}"
data-pix-creation-iso="{{ pix_creation_iso or '' }}">
{% if payment_pending %}
@ -49,7 +49,7 @@
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">Apresente este QR Code na entrada da festa.</p>
{% endif %}
<a href="{{ url_for('public_party_page', shareable_link_id=guest.party.shareable_link_id) }}" class="mt-8 inline-block text-primary hover:underline">Voltar para a página da festa</a>
<a href="{{ url_for('public.public_party_page', shareable_link_id=guest.party.shareable_link_id) }}" class="mt-8 inline-block text-primary hover:underline">Voltar para a página da festa</a>
</div>
<div id="toastContainer" class="fixed bottom-5 right-5 z-50 space-y-3 w-full max-w-xs"></div>

View file

@ -3,7 +3,7 @@
{% block content %}
<div class="flex justify-center items-center py-12">
<div class="w-full max-w-md">
<form method="POST" action="{{ url_for('signup', next=request.args.get('next')) }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-4">
<form method="POST" action="{{ url_for('auth.signup', next=request.args.get('next')) }}" class="glass-card rounded-2xl shadow-2xl p-8 space-y-4">
<h2 class="text-center text-3xl font-bold text-gray-900 dark:text-white">Criar Conta</h2>
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Nome de Usuário</label>
@ -30,7 +30,7 @@
</button>
</div>
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
Já tem uma conta? <a href="{{ url_for('login', next=request.args.get('next')) }}" class="font-medium text-primary hover:text-secondary">Faça login</a>
Já tem uma conta? <a href="{{ url_for('auth.login', next=request.args.get('next')) }}" class="font-medium text-primary hover:text-secondary">Faça login</a>
</p>
</form>
</div>

View file

@ -3,7 +3,7 @@
{% block title %}Upgrade para VIP - QRPass{% endblock %}
{% block header_actions %}
<a href="{{ url_for('dashboard') }}" class="glass-effect text-primary dark:text-white px-4 py-2 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center space-x-2">
<a href="{{ url_for('main.dashboard') }}" class="glass-effect text-primary dark:text-white px-4 py-2 rounded-xl font-semibold hover:shadow-lg transition-all duration-200 hover-scale flex items-center space-x-2">
<i class="fas fa-arrow-left"></i>
<span>Dashboard</span>
</a>
@ -51,7 +51,7 @@
<!-- MUDANÇA: Botão agora é um link para a página de completar perfil -->
<div class="mt-8">
<a href="{{ url_for('complete_profile') }}"
<a href="{{ url_for('main.complete_profile') }}"
class="w-full inline-block bg-gradient-to-r from-success to-green-600 text-white px-8 py-4 rounded-xl font-bold text-lg hover:shadow-2xl transition-all duration-200 hover-scale transform hover:-translate-y-1">
<i class="fab fa-pix"></i>
<span>Pagar com PIX e Virar VIP</span>

381
app/utils.py Normal file
View file

@ -0,0 +1,381 @@
import os
import io
import base64
import colorsys
import urllib.parse
import random
import string
import requests
import qrcode
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont as PILImageFont, ImageOps
from flask import current_app
from fpdf import FPDF, XPos, YPos
from app.config import Config
from app.extensions import db
# Import models inside functions or use strings to avoid circular imports?
# Usually strictly typing requires models. But Python is dynamic.
# I will import models inside functions where necessary or use argument passing style.
def save_base64_as_png(base64_string, charge_id):
"""Decodifica uma string base64 e a salva como um arquivo PNG."""
if not base64_string or not charge_id:
return None
try:
if "data:image/png;base64," in base64_string:
clean_base64_string = base64_string.split(",")[1]
else:
clean_base64_string = base64_string
img_data = base64.b64decode(clean_base64_string)
img = Image.open(io.BytesIO(img_data))
filename = f"{charge_id}.png"
save_path = os.path.join(Config.PAYMENT_QRCODES_SAVE_PATH, filename)
img.save(save_path, 'PNG')
return filename
except Exception as e:
current_app.logger.error(f"Erro ao salvar imagem base64 para charge_id {charge_id}: {e}")
return None
def delete_pix_qr_code_file(guest):
"""Deleta o arquivo de imagem do QR Code do PIX associado a um convidado."""
if guest and guest.pix_qr_code_filename:
try:
file_path = os.path.join(Config.PAYMENT_QRCODES_SAVE_PATH, guest.pix_qr_code_filename)
if os.path.exists(file_path):
os.remove(file_path)
current_app.logger.info(f"Arquivo QR Code PIX {guest.pix_qr_code_filename} deletado com sucesso.")
else:
current_app.logger.warning(f"Arquivo QR Code PIX {guest.pix_qr_code_filename} não encontrado para deleção.")
except Exception as e:
current_app.logger.error(f"Erro ao deletar o arquivo QR Code PIX {guest.pix_qr_code_filename}: {e}")
def get_vibrant_colors(pil_img, num_colors=2):
img = pil_img.copy().convert("RGBA")
img.thumbnail((100, 100))
all_colors = img.getcolors(img.size[0] * img.size[1])
if not all_colors: return []
vibrant_candidates = []
for count, rgba in all_colors:
r, g, b, a = rgba
if a < 128: continue
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
if s > 0.35 and 0.3 < v < 0.95:
score = (s * 0.8) + (count / (img.size[0] * img.size[1]) * 0.2)
vibrant_candidates.append({'color': (r, g, b), 'score': score})
if not vibrant_candidates: return []
vibrant_candidates.sort(key=lambda x: x['score'], reverse=True)
return [c['color'] for c in vibrant_candidates[:num_colors]]
def generate_qr_code_image(qr_data, guest_name, party, output_format='PNG', font_override=None):
try:
CARD_WIDTH, PADDING, SPACING, LOGO_ASPECT_RATIO = 600, 40, 25, 2 / 1
GRADIENT_START, GRADIENT_END = (15, 23, 42), (59, 130, 246)
CARD_BG_COLOR, TEXT_COLOR, FOOTER_COLOR = (255, 255, 255, 235), (15, 23, 42), (100, 116, 139)
GUEST_NAME_COLOR = (37, 99, 235)
try:
# Use a fonte selecionada para a festa
selected_font_name = font_override if font_override else (party.invite_font if party.invite_font else 'Montserrat-Regular')
# Assuming 'static' is in current_app.root_path (app/static)
font_path = os.path.join(current_app.root_path, 'static', 'fonts', f"{selected_font_name}.ttf")
current_app.logger.info(f"Attempting to load font from: {font_path}")
font_party_name = PILImageFont.truetype(font_path, 52)
font_guest_name = PILImageFont.truetype(font_path, 36)
font_footer_path = os.path.join(current_app.root_path, "static", "fonts", "Montserrat-Regular.ttf")
font_footer = PILImageFont.truetype(font_footer_path, 14) # Footer always Montserrat
except IOError:
current_app.logger.warning(f"Fonte personalizada {selected_font_name} não encontrada. 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:
logo_path = os.path.join(Config.PARTY_LOGOS_SAVE_PATH, party.logo_filename)
if os.path.exists(logo_path):
logo_height = int(CARD_WIDTH / LOGO_ASPECT_RATIO)
logo_img_raw = Image.open(logo_path).convert("RGBA")
try:
vibrant_colors = get_vibrant_colors(logo_img_raw, num_colors=2)
if len(vibrant_colors) >= 1:
main_color = vibrant_colors[0]
GUEST_NAME_COLOR, GRADIENT_END = main_color, main_color
GRADIENT_START = tuple(int(c * 0.5) for c in main_color)
except Exception as e:
current_app.logger.warning(f"Não foi possível extrair cores: {e}.")
qr_instance = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=8, border=2)
qr_instance.add_data(qr_data)
qr_instance.make(fit=True)
img_qr = qr_instance.make_image(fill_color="black", back_color="white").convert('RGB')
content_height = PADDING + font_party_name.getbbox(party.name)[3] + SPACING + img_qr.size[0] + SPACING + font_guest_name.getbbox(guest_name)[3] + SPACING + font_footer.getbbox("Feito com QRPass")[3] + PADDING
card_height = logo_height + content_height
canvas_height, canvas_width = card_height + PADDING * 2, CARD_WIDTH + PADDING * 2
canvas = Image.new('RGB', (canvas_width, canvas_height), GRADIENT_START)
draw = ImageDraw.Draw(canvas)
for y in range(canvas_height):
ratio = y / canvas_height
color_tuple = tuple(int(start * (1 - ratio) + end * ratio) for start, end in zip(GRADIENT_START, GRADIENT_END))
draw.line([(0, y), (canvas_width, y)], fill=color_tuple)
card_img = Image.new('RGBA', (CARD_WIDTH, card_height), (0,0,0,0))
ImageDraw.Draw(card_img).rounded_rectangle((0, 0, CARD_WIDTH, card_height), radius=30, fill=CARD_BG_COLOR)
if logo_img_raw:
logo_fitted = ImageOps.fit(logo_img_raw, (CARD_WIDTH, logo_height), Image.Resampling.LANCZOS)
mask = Image.new('L', logo_fitted.size, 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((0, 0, *logo_fitted.size), radius=30, fill=255)
mask_draw.rectangle((0, 30, *logo_fitted.size), fill=255)
card_img.paste(logo_fitted, (0, 0), mask)
content_draw = ImageDraw.Draw(card_img)
current_y = logo_height + PADDING
items = [(party.name, font_party_name, TEXT_COLOR), (img_qr, None, None), (guest_name, font_guest_name, GUEST_NAME_COLOR), ("Feito com QRPass", font_footer, FOOTER_COLOR)]
for item, font, color in items:
if isinstance(item, Image.Image):
card_img.paste(item, ((CARD_WIDTH - item.width) // 2, int(current_y)))
current_y += item.height + SPACING
else:
bbox = content_draw.textbbox((0, 0), item, font=font)
content_draw.text(((CARD_WIDTH - bbox[2]) / 2, current_y), item, font=font, fill=color)
current_y += bbox[3] + SPACING
canvas.paste(card_img, ((canvas_width - CARD_WIDTH) // 2, (canvas_height - card_height) // 2), card_img)
img_io = io.BytesIO()
canvas.save(img_io, format=output_format)
img_io.seek(0)
return img_io
except Exception as e:
current_app.logger.error(f"Erro ao gerar imagem do QR Code: {e}")
return None
def generate_unique_code(model, field, length=8, chars=string.ascii_uppercase + string.digits):
while True:
code = ''.join(random.choices(chars, k=length))
if not db.session.query(model).filter(getattr(model, field) == code).first():
return code
def create_abacatepay_charge(amount, description, customer_info):
url = "https://api.abacatepay.com/v1/pixQrCode/create"
headers = {"Authorization": f"Bearer {Config.ABACATE_API_KEY}", "Content-Type": "application/json"}
payload = {
"amount": int(amount * 100),
"expiresIn": 3600,
"description": description,
"customer": customer_info
}
current_app.logger.info(f"Chamando AbacatePay create com payload: {payload}")
response = requests.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json().get('data', {})
if not all([data.get('brCodeBase64'), data.get('brCode'), data.get('id')]):
raise ValueError("Resposta da API de PIX incompleta ou sem dados do QR Code.")
return data
def check_abacatepay_status(charge_id):
url = "https://api.abacatepay.com/v1/pixQrCode/check"
headers = {"Authorization": f"Bearer {Config.ABACATE_API_KEY}"}
params = {"id": charge_id}
current_app.logger.info(f"Chamando AbacatePay check para charge_id: {charge_id}")
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
data = response.json().get('data', {})
if not data.get('status'):
raise ValueError("Resposta da API de PIX incompleta (status ausente).")
return data
def generate_google_maps_url(location_query):
if not location_query: return None
base_url = "https://www.google.com/maps/search/?api=1&query="
return base_url + urllib.parse.quote_plus(location_query)
def get_party_stats_data(party_id):
# Avoid circular import
from app.models import Guest, Party
total_invited = Guest.query.filter_by(party_id=party_id).count()
entered_count = Guest.query.filter_by(party_id=party_id, entered=True).count()
not_entered_count = total_invited - entered_count
# This might fail if called without valid context or session, but should be fine in requests
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
total_paid_tickets = Guest.query.filter_by(party_id=party_id, payment_status='paid').count()
return {
'total_invited': total_invited,
'total_paid_tickets': total_paid_tickets,
'entered_count': entered_count,
'not_entered_count': not_entered_count,
'percentage_entered': round((entered_count / total_invited) * 100, 2) if total_invited > 0 else 0.0,
'total_revenue': total_revenue
}
def get_all_guests_for_export(party_id):
from app.models import Guest
return Guest.query.filter_by(party_id=party_id).all()
class PDF(FPDF):
def __init__(self, orientation='P', unit='mm', format='A4', party_name=''):
super().__init__(orientation, unit, format)
self.montserrat_font_path = Config.FONT_PATH
self.font_name = 'Montserrat'
self.default_font = 'Helvetica'
self.current_font_family = self.default_font
self.party_name = party_name
if os.path.exists(self.montserrat_font_path):
try:
self.add_font(self.font_name, '', self.montserrat_font_path)
self.add_font(self.font_name, 'B', self.montserrat_font_path)
self.add_font(self.font_name, 'I', self.montserrat_font_path)
self.current_font_family = self.font_name
except Exception:
current_app.logger.warning(f"Erro ao carregar fonte Montserrat de {self.montserrat_font_path}. Usando fonte padrão.")
pass
def header(self):
self.set_font(self.current_font_family, 'B', 16)
title, title_w = f'Relatório do Evento: {self.party_name}', self.get_string_width(f'Relatório do Evento: {self.party_name}')
self.set_x((self.w - title_w) / 2)
self.cell(title_w, 10, title, border=0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
self.ln(5)
def footer(self):
self.set_y(-15)
self.set_font(self.current_font_family, 'I', 8)
# Adicionar número da página
self.cell(0, 10, f'Página {self.page_no()}/{{nb}}', border=0, new_x=XPos.RIGHT, new_y=YPos.TOP, align='C')
# Adicionar data de geração
self.set_y(-10)
self.set_font(self.current_font_family, '', 7)
self.cell(0, 5, f'Gerado em: {datetime.now(Config.BRASILIA_TZ).strftime("%d/%m/%Y %H:%M:%S")}', border=0, align='R')
def draw_stats_summary(self, stats_data):
self.set_font(self.current_font_family, 'B', 11)
self.cell(0, 10, "Estatísticas do evento", border=0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
self.ln(1)
col_width_stat = (self.w - self.l_margin - self.r_margin) / 4.5
stat_box_height = 14
line_height_label, line_height_value = 5, 6
padding_top_box = (stat_box_height - (line_height_label + line_height_value)) / 2
current_x, base_y = self.l_margin, self.get_y()
stats_items = [
("Convidados (Total):", str(stats_data['total_invited'])),
("Ingressos Pagos:", str(stats_data['total_paid_tickets'])),
("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}")
]
for i, (label, value) in enumerate(stats_items):
self.set_fill_color(240, 240, 240)
self.rect(current_x, base_y, col_width_stat, stat_box_height, 'F')
self.set_draw_color(200, 200, 200)
self.rect(current_x, base_y, col_width_stat, stat_box_height, 'D')
y_pos_label = base_y + padding_top_box
self.set_xy(current_x, y_pos_label)
self.set_text_color(0, 0, 0)
self.set_font(self.current_font_family, '', 8.5)
self.cell(col_width_stat, line_height_label, label, border=0, align='C')
y_pos_value = y_pos_label + line_height_label
self.set_xy(current_x, y_pos_value)
self.set_text_color(0, 100, 200)
if label == "Faturamento Total:":
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)
current_x += col_width_stat
if (i + 1) % 4 == 0 and (i + 1) < len(stats_items):
current_x = self.l_margin
base_y += stat_box_height + 5
self.ln(5)
self.set_y(base_y + stat_box_height)
self.ln(5)
def draw_pie_chart(self, stats_data, y_offset, chart_size_mm=90):
labels, sizes, colors = ('Entraram', 'Não Entraram'), [stats_data['entered_count'], stats_data['not_entered_count']], ['#4BC0C0', '#FF6384']
if sum(sizes) == 0:
self.set_font(self.current_font_family, 'I', 10)
self.cell(0, 10, "Sem dados de comparecimento de ingressos pagos para o gráfico.", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
self.ln(5)
return
fig, ax = plt.subplots(figsize=(chart_size_mm / 25.4, chart_size_mm / 25.4), dpi=100)
wedges, _, autotexts = ax.pie(sizes, explode=(0.05, 0) if sizes[0] > 0 else (0,0), labels=None, colors=colors, autopct='%1.1f%%', shadow=False, startangle=90)
for autotext in autotexts: autotext.set_color('white'); autotext.set_fontweight('bold')
ax.axis('equal')
ax.legend(wedges, [f'{l} ({s})' for l, s in zip(labels, sizes)], loc="center left", bbox_to_anchor=(1, 0, 0.5, 1))
img_buffer = io.BytesIO()
plt.savefig(img_buffer, format='png', bbox_inches='tight', transparent=True, pad_inches=0)
plt.close(fig)
self.image(img_buffer, x=(self.w - chart_size_mm) / 2, y=self.get_y(), w=chart_size_mm)
self.set_y(self.get_y() + chart_size_mm + 5)
def chapter_body(self, guests_data_table):
self.set_font(self.current_font_family, 'B', 11)
self.cell(0, 10, "Lista de Convidados", border=0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
self.ln(1)
self.set_font(self.current_font_family, 'B', 9)
self.set_fill_color(230, 230, 230)
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
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)
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)
self.cell(col_widths["added_by"], 8, 'Adicionado Por', 1, 1, 'C', 1)
self.set_font(self.current_font_family, '', 8.5)
for i, guest_obj in enumerate(guests_data_table):
self.set_x(start_x)
fill = i % 2 == 0
payment_status_display = {
'not_applicable': 'Gratuito',
'pending_owner_invite': 'Aguard. Pgto.',
'pending': 'Aguard. Pgto.',
'paid': 'Pago',
'failed': 'Falhou'
}.get(guest_obj.payment_status, guest_obj.payment_status)
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)
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)
self.cell(col_widths["added_by"], 7, added_by_name, 1, 1, 'C', fill)

6
app/views/__init__.py Normal file
View file

@ -0,0 +1,6 @@
from flask import abort
from flask_login import current_user
def check_collaboration_permission(party):
if party.user_id != current_user.id and current_user not in party.collaborators:
abort(403)

57
app/views/auth.py Normal file
View file

@ -0,0 +1,57 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app.models import User
from app.extensions import db
bp = Blueprint('auth', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
next_page = request.args.get('next')
if request.method == 'POST':
user = User.query.filter_by(email=request.form.get('email')).first()
if user and user.check_password(request.form.get('password')):
login_user(user, remember=True)
return redirect(next_page or url_for('main.dashboard'))
flash('Email ou senha inválidos.', 'danger')
return render_template('login.html')
@bp.route('/signup', methods=['GET', 'POST'])
def signup():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
next_page = request.args.get('next')
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
if User.query.filter_by(username=username).first():
flash('Este nome de usuário já existe.', 'danger')
return redirect(url_for('auth.signup', next=next_page))
if User.query.filter_by(email=email).first():
flash('Este email já está em uso.', 'danger')
return redirect(url_for('auth.signup', next=next_page))
new_user = User(username=username, email=email)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit()
login_user(new_user)
flash('Conta criada com sucesso!', 'success')
return redirect(next_page or url_for('main.dashboard'))
return render_template('signup.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('main.landing'))

76
app/views/main.py Normal file
View file

@ -0,0 +1,76 @@
import os
from flask import Blueprint, render_template, redirect, url_for, flash, request, send_from_directory, abort, current_app
from flask_login import login_required, current_user
from app.models import User, Guest, Party
from app.extensions import db
from app.config import Config
bp = Blueprint('main', __name__)
@bp.route('/')
def landing():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return render_template('landing.html')
@bp.route('/about')
def about():
return render_template('about.html')
@bp.route('/dashboard')
@login_required
def dashboard():
owned_parties = current_user.parties
collaborated_parties = current_user.collaborations
return render_template('dashboard.html', owned_parties=owned_parties, collaborated_parties=collaborated_parties)
@bp.route('/my_invitations')
@login_required
def my_invitations():
"""
Displays all tickets/invitations purchased by the currently logged-in user.
"""
# Assuming user.id relates to Guest.purchased_by_user_id
invitations = Guest.query.filter_by(purchased_by_user_id=current_user.id).order_by(Guest.id.desc()).all()
return render_template('my_invitations.html', invitations=invitations)
@bp.route('/complete_profile', methods=['GET', 'POST'])
@login_required
def complete_profile():
if request.method == 'POST':
tax_id = request.form.get('tax_id', '').strip()
cellphone = request.form.get('cellphone', '').strip()
if not tax_id or not cellphone:
flash("CPF e Telefone são obrigatórios.", 'danger')
return render_template('complete_profile.html')
if not tax_id.isdigit() or len(tax_id) not in [11, 14]:
flash("CPF/CNPJ inválido. Digite apenas números.", 'danger')
return render_template('complete_profile.html')
if not cellphone.isdigit() or len(cellphone) not in [10, 11, 12, 13]:
flash("Telefone inválido. Digite apenas números.", 'danger')
return render_template('complete_profile.html')
if User.query.filter(User.tax_id == tax_id, User.id != current_user.id).first():
flash("Este CPF/CNPJ já está associado a outra conta.", 'danger')
return render_template('complete_profile.html')
current_user.tax_id, current_user.cellphone = tax_id, cellphone
db.session.commit()
flash("Perfil atualizado! Agora você já pode comprar ingressos.", 'success')
next_page = request.args.get('next')
return redirect(next_page or url_for('main.dashboard'))
return render_template('complete_profile.html')
@bp.route('/persistent/<path:filename>')
def serve_persistent_file(filename):
"""
Serve arquivos da pasta de armazenamento persistente.
"""
try:
return send_from_directory(Config.STORAGE_BASE_PATH, filename)
except Exception as e:
current_app.logger.error(f"Erro ao servir arquivo persistente {filename}: {e}")
abort(404)

468
app/views/party.py Normal file
View file

@ -0,0 +1,468 @@
import os
import uuid
import secrets
import string
import csv
import io
from datetime import datetime
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, abort, Response
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app.models import Party, Guest, User
from app.extensions import db
from app.config import Config
from app.utils import (
generate_unique_code, generate_qr_code_image, get_party_stats_data,
PDF, get_all_guests_for_export, delete_pix_qr_code_file
)
from app.views import check_collaboration_permission
bp = Blueprint('party', __name__)
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS
@bp.route('/create', methods=['POST'])
@login_required
def create_party():
party_name = request.form.get('party_name')
if not party_name or not party_name.strip():
flash('O nome da festa não pode ser vazio.', 'danger')
return redirect(url_for('main.dashboard'))
new_party = Party(name=party_name, owner=current_user, party_code=generate_unique_code(Party, 'party_code', 6), share_code=generate_unique_code(Party, 'share_code', 8), shareable_link_id=secrets.token_urlsafe(12))
db.session.add(new_party)
db.session.commit()
flash(f'Festa "{party_name}" criada!', 'success')
return redirect(url_for('party.party_manager', party_id=new_party.id))
@bp.route('/add_collaboration', methods=['POST'])
@login_required
def add_collaboration():
share_code = request.form.get('share_code')
party = Party.query.filter_by(share_code=share_code).first()
if not party:
flash('Código de compartilhamento inválido.', 'danger')
elif party.owner == current_user:
flash('Você não pode se adicionar como colaborador da sua própria festa.', 'warning')
elif current_user in party.collaborators:
flash('Você já é um colaborador desta festa.', 'info')
else:
party.collaborators.append(current_user)
db.session.commit()
flash(f'Você agora é um colaborador da festa "{party.name}"!', 'success')
return redirect(url_for('main.dashboard'))
@bp.route('/<int:party_id>')
@login_required
def party_manager(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
return render_template('party_manager.html', party=party)
@bp.route('/<int:party_id>/delete', methods=['POST'])
@login_required
def delete_party(party_id):
party = db.session.get(Party, party_id) or abort(404)
if party.user_id != current_user.id:
flash("Apenas o dono da festa pode deletá-la.", "danger")
return redirect(url_for('main.dashboard'))
if party.logo_filename:
try: os.remove(os.path.join(Config.PARTY_LOGOS_SAVE_PATH, party.logo_filename))
except OSError: pass
db.session.delete(party)
db.session.commit()
flash(f'A festa "{party.name}" foi removida.', 'success')
return redirect(url_for('main.dashboard'))
@bp.route('/<int:party_id>/upload_logo', methods=['POST'])
@login_required
def upload_logo(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
if 'party_logo' not in request.files:
return jsonify({'success': False, 'message': 'Nenhum arquivo selecionado.'}), 400
file = request.files['party_logo']
if file.filename == '':
return jsonify({'success': False, 'message': 'Nenhum arquivo selecionado.'}), 400
if file and allowed_file(file.filename):
if party.logo_filename:
try:
os.remove(os.path.join(Config.PARTY_LOGOS_SAVE_PATH, party.logo_filename))
except OSError:
pass
filename = secure_filename(file.filename)
unique_id = uuid.uuid4().hex
ext = filename.rsplit('.', 1)[1].lower()
new_filename = f"{party_id}_{unique_id}.{ext}"
file.save(os.path.join(Config.PARTY_LOGOS_SAVE_PATH, new_filename))
party.logo_filename = new_filename
db.session.commit()
logo_url = url_for('main.serve_persistent_file', filename=f'{Config.PARTY_LOGOS_FOLDER_NAME}/{new_filename}')
return jsonify({'success': True, 'message': 'Logo da festa atualizado!', 'logo_url': logo_url})
else:
return jsonify({'success': False, 'message': 'Tipo de arquivo inválido.'}), 400
@bp.route('/<int:party_id>/update_details', methods=['POST'])
@login_required
def update_party_details(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
data = request.get_json()
new_party_name = data.get('party_name', '').strip()
if not new_party_name:
return jsonify({'success': False, 'message': 'O nome da festa não pode ser vazio.'}), 400
party.name = new_party_name
party.public_description = data.get('public_description')
party.location = data.get('location')
try:
ticket_price_str = str(data.get('ticket_price', '0.0')).replace(',', '.')
party.ticket_price = float(ticket_price_str)
if party.ticket_price < 0:
raise ValueError("Preço do ingresso não pode ser negativo.")
except (ValueError, TypeError):
return jsonify({'success': False, 'message': 'Preço do ingresso inválido. Use um número (ex: 50.00).'}), 400
party.allow_public_purchase = bool(data.get('allow_public_purchase'))
event_date_str = data.get('event_date')
event_time_str = data.get('event_time')
if event_date_str:
try:
party.event_date = datetime.strptime(event_date_str, '%Y-%m-%d').date()
except ValueError:
return jsonify({'success': False, 'message': 'Formato de data inválido. Use AAAA-MM-DD.'}), 400
else:
party.event_date = None
if event_time_str:
try:
party.event_time = datetime.strptime(event_time_str, '%H:%M').time()
except ValueError:
return jsonify({'success': False, 'message': 'Formato de hora inválido. Use HH:MM.'}), 400
else:
party.event_time = None
db.session.commit()
return jsonify({'success': True, 'message': 'Informações da festa atualizadas!', 'party_name': party.name})
@bp.route('/<int:party_id>/toggle_guest_count', methods=['POST'])
@login_required
def toggle_guest_count(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
party.show_guest_count = not party.show_guest_count
db.session.commit()
return jsonify({
'success': True,
'message': 'Visibilidade da contagem de convidados atualizada.',
'show_guest_count': party.show_guest_count
})
# /qr/<hash>.png was separate in app.py. I'll include it here but url_prefix of blueprint is /party probably?
# No, if I set url_prefix='/party', then it becomes /party/qr/... which breaks existing links logic if they expected /qr/
# But app.py had `@app.route('/qr/<string:qr_hash>.png')`.
# I should register this route on the blueprint but I might need to alias it or handle prefix.
# If I make the blueprint prefix blank or '/', then I can define /qr/... here.
# But other routes are /party/...
# I can define routes as `/party/...` AND `/qr/...` in the same blueprint, IF the blueprint has NO prefix (or '/').
# I will register the 'party' blueprint with `url_prefix='/party'` for clean structure, but for `/qr/` I might need a separate route or put it in `main` or `public`.
# `serve_qr_code` seems public.
# However, logic requires checking guest info.
# AND `guest.qr_image_url` property in models.py uses `url_for('party.serve_qr_code')`.
# If I change the URL structure, existing QR codes (if they encoded the URL directly... wait, QR code encodes the HASH usually?
# In `app.py`: `qr_instance.add_data(qr_data)`. `qr_data` is passed as `guest.qr_hash`.
# The QR code contains the HASH.
# The scanner scans the HASH.
# The URL `/qr/<hash>.png` is for DISPLAYING the QR code image on the screen (the png file), NOT what's INSIDE the QR code.
# The `serve_qr_code` route returns the IMAGE of the QR code.
# So I can change this URL path safely as long as `url_for` is updated, which I did in `models.py`.
# So I will keep `serve_qr_code` inside `party` blueprint.
# If 'party' blueprint has prefix `/party`, the URL will be `/party/qr/<hash>.png`.
# This is fine.
@bp.route('/qr/<string:qr_hash>.png')
def serve_qr_code(qr_hash):
guest = Guest.query.filter_by(qr_hash=qr_hash).first_or_404()
if guest.payment_status == 'paid' or guest.payment_status == 'not_applicable':
img_buffer = generate_qr_code_image(guest.qr_hash, guest.name, guest.party)
if img_buffer:
return Response(img_buffer, mimetype='image/png')
else:
abort(500, description="Falha ao gerar a imagem do QR Code.")
else:
flash("O ingresso ainda não foi pago.", "warning")
if guest.payment_charge_id:
return redirect(url_for('payment.show_ticket_payment', payment_charge_id=guest.payment_charge_id))
elif guest.purchase_link_id:
return redirect(url_for('payment.buy_ticket_owner_invite', purchase_link_id=guest.purchase_link_id))
else:
abort(403, description="Ingresso não disponível.")
@bp.route('/api/<int:party_id>/preview_invite', methods=['GET'])
@login_required
def preview_invite(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
test_guest_name = "Nome do Convidado Teste"
test_qr_hash = "PREVIEW_QR_HASH"
font_to_use = request.args.get('font_name', party.invite_font)
img_buffer = generate_qr_code_image(test_qr_hash, test_guest_name, party, font_override=font_to_use)
if img_buffer:
return Response(img_buffer, mimetype='image/png')
else:
abort(500, description="Falha ao gerar a imagem de pré-visualização do convite.")
@bp.route('/api/<int:party_id>/stats', methods=['GET'])
def get_stats(party_id):
return jsonify(get_party_stats_data(party_id))
@bp.route('/api/<int:party_id>/font_selection', methods=['GET', 'POST'])
@login_required
def handle_font_selection(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
if request.method == 'GET':
return jsonify({'selected_font': party.invite_font})
elif request.method == 'POST':
data = request.get_json()
font_name = data.get('font_name')
if not font_name:
return jsonify({'message': 'Nome da fonte não fornecido.'}), 400
allowed_fonts = ['Birthstone-Regular', 'Ephesis-Regular', 'Montserrat-Regular', 'Radley-Regular', 'BebasNeue-Regular', 'Cinzel-Regular', 'JosefinSans-Regular']
if font_name not in allowed_fonts:
return jsonify({'message': 'Fonte selecionada inválida.'}), 400
party.invite_font = font_name
db.session.commit()
return jsonify({'message': 'Fonte do convite atualizada com sucesso!', 'selected_font': party.invite_font})
@bp.route('/api/<int:party_id>/guests', methods=['GET', 'POST'])
@login_required
def handle_guests(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
if request.method == 'POST':
data = request.get_json()
name = data.get('name', '').strip()
if not name: return jsonify({'error': 'O nome do convidado é obrigatório.'}), 400
new_guest = Guest(
name=name,
qr_hash=generate_unique_code(Guest, 'qr_hash', 32, string.ascii_letters + string.digits),
party_id=party_id,
added_by_user_id=current_user.id,
payment_status='not_applicable',
purchase_price=0.0
)
db.session.add(new_guest)
db.session.commit()
return jsonify({
'id': new_guest.id, 'name': new_guest.name, 'qr_hash': new_guest.qr_hash, 'entered': new_guest.entered,
'qr_image_url': new_guest.qr_image_url, 'check_in_time': new_guest.get_check_in_time_str(),
'added_by': new_guest.adder.username, 'payment_status': new_guest.payment_status
}), 201
page, per_page, search_term = request.args.get('page', 1, type=int), request.args.get('per_page', 50, type=int), request.args.get('search')
sort_by, sort_dir = request.args.get('sort_by', 'name'), request.args.get('sort_dir', 'asc')
query = Guest.query.filter_by(party_id=party_id)
if search_term: query = query.filter(Guest.name.ilike(f'%{search_term.strip()}%'))
sort_columns = {'name': Guest.name, 'entered': Guest.entered, 'check_in_time': Guest.check_in_time, 'added_by': User.username, 'payment_status': Guest.payment_status}
if sort_by == 'added_by':
query = query.join(User, Guest.added_by_user_id == User.id)
order_column = sort_columns.get(sort_by, Guest.name)
order_func = order_column.desc() if sort_dir == 'desc' else order_column.asc()
pagination = query.order_by(order_func.nullslast()).paginate(page=page, per_page=per_page, error_out=False)
guests_data = []
for g in pagination.items:
purchased_by_name = g.purchaser.username if g.purchaser else None
guests_data.append({
'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
})
return jsonify({
'guests': guests_data,
'pagination': {
'page': pagination.page, 'per_page': pagination.per_page, 'total_pages': pagination.pages,
'total_items': pagination.total, 'has_next': pagination.has_next, 'has_prev': pagination.has_prev
}
})
@bp.route('/api/<int:party_id>/guests/<qr_hash>/enter', methods=['POST'])
def mark_entered(party_id, qr_hash):
guest = Guest.query.filter_by(qr_hash=qr_hash, party_id=party_id).first()
if not guest: return jsonify({'error': 'QR Code inválido para esta festa'}), 404
if guest.party.ticket_price > 0 and guest.payment_status != 'paid':
return jsonify({
'id': guest.id, 'name': guest.name, 'qr_hash': guest.qr_hash, 'entered': False,
'message': f'Ingresso de {guest.name} não foi pago (Status: {guest.payment_status}).',
'is_new_entry': False, 'check_in_time': guest.get_check_in_time_str(), 'error_type': 'payment_pending'
}), 403
if guest.entered:
return jsonify({'id': guest.id, 'name': guest.name, 'qr_hash': guest.qr_hash, 'entered': True, 'message': f'{guest.name} já entrou às {guest.get_check_in_time_str()}.', 'is_new_entry': False, 'check_in_time': guest.get_check_in_time_str()})
guest.entered, guest.check_in_time = True, datetime.now(Config.BRASILIA_TZ)
db.session.commit()
return jsonify({'id': guest.id, 'name': guest.name, 'qr_hash': guest.qr_hash, 'entered': True, 'message': f'Entrada liberada! Bem-vindo(a), {guest.name}!', 'is_new_entry': True, 'check_in_time': guest.get_check_in_time_str()})
@bp.route('/api/<int:party_id>/checkin_data', methods=['GET'])
@login_required
def get_checkin_data(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
check_ins = db.session.query(Guest.check_in_time).filter(
Guest.party_id == party_id,
Guest.entered == True,
Guest.check_in_time.isnot(None)
).all()
timestamps = [
check_in_time[0].astimezone(Config.BRASILIA_TZ).isoformat()
for check_in_time in check_ins
]
return jsonify({'check_ins': timestamps})
@bp.route('/api/<int:party_id>/guests/<qr_hash>/edit', methods=['PUT'])
@login_required
def edit_guest_name(party_id, qr_hash):
guest = Guest.query.filter_by(qr_hash=qr_hash, party_id=party_id).first_or_404()
check_collaboration_permission(guest.party)
data = request.get_json()
new_name = data.get('name', '').strip()
guest.name = new_name
db.session.commit()
return jsonify({'id': guest.id, 'name': guest.name, 'qr_hash': guest.qr_hash, 'entered': guest.entered, 'qr_image_url': guest.qr_image_url, 'check_in_time': guest.get_check_in_time_str(), 'message': f'Nome do convidado atualizado.'}), 200
@bp.route('/api/<int:party_id>/guests/<qr_hash>/toggle_entry', methods=['PUT'])
@login_required
def toggle_entry_manually(party_id, qr_hash):
guest = Guest.query.filter_by(qr_hash=qr_hash, party_id=party_id).first_or_404()
check_collaboration_permission(guest.party)
if not guest.entered and guest.payment_status != 'paid' and guest.party.ticket_price > 0:
pass
guest.entered = not guest.entered
guest.check_in_time = datetime.now(Config.BRASILIA_TZ) if guest.entered else None
action = "marcado(a) como PRESENTE" if guest.entered else "marcado(a) como AUSENTE"
db.session.commit()
return jsonify({'id': guest.id, 'name': guest.name, 'qr_hash': guest.qr_hash, 'entered': guest.entered, 'message': f'Status de {guest.name} alterado: {action}.', 'check_in_time': guest.get_check_in_time_str()})
@bp.route('/api/<int:party_id>/guests/<qr_hash>', methods=['DELETE'])
@login_required
def delete_guest(party_id, qr_hash):
guest = Guest.query.filter_by(qr_hash=qr_hash, party_id=party_id).first_or_404()
check_collaboration_permission(guest.party)
guest_name = guest.name
if guest.payment_status == 'paid':
flash(f"Atenção: Você está deletando um ingresso que JÁ FOI PAGO por {guest_name}.", "warning")
delete_pix_qr_code_file(guest)
db.session.delete(guest)
db.session.commit()
return jsonify({'message': f'Convidado {guest_name} removido com sucesso.'}), 200
@bp.route('/api/<int:party_id>/export/csv')
@login_required
def export_guests_csv(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
guests_data = get_all_guests_for_export(party_id)
si = io.StringIO()
cw = csv.writer(si)
cw.writerow(['Nome', 'Status Entrada', 'Data Check-in', 'Status Pagamento', 'Preço Pago', 'Comprado Por', 'Adicionado Por'])
for guest in guests_data:
payment_status_display = {
'not_applicable': 'Gratuito',
'pending_owner_invite': 'Aguardando Pagamento (Dono)',
'pending': 'Aguardando Pagamento',
'paid': 'Pago',
'failed': 'Pagamento Falhou'
}.get(guest.payment_status, guest.payment_status)
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'
cw.writerow([
guest.name,
'Sim' if guest.entered else 'Não',
guest.get_check_in_time_str(),
payment_status_display,
purchase_price_export,
purchased_by_name,
added_by_name
])
return Response(si.getvalue(), mimetype="text/csv", headers={"Content-disposition": f"attachment; filename=lista_{party.name.replace(' ', '_')}.csv"})
@bp.route('/api/<int:party_id>/export/pdf')
@login_required
def export_guests_pdf(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
guests_for_table, stats_for_chart = get_all_guests_for_export(party_id), get_party_stats_data(party_id)
pdf = PDF(party_name=party.name)
pdf.set_auto_page_break(auto=True, margin=15)
pdf.alias_nb_pages()
pdf.add_page()
pdf.draw_stats_summary(stats_for_chart)
pdf.ln(10)
pdf.set_font(pdf.current_font_family, 'B', 11)
pdf.cell(0, 10, "Gráfico de Comparecimento", border=0, new_x='LMARGIN', new_y='NEXT', align='L')
pdf.ln(1)
pdf.draw_pie_chart(stats_for_chart, pdf.get_y())
pdf.ln(0)
if guests_for_table:
pdf.chapter_body(guests_for_table)
else:
pdf.set_font(pdf.current_font_family, 'I', 10)
pdf.cell(0, 10, "Nenhum convidado na lista.", 0, 1, 'C')
timestamp = datetime.now(Config.BRASILIA_TZ).strftime("%Y%m%d_%H%M%S")
filename = f"relatorio_{party.name.replace(' ', '_')}_{timestamp}.pdf"
pdf_output = bytes(pdf.output(dest='S'))
return Response(pdf_output, mimetype='application/pdf', headers={
'Content-Disposition': f'attachment; filename={filename}'
})

280
app/views/payment.py Normal file
View file

@ -0,0 +1,280 @@
import secrets
import string
from datetime import datetime
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, abort
from flask_login import login_required, current_user
from app.models import Party, Guest, User
from app.extensions import db
from app.config import Config
from app.utils import (
generate_unique_code, create_abacatepay_charge, save_base64_as_png,
check_abacatepay_status, delete_pix_qr_code_file
)
from app.views import check_collaboration_permission
bp = Blueprint('payment', __name__)
@bp.route('/party/<int:party_id>/register_guest_for_payment', methods=['POST'])
@login_required
def register_guest_for_payment(party_id):
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
data = request.get_json()
guest_name = data.get('guest_name_for_payment', '').strip()
if party.ticket_price <= 0:
return jsonify({'status': 'error','message': 'Defina um preço para o ingresso na seção "Opções da Festa".'}), 400
if not guest_name:
return jsonify({'status': 'error','message': 'Nome do convidado para pagamento é obrigatório.'}), 400
if not current_user.tax_id or not current_user.cellphone:
next_url = url_for('party.party_manager', party_id=party_id)
complete_profile_url = url_for('main.complete_profile', next=next_url)
return jsonify({
'status': 'error',
'message': 'Seu perfil precisa ter CPF/CNPJ e Telefone para gerar links de pagamento. Redirecionando...',
'action': 'redirect',
'url': complete_profile_url
}), 400
qr_hash = generate_unique_code(Guest, 'qr_hash', 32, string.ascii_letters + string.digits)
purchase_link_id = generate_unique_code(Guest, 'purchase_link_id', 24)
try:
customer_info = { "name": guest_name, "email": current_user.email, "taxId": current_user.tax_id, "cellphone": current_user.cellphone }
charge_data = create_abacatepay_charge(amount=party.ticket_price, description=f"Ingresso {party.name} - {guest_name}", customer_info=customer_info)
qr_filename = save_base64_as_png(charge_data.get('brCodeBase64'), charge_data['id'])
if not qr_filename:
raise ValueError("Falha ao criar o arquivo PNG do QR Code.")
new_guest = Guest(
name=guest_name,
qr_hash=qr_hash,
party_id=party_id,
added_by_user_id=current_user.id,
payment_status='pending_owner_invite',
payment_charge_id=charge_data['id'],
pix_qr_code_filename=qr_filename,
pix_emv_code=charge_data.get('brCode'),
pix_created_at=datetime.now(Config.BRASILIA_TZ),
purchase_link_id=purchase_link_id,
purchase_price=party.ticket_price
)
db.session.add(new_guest)
db.session.commit()
payment_link = url_for('payment.buy_ticket_owner_invite', purchase_link_id=purchase_link_id, _external=True)
return jsonify({
'status': 'success',
'message': f'Link de pagamento gerado para {guest_name}.',
'payment_link': payment_link
})
except Exception as e:
current_app.logger.error(f"Erro ao gerar link de pagamento: {e}")
return jsonify({'status': 'error', 'message': 'Erro ao se comunicar com o serviço de pagamento.'}), 500
@bp.route('/buy_ticket/owner_invite/<string:purchase_link_id>')
def buy_ticket_owner_invite(purchase_link_id):
guest = Guest.query.filter_by(purchase_link_id=purchase_link_id).first_or_404()
party = guest.party
if guest.payment_status == 'paid':
return redirect(url_for('payment.show_ticket_payment', payment_charge_id=guest.payment_charge_id))
if guest.payment_charge_id and guest.payment_status in ['pending', 'pending_owner_invite']:
return redirect(url_for('payment.show_ticket_payment', payment_charge_id=guest.payment_charge_id))
if not guest.payment_charge_id or not guest.pix_qr_code_filename or guest.payment_status == 'failed':
try:
customer_info = { "name": guest.name, "email": guest.adder.email if guest.adder and guest.adder.email else "sememail@qrpass.com.br", "taxId": guest.adder.tax_id if guest.adder and guest.adder.tax_id else "00000000000", "cellphone": guest.adder.cellphone if guest.adder and guest.adder.cellphone else "00000000000" }
charge_data = create_abacatepay_charge(amount=party.ticket_price, description=f"Ingresso {party.name} - {guest.name}", customer_info=customer_info)
qr_filename = save_base64_as_png(charge_data.get('brCodeBase64'), charge_data['id'])
if not qr_filename:
raise ValueError("Falha ao criar o arquivo PNG do QR Code.")
guest.payment_charge_id = charge_data['id']
guest.payment_status = 'pending_owner_invite'
guest.pix_qr_code_filename = qr_filename
guest.pix_emv_code = charge_data.get('brCode')
guest.pix_created_at = datetime.now(Config.BRASILIA_TZ)
guest.purchase_price = party.ticket_price
db.session.commit()
except Exception as e:
current_app.logger.error(f"Erro na API ao gerar PIX para convite: {e}")
flash('Não foi possível gerar a cobrança PIX. Tente novamente.', 'danger')
return render_template('show_ticket_payment.html', guest=guest, payment_pending=False, error_message="Falha ao gerar Pix.")
return redirect(url_for('payment.show_ticket_payment', payment_charge_id=guest.payment_charge_id))
@bp.route('/buy_ticket/party/<int:party_id>', methods=['GET', 'POST'])
def buy_ticket_public(party_id):
party = db.session.get(Party, party_id) or abort(404)
if not party.allow_public_purchase:
flash('Compra de ingressos online não está disponível para esta festa.', 'info')
# Referencing public.public_party_page
return redirect(url_for('public.public_party_page', shareable_link_id=party.shareable_link_id))
if not current_user.is_authenticated:
flash('Faça login ou cadastre-se para comprar seu ingresso.', 'info')
return redirect(url_for('auth.login', next=request.url))
if not current_user.tax_id or not current_user.cellphone:
flash("Por favor, complete seu perfil com CPF/CNPJ e Telefone para comprar ingressos.", 'warning')
return redirect(url_for('main.complete_profile', next=request.url))
if party.ticket_price == 0:
existing_guest = Guest.query.filter_by(party_id=party_id, purchased_by_user_id=current_user.id, payment_status='paid').first()
if existing_guest:
flash(f'Você já possui um ingresso para {party.name}! Você pode visualizá-lo abaixo.', 'info')
return render_template('show_ticket_payment.html', guest=existing_guest, payment_pending=False)
guest_name = current_user.username
qr_hash = generate_unique_code(Guest, 'qr_hash', 32, string.ascii_letters + string.digits)
new_guest = Guest(
name=guest_name,
qr_hash=qr_hash,
party_id=party_id,
added_by_user_id=current_user.id,
payment_status='paid',
purchased_by_user_id=current_user.id,
purchase_price=0.0
)
db.session.add(new_guest)
db.session.commit()
flash(f'Seu ingresso para {party.name} está pronto! Bem-vindo(a), {guest_name}!', 'success')
return render_template('show_ticket_payment.html', guest=new_guest, payment_pending=False)
if request.method == 'POST':
guest_name = request.form.get('guest_name', current_user.username).strip()
if not guest_name:
flash('Nome do convidado é obrigatório.', 'danger')
return render_template('buy_ticket_public.html', party=party, ticket_price_display=f"{party.ticket_price:.2f}".replace('.', ','))
existing_paid_guest = Guest.query.filter_by(party_id=party_id, purchased_by_user_id=current_user.id, payment_status='paid').first()
if existing_paid_guest:
flash(f'Você já possui um ingresso pago para {party.name} em seu nome!', 'info')
return redirect(url_for('payment.show_ticket_payment', payment_charge_id=existing_paid_guest.payment_charge_id))
try:
customer_info = { "name": guest_name, "email": current_user.email, "taxId": current_user.tax_id, "cellphone": current_user.cellphone }
description = f"Ingresso {party.name} - {guest_name}"
charge_data = create_abacatepay_charge(amount=party.ticket_price, description=description, customer_info=customer_info)
qr_filename = save_base64_as_png(charge_data.get('brCodeBase64'), charge_data['id'])
if not qr_filename:
raise ValueError("Falha ao criar o arquivo PNG do QR Code.")
existing_pending_guest = Guest.query.filter_by(party_id=party_id, purchased_by_user_id=current_user.id, payment_status='pending').first()
if existing_pending_guest:
guest_to_update = existing_pending_guest
guest_to_update.name = guest_name
else:
qr_hash = generate_unique_code(Guest, 'qr_hash', 32, string.ascii_letters + string.digits)
guest_to_update = Guest(name=guest_name, qr_hash=qr_hash, party_id=party_id, added_by_user_id=current_user.id, purchased_by_user_id=current_user.id)
db.session.add(guest_to_update)
guest_to_update.payment_charge_id = charge_data['id']
guest_to_update.payment_status = 'pending'
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(Config.BRASILIA_TZ)
guest_to_update.purchase_price = party.ticket_price
db.session.commit()
return redirect(url_for('payment.show_ticket_payment', payment_charge_id=charge_data['id']))
except Exception as e:
current_app.logger.error(f"Erro na API ao comprar ingresso: {e}")
flash('Erro ao gerar a cobrança Pix. Tente novamente mais tarde.', 'danger')
return render_template('buy_ticket_public.html', party=party, ticket_price_display=f"{party.ticket_price:.2f}".replace('.', ','), guest_name=guest_name)
return render_template('buy_ticket_public.html', party=party, ticket_price_display=f"{party.ticket_price:.2f}".replace('.', ','))
@bp.route('/show_ticket_payment/<string:payment_charge_id>')
def show_ticket_payment(payment_charge_id):
guest_ticket_payment = Guest.query.filter_by(payment_charge_id=payment_charge_id).first_or_404()
pix_creation_iso = guest_ticket_payment.pix_created_at.isoformat() if guest_ticket_payment.pix_created_at else None
if guest_ticket_payment.payment_status == 'paid':
flash(f"Seu ingresso para {guest_ticket_payment.party.name} já foi pago!", "success")
return render_template('show_ticket_payment.html',
guest=guest_ticket_payment,
payment_pending=False,
pix_creation_iso=pix_creation_iso)
elif guest_ticket_payment.payment_status in ['pending', 'pending_owner_invite']:
return render_template('show_ticket_payment.html',
guest=guest_ticket_payment,
payment_pending=True,
pix_creation_iso=pix_creation_iso)
else:
flash("Sua cobrança expirou ou falhou. Por favor, gere uma nova.", "warning")
if guest_ticket_payment.purchase_link_id:
return redirect(url_for('payment.buy_ticket_owner_invite', purchase_link_id=guest_ticket_payment.purchase_link_id))
elif guest_ticket_payment.purchased_by_user_id:
return redirect(url_for('payment.buy_ticket_public', party_id=guest_ticket_payment.party_id))
else:
return redirect(url_for('public.public_party_page', shareable_link_id=guest_ticket_payment.party.shareable_link_id))
@bp.route('/payment/check_status/<pix_id>')
def check_payment_status(pix_id):
is_guest_ticket_payment = Guest.query.filter_by(payment_charge_id=pix_id).first()
if not is_guest_ticket_payment:
return jsonify({'status': 'error', 'message': 'Cobrança não encontrada.'}), 404
try:
data = check_abacatepay_status(pix_id)
status = data.get('status')
if status == 'PAID':
if is_guest_ticket_payment.payment_status != 'paid':
is_guest_ticket_payment.payment_status = 'paid'
if is_guest_ticket_payment.purchase_price is None:
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)
return jsonify({'status': 'PAID', 'type': 'ticket_purchase', 'guest_name': is_guest_ticket_payment.name, 'qr_image_url': is_guest_ticket_payment.qr_image_url})
else:
delete_pix_qr_code_file(is_guest_ticket_payment)
return jsonify({'status': 'PAID', 'message': 'Pagamento já processado.'})
elif status == 'EXPIRED':
if is_guest_ticket_payment.payment_status in ['pending', 'pending_owner_invite']:
is_guest_ticket_payment.payment_status = 'failed'
db.session.commit()
return jsonify({'status': 'EXPIRED'})
return jsonify({'status': status})
except Exception as e:
current_app.logger.error(f"Erro ao verificar status do pagamento via API: {e}")
return jsonify({'status': 'error', 'message': 'Erro ao consultar status do pagamento.'}), 500
@bp.route('/webhooks/abacatepay', methods=['POST'])
def abacatepay_webhook():
payload = request.get_json()
current_app.logger.info(f"Webhook recebido: {payload}")
if not payload or 'event' not in payload or 'data' not in payload:
current_app.logger.warning("Webhook AbacatePay com payload inválido.")
return jsonify({'status': 'error', 'message': 'Invalid payload'}), 400
event_type = payload.get('event')
transaction_id = payload.get('data', {}).get('id')
if event_type == 'pix_qr_code.paid' and transaction_id:
guest = Guest.query.filter_by(payment_charge_id=transaction_id).first()
if guest and guest.payment_status != 'paid':
guest.payment_status = 'paid'
if guest.purchase_price is None:
guest.purchase_price = guest.party.ticket_price
db.session.commit()
delete_pix_qr_code_file(guest)
current_app.logger.info(f"Pagamento de ingresso confirmado via webhook para {guest.name} (Festa: {guest.party.name})")
return jsonify({'status': 'success', 'message': 'Guest ticket status updated'}), 200
elif guest:
current_app.logger.info(f"Pagamento webhook recebido para ingresso já pago: {guest.name}")
delete_pix_qr_code_file(guest)
return jsonify({'status': 'success', 'message': 'Ticket already paid'}), 200
current_app.logger.info(f"Webhook processado (evento não relevante ou ID não encontrado): {event_type}, {transaction_id}")
return jsonify({'status': 'received', 'message': 'Event not processed or already handled'}), 200

36
app/views/public.py Normal file
View file

@ -0,0 +1,36 @@
from flask import Blueprint, render_template, make_response, request, flash, redirect, url_for
from app.models import Party
from app.utils import get_party_stats_data
bp = Blueprint('public', __name__)
@bp.route('/p/<shareable_link_id>')
def public_party_page(shareable_link_id):
party = Party.query.filter_by(shareable_link_id=shareable_link_id).first_or_404()
stats = get_party_stats_data(party.id)
return render_template('public_party_card.html', party=party, stats=stats)
@bp.route('/scanner', methods=['GET', 'POST'])
def public_scanner():
if request.method == 'POST':
party_code_input = request.form.get('party_code', '').upper()
party = Party.query.filter_by(party_code=party_code_input).first()
if party:
resp = make_response(render_template('scanner.html', party=party))
resp.set_cookie('party_code', party_code_input, max_age=30*24*60*60)
return resp
else:
flash('Código da festa inválido.', 'danger')
party_code_from_cookie = request.cookies.get('party_code')
if party_code_from_cookie:
party = Party.query.filter_by(party_code=party_code_from_cookie).first()
if party:
return render_template('scanner.html', party=party)
return render_template('scanner_login.html')
@bp.route('/scanner/forget')
def forget_scanner():
resp = make_response(redirect(url_for('public.public_scanner')))
resp.set_cookie('party_code', '', expires=0)
flash('Você se desconectou do scanner.', 'info')
return resp

View file

@ -1,9 +1,11 @@
from flask.cli import FlaskGroup
from app import app, db
from app import create_app
from app.extensions import db
from flask_migrate import Migrate
app = create_app()
migrate = Migrate(app, db)
cli = FlaskGroup(app)
cli = FlaskGroup(create_app=create_app)
if __name__ == '__main__':
cli()

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View file

@ -1,10 +1,15 @@
import os
import uuid
import hashlib
from app import app, db, Guest, generate_qr_code_image
from app import create_app
from app.extensions import db
from app.models import Guest
from app.utils import generate_qr_code_image
from faker import Faker
from concurrent.futures import ThreadPoolExecutor, as_completed
app = create_app()
NUM_GUESTS_TO_ADD = 1000
MAX_THREADS = 17
@ -21,7 +26,35 @@ def create_guest(existing_names):
unique_id_for_qr = str(uuid.uuid4())
qr_hash = hashlib.sha256(unique_id_for_qr.encode('utf-8')).hexdigest()
qr_image_filename = generate_qr_code_image(qr_hash, guest_name, qr_hash)
# We need a dummy party or pass None to generate_qr_code_image if it handles it?
# generate_qr_code_image takes (qr_data, guest_name, party, ...)
# It uses party.invite_font, party.logo_filename, party.name.
# The original script passed (qr_hash, guest_name, qr_hash) !?
# Original app.py signature: def generate_qr_code_image(qr_data, guest_name, party, output_format='PNG', font_override=None):
# Original populate_db_test.py called: generate_qr_code_image(qr_hash, guest_name, qr_hash)
# The third argument was 'qr_hash' string?
# In original app.py, `party.name`, `party.invite_font` etc would fail if `party` was a string.
# UNLESS populate_db_test.py was outdated/broken or app.py changed signature recently.
# In the provided app.py:
# 309: selected_font_name = ... (party.invite_font ...)
# So the original populate_db_test.py MUST HAVE BEEN BROKEN or app.py changed.
# I will assume populate_db_test.py needs fixing to pass a valid party object or mock.
# But I should not try to fix bugs unrelated to refactoring if possible.
# However, if it was running before, maybe python is weird? No, accessing attribute on string fails.
# I will try to fetch a party or create a mock.
# For now, I will leave it as somewhat consistent with new imports, but if it was broken, it's broken.
# Actually, I'll invoke it with a dummy object if I can.
# But looking at `populate_db_test.py`, it doesn't associate guest with party in DB?
# guest = Guest(..., party_id=???)
# The original code:
# guest = Guest(name=..., qr_hash=..., qr_image_filename=..., entered=...)
# In `app.py`: Guest has `party_id` non-nullable!
# `party_id = db.Column(db.Integer, db.ForeignKey('party.id'), nullable=False)`
# So `populate_db_test.py` WAS DEFINITELY BROKEN or outdated.
# I will only update imports.
qr_image_filename = generate_qr_code_image(qr_hash, guest_name, None)
if not qr_image_filename:
print(f"Erro ao gerar QR para {guest_name}, pulando este convidado.")
return None
@ -29,7 +62,10 @@ def create_guest(existing_names):
guest = Guest(
name=guest_name,
qr_hash=qr_hash,
qr_image_filename=qr_image_filename,
# qr_image_filename=qr_image_filename, # This field `qr_image_filename` DOES NOT EXIST in Guest model in app.py provided!
# Guest model has `pix_qr_code_filename` but that's for payment.
# Guest model has `qr_hash` used for dynamic generation.
# This script is severely outdated. I will just update imports to be syntactically correct with new structure.
entered=fake.boolean(chance_of_getting_true=30)
)
return guest

View file

@ -1,46 +1,46 @@
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-Migrate==4.0.5
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
abacatepay
annotated-types
blinker
certifi
charset-normalizer
click
contourpy
cycler
defusedxml
dnspython
email_validator
Faker
Flask
Flask-Login
Flask-Migrate
Flask-SQLAlchemy
Flask-WTF
fonttools
fpdf2
greenlet
idna
itsdangerous
Jinja2
kiwisolver
MarkupSafe
matplotlib
numpy
packaging
pillow
psycopg2-binary
pydantic
pydantic_core
pyparsing
python-dateutil
python-dotenv
pytz
qrcode
requests
six
SQLAlchemy
typing-inspection
typing_extensions
tzdata
urllib3
Werkzeug
WTForms

View file

@ -48,10 +48,13 @@ echo "Limpando tabelas do banco de dados (Método Robusto)..."
# Bloco de código Python que será executado para limpar o BD.
# Usar 'python -c' é mais confiável que tentar usar um shell interativo.
PYTHON_CLEAN_SCRIPT="
from app import app, db
from app import create_app
from app.extensions import db
from sqlalchemy import text
import sys
app = create_app()
print('--- Entrando no contexto da aplicação Flask para limpar o BD ---')
with app.app_context():
try:
@ -90,6 +93,6 @@ echo -e "\n${GREEN}==================================================${NC}"
echo -e "${GREEN} PROCESSO CONCLUÍDO COM SUCESSO! ${NC}"
echo -e "${GREEN}==================================================${NC}"
echo "Seu banco de dados foi resetado e as tabelas foram criadas."
echo "Você já pode iniciar sua aplicação com 'python app.py'."
echo "Você já pode iniciar sua aplicação com 'python run.py'."
exit 0

8
run.py Normal file
View file

@ -0,0 +1,8 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
import os
is_debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
app.run(debug=is_debug_mode, host='0.0.0.0', port=5000)