diff --git a/STORAGE.md b/STORAGE.md new file mode 100644 index 0000000..5aa24d3 --- /dev/null +++ b/STORAGE.md @@ -0,0 +1,403 @@ +# Django Icon Picker with Django-storages Support + +A Django package that provides an icon picker field with support for both Iconify icons and local/cloud storage through django-storages integration. + +## Features + +- 🎨 **Iconify Integration**: Access thousands of icons from Iconify API +- ☁️ **Cloud Storage Support**: Full django-storages integration (S3, GCS, Azure, etc.) +- 🎯 **Easy Integration**: Simple field that works like any Django model field +- 🎨 **Color Customization**: Built-in color picker for icons +- 🔍 **Real-time Search**: Live icon search with preview +- 📱 **Responsive Design**: Works on desktop and mobile +- 🌙 **Dark Mode Support**: Automatic dark theme detection +- 🔒 **Permission-based**: Respects Django's permission system + +## Installation + +```bash +# Basic installation +pip install django-icon-picker + +# For cloud storage support, choose your provider: + +# AWS S3 +pip install django-storages[boto3] + +# Google Cloud Storage +pip install django-storages[google] + +# Azure Blob Storage +pip install django-storages[azure] + +# Or install all storage backends +pip install django-storages[boto3,google,azure] +``` + +### Basic Setup + +Add to your `INSTALLED_APPS`: + +```python +INSTALLED_APPS = [ + # ... other apps + 'django_icon_picker', + 'storages', # If using cloud storage +] +``` + +Include URLs in your project: + +```python +from django.urls import path, include + +urlpatterns = [ + # ... other URLs + path('icon_picker/', include('django_icon_picker.urls')), +] +``` + +## Configuration + +### Local Storage (Default) + +```python +# settings.py +ICON_PICKER_PATH = 'icons' # Path relative to MEDIA_ROOT +ICON_PICKER_COLOR = '#00bcc9' # Default icon color +``` + +### AWS S3 Storage + +```python +# settings.py +ICON_PICKER_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + +# AWS S3 Configuration +AWS_ACCESS_KEY_ID = 'your-access-key-id' +AWS_SECRET_ACCESS_KEY = 'your-secret-access-key' +AWS_STORAGE_BUCKET_NAME = 'your-bucket-name' +AWS_S3_REGION_NAME = 'us-east-1' # or your preferred region +AWS_DEFAULT_ACL = 'public-read' # Make icons publicly accessible +AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' + +# Optional: CloudFront CDN +# AWS_S3_CUSTOM_DOMAIN = 'your-cloudfront-domain.cloudfront.net' +``` + +### Google Cloud Storage + +```python +# settings.py +ICON_PICKER_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage' + +# GCS Configuration +from google.oauth2 import service_account + +GS_BUCKET_NAME = 'your-bucket-name' +GS_PROJECT_ID = 'your-project-id' +GS_DEFAULT_ACL = 'publicRead' +GS_CREDENTIALS = service_account.Credentials.from_service_account_file( + "path/to/your/service-account-key.json" +) +``` + +### Azure Blob Storage + +```python +# settings.py +ICON_PICKER_STORAGE = 'storages.backends.azure_storage.AzureStorage' + +# Azure Configuration +AZURE_ACCOUNT_NAME = 'your-storage-account' +AZURE_ACCOUNT_KEY = 'your-account-key' +AZURE_CONTAINER = 'icons' +AZURE_CUSTOM_DOMAIN = f'{AZURE_ACCOUNT_NAME}.blob.core.windows.net' +``` + +### DigitalOcean Spaces + +```python +# settings.py +ICON_PICKER_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + +# DO Spaces uses S3-compatible API +AWS_ACCESS_KEY_ID = 'your-spaces-access-key' +AWS_SECRET_ACCESS_KEY = 'your-spaces-secret-key' +AWS_STORAGE_BUCKET_NAME = 'your-space-name' +AWS_S3_ENDPOINT_URL = 'https://nyc3.digitaloceanspaces.com' # Your region +AWS_S3_REGION_NAME = 'nyc3' +AWS_DEFAULT_ACL = 'public-read' +``` + +## Usage + +### Basic Model Usage + +```python +# models.py +from django.db import models +from django_icon_picker.field import IconField + +class Category(models.Model): + name = models.CharField(max_length=100) + icon = IconField() # Uses default storage configuration + + def __str__(self): + return self.name + +class Product(models.Model): + name = models.CharField(max_length=200) + # Custom storage for this specific field + icon = IconField(storage=MyCustomStorage()) +``` + +### Admin Integration + +```python +# admin.py +from django.contrib import admin +from django.utils.html import format_html +from .models import Category + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'icon_preview'] + + def icon_preview(self, obj): + if obj.icon: + from django_icon_picker.field import IconField + field = IconField() + icon_url = field.get_file_url(obj.icon) + if icon_url: + return format_html( + '', + icon_url + ) + return "No icon" + + icon_preview.short_description = "Icon" +``` + +### Template Usage + +```html + +{% load static %} + +
+ {% for category in categories %} +
+ {% if category.icon %} + {{ category.name }} + {% endif %} + {{ category.name }} +
+ {% endfor %} +
+``` + +### View Usage + +```python +# views.py +from django.shortcuts import render +from .models import Category + +def category_list(request): + categories = [] + for category in Category.objects.all(): + from django_icon_picker.field import IconField + field = IconField() + icon_url = field.get_file_url(category.icon) if category.icon else None + + categories.append({ + 'name': category.name, + 'icon_url': icon_url + }) + + return render(request, 'categories.html', {'categories': categories}) +``` + +## Management Commands + +### Migrate Icons to Cloud Storage + +```bash +# Migrate all icons to configured storage +python manage.py migrate_icons_to_storage + +# Dry run to see what would be migrated +python manage.py migrate_icons_to_storage --dry-run + +# Migrate specific app or model +python manage.py migrate_icons_to_storage --app myapp --model Category + +# Create backups during migration +python manage.py migrate_icons_to_storage --backup +``` + +### Test Storage Configuration + +```bash +# Test your storage setup +python manage.py test_icon_storage +``` + +### Clean Up Orphaned Files + +```bash +# Remove unused icon files +python manage.py cleanup_icon_files + +# Dry run first +python manage.py cleanup_icon_files --dry-run +``` + +## Troubleshooting + +### Common Issues + +#### 1. Missing boto3 for S3 +```bash +# Error: Could not load Boto3's S3 bindings. No module named 'boto3' +pip install django-storages[boto3] +``` + +#### 2. Permission Denied +Ensure your cloud storage credentials have proper permissions: +- S3: `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject` +- GCS: Storage Admin or Storage Object Admin +- Azure: Storage Blob Data Contributor + +#### 3. CORS Issues +For direct browser access to cloud storage, configure CORS: + +**AWS S3 CORS Configuration:** +```json +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedOrigins": ["https://yourdomain.com"], + "ExposeHeaders": [] + } +] +``` + +#### 4. Icons Not Loading +Check your storage configuration: +```python +# Debug storage +from django_icon_picker.field import IconField +field = IconField() +storage = field.get_storage() +print(f"Using storage: {storage.__class__.__name__}") + +# Test with management command +python manage.py test_icon_storage +``` + +## Advanced Usage + +### Custom Storage Backend + +```python +# custom_storage.py +from django.core.files.storage import FileSystemStorage +from storages.backends.s3boto3 import S3Boto3Storage + +class CustomIconStorage: + def __init__(self): + self.cloud = S3Boto3Storage() + self.local = FileSystemStorage() + + def save(self, name, content): + try: + return self.cloud.save(name, content) + except: + return self.local.save(name, content) + + # Implement other required methods... + +# settings.py +ICON_PICKER_STORAGE = 'myapp.custom_storage.CustomIconStorage' +``` + +### Field-Specific Storage + +```python +# models.py +from storages.backends.s3boto3 import S3Boto3Storage + +class MyModel(models.Model): + # Use default storage + icon = IconField() + + # Use specific S3 bucket + badge = IconField(storage=S3Boto3Storage(bucket_name='badges')) +``` + +### Bulk Icon Operations + +```python +# utils.py +from django_icon_picker.field import IconField +import requests +from django.core.files.base import ContentFile + +def bulk_import_from_iconify(model_instances, icon_mapping): + """ + Bulk import icons from Iconify API + + Usage: + bulk_import_from_iconify( + Category.objects.all(), + {'Tech': 'mdi:laptop', 'Food': 'mdi:food'} + ) + """ + field = IconField() + storage = field.get_storage() + + for instance in model_instances: + iconify_name = icon_mapping.get(instance.name) + if iconify_name: + response = requests.get(f'https://api.iconify.design/{iconify_name}.svg') + if response.status_code == 200: + filename = f"icons/{instance._meta.model_name}/icon-{instance.id}.svg" + path = storage.save(filename, ContentFile(response.content)) + instance.icon = path + instance.save() +``` + +## Security Considerations + +1. **File Permissions**: Ensure icons are publicly readable if needed +2. **Storage Access**: Use least-privilege IAM policies +3. **HTTPS**: Always use HTTPS for production +4. **Validation**: Icons are validated as SVG files +5. **Authentication**: Respects Django's permission system + +## Performance Tips + +1. **CDN**: Use CloudFront/CloudFlare for better performance +2. **Caching**: Enable browser caching with proper headers +3. **Compression**: Use gzip compression for SVG files +4. **Lazy Loading**: Implement lazy loading for icon-heavy pages + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Submit a pull request + +## License + +This project is licensed under the MIT License. + +## Support + +- GitHub Issues: Report bugs and request features +- Documentation: Full API documentation available +- Community: Join our community discussions \ No newline at end of file diff --git a/django-icon-picker/django_icon_picker/field.py b/django-icon-picker/django_icon_picker/field.py index f704b1a..9b49a0c 100644 --- a/django-icon-picker/django_icon_picker/field.py +++ b/django-icon-picker/django_icon_picker/field.py @@ -1,14 +1,20 @@ -# fields.py +# field.py from django.db import models -from .widgets import IconPicker +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.conf import settings from django.db.models.signals import pre_delete +from .widgets import IconPicker import os +import uuid class IconField(models.CharField): - description = "A custom field to store icon information." + description = "A custom field to store icon information with Django-storages support." def __init__(self, *args, **kwargs): + # Allow custom storage backend + self.storage = kwargs.pop('storage', None) kwargs["max_length"] = 255 super().__init__(*args, **kwargs) @@ -25,8 +31,40 @@ def contribute_to_class(self, cls, name, **kwargs): def _delete_file(self, sender, instance, **kwargs): file_path = getattr(instance, self.attname) - if file_path and os.path.exists(file_path): + if file_path: try: - os.remove(file_path) - except OSError as e: - pass + # Use the configured storage backend + storage = self.storage or default_storage + + # Check if using cloud storage or local storage + if hasattr(storage, 'delete'): + # For cloud storage backends + if storage.exists(file_path): + storage.delete(file_path) + else: + # Fallback for local file system + if os.path.exists(file_path): + os.remove(file_path) + except Exception as e: + # Log the error but don't raise it to prevent deletion failures + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Failed to delete icon file {file_path}: {str(e)}") + + def get_storage(self): + """Get the storage backend for this field""" + return self.storage or default_storage + + def get_file_url(self, file_path): + """Get the URL for accessing the file""" + if not file_path: + return None + + storage = self.get_storage() + + # For cloud storage, use the storage's url method + if hasattr(storage, 'url'): + return storage.url(file_path) + + # For local storage fallback + return f"/{file_path}" if not file_path.startswith('/') else file_path \ No newline at end of file diff --git a/django-icon-picker/django_icon_picker/static/django_icon_picker/js/icon_picker.js b/django-icon-picker/django_icon_picker/static/django_icon_picker/js/icon_picker.js index 95cca71..da4fad5 100644 --- a/django-icon-picker/django_icon_picker/static/django_icon_picker/js/icon_picker.js +++ b/django-icon-picker/django_icon_picker/static/django_icon_picker/js/icon_picker.js @@ -10,6 +10,8 @@ class IconPicker { this.objectId = options.objectId; this.model = options.model; this.icon = ""; + this.usesCloudStorage = options.usesCloudStorage || false; + this.storageType = options.storageType || 'FileSystemStorage'; this.init(); } @@ -17,15 +19,87 @@ class IconPicker { init() { this.setupInitialIcon(); this.setupEventListeners(); + this.showStorageInfo(); } setupInitialIcon() { - this.selectedIcon.src = this.searchInput.value.endsWith(".svg") - ? `/${this.searchInput.value}` - : `https://api.iconify.design/${this.searchInput.value}.svg`; + const currentValue = this.searchInput.value; + + if (!currentValue) { + this.selectedIcon.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23cccccc' d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E"; + return; + } + + // Determine icon URL based on storage type and value format + let iconUrl = this.getIconUrl(currentValue); + this.selectedIcon.src = iconUrl; + } + + getIconUrl(value) { + if (!value) return ""; + + // If it's already a full URL, use it + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + + // If it's an Iconify icon format (contains colon) + if (value.includes(':') && !value.endsWith('.svg')) { + const color = this.colorPicker ? this.colorPicker.value.replace('#', '%23') : ''; + return `https://api.iconify.design/${value}.svg${color ? `?color=${color}` : ''}`; + } + + // If it's a stored file path + if (value.endsWith('.svg')) { + // For cloud storage, we might need to fetch the URL + if (this.usesCloudStorage) { + this.fetchStoredIconUrl(value); + // Return a placeholder while fetching + return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23cccccc' d='M12 2v20l7-5V7l-7-5z'/%3E%3C/svg%3E"; + } + + // For local storage + return value.startsWith('/') ? value : `/${value}`; + } + + return value; + } + + async fetchStoredIconUrl(filePath) { + try { + const response = await fetch(`/icon_picker/get-icon-url/?model=${this.model}&id=${this.objectId}`); + const data = await response.json(); + + if (data.url) { + this.selectedIcon.src = data.url; + } + } catch (error) { + console.warn('Failed to fetch stored icon URL:', error); + } + } + + showStorageInfo() { + // Add visual indicator for storage type (optional) + if (this.usesCloudStorage) { + const storageIndicator = document.createElement('div'); + storageIndicator.className = 'storage-indicator'; + storageIndicator.textContent = `Using ${this.storageType}`; + storageIndicator.style.cssText = ` + font-size: 11px; + color: #666; + margin-top: 2px; + font-style: italic; + `; + + const container = this.searchInput.parentNode; + if (container && !container.querySelector('.storage-indicator')) { + container.appendChild(storageIndicator); + } + } } setupEventListeners() { + // Search input listener this.searchInput.addEventListener("input", () => { const query = this.searchInput.value; if (query.length > 2) { @@ -35,52 +109,85 @@ class IconPicker { } }); - this.form.addEventListener("submit", (event) => { - if (this.savePath) { - this.downloadAndSaveSvg( - `${this.icon}.svg&color=${this.colorPicker.value.replace("#", "%23")}` - ); - } - this.form.submit(); - }); + // Color picker listener + if (this.colorPicker) { + this.colorPicker.addEventListener("input", () => { + this.updateSelectedIconColor(); + }); + } + + // Form submit listener + if (this.form) { + this.form.addEventListener("submit", (event) => { + if (this.savePath && this.icon) { + this.downloadAndSaveSvg(); + } + }); + } + } + + updateSelectedIconColor() { + const currentValue = this.searchInput.value; + if (currentValue && currentValue.includes(':') && !currentValue.endsWith('.svg')) { + // Update Iconify icon with new color + const color = this.colorPicker.value.replace('#', '%23'); + this.selectedIcon.src = `https://api.iconify.design/${currentValue}.svg?color=${color}`; + } } async searchIcons(query) { - const response = await fetch( - `https://api.iconify.design/search?query=${encodeURIComponent( - query - )}&limit=10&start=0&prefix=${this.selectedPrefix}` - ); - const data = await response.json(); + try { + const response = await fetch( + `https://api.iconify.design/search?query=${encodeURIComponent(query)}&limit=10&start=0&prefix=${this.selectedPrefix}` + ); - this.resultsDiv.innerHTML = ""; - if (data.icons.length > 0) { - const dropdownList = document.createElement("div"); - dropdownList.className = "icon-dropdown-list"; - - data.icons.forEach((icon) => { - const iconName = icon.replace(":", "-"); - const color = this.colorPicker.value.replace("#", "%23"); - const iconBaseUrl = `https://api.iconify.design/${icon}.svg`; - const iconUrl = `${iconBaseUrl}?color=${color}`; - - const dropdownItem = this.createDropdownItem(icon, iconUrl); - dropdownList.appendChild(dropdownItem); - }); + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } - this.resultsDiv.appendChild(dropdownList); - } else { - this.resultsDiv.textContent = "No icons found."; + const data = await response.json(); + + this.resultsDiv.innerHTML = ""; + + if (data.icons && data.icons.length > 0) { + const dropdownList = document.createElement("div"); + dropdownList.className = "icon-dropdown-list"; + + data.icons.forEach((icon) => { + const color = this.colorPicker ? this.colorPicker.value.replace("#", "%23") : ''; + const iconUrl = `https://api.iconify.design/${icon}.svg${color ? `?color=${color}` : ''}`; + + const dropdownItem = this.createDropdownItem(icon, iconUrl); + dropdownList.appendChild(dropdownItem); + }); + + this.resultsDiv.appendChild(dropdownList); + } else { + this.showNoResults("No icons found for your search."); + } + } catch (error) { + console.error("Search error:", error); + this.showNoResults("Search failed. Please try again."); } } + showNoResults(message) { + this.resultsDiv.innerHTML = `
${message}
`; + } + createDropdownItem(icon, iconUrl) { const item = document.createElement("div"); - item.className = "icon-dropdown-item" + item.className = "icon-dropdown-item"; const iconImg = document.createElement("img"); iconImg.src = iconUrl; iconImg.className = "icon-preview"; + iconImg.alt = `Icon: ${icon}`; + + // Handle image load errors + iconImg.addEventListener('error', () => { + iconImg.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Ctext x='12' y='12' text-anchor='middle' fill='%23999'%3E?%3C/text%3E%3C/svg%3E"; + }); const iconText = document.createElement("span"); iconText.textContent = icon; @@ -90,30 +197,105 @@ class IconPicker { item.appendChild(iconText); item.addEventListener("click", () => { + this.selectIcon(icon, iconUrl); + }); + + return item; + } + + selectIcon(icon, iconUrl) { + this.icon = icon; + this.selectedIcon.src = iconUrl; + this.resultsDiv.innerHTML = ""; + + // Update the input value based on whether we're saving locally or not + if (this.savePath) { + // Will be updated after successful save this.searchInput.value = icon; - this.selectedIcon.src = iconUrl; - this.resultsDiv.innerHTML = ""; - this.icon = this.searchInput.value; - if (this.savePath) { - this.searchInput.value = - this.savePath + `/${this.model}/icon-${this.objectId}.svg`; + } else { + // Store the Iconify icon reference + this.searchInput.value = icon; + } + } + + async downloadAndSaveSvg() { + if (!this.icon) return; + + const color = this.colorPicker ? this.colorPicker.value : ''; + const params = new URLSearchParams({ + icon: this.icon, + id: this.objectId, + model: this.model + }); + + if (color) { + params.append('color', color); + } + + try { + const response = await fetch(`/icon_picker/download-svg/?${params}`); + + if (response.ok) { + const savedPath = await response.text(); + this.searchInput.value = savedPath; + + // Update the preview with the saved file + if (this.usesCloudStorage) { + this.fetchStoredIconUrl(savedPath); + } else { + this.selectedIcon.src = `/${savedPath}`; + } + + this.showSuccess("Icon saved successfully!"); } else { - this.searchInput.value = icon; + const errorData = await response.json().catch(() => ({ error: 'Save failed' })); + this.showError(errorData.error || 'Failed to save icon'); } - }); + } catch (error) { + console.error("Save error:", error); + this.showError("Network error while saving icon"); + } + } - return item; + showSuccess(message) { + this.showMessage(message, 'success'); } - downloadAndSaveSvg(svgIcon) { - const downloadUrl = `/icon_picker/download-svg/?icon=${svgIcon}&id=${this.objectId}&model=${this.model}`; - fetch(downloadUrl) - .then((response) => response.text()) - .then((data) => { - this.searchInput.value = data; - }) - .catch((error) => { - console.error("Error:", error); - }); + showError(message) { + this.showMessage(message, 'error'); + } + + showMessage(message, type) { + // Create or update message element + let messageEl = document.getElementById('icon-picker-message'); + if (!messageEl) { + messageEl = document.createElement('div'); + messageEl.id = 'icon-picker-message'; + messageEl.style.cssText = ` + padding: 8px 12px; + margin: 5px 0; + border-radius: 4px; + font-size: 13px; + position: relative; + `; + this.resultsDiv.parentNode.insertBefore(messageEl, this.resultsDiv); + } + + messageEl.textContent = message; + messageEl.className = `icon-picker-message ${type}`; + + // Style based on type + if (type === 'success') { + messageEl.style.cssText += 'background: #d4edda; color: #155724; border: 1px solid #c3e6cb;'; + } else { + messageEl.style.cssText += 'background: #f8d7da; color: #721c24; border: 1px solid #f1aeb5;'; + } + + // Auto-hide after 3 seconds + setTimeout(() => { + if (messageEl && messageEl.parentNode) { + messageEl.parentNode.removeChild(messageEl); + } + }, 3000); } -} +} \ No newline at end of file diff --git a/django-icon-picker/django_icon_picker/templates/django_icon_picker/icon_picker.html b/django-icon-picker/django_icon_picker/templates/django_icon_picker/icon_picker.html index fe01352..fea4357 100644 --- a/django-icon-picker/django_icon_picker/templates/django_icon_picker/icon_picker.html +++ b/django-icon-picker/django_icon_picker/templates/django_icon_picker/icon_picker.html @@ -5,47 +5,37 @@
- - + Selected icon preview - - + style="margin-right: 10px; width: 24px; height: 24px; object-fit: contain; border: 1px solid #ddd; border-radius: 3px; padding: 2px;" + aria-label="Preview of selected icon" title="Selected icon preview"> + + {% include "django/forms/widgets/text.html" %} - - - +
- + + + {% if uses_cloud_storage %} +
+ Using {{ storage_type }} for icon storage +
+ {% endif %} + -
+
- - -
@@ -54,23 +44,78 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/django-icon-picker/django_icon_picker/urls.py b/django-icon-picker/django_icon_picker/urls.py index e8c7a64..bc0e1e5 100644 --- a/django-icon-picker/django_icon_picker/urls.py +++ b/django-icon-picker/django_icon_picker/urls.py @@ -1,6 +1,9 @@ from django.urls import path from . import views +app_name = 'django_icon_picker' + urlpatterns = [ path("download-svg/", views.download_and_save_svg, name="download_svg"), -] + path("get-icon-url/", views.get_icon_url, name="get_icon_url"), +] \ No newline at end of file diff --git a/django-icon-picker/django_icon_picker/views.py b/django-icon-picker/django_icon_picker/views.py index 48585e4..2013f82 100644 --- a/django-icon-picker/django_icon_picker/views.py +++ b/django-icon-picker/django_icon_picker/views.py @@ -1,38 +1,129 @@ # views.py - -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.decorators import login_required import requests import os +import uuid +import logging + +logger = logging.getLogger(__name__) + + +def get_icon_storage(): + """ + Get the storage backend for icons. + Uses ICON_PICKER_STORAGE setting if provided, otherwise default_storage. + """ + storage_setting = getattr(settings, 'ICON_PICKER_STORAGE', None) + + if storage_setting: + # Import and instantiate custom storage + if isinstance(storage_setting, str): + # If it's a string, import the storage class + from django.utils.module_loading import import_string + storage_class = import_string(storage_setting) + return storage_class() + else: + # If it's already an instance, use it + return storage_setting + + return default_storage +def get_icon_path(model, icon_id): + """Generate the storage path for an icon""" + base_path = getattr(settings, 'ICON_PICKER_PATH', 'icons') + + # Remove leading slash for cloud storage compatibility + if base_path.startswith('/'): + base_path = base_path[1:] + + return f"{base_path}/{model}/icon-{icon_id}.svg" + + +@csrf_exempt +@login_required def download_and_save_svg(request): + """Download SVG from Iconify API and save using Django-storages""" + + model = request.GET.get("model") + icon_id = request.GET.get("id") + svg_icon = request.GET.get("icon") + + # Permission check + if not (request.user.is_superuser or request.user.has_perm(f"edit_{model}")): + return JsonResponse({"error": "Permission denied"}, status=403) + + if not all([model, icon_id, svg_icon]): + return JsonResponse({"error": "Missing required parameters"}, status=400) + + try: + # Construct the Iconify URL + color = request.GET.get("color", "").replace("#", "%23") + svg_url = f"https://api.iconify.design/{svg_icon}" + if color: + svg_url += f"?color={color}" + + # Download the SVG content + response = requests.get(svg_url, timeout=10) + if response.status_code != 200: + return JsonResponse({ + "error": f"Failed to download SVG. Status: {response.status_code}" + }, status=400) + + # Get storage backend and file path + storage = get_icon_storage() + file_path = get_icon_path(model, icon_id) + + # Save the SVG content + svg_content = ContentFile(response.content) + + # Delete existing file if it exists + if storage.exists(file_path): + storage.delete(file_path) + + # Save the new file + saved_path = storage.save(file_path, svg_content) + + # Return the saved path (for the form field) + return HttpResponse(saved_path) + + except requests.RequestException as e: + logger.error(f"Network error downloading SVG: {str(e)}") + return JsonResponse({"error": "Network error downloading icon"}, status=500) + + except Exception as e: + logger.error(f"Error saving SVG: {str(e)}") + return JsonResponse({"error": "Error saving icon file"}, status=500) + + +def get_icon_url(request): + """Get the URL for an icon file (useful for AJAX requests)""" model = request.GET.get("model") - if request.user.is_superuser or request.user.has_perm(f"edit_{model}"): - svg_icon = request.GET.get("icon") - color = request.GET.get("color").replace("#", "%23") - svg_url = f"https://api.iconify.design/{svg_icon}?color={color}" - id = request.GET.get("id") - # Define the path where you want to save the SVG file - save_path = getattr(settings, "ICON_PICKER_PATH") - - save_path = f"{save_path}/{model}" - # Ensure the save path exists - os.makedirs(save_path, exist_ok=True) - # Download the SVG file - response = requests.get(svg_url) - if response.status_code == 200: - # Extract the filename from the URL - filename = os.path.basename(f"icon-{id}.svg") - file_path = os.path.join(save_path, filename) - - # Save the SVG file - with open(file_path, "wb") as f: - f.write(response.content) - return HttpResponse(file_path) + icon_id = request.GET.get("id") + + if not all([model, icon_id]): + return JsonResponse({"error": "Missing parameters"}, status=400) + + try: + storage = get_icon_storage() + file_path = get_icon_path(model, icon_id) + + if storage.exists(file_path): + if hasattr(storage, 'url'): + url = storage.url(file_path) + else: + # Fallback for local storage + url = f"/{file_path}" + + return JsonResponse({"url": url}) else: - return HttpResponse( - f"Failed to download SVG file. Status code: {response.reason}" - ) - else: - return HttpResponse("Not permitted") + return JsonResponse({"error": "Icon not found"}, status=404) + + except Exception as e: + logger.error(f"Error getting icon URL: {str(e)}") + return JsonResponse({"error": "Error retrieving icon"}, status=500) \ No newline at end of file diff --git a/django-icon-picker/django_icon_picker/widgets.py b/django-icon-picker/django_icon_picker/widgets.py index 96b77b4..5d5ba34 100644 --- a/django-icon-picker/django_icon_picker/widgets.py +++ b/django-icon-picker/django_icon_picker/widgets.py @@ -1,31 +1,95 @@ from django.forms import Widget from django.conf import settings +from django.core.files.storage import default_storage import uuid +import os class IconPicker(Widget): template_name = "django_icon_picker/icon_picker.html" def get_settings_attr(self, context, key, attr): + """Safely get settings attributes""" try: context[key] = getattr(settings, attr) - except: + except AttributeError: pass + def get_storage(self): + """Get the storage backend for icons""" + storage_setting = getattr(settings, 'ICON_PICKER_STORAGE', None) + + if storage_setting: + if isinstance(storage_setting, str): + from django.utils.module_loading import import_string + storage_class = import_string(storage_setting) + return storage_class() + else: + return storage_setting + + return default_storage + + def get_icon_url(self, value): + """Get the URL for the icon file""" + if not value: + return None + + storage = self.get_storage() + + # If value is already a URL (starts with http), return as is + if value.startswith(('http://', 'https://')): + return value + + # If it's an Iconify icon (contains colon), construct Iconify URL + if ':' in value and not value.endswith('.svg'): + return f"https://api.iconify.design/{value}.svg" + + # For stored files, use storage backend + try: + if hasattr(storage, 'url') and storage.exists(value): + return storage.url(value) + elif os.path.exists(value): + # Local file fallback + return f"/{value}" if not value.startswith('/') else value + except Exception: + pass + + return None + def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) + + # Add storage-related context self.get_settings_attr(context, "save_path", "ICON_PICKER_PATH") self.get_settings_attr(context, "default_color", "ICON_PICKER_COLOR") - context.update( - { - "object_id": self.get_object_id(value), - } - ) + + # Add storage type information + storage = self.get_storage() + context.update({ + "object_id": self.get_object_id(value), + "icon_url": self.get_icon_url(value), + "uses_cloud_storage": hasattr(storage, 'bucket_name') or hasattr(storage, 'azure_container'), + "storage_type": storage.__class__.__name__, + }) + return context def get_object_id(self, value): + """Extract object ID from the stored value""" if value: - return value.split("/")[-1].split(".")[0].replace("icon-", "") + # Try to extract ID from file path + if value.endswith('.svg'): + filename = os.path.basename(value) + if filename.startswith('icon-') and filename.endswith('.svg'): + return filename[5:-4] # Remove 'icon-' prefix and '.svg' suffix + + # For other formats, try to extract from path + parts = value.split('/') + if len(parts) > 0: + last_part = parts[-1] + if last_part.startswith('icon-'): + return last_part.replace('icon-', '').replace('.svg', '') + return str(uuid.uuid4()) class Media: @@ -38,4 +102,4 @@ class Media: js = ( "https://cdn.jsdelivr.net/gh/mdbassit/Coloris@latest/dist/coloris.min.js", "django_icon_picker/js/icon_picker.js", - ) + ) \ No newline at end of file diff --git a/django-icon-picker/setup.py b/django-icon-picker/setup.py index 7070221..5007b76 100644 --- a/django-icon-picker/setup.py +++ b/django-icon-picker/setup.py @@ -23,18 +23,54 @@ "django_icon_picker": ["templates/django_icon_picker/icon_picker.html"], }, classifiers=[ - "Development Status :: 3 - Alpha", - "Environment :: Web Environment", - "Framework :: Django", - "Framework :: Django :: 3.0", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", ], -) + python_requires=">=3.8", + install_requires=[ + "Django>=3.2,<5.0", + "requests>=2.25.0", + ], + extras_require={ + "s3": ["django-storages[boto3]>=1.12.0"], + "gcs": ["django-storages[google]>=1.12.0"], + "azure": ["django-storages[azure]>=1.12.0"], + "all-storage": ["django-storages[boto3,google,azure]>=1.12.0"], + "dev": [ + "pytest>=6.0", + "pytest-django>=4.0", + "black>=21.0", + "isort>=5.0", + "flake8>=3.8", + "coverage>=5.0", + ], + }, + include_package_data=True, + package_data={ + "django_icon_picker": [ + "static/django_icon_picker/css/*.css", + "static/django_icon_picker/js/*.js", + "templates/django_icon_picker/*.html", + ], + }, + keywords="django icons iconify storage s3 gcs azure", + project_urls={ + "Bug Reports": "https://github.com/bixat/django-icon-picker/issues", + "Source": "https://github.com/bixat/django-icon-picker", + "Documentation": "https://pypi.org/project/django-icon-picker/", + }, +) \ No newline at end of file diff --git a/django_icon_picker_example/django_icon_picker_example/settings.py b/django_icon_picker_example/django_icon_picker_example/settings.py index c330402..afcfb52 100644 --- a/django_icon_picker_example/django_icon_picker_example/settings.py +++ b/django_icon_picker_example/django_icon_picker_example/settings.py @@ -39,6 +39,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django_icon_picker", + "storages", "example", ] @@ -123,6 +124,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_URL = "/media/" +# Basic configuration (works with any storage backend) ICON_PICKER_PATH = "media" ICON_PICKER_COLOR = "#00bcc9" @@ -130,3 +132,95 @@ # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Django Settings Examples for Icon Picker with Django-storages + +# Example 1: AWS S3 Storage +# pip install django-storages[boto3] +ICON_PICKER_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + +# S3 Configuration +# AWS_ACCESS_KEY_ID = 'your-access-key' +# AWS_SECRET_ACCESS_KEY = 'your-secret-key' +# AWS_STORAGE_BUCKET_NAME = 'your-bucket-name' +# AWS_S3_REGION_NAME = 'us-east-1' +# AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' +# AWS_DEFAULT_ACL = 'public-read' +# AWS_S3_OBJECT_PARAMETERS = { +# 'CacheControl': 'max-age=86400', +# } + +# Example 2: Google Cloud Storage +# pip install django-storages[google] +# ICON_PICKER_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage' + +# GCS Configuration +# GS_BUCKET_NAME = 'your-bucket-name' +# GS_PROJECT_ID = 'your-project-id' +# GS_DEFAULT_ACL = 'publicRead' +# GS_CREDENTIALS = service_account.Credentials.from_service_account_file( +# "path/to/your/service-account-key.json" +# ) + +# Example 3: Azure Blob Storage +# pip install django-storages[azure] +# ICON_PICKER_STORAGE = 'storages.backends.azure_storage.AzureStorage' + +# Azure Configuration +# AZURE_ACCOUNT_NAME = 'your-account-name' +# AZURE_ACCOUNT_KEY = 'your-account-key' +# AZURE_CONTAINER = 'your-container-name' +# AZURE_CUSTOM_DOMAIN = f'{AZURE_ACCOUNT_NAME}.blob.core.windows.net' + +# Example 4: DigitalOcean Spaces +# pip install django-storages[boto3] +# ICON_PICKER_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + +# DO Spaces Configuration +# AWS_ACCESS_KEY_ID = 'your-spaces-key' +# AWS_SECRET_ACCESS_KEY = 'your-spaces-secret' +# AWS_STORAGE_BUCKET_NAME = 'your-space-name' +# AWS_S3_ENDPOINT_URL = 'https://nyc3.digitaloceanspaces.com' +# AWS_S3_REGION_NAME = 'nyc3' +# AWS_DEFAULT_ACL = 'public-read' + +# Example 5: Custom Storage Backend +# You can also pass a storage instance directly +# from django.core.files.storage import FileSystemStorage +# import os + +# Custom local storage with different base path +# ICON_STORAGE = FileSystemStorage( +# location=os.path.join(BASE_DIR, 'custom_icons'), +# base_url='/custom_icons/' +# ) +# ICON_PICKER_STORAGE = ICON_STORAGE + +# Example 6: CDN Integration (with S3 + CloudFront) +# ICON_PICKER_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' +# AWS_S3_CUSTOM_DOMAIN = 'your-cdn-domain.cloudfront.net' + +# Example 7: Multiple Storage Backends +# You can even create different storage for different environments +# import os +# from django.conf import settings + +# if settings.DEBUG: +# Use local storage in development +# ICON_PICKER_STORAGE = None # Uses default_storage +# else: +# Use cloud storage in production +# ICON_PICKER_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + +# Security Settings for Production +# SECURE_SSL_REDIRECT = True +# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# Optional: Custom permissions +# For icons that should be publicly accessible +# AWS_DEFAULT_ACL = 'public-read' + +# For private icons (requires signed URLs) +# AWS_DEFAULT_ACL = 'private' +# AWS_QUERYSTRING_AUTH = True +# AWS_QUERYSTRING_EXPIRE = 3600 # 1 hour diff --git a/django_icon_picker_example/example/tests.py b/django_icon_picker_example/example/tests.py index 7ce503c..a79ca8b 100644 --- a/django_icon_picker_example/example/tests.py +++ b/django_icon_picker_example/example/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/django_icon_picker_example/example/views.py b/django_icon_picker_example/example/views.py index 91ea44a..fd0e044 100644 --- a/django_icon_picker_example/example/views.py +++ b/django_icon_picker_example/example/views.py @@ -1,3 +1,3 @@ -from django.shortcuts import render +# from django.shortcuts import render # Create your views here.