feat: Implement image compression, S3 storage, and LRU caching for party logos, updating documentation accordingly.

This commit is contained in:
Murilo Scolari 2026-01-31 00:46:35 -03:00
parent eea1c65ca5
commit 8c4b5462b1
5 changed files with 254 additions and 31 deletions

View file

@ -49,23 +49,24 @@ Chega de planilhas e filas! O QRPass é a solução completa e moderna para voc
## 🛠️ Tecnologias Utilizadas:
* **Backend:** Python 3, Flask, Flask-SQLAlchemy (PostgreSQL), Pillow, qrcode, requests.
* **Backend:** Python 3, Flask, Flask-SQLAlchemy (PostgreSQL), Flask-Migrate, Pillow, qrcode, boto3.
* **Armazenamento:** Backblaze B2 (S3-compatible) para logos e arquivos, com cache LRU em memória.
* **Frontend:** HTML5, CSS3 (Tailwind CSS), JavaScript (Vanilla JS), Chart.js, html5-qrcode, Anime.js.
* **Banco de Dados:** PostgreSQL (para escalabilidade e robustez).
## 🚀 Setup e Execução do Projeto Localmente:
### Pré-requisitos:
* Python 3.7+
* Python 3.10+
* `pip` (gerenciador de pacotes Python)
* Um arquivo de fonte TrueType (TTF), como `Montserrat-Regular.ttf` (já incluído no projeto).
* Acesso a um banco de dados PostgreSQL (pode ser local ou em nuvem).
* Conta no Backblaze B2 com um bucket configurado (para armazenamento de imagens).
### Passos para Configuração:
1. **Clone o Repositório:**
```bash
git clone <url_do_repositorio>
git clone https://github.com/muriloscolari/AttendanceManager.git
cd AttendanceManager
```
@ -82,18 +83,25 @@ Chega de planilhas e filas! O QRPass é a solução completa e moderna para voc
```
4. **Configure o Ambiente:**
* Crie um arquivo `.env` na raiz do projeto com as seguintes variáveis:
* Crie um arquivo `.env` na raiz do projeto baseado no `.env.example`:
```env
SECRET_KEY=sua_chave_secreta_aqui_gerada_aleatoriamente
ABACATE_API_KEY=sua_chave_api_do_abacatepay
FLASK_DEBUG=True
DATABASE_URL=postgresql://user:password@host:port/database_name
FLASK_DEBUG=True # Opcional: para ativar o modo debug
# API de Pagamento
ABACATE_API_KEY=sua_chave_api_do_abacatepay
# Backblaze B2 Storage
B2_KEY_ID=seu_key_id_do_b2
B2_APP_KEY=sua_application_key_do_b2
B2_BUCKET_NAME=nome_do_seu_bucket
B2_ENDPOINT_URL=https://s3.us-east-005.backblazeb2.com
```
* **Importante:** A `DATABASE_URL` deve apontar para o seu banco de dados PostgreSQL.
* Certifique-se de que o arquivo de fonte TTF está na raiz do projeto (ou no caminho `static/fonts`).
* **Importante:** Crie uma Application Key no Backblaze B2 (não use a Master Key).
5. **Inicialize o Banco de Dados (Alembic):**
* Certifique-se de que o Alembic está configurado (o arquivo `alembic.ini` deve estar na raiz).
* Execute as migrações para criar as tabelas no seu banco de dados PostgreSQL:
```bash
flask db upgrade
@ -101,7 +109,7 @@ Chega de planilhas e filas! O QRPass é a solução completa e moderna para voc
6. **Execute a Aplicação:**
```bash
python app.py
python run.py
```
Acesse `http://localhost:5000/` no seu navegador.

View file

