From 4f5c5fd09d99313eab3663bab6e906dc5d0e8df8 Mon Sep 17 00:00:00 2001 From: Timothy Rogers Date: Sat, 24 May 2025 19:01:29 -0400 Subject: [PATCH] Added spinner and fixed asset delete bug --- .env.example | 3 + app.py | 206 +++++++++++++++++++++++------------- config.py | 7 ++ storage.py | 44 ++++++-- templates/add_asset.html | 48 ++++++++- templates/asset_detail.html | 13 --- templates/base.html | 37 ++++++- templates/edit_asset.html | 54 +++++++++- 8 files changed, 314 insertions(+), 98 deletions(-) diff --git a/.env.example b/.env.example index 11e4098..8497b77 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +## Debugging +LOGGING_LEVEL=DEBUG + ## Database Configuration DATABASE_URL=sqlite:///instance/app.db diff --git a/app.py b/app.py index 7936bb6..e9b253c 100644 --- a/app.py +++ b/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//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//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) diff --git a/config.py b/config.py index 2b158fc..106e041 100644 --- a/config.py +++ b/config.py @@ -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'])) diff --git a/storage.py b/storage.py index 46852df..75e72bd 100644 --- a/storage.py +++ b/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""" diff --git a/templates/add_asset.html b/templates/add_asset.html index 1840eeb..421aa94 100644 --- a/templates/add_asset.html +++ b/templates/add_asset.html @@ -4,7 +4,7 @@
-
+
- @@ -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); + }); }); {% endblock %} diff --git a/templates/asset_detail.html b/templates/asset_detail.html index 1c6325c..6d32fe7 100644 --- a/templates/asset_detail.html +++ b/templates/asset_detail.html @@ -78,19 +78,6 @@ {{ file.original_filename or file.filename }} - - - {% endfor %} diff --git a/templates/base.html b/templates/base.html index 562a66f..9695792 100644 --- a/templates/base.html +++ b/templates/base.html @@ -27,11 +27,47 @@ crossorigin="anonymous" referrerpolicy="no-referrer" /> + {% block head %}{% endblock %} +
+
+
Processing...
+