Added spinner and fixed asset delete bug
All checks were successful
Build and Publish Docker Image / build (push) Successful in 39s
All checks were successful
Build and Publish Docker Image / build (push) Successful in 39s
This commit is contained in:
parent
85086c0077
commit
4f5c5fd09d
8 changed files with 314 additions and 98 deletions
|
@ -1,3 +1,6 @@
|
|||
## Debugging
|
||||
LOGGING_LEVEL=DEBUG
|
||||
|
||||
## Database Configuration
|
||||
DATABASE_URL=sqlite:///instance/app.db
|
||||
|
||||
|
|
206
app.py
206
app.py
|
@ -1,6 +1,6 @@
|
|||
import os
|
||||
import uuid
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
|
||||
from werkzeug.utils import secure_filename
|
||||
from config import Config
|
||||
from flask_migrate import Migrate
|
||||
|
@ -47,13 +47,22 @@ def index():
|
|||
@app.route('/asset/add', methods=['GET', 'POST'])
|
||||
def add_asset():
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
license_key = request.form.get('license_key')
|
||||
featured_image = request.files.get('featured_image')
|
||||
additional_files = request.files.getlist('additional_files')
|
||||
try:
|
||||
title = request.form.get('title')
|
||||
description = request.form.get('description')
|
||||
license_key = request.form.get('license_key')
|
||||
featured_image = request.files.get('featured_image')
|
||||
additional_files = request.files.getlist('additional_files')
|
||||
|
||||
if not title:
|
||||
return jsonify({'success': False, 'error': 'Title is required'})
|
||||
|
||||
if not featured_image:
|
||||
return jsonify({'success': False, 'error': 'Featured image is required'})
|
||||
|
||||
if not allowed_file(featured_image.filename, is_featured_image=True):
|
||||
return jsonify({'success': False, 'error': 'Invalid featured image format'})
|
||||
|
||||
if title and featured_image and allowed_file(featured_image.filename, is_featured_image=True):
|
||||
# Process and convert featured image to WebP
|
||||
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
||||
|
||||
|
@ -96,8 +105,18 @@ def add_asset():
|
|||
db.session.add(asset_file)
|
||||
|
||||
db.session.commit()
|
||||
flash('Asset added successfully!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Asset added successfully!',
|
||||
'redirect': url_for('index')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return render_template('add_asset.html')
|
||||
|
||||
|
@ -111,90 +130,135 @@ def edit_asset(id):
|
|||
asset = Asset.query.get_or_404(id)
|
||||
|
||||
if request.method == 'POST':
|
||||
asset.title = request.form.get('title')
|
||||
asset.set_description(request.form.get('description'))
|
||||
license_key = request.form.get('license_key')
|
||||
asset.license_key = license_key.strip() if license_key else None
|
||||
try:
|
||||
asset.title = request.form.get('title')
|
||||
if not asset.title:
|
||||
return jsonify({'success': False, 'error': 'Title is required'})
|
||||
|
||||
# Handle featured image update
|
||||
featured_image = request.files.get('featured_image')
|
||||
if featured_image and featured_image.filename and allowed_file(featured_image.filename, is_featured_image=True):
|
||||
# Delete old featured image
|
||||
if asset.featured_image:
|
||||
storage.delete(asset.featured_image)
|
||||
asset.set_description(request.form.get('description'))
|
||||
license_key = request.form.get('license_key')
|
||||
asset.license_key = license_key.strip() if license_key else None
|
||||
|
||||
# Process and convert featured image to WebP
|
||||
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
||||
|
||||
# Generate unique filename
|
||||
original_featured_filename = secure_filename(featured_image.filename)
|
||||
unique_featured_filename = f"{uuid.uuid4().hex}{ext}"
|
||||
# Handle featured image update
|
||||
featured_image = request.files.get('featured_image')
|
||||
if featured_image and featured_image.filename:
|
||||
if not allowed_file(featured_image.filename, is_featured_image=True):
|
||||
return jsonify({'success': False, 'error': 'Invalid featured image format'})
|
||||
|
||||
# Create a FileStorage object from the processed image
|
||||
processed_file = FileStorage(
|
||||
stream=processed_image,
|
||||
filename=unique_featured_filename,
|
||||
content_type='image/webp'
|
||||
)
|
||||
# Delete old featured image
|
||||
if asset.featured_image:
|
||||
storage.delete(asset.featured_image)
|
||||
|
||||
# Save the processed image
|
||||
storage.save(processed_file, unique_featured_filename)
|
||||
asset.featured_image = unique_featured_filename
|
||||
asset.original_featured_image = original_featured_filename
|
||||
# Process and convert featured image to WebP
|
||||
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
||||
|
||||
# Generate unique filename
|
||||
original_featured_filename = secure_filename(featured_image.filename)
|
||||
unique_featured_filename = f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
# Handle additional files
|
||||
additional_files = request.files.getlist('additional_files')
|
||||
for file in additional_files:
|
||||
if file and allowed_file(file.filename):
|
||||
original_filename = secure_filename(file.filename)
|
||||
unique_filename = generate_unique_filename(original_filename)
|
||||
storage.save(file, unique_filename)
|
||||
asset_file = AssetFile(
|
||||
filename=unique_filename,
|
||||
original_filename=original_filename,
|
||||
asset_id=asset.id
|
||||
# Create a FileStorage object from the processed image
|
||||
processed_file = FileStorage(
|
||||
stream=processed_image,
|
||||
filename=unique_featured_filename,
|
||||
content_type='image/webp'
|
||||
)
|
||||
db.session.add(asset_file)
|
||||
|
||||
db.session.commit()
|
||||
flash('Asset updated successfully!', 'success')
|
||||
return redirect(url_for('asset_detail', id=asset.id))
|
||||
# Save the processed image
|
||||
storage.save(processed_file, unique_featured_filename)
|
||||
asset.featured_image = unique_featured_filename
|
||||
asset.original_featured_image = original_featured_filename
|
||||
|
||||
# Handle additional files
|
||||
additional_files = request.files.getlist('additional_files')
|
||||
for file in additional_files:
|
||||
if file and allowed_file(file.filename):
|
||||
original_filename = secure_filename(file.filename)
|
||||
unique_filename = generate_unique_filename(original_filename)
|
||||
storage.save(file, unique_filename)
|
||||
asset_file = AssetFile(
|
||||
filename=unique_filename,
|
||||
original_filename=original_filename,
|
||||
asset_id=asset.id
|
||||
)
|
||||
db.session.add(asset_file)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Asset updated successfully!',
|
||||
'redirect': url_for('asset_detail', id=asset.id)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return render_template('edit_asset.html', asset=asset)
|
||||
|
||||
@app.route('/asset/<int:id>/delete', methods=['POST'])
|
||||
def delete_asset(id):
|
||||
asset = Asset.query.get_or_404(id)
|
||||
try:
|
||||
asset = Asset.query.get_or_404(id)
|
||||
deletion_errors = []
|
||||
|
||||
# Delete featured image
|
||||
if asset.featured_image:
|
||||
storage.delete(asset.featured_image)
|
||||
# Delete featured image
|
||||
if asset.featured_image:
|
||||
if not storage.delete(asset.featured_image):
|
||||
deletion_errors.append(f"Failed to delete featured image: {asset.featured_image}")
|
||||
|
||||
# Delete additional files
|
||||
for file in asset.files:
|
||||
storage.delete(file.filename)
|
||||
db.session.delete(file)
|
||||
# Delete additional files
|
||||
for file in asset.files:
|
||||
if not storage.delete(file.filename):
|
||||
deletion_errors.append(f"Failed to delete file: {file.filename}")
|
||||
db.session.delete(file)
|
||||
|
||||
db.session.delete(asset)
|
||||
db.session.commit()
|
||||
db.session.delete(asset)
|
||||
db.session.commit()
|
||||
|
||||
flash('Asset deleted successfully!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
if deletion_errors:
|
||||
app.logger.error("Asset deletion had errors: %s", deletion_errors)
|
||||
flash('Asset deleted from database, but some files could not be deleted: ' + '; '.join(deletion_errors), 'warning')
|
||||
else:
|
||||
flash('Asset deleted successfully!', 'success')
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error("Failed to delete asset: %s", str(e))
|
||||
flash('Failed to delete asset: ' + str(e), 'error')
|
||||
return redirect(url_for('asset_detail', id=id))
|
||||
|
||||
@app.route('/asset/file/<int:id>/delete', methods=['POST'])
|
||||
def delete_asset_file(id):
|
||||
asset_file = AssetFile.query.get_or_404(id)
|
||||
asset_id = asset_file.asset_id
|
||||
try:
|
||||
asset_file = AssetFile.query.get_or_404(id)
|
||||
asset_id = asset_file.asset_id
|
||||
filename = asset_file.filename
|
||||
display_name = asset_file.original_filename or asset_file.filename
|
||||
|
||||
# Delete the file using storage backend
|
||||
storage.delete(asset_file.filename)
|
||||
# Delete the file using storage backend
|
||||
if not storage.delete(filename):
|
||||
error_msg = f'Failed to delete file {display_name} from storage'
|
||||
app.logger.error(error_msg)
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('asset_detail', id=asset_id))
|
||||
|
||||
# Remove from database
|
||||
db.session.delete(asset_file)
|
||||
db.session.commit()
|
||||
# Only remove from database if storage deletion was successful
|
||||
db.session.delete(asset_file)
|
||||
db.session.commit()
|
||||
|
||||
flash('File deleted successfully!', 'success')
|
||||
return redirect(url_for('asset_detail', id=asset_id))
|
||||
flash('File deleted successfully!', 'success')
|
||||
return redirect(url_for('asset_detail', id=asset_id))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error("Failed to delete asset file: %s", str(e))
|
||||
flash('Failed to delete file: ' + str(e), 'error')
|
||||
return redirect(url_for('asset_detail', id=asset_id))
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5432, debug=True)
|
||||
|
|
|
@ -22,6 +22,9 @@ class Config:
|
|||
S3_ENDPOINT_URL = os.environ.get('S3_ENDPOINT_URL')
|
||||
S3_PUBLIC_URL = os.environ.get('S3_PUBLIC_URL')
|
||||
|
||||
# Logging configuration
|
||||
LOGGING_LEVEL = os.environ.get('LOGGING_LEVEL', 'DEBUG' if os.environ.get('FLASK_ENV') != 'production' else 'INFO')
|
||||
|
||||
@staticmethod
|
||||
def init_app(app):
|
||||
# Create necessary directories
|
||||
|
@ -31,3 +34,7 @@ class Config:
|
|||
if app.config['STORAGE_URL'].startswith('file://'):
|
||||
storage_path = app.config['STORAGE_URL'].replace('file://', '')
|
||||
os.makedirs(storage_path, exist_ok=True)
|
||||
|
||||
# Configure logging
|
||||
import logging
|
||||
logging.basicConfig(level=getattr(logging, app.config['LOGGING_LEVEL']))
|
||||
|
|
44
storage.py
44
storage.py
|
@ -61,13 +61,43 @@ class StorageBackend:
|
|||
return self.fs.open(f"{self.bucket}/{full_path}", mode)
|
||||
return self.fs.open(full_path, mode)
|
||||
|
||||
def delete(self, filename: str) -> None:
|
||||
"""Delete a file from storage"""
|
||||
full_path = self._get_full_path(filename)
|
||||
if self.protocol == 's3':
|
||||
self.fs.delete(f"{self.bucket}/{full_path}")
|
||||
else:
|
||||
self.fs.delete(full_path)
|
||||
def delete(self, filename: str) -> bool:
|
||||
"""
|
||||
Delete a file from storage
|
||||
Returns True if file was deleted or didn't exist, False if deletion failed
|
||||
"""
|
||||
try:
|
||||
full_path = self._get_full_path(filename)
|
||||
if self.protocol == 's3':
|
||||
path = f"{self.bucket}/{full_path}"
|
||||
current_app.logger.debug(f"Attempting to delete S3 file: {path}")
|
||||
if self.fs.exists(path):
|
||||
current_app.logger.debug(f"File exists, deleting: {path}")
|
||||
self.fs.delete(path)
|
||||
deleted = not self.fs.exists(path)
|
||||
if deleted:
|
||||
current_app.logger.debug(f"Successfully deleted file: {path}")
|
||||
else:
|
||||
current_app.logger.error(f"Failed to delete file: {path}")
|
||||
return deleted
|
||||
current_app.logger.debug(f"File doesn't exist, skipping delete: {path}")
|
||||
return True # File didn't exist
|
||||
else:
|
||||
current_app.logger.debug(f"Attempting to delete local file: {full_path}")
|
||||
if self.fs.exists(full_path):
|
||||
current_app.logger.debug(f"File exists, deleting: {full_path}")
|
||||
self.fs.delete(full_path)
|
||||
deleted = not os.path.exists(full_path)
|
||||
if deleted:
|
||||
current_app.logger.debug(f"Successfully deleted file: {full_path}")
|
||||
else:
|
||||
current_app.logger.error(f"Failed to delete file: {full_path}")
|
||||
return deleted
|
||||
current_app.logger.debug(f"File doesn't exist, skipping delete: {full_path}")
|
||||
return True # File didn't exist
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to delete file {filename}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
def url_for(self, filename: str) -> str:
|
||||
"""Get URL for a file"""
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<form method="POST" enctype="multipart/form-data" class="form">
|
||||
<form method="POST" enctype="multipart/form-data" class="form" id="assetForm">
|
||||
<div class="form-group">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input
|
||||
|
@ -82,7 +82,7 @@
|
|||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button button-primary">
|
||||
<button type="submit" class="button button-primary" id="submitBtn">
|
||||
<i class="fas fa-save"></i> Save Asset
|
||||
</button>
|
||||
<a href="{{ url_for('index') }}" class="button button-secondary">
|
||||
|
@ -115,8 +115,11 @@
|
|||
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
|
||||
});
|
||||
|
||||
// File input preview handling
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const form = document.getElementById("assetForm");
|
||||
const loadingOverlay = document.querySelector(".loading-overlay");
|
||||
const loadingText = document.querySelector(".loading-text");
|
||||
|
||||
// Featured image preview
|
||||
const featuredInput = document.getElementById("featured_image");
|
||||
const featuredPreview = document.querySelector(".file-input-preview");
|
||||
|
@ -152,6 +155,45 @@
|
|||
`;
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
loadingOverlay.style.display = "flex";
|
||||
loadingText.textContent = "Processing...";
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "{{ url_for('add_asset') }}", true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.success) {
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
loadingText.textContent = "Failed: " + response.error;
|
||||
setTimeout(() => {
|
||||
loadingOverlay.style.display = "none";
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
loadingText.textContent = "Upload failed! Please try again.";
|
||||
setTimeout(() => {
|
||||
loadingOverlay.style.display = "none";
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
loadingText.textContent = "Network error! Please try again.";
|
||||
setTimeout(() => {
|
||||
loadingOverlay.style.display = "none";
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -78,19 +78,6 @@
|
|||
<i class="fas fa-file"></i>
|
||||
{{ file.original_filename or file.filename }}
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ url_for('delete_asset_file', id=file.id) }}"
|
||||
class="inline-form"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="button button-small button-danger"
|
||||
onclick="return confirm('Are you sure you want to delete this file?')"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
@ -27,11 +27,47 @@
|
|||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<style>
|
||||
.loading-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid #f3f3f3;
|
||||
border-top: 5px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.loading-text {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/trumbowyg.min.js"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Processing...</div>
|
||||
</div>
|
||||
<nav class="main-nav">
|
||||
<div class="nav-container">
|
||||
<div class="nav-brand">Digital Assets Manager</div>
|
||||
|
@ -68,6 +104,5 @@
|
|||
});
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<form method="POST" enctype="multipart/form-data" class="form">
|
||||
<form method="POST" enctype="multipart/form-data" class="form" id="editAssetForm">
|
||||
<div class="form-group">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input
|
||||
|
@ -90,7 +90,7 @@
|
|||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button button-primary">
|
||||
<button type="submit" class="button button-primary" id="submitBtn">
|
||||
<i class="fas fa-save"></i> Update Asset
|
||||
</button>
|
||||
<a
|
||||
|
@ -159,8 +159,11 @@
|
|||
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
|
||||
});
|
||||
|
||||
// File input preview handling (keeping your existing code)
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const form = document.getElementById("editAssetForm");
|
||||
const loadingOverlay = document.querySelector(".loading-overlay");
|
||||
const loadingText = document.querySelector(".loading-text");
|
||||
|
||||
// Featured image preview
|
||||
const featuredInput = document.getElementById("featured_image");
|
||||
const featuredPreview = document.querySelector(".file-input-preview");
|
||||
|
@ -196,6 +199,51 @@
|
|||
`;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
loadingOverlay.style.display = "flex";
|
||||
loadingText.textContent = "Processing...";
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "{{ url_for('edit_asset', id=asset.id) }}", true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.success) {
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
loadingText.textContent = "Failed: " + response.error;
|
||||
setTimeout(() => {
|
||||
loadingOverlay.style.display = "none";
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle non-JSON response (redirect)
|
||||
window.location.href = "{{ url_for('asset_detail', id=asset.id) }}";
|
||||
}
|
||||
} else {
|
||||
loadingText.textContent = "Update failed! Please try again.";
|
||||
setTimeout(() => {
|
||||
loadingOverlay.style.display = "none";
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
loadingText.textContent = "Network error! Please try again.";
|
||||
setTimeout(() => {
|
||||
loadingOverlay.style.display = "none";
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue