Refactoring to MVC clean [Security Fix]
This commit is contained in:
parent
44b32f3222
commit
de1b93c796
46 changed files with 2225 additions and 1836 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,5 +4,5 @@ static/party_logos/
|
|||
static/payment_qrcodes/
|
||||
__pycache__/
|
||||
data/
|
||||
.env
|
||||
.env*
|
||||
venv
|
||||
50
app/__init__.py
Normal file
50
app/__init__.py
Normal 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
29
app/config.py
Normal 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
5
app/extensions.py
Normal 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
98
app/models.py
Normal 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))
|
||||
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
381
app/utils.py
Normal 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
6
app/views/__init__.py
Normal 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
57
app/views/auth.py
Normal 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
76
app/views/main.py
Normal 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
468
app/views/party.py
Normal 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
280
app/views/payment.py
Normal 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
36
app/views/public.py
Normal 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
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
8
run.py
Normal 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)
|
||||
Loading…
Reference in a new issue