Add image picker to site builder components and fix media gallery bugs
- Add ImagePickerField to all site builder components with image URLs:
- Marketing: Hero, SplitContent, LogoCloud, GalleryGrid, Testimonials,
ContentBlocks, Header, Footer
- Email: EmailImage, EmailHeader
- Fix media gallery issues:
- Change API endpoint from /media/ to /media-files/ to avoid URL conflict
- Fix album file_count annotation conflict with model property
- Fix image URLs to use absolute paths for cross-domain access
- Add clipboard fallback for non-HTTPS copy URL
- Show permission slugs in plan feature editor
- Fix branding settings access for tenants with custom_branding feature
- Fix EntitlementService method call in StorageQuotaService
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -123,7 +123,7 @@ export async function deleteAlbum(id: number): Promise<void> {
|
||||
*/
|
||||
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
|
||||
const params = albumId !== undefined ? { album: albumId } : {};
|
||||
const response = await apiClient.get('/media/', { params });
|
||||
const response = await apiClient.get('/media-files/', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFi
|
||||
* Get a single media file
|
||||
*/
|
||||
export async function getMediaFile(id: number): Promise<MediaFile> {
|
||||
const response = await apiClient.get(`/media/${id}/`);
|
||||
const response = await apiClient.get(`/media-files/${id}/`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function uploadMediaFile(
|
||||
formData.append('alt_text', altText);
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/media/', formData, {
|
||||
const response = await apiClient.post('/media-files/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -167,7 +167,7 @@ export async function updateMediaFile(
|
||||
id: number,
|
||||
data: MediaFileUpdatePayload
|
||||
): Promise<MediaFile> {
|
||||
const response = await apiClient.patch(`/media/${id}/`, data);
|
||||
const response = await apiClient.patch(`/media-files/${id}/`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export async function updateMediaFile(
|
||||
* Delete a media file
|
||||
*/
|
||||
export async function deleteMediaFile(id: number): Promise<void> {
|
||||
await apiClient.delete(`/media/${id}/`);
|
||||
await apiClient.delete(`/media-files/${id}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +185,7 @@ export async function bulkMoveFiles(
|
||||
fileIds: number[],
|
||||
albumId: number | null
|
||||
): Promise<{ updated: number }> {
|
||||
const response = await apiClient.post('/media/bulk_move/', {
|
||||
const response = await apiClient.post('/media-files/bulk_move/', {
|
||||
file_ids: fileIds,
|
||||
album_id: albumId,
|
||||
});
|
||||
@@ -196,7 +196,7 @@ export async function bulkMoveFiles(
|
||||
* Delete multiple files
|
||||
*/
|
||||
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
|
||||
const response = await apiClient.post('/media/bulk_delete/', {
|
||||
const response = await apiClient.post('/media-files/bulk_delete/', {
|
||||
file_ids: fileIds,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -171,6 +171,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{feature.name}
|
||||
</span>
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
|
||||
{feature.code}
|
||||
</code>
|
||||
{feature.description && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||
{feature.description}
|
||||
@@ -207,17 +210,22 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<label className="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleIntegerFeature(feature.code)}
|
||||
aria-label={feature.name}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
||||
{feature.name}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white block">
|
||||
{feature.name}
|
||||
</span>
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{feature.code}
|
||||
</code>
|
||||
</div>
|
||||
</label>
|
||||
{selected && (
|
||||
<input
|
||||
|
||||
@@ -81,6 +81,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
||||
webhooks: 'Webhooks',
|
||||
api_access: 'API Access',
|
||||
custom_domain: 'Custom Domain',
|
||||
custom_branding: 'Custom Branding',
|
||||
remove_branding: 'Remove Branding',
|
||||
custom_oauth: 'Custom OAuth',
|
||||
automations: 'Automations',
|
||||
@@ -104,6 +105,7 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
||||
webhooks: 'Integrate with external services using webhooks',
|
||||
api_access: 'Access the SmoothSchedule API for custom integrations',
|
||||
custom_domain: 'Use your own custom domain for your booking site',
|
||||
custom_branding: 'Customize branding colors, logo, and styling',
|
||||
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
|
||||
custom_oauth: 'Configure your own OAuth credentials for social login',
|
||||
automations: 'Automate repetitive tasks with custom workflows',
|
||||
|
||||
@@ -41,7 +41,7 @@ interface ParentContext {
|
||||
|
||||
// Map settings pages to their required plan features
|
||||
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
|
||||
'/dashboard/settings/branding': 'remove_branding',
|
||||
'/dashboard/settings/branding': 'custom_branding',
|
||||
'/dashboard/settings/custom-domains': 'custom_domain',
|
||||
'/dashboard/settings/api': 'api_access',
|
||||
'/dashboard/settings/authentication': 'custom_oauth',
|
||||
@@ -154,7 +154,7 @@ const SettingsLayout: React.FC = () => {
|
||||
icon={Palette}
|
||||
label={t('settings.appearance.title', 'Appearance')}
|
||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||
locked={isLocked('remove_branding')}
|
||||
locked={isLocked('custom_branding')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_email_templates') && (
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
import { Modal, FormInput, Button, Alert, FormTextarea } from '../components/ui';
|
||||
import { Modal, FormInput, Button, FormTextarea } from '../components/ui';
|
||||
import {
|
||||
Album,
|
||||
MediaFile,
|
||||
@@ -55,7 +55,7 @@ interface StorageUsageBarProps {
|
||||
}
|
||||
|
||||
const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => {
|
||||
if (isLoading || !usage) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2" />
|
||||
@@ -64,6 +64,10 @@ const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) =
|
||||
);
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentUsed = usage.percent_used;
|
||||
const barColor =
|
||||
percentUsed >= 95
|
||||
@@ -291,10 +295,27 @@ const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(file.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(file.url);
|
||||
} else {
|
||||
// Fallback for non-secure contexts (http://)
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = file.url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy URL:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -763,12 +784,17 @@ const MediaGalleryPage: React.FC = () => {
|
||||
{/* Storage usage */}
|
||||
<StorageUsageBar usage={storageUsage} isLoading={usageLoading} />
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<Alert type="error" className="mt-4" onClose={() => setError(null)}>
|
||||
{/* Error Modal */}
|
||||
<Modal isOpen={!!error} onClose={() => setError(null)} title="Error" size="sm">
|
||||
<div className="text-red-600 dark:text-red-400">
|
||||
<pre className="whitespace-pre-wrap text-sm">{error}</pre>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="primary" onClick={() => setError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedFiles.size > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailHeaderProps } from './types';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
/**
|
||||
* EmailHeader - Business logo and name header
|
||||
@@ -61,8 +62,8 @@ export const EmailHeader: ComponentConfig<EmailHeaderProps> = {
|
||||
label: 'Email Header',
|
||||
fields: {
|
||||
logoUrl: {
|
||||
type: 'text',
|
||||
label: 'Logo URL',
|
||||
...imagePickerField,
|
||||
label: 'Logo Image',
|
||||
},
|
||||
businessName: {
|
||||
type: 'text',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailImageProps } from './types';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
/**
|
||||
* EmailImage - Image component
|
||||
@@ -12,8 +13,8 @@ export const EmailImage: ComponentConfig<EmailImageProps> = {
|
||||
label: 'Email Image',
|
||||
fields: {
|
||||
src: {
|
||||
type: 'text',
|
||||
label: 'Image URL',
|
||||
...imagePickerField,
|
||||
label: 'Image',
|
||||
},
|
||||
alt: {
|
||||
type: 'text',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface Feature {
|
||||
heading: string;
|
||||
@@ -113,7 +114,7 @@ export const ContentBlocks: ComponentConfig<ContentBlocksProps> = {
|
||||
// Using external field for string arrays
|
||||
},
|
||||
},
|
||||
image: { type: 'text', label: 'Image URL' },
|
||||
image: { ...imagePickerField, label: 'Image' },
|
||||
},
|
||||
defaultItemProps: {
|
||||
heading: 'Feature Heading',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface FooterLink {
|
||||
label: string;
|
||||
@@ -156,8 +157,8 @@ export const Footer: ComponentConfig<FooterProps> = {
|
||||
label: 'Brand Name',
|
||||
},
|
||||
brandLogo: {
|
||||
type: 'text',
|
||||
label: 'Brand Logo URL',
|
||||
...imagePickerField,
|
||||
label: 'Brand Logo',
|
||||
},
|
||||
description: {
|
||||
type: 'textarea',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface GalleryItem {
|
||||
image: string;
|
||||
@@ -105,7 +106,7 @@ export const GalleryGrid: ComponentConfig<GalleryGridProps> = {
|
||||
type: 'array',
|
||||
label: 'Items',
|
||||
arrayFields: {
|
||||
image: { type: 'text', label: 'Image URL' },
|
||||
image: { ...imagePickerField, label: 'Image' },
|
||||
title: { type: 'text', label: 'Title' },
|
||||
text: { type: 'textarea', label: 'Description' },
|
||||
link: { type: 'text', label: 'Link URL (optional)' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface HeaderLink {
|
||||
label: string;
|
||||
@@ -145,8 +146,8 @@ export const Header: ComponentConfig<HeaderProps> = {
|
||||
label: 'Business Name',
|
||||
},
|
||||
brandLogo: {
|
||||
type: 'text',
|
||||
label: 'Logo URL',
|
||||
...imagePickerField,
|
||||
label: 'Logo Image',
|
||||
},
|
||||
links: {
|
||||
type: 'array',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface HeroCtaButton {
|
||||
text: string;
|
||||
@@ -208,7 +209,7 @@ export const Hero: ComponentConfig<HeroProps> = {
|
||||
{ label: 'Image', value: 'image' },
|
||||
],
|
||||
},
|
||||
src: { type: 'text', label: 'Image URL' },
|
||||
src: { ...imagePickerField, label: 'Image' },
|
||||
alt: { type: 'text', label: 'Alt Text' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface Logo {
|
||||
src: string;
|
||||
@@ -102,7 +103,7 @@ export const LogoCloud: ComponentConfig<LogoCloudProps> = {
|
||||
type: 'array',
|
||||
label: 'Logos',
|
||||
arrayFields: {
|
||||
src: { type: 'text', label: 'Image URL' },
|
||||
src: { ...imagePickerField, label: 'Logo Image' },
|
||||
alt: { type: 'text', label: 'Alt Text' },
|
||||
href: { type: 'text', label: 'Link URL (optional)' },
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface Bullet {
|
||||
text: string;
|
||||
@@ -223,7 +224,7 @@ export const SplitContent: ComponentConfig<SplitContentProps> = {
|
||||
type: 'object',
|
||||
label: 'Media',
|
||||
objectFields: {
|
||||
src: { type: 'text', label: 'Image URL' },
|
||||
src: { ...imagePickerField, label: 'Image' },
|
||||
alt: { type: 'text', label: 'Alt Text' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface TestimonialItem {
|
||||
quote: string;
|
||||
@@ -180,7 +181,7 @@ export const Testimonials: ComponentConfig<TestimonialsProps> = {
|
||||
quote: { type: 'textarea', label: 'Quote' },
|
||||
name: { type: 'text', label: 'Name' },
|
||||
title: { type: 'text', label: 'Title/Company' },
|
||||
avatar: { type: 'text', label: 'Avatar URL' },
|
||||
avatar: { ...imagePickerField, label: 'Avatar Image' },
|
||||
rating: {
|
||||
type: 'select',
|
||||
label: 'Rating',
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PlanPermissions {
|
||||
webhooks: boolean;
|
||||
api_access: boolean;
|
||||
custom_domain: boolean;
|
||||
custom_branding: boolean;
|
||||
remove_branding: boolean;
|
||||
custom_oauth: boolean;
|
||||
automations: boolean;
|
||||
|
||||
@@ -33,11 +33,9 @@ class StorageQuotaService:
|
||||
|
||||
# Get storage_gb feature value from billing
|
||||
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
|
||||
storage_gb = EntitlementService.get_feature_value(
|
||||
tenant,
|
||||
'storage_gb',
|
||||
default=StorageQuotaService.DEFAULT_STORAGE_GB
|
||||
)
|
||||
storage_gb = EntitlementService.get_limit(tenant, 'storage_gb')
|
||||
if storage_gb is None:
|
||||
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
|
||||
else:
|
||||
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ def current_business_view(request):
|
||||
'webhooks': tenant.has_feature('integrations_enabled'),
|
||||
'api_access': tenant.has_feature('api_access'),
|
||||
'custom_domain': tenant.has_feature('custom_domain'),
|
||||
'custom_branding': tenant.has_feature('custom_branding'),
|
||||
'remove_branding': tenant.has_feature('remove_branding'),
|
||||
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
||||
'automations': tenant.has_feature('can_use_automations'),
|
||||
|
||||
@@ -1413,7 +1413,7 @@ class AlbumSerializer(serializers.ModelSerializer):
|
||||
|
||||
Provides read-only computed fields for file_count and cover_url.
|
||||
"""
|
||||
file_count = serializers.IntegerField(read_only=True)
|
||||
file_count = serializers.SerializerMethodField()
|
||||
cover_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -1424,23 +1424,30 @@ class AlbumSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def get_cover_url(self, obj):
|
||||
"""Get the URL of the cover image."""
|
||||
if obj.cover_image and obj.cover_image.file:
|
||||
return obj.cover_image.file.url
|
||||
# If no explicit cover, use first file in album
|
||||
first_file = obj.files.first()
|
||||
if first_file and first_file.file:
|
||||
return first_file.file.url
|
||||
return None
|
||||
def get_file_count(self, obj):
|
||||
"""Get file count from annotation or compute it."""
|
||||
# Try to get from annotation first (more efficient for list views)
|
||||
if hasattr(obj, 'annotated_file_count'):
|
||||
return obj.annotated_file_count
|
||||
# Fall back to model property
|
||||
return obj.file_count
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Add file_count if not already annotated."""
|
||||
data = super().to_representation(instance)
|
||||
# If file_count wasn't annotated in the queryset, compute it
|
||||
if data.get('file_count') is None:
|
||||
data['file_count'] = instance.files.count()
|
||||
return data
|
||||
def get_cover_url(self, obj):
|
||||
"""Get the full URL of the cover image."""
|
||||
request = self.context.get('request')
|
||||
url = None
|
||||
|
||||
if obj.cover_image and obj.cover_image.file:
|
||||
url = obj.cover_image.file.url
|
||||
else:
|
||||
# If no explicit cover, use first file in album
|
||||
first_file = obj.files.first()
|
||||
if first_file and first_file.file:
|
||||
url = first_file.file.url
|
||||
|
||||
if url and request:
|
||||
return request.build_absolute_uri(url)
|
||||
return url
|
||||
|
||||
|
||||
class MediaFileSerializer(serializers.ModelSerializer):
|
||||
@@ -1471,8 +1478,11 @@ class MediaFileSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ['id', 'filename', 'file_size', 'width', 'height', 'mime_type', 'created_at']
|
||||
|
||||
def get_url(self, obj):
|
||||
"""Get the URL to access the file."""
|
||||
"""Get the full URL to access the file."""
|
||||
if obj.file:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.file.url)
|
||||
return obj.file.url
|
||||
return None
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ router.register(r'holidays', HolidayViewSet, basename='holiday')
|
||||
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
|
||||
router.register(r'locations', LocationViewSet, basename='location')
|
||||
router.register(r'albums', AlbumViewSet, basename='album')
|
||||
router.register(r'media', MediaFileViewSet, basename='media')
|
||||
router.register(r'media-files', MediaFileViewSet, basename='media')
|
||||
|
||||
# URL patterns
|
||||
urlpatterns = [
|
||||
|
||||
@@ -1983,7 +1983,7 @@ class AlbumViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
"""Annotate with file count for list view."""
|
||||
queryset = super().get_queryset()
|
||||
return queryset.annotate(file_count=Count('files'))
|
||||
return queryset.annotate(annotated_file_count=Count('files'))
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""When deleting an album, move files to uncategorized (null album)."""
|
||||
|
||||
Reference in New Issue
Block a user