Device
This commit is contained in:
49
backend/storage/s3_client.py
Normal file
49
backend/storage/s3_client.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import boto3
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_s3_client():
|
||||
"""
|
||||
Tạo và trả về S3 client dùng để làm việc với MinIO/S3.
|
||||
|
||||
MinIO tương thích với S3 API, nên mình dùng boto3 như khi làm với AWS S3.
|
||||
Hàm này chỉ chịu trách nhiệm tạo kết nối, không xử lý upload hay nghiệp vụ file.
|
||||
"""
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||||
aws_access_key_id=os.getenv("S3_ACCESS_KEY"),
|
||||
aws_secret_access_key=os.getenv("S3_SECRET_KEY"),
|
||||
region_name=os.getenv("S3_REGION", "us-east-1"),
|
||||
)
|
||||
|
||||
|
||||
def get_bucket_name():
|
||||
"""
|
||||
Lấy tên bucket từ biến môi trường.
|
||||
|
||||
Project này nên dùng một bucket chung, ví dụ: ndms.
|
||||
Sau đó phân loại file bằng object key:
|
||||
- device-types/router-uuid.svg
|
||||
- devices/avatar-uuid.png
|
||||
"""
|
||||
|
||||
return os.getenv("S3_BUCKET_NAME")
|
||||
|
||||
|
||||
def get_public_url():
|
||||
"""
|
||||
Lấy public URL của MinIO/S3.
|
||||
|
||||
Ví dụ khi chạy local:
|
||||
S3_PUBLIC_URL=http://localhost:9000
|
||||
|
||||
URL cuối cùng sẽ có dạng:
|
||||
http://localhost:9000/ndms/device-types/router-uuid.svg
|
||||
"""
|
||||
|
||||
return os.getenv("S3_PUBLIC_URL")
|
||||
141
backend/storage/storage_service.py
Normal file
141
backend/storage/storage_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import re
|
||||
import uuid
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from storage.s3_client import (
|
||||
get_s3_client,
|
||||
get_bucket_name,
|
||||
get_public_url,
|
||||
)
|
||||
from storage.validators import validate_image_file
|
||||
|
||||
|
||||
def slugify(text):
|
||||
"""
|
||||
Chuyển text thành dạng an toàn để dùng trong tên file.
|
||||
|
||||
Ví dụ:
|
||||
- "Router" -> "router"
|
||||
- "Core Switch 01" -> "core-switch-01"
|
||||
- "Thiết bị định tuyến" -> "thi-t-b-nh-tuy-n"
|
||||
|
||||
Lưu ý:
|
||||
Hàm này đơn giản, chưa xử lý tiếng Việt hoàn hảo.
|
||||
Nếu muốn slug tiếng Việt đẹp hơn, sau này có thể dùng thư viện python-slugify.
|
||||
"""
|
||||
|
||||
if text is None or text.strip() == "":
|
||||
return "file"
|
||||
|
||||
text = text.lower().strip()
|
||||
|
||||
# Thay toàn bộ ký tự không phải chữ/số bằng dấu gạch ngang.
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
|
||||
# Xóa dấu gạch ngang thừa ở đầu/cuối.
|
||||
text = text.strip("-")
|
||||
|
||||
return text or "file"
|
||||
|
||||
|
||||
def build_object_key(folder, business_name, extension):
|
||||
"""
|
||||
Tạo object key để lưu file trong bucket.
|
||||
|
||||
MinIO/S3 không có folder thật như filesystem.
|
||||
Folder ở đây chỉ là prefix trong object key.
|
||||
|
||||
Ví dụ:
|
||||
folder = "device-types"
|
||||
business_name = "Router"
|
||||
extension = "svg"
|
||||
|
||||
Kết quả:
|
||||
device-types/router-550e8400.svg
|
||||
|
||||
Dùng UUID để tránh trùng tên file.
|
||||
"""
|
||||
|
||||
safe_name = slugify(business_name)
|
||||
unique_id = uuid.uuid4()
|
||||
|
||||
return f"{folder}/{safe_name}-{unique_id}.{extension}"
|
||||
|
||||
|
||||
def upload_image(file, folder, business_name): # Hàm Upload dùng chung cho device_types và devices
|
||||
"""
|
||||
Upload một file ảnh lên MinIO/S3 và trả về public URL.
|
||||
|
||||
Tham số:
|
||||
- file: file lấy từ request.files
|
||||
- folder: nhóm lưu trữ, ví dụ "device-types" hoặc "devices"
|
||||
- business_name: tên nghiệp vụ để đặt tên file đẹp hơn
|
||||
Ví dụ:
|
||||
+ DeviceType name = Router
|
||||
+ Device name = Switch tầng 2
|
||||
|
||||
Flow:
|
||||
1. Validate file
|
||||
2. Tạo object key
|
||||
3. Upload file lên bucket
|
||||
4. Trả về URL public để lưu vào database
|
||||
"""
|
||||
|
||||
extension = validate_image_file(file)
|
||||
|
||||
object_key = build_object_key(
|
||||
folder=folder,
|
||||
business_name=business_name,
|
||||
extension=extension,
|
||||
)
|
||||
|
||||
s3_client = get_s3_client()
|
||||
bucket_name = get_bucket_name()
|
||||
public_url = get_public_url()
|
||||
|
||||
s3_client.upload_fileobj(
|
||||
file,
|
||||
bucket_name,
|
||||
object_key,
|
||||
ExtraArgs={
|
||||
"ContentType": file.content_type
|
||||
},
|
||||
)
|
||||
|
||||
return f"{public_url}/{bucket_name}/{object_key}"
|
||||
|
||||
|
||||
def upload_device_type_icon(file, device_type_name):
|
||||
"""
|
||||
Upload icon cho DeviceType.
|
||||
|
||||
File sẽ được lưu trong bucket với prefix:
|
||||
device-types/
|
||||
|
||||
Ví dụ URL:
|
||||
http://localhost:9000/ndms/device-types/router-uuid.svg
|
||||
"""
|
||||
|
||||
return upload_image( # Ở đoạn này có thể trả về một hàm luôn á ? Mà không cần phải gọi hàm đó vào bên trong và truyền tham số à ?
|
||||
file=file,
|
||||
folder="device-types",
|
||||
business_name=device_type_name,
|
||||
)
|
||||
|
||||
|
||||
def upload_device_avatar(file, device_name):
|
||||
"""
|
||||
Upload avatar cho Device.
|
||||
|
||||
File sẽ được lưu trong bucket với prefix:
|
||||
devices/
|
||||
|
||||
Ví dụ URL:
|
||||
http://localhost:9000/ndms/devices/switch-tang-2-uuid.png
|
||||
"""
|
||||
|
||||
return upload_image(
|
||||
file=file,
|
||||
folder="devices",
|
||||
business_name=device_name,
|
||||
)
|
||||
49
backend/storage/validators.py
Normal file
49
backend/storage/validators.py
Normal file
@@ -0,0 +1,49 @@
|
||||
ALLOWED_IMAGE_EXTENSIONS = {"svg", "png", "jpg", "jpeg", "webp"}
|
||||
|
||||
MAX_FILE_SIZE_MB = 5
|
||||
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
def validate_image_file(file):
|
||||
"""
|
||||
Validate file ảnh trước khi upload lên MinIO/S3.
|
||||
|
||||
Hàm này kiểm tra:
|
||||
1. Có file được gửi lên không
|
||||
2. File có tên không
|
||||
3. File có extension không
|
||||
4. Extension có hợp lệ không
|
||||
5. File có vượt quá dung lượng cho phép không
|
||||
|
||||
Trả về:
|
||||
- extension của file nếu hợp lệ
|
||||
|
||||
Raise:
|
||||
- ValueError nếu file không hợp lệ
|
||||
"""
|
||||
|
||||
if file is None:
|
||||
raise ValueError("No file uploaded")
|
||||
|
||||
if file.filename == "":
|
||||
raise ValueError("Filename is empty")
|
||||
|
||||
if "." not in file.filename:
|
||||
raise ValueError("File must have an extension")
|
||||
|
||||
extension = file.filename.rsplit(".", 1)[1].lower()
|
||||
|
||||
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
raise ValueError("Invalid image type. Allowed: svg, png, jpg, jpeg, webp")
|
||||
|
||||
# Di chuyển con trỏ file tới cuối để đo dung lượng file.
|
||||
file.seek(0, 2)
|
||||
file_size = file.tell()
|
||||
|
||||
# Đưa con trỏ file về đầu để lát nữa upload không bị mất dữ liệu.
|
||||
file.seek(0)
|
||||
|
||||
if file_size > MAX_FILE_SIZE_BYTES:
|
||||
raise ValueError(f"File size must be less than {MAX_FILE_SIZE_MB}MB")
|
||||
|
||||
return extension
|
||||
Reference in New Issue
Block a user