@ -1,8 +1,91 @@
import io
import threading
from collections import OrderedDict
import boto3
from botocore.exceptions import ClientError
from flask import current_app
from PIL import Image
from app.config import Config
# Image compression settings
MAX_IMAGE_HEIGHT = 1080
JPEG_QUALITY = 85
# Cache settings (adjustable for VPS with limited resources)
CACHE_MAX_SIZE_BYTES = 50 * 1024 * 1024 # 50MB max total cache size
CACHE_MAX_ITEMS = 100 # Max 100 images in cache
class ImageCache:
"""
Thread-safe LRU cache for images with size limits.
Evicts least recently used items when limits are exceeded.
"""
def __init__(self, max_size_bytes=CACHE_MAX_SIZE_BYTES, max_items=CACHE_MAX_ITEMS):
self._cache = OrderedDict() # key -> (data, content_type, size)
self._lock = threading.Lock()
self._max_size_bytes = max_size_bytes
self._max_items = max_items
self._current_size = 0
def get(self, key):
"""Get item from cache, returns (data, content_type) or None"""
with self._lock:
if key in self._cache:
# Move to end (most recently used)
self._cache.move_to_end(key)
data, content_type, _ = self._cache[key]
return data, content_type
return None
def set(self, key, data, content_type):
"""Add item to cache with LRU eviction"""
size = len(data)
# Don't cache items larger than 25% of max cache size
if size > self._max_size_bytes * 0.25:
return
with self._lock:
# Remove existing entry if present
if key in self._cache:
_, _, old_size = self._cache.pop(key)
self._current_size -= old_size
# Evict LRU items until we have space
while (self._current_size + size > self._max_size_bytes or
len(self._cache) >= self._max_items):
if not self._cache:
break
oldest_key, (_, _, oldest_size) = self._cache.popitem(last=False)
self._current_size -= oldest_size
# Add new item
self._cache[key] = (data, content_type, size)
self._current_size += size
def delete(self, key):
"""Remove item from cache"""
with self._lock:
if key in self._cache:
_, _, size = self._cache.pop(key)
self._current_size -= size
def stats(self):
"""Return cache statistics"""
with self._lock:
return {
'items': len(self._cache),
'size_bytes': self._current_size,
'size_mb': round(self._current_size / (1024 * 1024), 2),
'max_size_mb': self._max_size_bytes / (1024 * 1024)
}
# Global cache instance
_image_cache = ImageCache()
class S3Service:
def __init__(self):
self.s3_client = boto3.client(
@ -34,6 +117,7 @@ class S3Service:
full_key,
ExtraArgs=extra_args
)
current_app.logger.info(f"S3 Upload Success: {full_key}")
return full_key
except ClientError as e:
current_app.logger.error(f"S3 Upload Error: {e}")
@ -41,15 +125,58 @@ class S3Service:
def delete_file(self, filename):
"""
Deletes a file from B2.
Deletes a file from B2 and removes from cache.
:param filename: Full key of the file to delete
"""
try:
current_app.logger.info(f"S3 Delete: Attempting to delete {filename}")
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
# Also remove from cache
_image_cache.delete(filename)
current_app.logger.info(f"S3 Delete Success: {filename}")
return True
except ClientError as e:
current_app.logger.error(f"S3 Delete Error: {e}")
current_app.logger.error(f"S3 Delete Error for {filename}: {e}")
return False
def get_file_cached(self, filename):
"""
Get file with caching. Returns (data, content_type) or (None, None).
Checks cache first, fetches from S3 if not cached.
"""
import mimetypes
# Check cache first
cached = _image_cache.get(filename)
if cached:
current_app.logger.debug(f"Cache HIT: {filename}")
return cached
current_app.logger.debug(f"Cache MISS: {filename}")
# Fetch from S3
try:
response = self.s3_client.get_object(
Bucket=self.bucket_name,
Key=filename
)
data = response['Body'].read()
content_type = response.get('ContentType', 'application/octet-stream')
if not content_type or content_type == 'application/octet-stream':
guessed_type, _ = mimetypes.guess_type(filename)
if guessed_type:
content_type = guessed_type
# Store in cache
_image_cache.set(filename, data, content_type)
return data, content_type
except ClientError as e:
current_app.logger.error(f"S3 Get Error for {filename}: {e}")
return None, None
def get_presigned_url(self, filename, expiration=3600):
"""
@ -69,6 +196,81 @@ class S3Service:
current_app.logger.error(f"S3 Presigned URL Error: {e}")
return None
def compress_image(file_stream, original_filename):
"""
Compresses and optimizes an image before upload.
- Resizes to max 1080px height (maintains aspect ratio)
- Converts to JPEG if no transparency, keeps PNG otherwise
:param file_stream: File stream from request.files
:param original_filename: Original filename for extension detection
:return: Tuple of (BytesIO, new_filename, content_type)
"""
try:
img = Image.open(file_stream)
original_format = img.format or 'PNG'
current_app.logger.info(f"Processing image: {original_filename}, mode: {img.mode}, size: {img.size}")
# Check for ACTUAL transparency (not just mode)
has_transparency = False
if img.mode == 'RGBA':
# Check if any pixel has alpha < 255
alpha = img.getchannel('A')
if alpha.getextrema()[0] < 255: # Min alpha value < 255 means transparency exists
has_transparency = True
current_app.logger.info("Image has actual transparent pixels")
elif img.mode == 'LA':
alpha = img.getchannel('A')
if alpha.getextrema()[0] < 255:
has_transparency = True
elif img.mode == 'P' and 'transparency' in img.info:
has_transparency = True
# Resize if height exceeds max
if img.height > MAX_IMAGE_HEIGHT:
ratio = MAX_IMAGE_HEIGHT / img.height
new_width = int(img.width * ratio)
img = img.resize((new_width, MAX_IMAGE_HEIGHT), Image.Resampling.LANCZOS)
current_app.logger.info(f"Image resized to {new_width}x{MAX_IMAGE_HEIGHT}")
output = io.BytesIO()
# Determine output format
if has_transparency:
# Keep PNG for transparency
img.save(output, format='PNG', optimize=True)
ext = 'png'
content_type = 'image/png'
current_app.logger.info("Keeping PNG format (has transparency)")
else:
# Convert to JPEG for better compression
if img.mode in ('RGBA', 'LA', 'P'):
img = img.convert('RGB')
img.save(output, format='JPEG', quality=JPEG_QUALITY, optimize=True)
ext = 'jpg'
content_type = 'image/jpeg'
current_app.logger.info("Converting to JPEG (no transparency)")
output.seek(0)
# Generate new filename with correct extension
base_name = original_filename.rsplit('.', 1)[0] if '.' in original_filename else original_filename
new_filename = f"{base_name}.{ext}"
current_app.logger.info(f"Image compressed: {original_filename} -> {new_filename} ({output.getbuffer().nbytes} bytes)")
return output, new_filename, content_type
except Exception as e:
current_app.logger.error(f"Image compression error: {e}")
# Return original if compression fails
file_stream.seek(0)
ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'png'
content_type = f"image/{ext}" if ext in ['png', 'jpg', 'jpeg', 'gif'] else 'application/octet-stream'
return file_stream, original_filename, content_type
# Singleton instance
_s3_service_instance = None

View file

@ -68,16 +68,23 @@ def complete_profile():
@bp.route('/persistent/<path:filename>')
def serve_persistent_file(filename):
"""
Redireciona para URL pré-assinada do S3 para servir arquivos.
Serve arquivo do S3 com cache em memória.
Evita problemas de CORS e melhora performance.
"""
try:
storage = get_storage_service()
presigned_url = storage.get_presigned_url(filename, expiration=3600)
if presigned_url:
return redirect(presigned_url)
else:
current_app.logger.error(f"Falha ao gerar URL pré-assinada para {filename}")
abort(404)
except Exception as e:
current_app.logger.error(f"Erro ao servir arquivo persistente {filename}: {e}")
from flask import Response
storage = get_storage_service()
data, content_type = storage.get_file_cached(filename)
if data is None:
current_app.logger.error(f"Arquivo não encontrado: {filename}")
abort(404)
return Response(
data,
mimetype=content_type,
headers={
'Cache-Control': 'public, max-age=31536000',
'Content-Disposition': 'inline'
}
)

View file

@ -91,6 +91,8 @@ def delete_party(party_id):
@bp.route('/<int:party_id>/upload_logo', methods=['POST'])
@login_required
def upload_logo(party_id):
from app.services.storage import compress_image
party = db.session.get(Party, party_id) or abort(404)
check_collaboration_permission(party)
if 'party_logo' not in request.files:
@ -109,22 +111,26 @@ def upload_logo(party_id):
except Exception as e:
current_app.logger.warning(f"Failed to delete old logo: {e}")
# Compress image before upload
filename = secure_filename(file.filename)
unique_id = uuid.uuid4().hex
ext = filename.rsplit('.', 1)[1].lower()
new_filename = f"{party_id}_{unique_id}.{ext}"
compressed_stream, new_filename, content_type = compress_image(file.stream, filename)
# Upload to S3
content_type = f"image/{ext}" if ext in ['png', 'jpg', 'jpeg', 'gif'] else 'application/octet-stream'
# Add unique ID to filename
unique_id = uuid.uuid4().hex
base_name = new_filename.rsplit('.', 1)[0] if '.' in new_filename else new_filename
ext = new_filename.rsplit('.', 1)[1] if '.' in new_filename else 'jpg'
final_filename = f"{party_id}_{unique_id}.{ext}"
# Upload compressed image to S3
full_key = storage.upload_file(
file.stream,
new_filename,
compressed_stream,
final_filename,
folder=Config.PARTY_LOGOS_FOLDER_NAME,
content_type=content_type
)
if full_key:
party.logo_filename = new_filename
party.logo_filename = final_filename
db.session.commit()
# Generate presigned URL for immediate display
logo_url = storage.get_presigned_url(full_key, expiration=3600)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB