feat: Implement image compression, S3 storage, and LRU caching for party logos, updating documentation accordingly.
This commit is contained in:
parent
eea1c65ca5
commit
8c4b5462b1
5 changed files with 254 additions and 31 deletions
28
README.md
28
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in a new issue