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:
poduck
2025-12-22 18:21:52 -05:00
parent f1b1f18bc5
commit 2bfa01e0d4
21 changed files with 120 additions and 64 deletions

View File

@@ -123,7 +123,7 @@ export async function deleteAlbum(id: number): Promise<void> {
*/ */
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> { export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
const params = albumId !== undefined ? { album: albumId } : {}; const params = albumId !== undefined ? { album: albumId } : {};
const response = await apiClient.get('/media/', { params }); const response = await apiClient.get('/media-files/', { params });
return response.data; return response.data;
} }
@@ -131,7 +131,7 @@ export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFi
* Get a single media file * Get a single media file
*/ */
export async function getMediaFile(id: number): Promise<MediaFile> { 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; return response.data;
} }
@@ -152,7 +152,7 @@ export async function uploadMediaFile(
formData.append('alt_text', altText); formData.append('alt_text', altText);
} }
const response = await apiClient.post('/media/', formData, { const response = await apiClient.post('/media-files/', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -167,7 +167,7 @@ export async function updateMediaFile(
id: number, id: number,
data: MediaFileUpdatePayload data: MediaFileUpdatePayload
): Promise<MediaFile> { ): Promise<MediaFile> {
const response = await apiClient.patch(`/media/${id}/`, data); const response = await apiClient.patch(`/media-files/${id}/`, data);
return response.data; return response.data;
} }
@@ -175,7 +175,7 @@ export async function updateMediaFile(
* Delete a media file * Delete a media file
*/ */
export async function deleteMediaFile(id: number): Promise<void> { 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[], fileIds: number[],
albumId: number | null albumId: number | null
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
const response = await apiClient.post('/media/bulk_move/', { const response = await apiClient.post('/media-files/bulk_move/', {
file_ids: fileIds, file_ids: fileIds,
album_id: albumId, album_id: albumId,
}); });
@@ -196,7 +196,7 @@ export async function bulkMoveFiles(
* Delete multiple files * Delete multiple files
*/ */
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> { 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, file_ids: fileIds,
}); });
return response.data; return response.data;

View File

@@ -171,6 +171,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
<span className="text-sm font-medium text-gray-900 dark:text-white"> <span className="text-sm font-medium text-gray-900 dark:text-white">
{feature.name} {feature.name}
</span> </span>
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
{feature.code}
</code>
{feature.description && ( {feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"> <span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{feature.description} {feature.description}
@@ -207,17 +210,22 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
: 'border-gray-200 dark:border-gray-700' : '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 <input
type="checkbox" type="checkbox"
checked={selected} checked={selected}
onChange={() => toggleIntegerFeature(feature.code)} onChange={() => toggleIntegerFeature(feature.code)}
aria-label={feature.name} 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"> <div className="flex-1 min-w-0">
{feature.name} <span className="text-sm font-medium text-gray-900 dark:text-white block">
</span> {feature.name}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
{feature.code}
</code>
</div>
</label> </label>
{selected && ( {selected && (
<input <input

View File

@@ -81,6 +81,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
webhooks: 'Webhooks', webhooks: 'Webhooks',
api_access: 'API Access', api_access: 'API Access',
custom_domain: 'Custom Domain', custom_domain: 'Custom Domain',
custom_branding: 'Custom Branding',
remove_branding: 'Remove Branding', remove_branding: 'Remove Branding',
custom_oauth: 'Custom OAuth', custom_oauth: 'Custom OAuth',
automations: 'Automations', automations: 'Automations',
@@ -104,6 +105,7 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
webhooks: 'Integrate with external services using webhooks', webhooks: 'Integrate with external services using webhooks',
api_access: 'Access the SmoothSchedule API for custom integrations', api_access: 'Access the SmoothSchedule API for custom integrations',
custom_domain: 'Use your own custom domain for your booking site', 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', remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
custom_oauth: 'Configure your own OAuth credentials for social login', custom_oauth: 'Configure your own OAuth credentials for social login',
automations: 'Automate repetitive tasks with custom workflows', automations: 'Automate repetitive tasks with custom workflows',

View File

@@ -41,7 +41,7 @@ interface ParentContext {
// Map settings pages to their required plan features // Map settings pages to their required plan features
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = { 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/custom-domains': 'custom_domain',
'/dashboard/settings/api': 'api_access', '/dashboard/settings/api': 'api_access',
'/dashboard/settings/authentication': 'custom_oauth', '/dashboard/settings/authentication': 'custom_oauth',
@@ -154,7 +154,7 @@ const SettingsLayout: React.FC = () => {
icon={Palette} icon={Palette}
label={t('settings.appearance.title', 'Appearance')} label={t('settings.appearance.title', 'Appearance')}
description={t('settings.appearance.description', 'Logo, colors, theme')} description={t('settings.appearance.description', 'Logo, colors, theme')}
locked={isLocked('remove_branding')} locked={isLocked('custom_branding')}
/> />
)} )}
{hasSettingsPermission('can_access_settings_email_templates') && ( {hasSettingsPermission('can_access_settings_email_templates') && (

View File

@@ -22,7 +22,7 @@ import {
ExternalLink, ExternalLink,
ChevronLeft, ChevronLeft,
} from 'lucide-react'; } from 'lucide-react';
import { Modal, FormInput, Button, Alert, FormTextarea } from '../components/ui'; import { Modal, FormInput, Button, FormTextarea } from '../components/ui';
import { import {
Album, Album,
MediaFile, MediaFile,
@@ -55,7 +55,7 @@ interface StorageUsageBarProps {
} }
const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => { const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => {
if (isLoading || !usage) { if (isLoading) {
return ( 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="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" /> <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 percentUsed = usage.percent_used;
const barColor = const barColor =
percentUsed >= 95 percentUsed >= 95
@@ -291,10 +295,27 @@ const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
}); });
}; };
const handleCopyUrl = () => { const handleCopyUrl = async () => {
navigator.clipboard.writeText(file.url); try {
setCopied(true); // Try modern clipboard API first
setTimeout(() => setCopied(false), 2000); 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 ( return (
@@ -763,12 +784,17 @@ const MediaGalleryPage: React.FC = () => {
{/* Storage usage */} {/* Storage usage */}
<StorageUsageBar usage={storageUsage} isLoading={usageLoading} /> <StorageUsageBar usage={storageUsage} isLoading={usageLoading} />
{/* Error message */} {/* Error Modal */}
{error && ( <Modal isOpen={!!error} onClose={() => setError(null)} title="Error" size="sm">
<Alert type="error" className="mt-4" onClose={() => setError(null)}> <div className="text-red-600 dark:text-red-400">
<pre className="whitespace-pre-wrap text-sm">{error}</pre> <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 */} {/* Bulk actions bar */}
{selectedFiles.size > 0 && ( {selectedFiles.size > 0 && (

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { ComponentConfig } from '@measured/puck'; import type { ComponentConfig } from '@measured/puck';
import type { EmailHeaderProps } from './types'; import type { EmailHeaderProps } from './types';
import { imagePickerField } from '../../fields/ImagePickerField';
/** /**
* EmailHeader - Business logo and name header * EmailHeader - Business logo and name header
@@ -61,8 +62,8 @@ export const EmailHeader: ComponentConfig<EmailHeaderProps> = {
label: 'Email Header', label: 'Email Header',
fields: { fields: {
logoUrl: { logoUrl: {
type: 'text', ...imagePickerField,
label: 'Logo URL', label: 'Logo Image',
}, },
businessName: { businessName: {
type: 'text', type: 'text',

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { ComponentConfig } from '@measured/puck'; import type { ComponentConfig } from '@measured/puck';
import type { EmailImageProps } from './types'; import type { EmailImageProps } from './types';
import { imagePickerField } from '../../fields/ImagePickerField';
/** /**
* EmailImage - Image component * EmailImage - Image component
@@ -12,8 +13,8 @@ export const EmailImage: ComponentConfig<EmailImageProps> = {
label: 'Email Image', label: 'Email Image',
fields: { fields: {
src: { src: {
type: 'text', ...imagePickerField,
label: 'Image URL', label: 'Image',
}, },
alt: { alt: {
type: 'text', type: 'text',

View File

@@ -6,6 +6,7 @@ import {
applyDesignControls, applyDesignControls,
type DesignControlsProps, type DesignControlsProps,
} from '../../theme'; } from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface Feature { export interface Feature {
heading: string; heading: string;
@@ -113,7 +114,7 @@ export const ContentBlocks: ComponentConfig<ContentBlocksProps> = {
// Using external field for string arrays // Using external field for string arrays
}, },
}, },
image: { type: 'text', label: 'Image URL' }, image: { ...imagePickerField, label: 'Image' },
}, },
defaultItemProps: { defaultItemProps: {
heading: 'Feature Heading', heading: 'Feature Heading',

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { ComponentConfig } from '@measured/puck'; import type { ComponentConfig } from '@measured/puck';
import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react'; import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface FooterLink { export interface FooterLink {
label: string; label: string;
@@ -156,8 +157,8 @@ export const Footer: ComponentConfig<FooterProps> = {
label: 'Brand Name', label: 'Brand Name',
}, },
brandLogo: { brandLogo: {
type: 'text', ...imagePickerField,
label: 'Brand Logo URL', label: 'Brand Logo',
}, },
description: { description: {
type: 'textarea', type: 'textarea',

View File

@@ -5,6 +5,7 @@ import {
applyDesignControls, applyDesignControls,
type DesignControlsProps, type DesignControlsProps,
} from '../../theme'; } from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface GalleryItem { export interface GalleryItem {
image: string; image: string;
@@ -105,7 +106,7 @@ export const GalleryGrid: ComponentConfig<GalleryGridProps> = {
type: 'array', type: 'array',
label: 'Items', label: 'Items',
arrayFields: { arrayFields: {
image: { type: 'text', label: 'Image URL' }, image: { ...imagePickerField, label: 'Image' },
title: { type: 'text', label: 'Title' }, title: { type: 'text', label: 'Title' },
text: { type: 'textarea', label: 'Description' }, text: { type: 'textarea', label: 'Description' },
link: { type: 'text', label: 'Link URL (optional)' }, link: { type: 'text', label: 'Link URL (optional)' },

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck'; import type { ComponentConfig } from '@measured/puck';
import { Menu, X } from 'lucide-react'; import { Menu, X } from 'lucide-react';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface HeaderLink { export interface HeaderLink {
label: string; label: string;
@@ -145,8 +146,8 @@ export const Header: ComponentConfig<HeaderProps> = {
label: 'Business Name', label: 'Business Name',
}, },
brandLogo: { brandLogo: {
type: 'text', ...imagePickerField,
label: 'Logo URL', label: 'Logo Image',
}, },
links: { links: {
type: 'array', type: 'array',

View File

@@ -5,6 +5,7 @@ import {
applyDesignControls, applyDesignControls,
type DesignControlsProps, type DesignControlsProps,
} from '../../theme'; } from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface HeroCtaButton { export interface HeroCtaButton {
text: string; text: string;
@@ -208,7 +209,7 @@ export const Hero: ComponentConfig<HeroProps> = {
{ label: 'Image', value: 'image' }, { label: 'Image', value: 'image' },
], ],
}, },
src: { type: 'text', label: 'Image URL' }, src: { ...imagePickerField, label: 'Image' },
alt: { type: 'text', label: 'Alt Text' }, alt: { type: 'text', label: 'Alt Text' },
}, },
}, },

View File

@@ -5,6 +5,7 @@ import {
applyDesignControls, applyDesignControls,
type DesignControlsProps, type DesignControlsProps,
} from '../../theme'; } from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface Logo { export interface Logo {
src: string; src: string;
@@ -102,7 +103,7 @@ export const LogoCloud: ComponentConfig<LogoCloudProps> = {
type: 'array', type: 'array',
label: 'Logos', label: 'Logos',
arrayFields: { arrayFields: {
src: { type: 'text', label: 'Image URL' }, src: { ...imagePickerField, label: 'Logo Image' },
alt: { type: 'text', label: 'Alt Text' }, alt: { type: 'text', label: 'Alt Text' },
href: { type: 'text', label: 'Link URL (optional)' }, href: { type: 'text', label: 'Link URL (optional)' },
}, },

View File

@@ -6,6 +6,7 @@ import {
applyDesignControls, applyDesignControls,
type DesignControlsProps, type DesignControlsProps,
} from '../../theme'; } from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface Bullet { export interface Bullet {
text: string; text: string;
@@ -223,7 +224,7 @@ export const SplitContent: ComponentConfig<SplitContentProps> = {
type: 'object', type: 'object',
label: 'Media', label: 'Media',
objectFields: { objectFields: {
src: { type: 'text', label: 'Image URL' }, src: { ...imagePickerField, label: 'Image' },
alt: { type: 'text', label: 'Alt Text' }, alt: { type: 'text', label: 'Alt Text' },
}, },
}, },

View File

@@ -6,6 +6,7 @@ import {
applyDesignControls, applyDesignControls,
type DesignControlsProps, type DesignControlsProps,
} from '../../theme'; } from '../../theme';
import { imagePickerField } from '../../fields/ImagePickerField';
export interface TestimonialItem { export interface TestimonialItem {
quote: string; quote: string;
@@ -180,7 +181,7 @@ export const Testimonials: ComponentConfig<TestimonialsProps> = {
quote: { type: 'textarea', label: 'Quote' }, quote: { type: 'textarea', label: 'Quote' },
name: { type: 'text', label: 'Name' }, name: { type: 'text', label: 'Name' },
title: { type: 'text', label: 'Title/Company' }, title: { type: 'text', label: 'Title/Company' },
avatar: { type: 'text', label: 'Avatar URL' }, avatar: { ...imagePickerField, label: 'Avatar Image' },
rating: { rating: {
type: 'select', type: 'select',
label: 'Rating', label: 'Rating',

View File

@@ -36,6 +36,7 @@ export interface PlanPermissions {
webhooks: boolean; webhooks: boolean;
api_access: boolean; api_access: boolean;
custom_domain: boolean; custom_domain: boolean;
custom_branding: boolean;
remove_branding: boolean; remove_branding: boolean;
custom_oauth: boolean; custom_oauth: boolean;
automations: boolean; automations: boolean;

View File

@@ -33,11 +33,9 @@ class StorageQuotaService:
# Get storage_gb feature value from billing # Get storage_gb feature value from billing
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription: if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
storage_gb = EntitlementService.get_feature_value( storage_gb = EntitlementService.get_limit(tenant, 'storage_gb')
tenant, if storage_gb is None:
'storage_gb', storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
default=StorageQuotaService.DEFAULT_STORAGE_GB
)
else: else:
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB

View File

@@ -169,6 +169,7 @@ def current_business_view(request):
'webhooks': tenant.has_feature('integrations_enabled'), 'webhooks': tenant.has_feature('integrations_enabled'),
'api_access': tenant.has_feature('api_access'), 'api_access': tenant.has_feature('api_access'),
'custom_domain': tenant.has_feature('custom_domain'), 'custom_domain': tenant.has_feature('custom_domain'),
'custom_branding': tenant.has_feature('custom_branding'),
'remove_branding': tenant.has_feature('remove_branding'), 'remove_branding': tenant.has_feature('remove_branding'),
'custom_oauth': tenant.has_feature('can_manage_oauth'), 'custom_oauth': tenant.has_feature('can_manage_oauth'),
'automations': tenant.has_feature('can_use_automations'), 'automations': tenant.has_feature('can_use_automations'),

View File

@@ -1413,7 +1413,7 @@ class AlbumSerializer(serializers.ModelSerializer):
Provides read-only computed fields for file_count and cover_url. 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() cover_url = serializers.SerializerMethodField()
class Meta: class Meta:
@@ -1424,23 +1424,30 @@ class AlbumSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ['id', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at']
def get_cover_url(self, obj): def get_file_count(self, obj):
"""Get the URL of the cover image.""" """Get file count from annotation or compute it."""
if obj.cover_image and obj.cover_image.file: # Try to get from annotation first (more efficient for list views)
return obj.cover_image.file.url if hasattr(obj, 'annotated_file_count'):
# If no explicit cover, use first file in album return obj.annotated_file_count
first_file = obj.files.first() # Fall back to model property
if first_file and first_file.file: return obj.file_count
return first_file.file.url
return None
def to_representation(self, instance): def get_cover_url(self, obj):
"""Add file_count if not already annotated.""" """Get the full URL of the cover image."""
data = super().to_representation(instance) request = self.context.get('request')
# If file_count wasn't annotated in the queryset, compute it url = None
if data.get('file_count') is None:
data['file_count'] = instance.files.count() if obj.cover_image and obj.cover_image.file:
return data 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): 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'] read_only_fields = ['id', 'filename', 'file_size', 'width', 'height', 'mime_type', 'created_at']
def get_url(self, obj): def get_url(self, obj):
"""Get the URL to access the file.""" """Get the full URL to access the file."""
if obj.file: if obj.file:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.file.url)
return obj.file.url return obj.file.url
return None return None

View File

@@ -31,7 +31,7 @@ router.register(r'holidays', HolidayViewSet, basename='holiday')
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock') router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
router.register(r'locations', LocationViewSet, basename='location') router.register(r'locations', LocationViewSet, basename='location')
router.register(r'albums', AlbumViewSet, basename='album') router.register(r'albums', AlbumViewSet, basename='album')
router.register(r'media', MediaFileViewSet, basename='media') router.register(r'media-files', MediaFileViewSet, basename='media')
# URL patterns # URL patterns
urlpatterns = [ urlpatterns = [

View File

@@ -1983,7 +1983,7 @@ class AlbumViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
"""Annotate with file count for list view.""" """Annotate with file count for list view."""
queryset = super().get_queryset() queryset = super().get_queryset()
return queryset.annotate(file_count=Count('files')) return queryset.annotate(annotated_file_count=Count('files'))
def perform_destroy(self, instance): def perform_destroy(self, instance):
"""When deleting an album, move files to uncategorized (null album).""" """When deleting an album, move files to uncategorized (null album)."""