fix fix fi x fix
This commit is contained in:
@@ -893,31 +893,6 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Allow deletion for superusers and developers"""
|
||||
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
"""Override delete to handle PROTECT relationship with Keywords"""
|
||||
from igny8_core.business.planning.models import Keywords
|
||||
# Soft-delete all Keywords referencing this SeedKeyword first
|
||||
site_keywords = Keywords.objects.filter(seed_keyword=obj)
|
||||
for kw in site_keywords:
|
||||
kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{obj.keyword}' deleted")
|
||||
# Now we can safely delete the SeedKeyword
|
||||
super().delete_model(request, obj)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
"""Override bulk delete to handle PROTECT relationship with Keywords"""
|
||||
from igny8_core.business.planning.models import Keywords
|
||||
for seed_keyword in queryset:
|
||||
# Soft-delete all Keywords referencing this SeedKeyword first
|
||||
site_keywords = Keywords.objects.filter(seed_keyword=seed_keyword)
|
||||
for kw in site_keywords:
|
||||
kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{seed_keyword.keyword}' deleted")
|
||||
# Now we can safely delete the SeedKeywords
|
||||
queryset.delete()
|
||||
|
||||
def bulk_activate(self, request, queryset):
|
||||
updated = queryset.update(is_active=True)
|
||||
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
|
||||
|
||||
@@ -1896,18 +1896,27 @@ class AutomationService:
|
||||
"""
|
||||
Get total credits used by this run so far.
|
||||
Uses CreditUsageLog (same source as /account/usage/credits endpoint) for accuracy.
|
||||
Filters by site to only count credits used by this specific automation run.
|
||||
"""
|
||||
if not self.run:
|
||||
return 0
|
||||
|
||||
# FIXED: Use CreditUsageLog instead of counting AITaskLog records
|
||||
# This matches the source of truth used by /account/usage/credits endpoint
|
||||
# Use CreditUsageLog - the source of truth for credit usage
|
||||
# Filter by site to get only credits used by this automation run
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
from django.db.models import Sum
|
||||
|
||||
filters = {
|
||||
'account': self.account,
|
||||
'created_at__gte': self.run.started_at
|
||||
}
|
||||
|
||||
# Filter by site if available for more accurate per-run counting
|
||||
if self.site:
|
||||
filters['site'] = self.site
|
||||
|
||||
total = CreditUsageLog.objects.filter(
|
||||
account=self.account,
|
||||
created_at__gte=self.run.started_at
|
||||
**filters
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
return total
|
||||
|
||||
@@ -1055,6 +1055,125 @@ Current Balance: {current_credits} credits
|
||||
To avoid service interruption, please top up your credits:
|
||||
{context['topup_url']}
|
||||
|
||||
Thank you,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send_invoice_email(invoice, is_reminder=False):
|
||||
"""
|
||||
Send invoice email to the account owner.
|
||||
Used for manual payment methods (bank transfer, local wallet, manual).
|
||||
|
||||
Args:
|
||||
invoice: Invoice model instance
|
||||
is_reminder: If True, sends as a reminder email
|
||||
"""
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
account = invoice.account
|
||||
|
||||
subject_prefix = 'Reminder: ' if is_reminder else ''
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'invoice_number': invoice.invoice_number or f'INV-{invoice.id}',
|
||||
'invoice_date': invoice.created_at.strftime('%Y-%m-%d'),
|
||||
'due_date': invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A',
|
||||
'total_amount': invoice.total_amount,
|
||||
'currency': invoice.currency or 'USD',
|
||||
'items': invoice.items or [],
|
||||
'payment_instructions': invoice.metadata.get('payment_instructions', ''),
|
||||
'is_reminder': is_reminder,
|
||||
'frontend_url': frontend_url,
|
||||
'invoice_url': f'{frontend_url}/billing/invoices/{invoice.id}',
|
||||
}
|
||||
|
||||
subject = f'{subject_prefix}Invoice #{context["invoice_number"]} - Payment Required'
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=subject,
|
||||
template='emails/invoice.html',
|
||||
context=context,
|
||||
tags=['billing', 'invoice', 'reminder' if is_reminder else 'new'],
|
||||
)
|
||||
logger.info(f'Invoice email sent for Invoice {invoice.id} (reminder={is_reminder})')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send invoice email: {str(e)}')
|
||||
reminder_text = 'This is a reminder that your ' if is_reminder else ''
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject=subject,
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
{reminder_text}Invoice #{context['invoice_number']} requires your attention.
|
||||
|
||||
Invoice Details:
|
||||
- Invoice Number: {context['invoice_number']}
|
||||
- Date: {context['invoice_date']}
|
||||
- Due Date: {context['due_date']}
|
||||
- Amount: {context['currency']} {context['total_amount']}
|
||||
|
||||
To view and pay your invoice:
|
||||
{context['invoice_url']}
|
||||
|
||||
{context['payment_instructions']}
|
||||
|
||||
If you have any questions, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def send_subscription_expired_email(account, subscription):
|
||||
"""
|
||||
Send email when subscription expires due to non-payment.
|
||||
"""
|
||||
service = get_email_service()
|
||||
frontend_url = BillingEmailService._get_frontend_url()
|
||||
|
||||
context = {
|
||||
'account_name': account.name,
|
||||
'plan_name': subscription.plan.name if subscription.plan else 'N/A',
|
||||
'frontend_url': frontend_url,
|
||||
'billing_url': f'{frontend_url}/account/plans',
|
||||
}
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Subscription Expired - Action Required',
|
||||
template='emails/subscription_expired.html',
|
||||
context=context,
|
||||
tags=['billing', 'subscription-expired'],
|
||||
)
|
||||
logger.info(f'Subscription expired email sent for account {account.id}')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send subscription expired email: {str(e)}')
|
||||
return service.send_transactional(
|
||||
to=account.billing_email or account.owner.email,
|
||||
subject='Subscription Expired - Action Required',
|
||||
text=f"""
|
||||
Hi {account.name},
|
||||
|
||||
Your subscription to the {context['plan_name']} plan has expired due to non-payment.
|
||||
|
||||
Your account access may be limited until you renew your subscription.
|
||||
|
||||
To reactivate your subscription:
|
||||
{context['billing_url']}
|
||||
|
||||
If you have any questions or need assistance, please contact our support team.
|
||||
|
||||
Thank you,
|
||||
The IGNY8 Team
|
||||
""".strip(),
|
||||
|
||||
@@ -14,6 +14,11 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grace period in days for manual payment before subscription expires
|
||||
RENEWAL_GRACE_PERIOD_DAYS = 7
|
||||
# Days between invoice reminder emails
|
||||
INVOICE_REMINDER_INTERVAL_DAYS = 3
|
||||
|
||||
|
||||
@shared_task(name='billing.send_renewal_notices')
|
||||
def send_renewal_notices():
|
||||
@@ -99,7 +104,7 @@ def renew_subscription(subscription_id: int):
|
||||
# Attempt automatic payment if payment method on file
|
||||
payment_attempted = False
|
||||
|
||||
# Check if account has saved payment method
|
||||
# Check if account has saved payment method for automatic billing
|
||||
if subscription.metadata.get('stripe_subscription_id'):
|
||||
payment_attempted = _attempt_stripe_renewal(subscription, invoice)
|
||||
elif subscription.metadata.get('paypal_subscription_id'):
|
||||
@@ -110,15 +115,24 @@ def renew_subscription(subscription_id: int):
|
||||
logger.info(f"Automatic payment initiated for subscription {subscription_id}")
|
||||
else:
|
||||
# No automatic payment - send invoice for manual payment
|
||||
# This handles all payment methods: bank_transfer, local_wallet, manual
|
||||
logger.info(f"Manual payment required for subscription {subscription_id}")
|
||||
|
||||
# Mark subscription as pending renewal
|
||||
# Mark subscription as pending renewal with grace period
|
||||
grace_period_end = timezone.now() + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)
|
||||
subscription.status = 'pending_renewal'
|
||||
subscription.metadata['renewal_invoice_id'] = invoice.id
|
||||
subscription.metadata['renewal_required_at'] = timezone.now().isoformat()
|
||||
subscription.metadata['grace_period_end'] = grace_period_end.isoformat()
|
||||
subscription.metadata['last_invoice_reminder_at'] = timezone.now().isoformat()
|
||||
subscription.save(update_fields=['status', 'metadata'])
|
||||
|
||||
# TODO: Send invoice email
|
||||
# Send invoice email for manual payment
|
||||
try:
|
||||
BillingEmailService.send_invoice_email(invoice, is_reminder=False)
|
||||
logger.info(f"Invoice email sent for subscription {subscription_id}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send invoice email for subscription {subscription_id}: {str(e)}")
|
||||
|
||||
# Clear renewal notice flag
|
||||
if 'renewal_notice_sent' in subscription.metadata:
|
||||
@@ -131,6 +145,117 @@ def renew_subscription(subscription_id: int):
|
||||
logger.exception(f"Error renewing subscription {subscription_id}: {str(e)}")
|
||||
|
||||
|
||||
@shared_task(name='billing.send_invoice_reminders')
|
||||
def send_invoice_reminders():
|
||||
"""
|
||||
Send invoice reminder emails for pending renewals
|
||||
Run daily to remind accounts with pending invoices
|
||||
"""
|
||||
now = timezone.now()
|
||||
reminder_threshold = now - timedelta(days=INVOICE_REMINDER_INTERVAL_DAYS)
|
||||
|
||||
# Get subscriptions pending renewal
|
||||
subscriptions = Subscription.objects.filter(
|
||||
status='pending_renewal'
|
||||
).select_related('account', 'plan')
|
||||
|
||||
for subscription in subscriptions:
|
||||
# Check if enough time has passed since last reminder
|
||||
last_reminder = subscription.metadata.get('last_invoice_reminder_at')
|
||||
if last_reminder:
|
||||
from datetime import datetime
|
||||
last_reminder_dt = datetime.fromisoformat(last_reminder.replace('Z', '+00:00'))
|
||||
if hasattr(last_reminder_dt, 'tzinfo') and last_reminder_dt.tzinfo is None:
|
||||
last_reminder_dt = timezone.make_aware(last_reminder_dt)
|
||||
if last_reminder_dt > reminder_threshold:
|
||||
continue
|
||||
|
||||
# Get the renewal invoice
|
||||
invoice_id = subscription.metadata.get('renewal_invoice_id')
|
||||
if not invoice_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
invoice = Invoice.objects.get(id=invoice_id)
|
||||
|
||||
# Only send reminder for unpaid invoices
|
||||
if invoice.status not in ['pending', 'overdue']:
|
||||
continue
|
||||
|
||||
BillingEmailService.send_invoice_email(invoice, is_reminder=True)
|
||||
|
||||
# Update last reminder timestamp
|
||||
subscription.metadata['last_invoice_reminder_at'] = now.isoformat()
|
||||
subscription.save(update_fields=['metadata'])
|
||||
|
||||
logger.info(f"Invoice reminder sent for subscription {subscription.id}")
|
||||
|
||||
except Invoice.DoesNotExist:
|
||||
logger.warning(f"Invoice {invoice_id} not found for subscription {subscription.id}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send invoice reminder for subscription {subscription.id}: {str(e)}")
|
||||
|
||||
|
||||
@shared_task(name='billing.check_expired_renewals')
|
||||
def check_expired_renewals():
|
||||
"""
|
||||
Check for subscriptions that have exceeded their grace period
|
||||
and automatically change their status to expired
|
||||
Run daily
|
||||
"""
|
||||
now = timezone.now()
|
||||
|
||||
# Get subscriptions pending renewal
|
||||
subscriptions = Subscription.objects.filter(
|
||||
status='pending_renewal'
|
||||
).select_related('account', 'plan')
|
||||
|
||||
expired_count = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
grace_period_end = subscription.metadata.get('grace_period_end')
|
||||
|
||||
if not grace_period_end:
|
||||
# No grace period set, use default from renewal_required_at
|
||||
renewal_required_at = subscription.metadata.get('renewal_required_at')
|
||||
if renewal_required_at:
|
||||
from datetime import datetime
|
||||
required_dt = datetime.fromisoformat(renewal_required_at.replace('Z', '+00:00'))
|
||||
if hasattr(required_dt, 'tzinfo') and required_dt.tzinfo is None:
|
||||
required_dt = timezone.make_aware(required_dt)
|
||||
grace_period_end = (required_dt + timedelta(days=RENEWAL_GRACE_PERIOD_DAYS)).isoformat()
|
||||
else:
|
||||
continue
|
||||
|
||||
# Check if grace period has expired
|
||||
from datetime import datetime
|
||||
grace_end_dt = datetime.fromisoformat(grace_period_end.replace('Z', '+00:00'))
|
||||
if hasattr(grace_end_dt, 'tzinfo') and grace_end_dt.tzinfo is None:
|
||||
grace_end_dt = timezone.make_aware(grace_end_dt)
|
||||
|
||||
if now > grace_end_dt:
|
||||
# Grace period expired - change status to expired
|
||||
subscription.status = 'expired'
|
||||
subscription.metadata['expired_at'] = now.isoformat()
|
||||
subscription.metadata['expired_reason'] = 'Grace period exceeded without payment'
|
||||
subscription.save(update_fields=['status', 'metadata'])
|
||||
|
||||
expired_count += 1
|
||||
logger.info(f"Subscription {subscription.id} expired due to non-payment")
|
||||
|
||||
# Send expiration notification
|
||||
try:
|
||||
BillingEmailService.send_subscription_expired_email(
|
||||
subscription.account,
|
||||
subscription
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send expiration email for subscription {subscription.id}: {str(e)}")
|
||||
|
||||
if expired_count > 0:
|
||||
logger.info(f"Expired {expired_count} subscriptions due to non-payment")
|
||||
|
||||
|
||||
def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> bool:
|
||||
"""
|
||||
Attempt to charge Stripe subscription
|
||||
|
||||
@@ -37,6 +37,23 @@ app.conf.beat_schedule = {
|
||||
'task': 'check_approaching_limits',
|
||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
|
||||
},
|
||||
# Subscription Renewal Tasks
|
||||
'send-renewal-notices': {
|
||||
'task': 'billing.send_renewal_notices',
|
||||
'schedule': crontab(hour=9, minute=0), # Daily at 09:00
|
||||
},
|
||||
'process-subscription-renewals': {
|
||||
'task': 'billing.process_subscription_renewals',
|
||||
'schedule': crontab(hour=0, minute=5), # Daily at 00:05
|
||||
},
|
||||
'send-invoice-reminders': {
|
||||
'task': 'billing.send_invoice_reminders',
|
||||
'schedule': crontab(hour=10, minute=0), # Daily at 10:00
|
||||
},
|
||||
'check-expired-renewals': {
|
||||
'task': 'billing.check_expired_renewals',
|
||||
'schedule': crontab(hour=0, minute=15), # Daily at 00:15
|
||||
},
|
||||
# Automation Tasks
|
||||
'check-scheduled-automations': {
|
||||
'task': 'automation.check_scheduled_automations',
|
||||
|
||||
@@ -147,17 +147,26 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
|
||||
# Default to current month if not provided
|
||||
now = timezone.now()
|
||||
|
||||
def parse_iso_datetime(dt_str):
|
||||
"""Parse ISO datetime string, handling Z suffix for UTC"""
|
||||
if not dt_str:
|
||||
return None
|
||||
# Handle Z suffix (UTC indicator) which Django's parse_datetime doesn't support
|
||||
if dt_str.endswith('Z'):
|
||||
dt_str = dt_str[:-1] + '+00:00'
|
||||
from django.utils.dateparse import parse_datetime
|
||||
return parse_datetime(dt_str)
|
||||
|
||||
if not start_date:
|
||||
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
start_date = parse_datetime(start_date) or start_date
|
||||
start_date = parse_iso_datetime(start_date) or start_date
|
||||
|
||||
if not end_date:
|
||||
end_date = now
|
||||
else:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
end_date = parse_datetime(end_date) or end_date
|
||||
end_date = parse_iso_datetime(end_date) or end_date
|
||||
|
||||
# Get usage logs in date range
|
||||
usage_logs = CreditUsageLog.objects.filter(
|
||||
|
||||
@@ -1114,7 +1114,9 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Parse datetime
|
||||
# Parse datetime - handle Z suffix (UTC indicator) which Django's parse_datetime doesn't support
|
||||
if scheduled_at_str.endswith('Z'):
|
||||
scheduled_at_str = scheduled_at_str[:-1] + '+00:00'
|
||||
scheduled_at = parse_datetime(scheduled_at_str)
|
||||
if not scheduled_at:
|
||||
return error_response(
|
||||
|
||||
@@ -90,13 +90,16 @@ export default function SiteCard({
|
||||
disabled={isToggling}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Badge
|
||||
variant="solid"
|
||||
color={site.is_active ? "success" : "error"}
|
||||
size="xs"
|
||||
>
|
||||
{site.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge
|
||||
variant="solid"
|
||||
color={site.is_active ? "success" : "error"}
|
||||
size="xs"
|
||||
>
|
||||
{site.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-500">Button</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center border-t border-gray-200 p-2 dark:border-gray-800">
|
||||
@@ -128,16 +131,15 @@ export default function SiteCard({
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
tone="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(site)}
|
||||
startIcon={<TrashBinIcon className="w-4 h-4" />}
|
||||
title="Delete site"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete && onDelete(site)}
|
||||
startIcon={<TrashBinIcon className="w-4 h-4" />}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import SiteCard from '../../components/common/SiteCard';
|
||||
import FormModal, { FormField } from '../../components/common/FormModal';
|
||||
import ConfirmDialog from '../../components/common/ConfirmDialog';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import Alert from '../../components/ui/alert/Alert';
|
||||
@@ -39,6 +40,9 @@ export default function Sites() {
|
||||
const [showSiteModal, setShowSiteModal] = useState(false);
|
||||
const [showSectorsModal, setShowSectorsModal] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [siteToDelete, setSiteToDelete] = useState<Site | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||
const [industries, setIndustries] = useState<Industry[]>([]);
|
||||
@@ -293,13 +297,17 @@ export default function Sites() {
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteSite = async (site: Site) => {
|
||||
if (!window.confirm(`Are you sure you want to delete "${site.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
const handleDeleteSite = (site: Site) => {
|
||||
setSiteToDelete(site);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDeleteSite = async () => {
|
||||
if (!siteToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteSite(site.id);
|
||||
await deleteSite(siteToDelete.id);
|
||||
toast.success('Site deleted successfully');
|
||||
await loadSites();
|
||||
if (showDetailsModal) {
|
||||
@@ -307,6 +315,10 @@ export default function Sites() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to delete site: ${error.message}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
setSiteToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -604,6 +616,22 @@ export default function Sites() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setSiteToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteSite}
|
||||
title="Delete Site"
|
||||
message={`Are you sure you want to delete "${siteToDelete?.name}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,17 @@ interface ContentGenerationSettings {
|
||||
defaultLength: string;
|
||||
}
|
||||
|
||||
// AI Model Config from API
|
||||
interface AIModelConfig {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
model_type: string;
|
||||
provider: string;
|
||||
valid_sizes?: string[];
|
||||
quality_tier?: string;
|
||||
credits_per_image?: number;
|
||||
}
|
||||
|
||||
// Map user-friendly quality to internal service/model configuration
|
||||
const QUALITY_TO_CONFIG: Record<string, { service: 'openai' | 'runware'; model: string }> = {
|
||||
standard: { service: 'openai', model: 'dall-e-2' },
|
||||
@@ -63,8 +74,24 @@ const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'p
|
||||
return 'standard';
|
||||
};
|
||||
|
||||
// Get available image sizes based on provider and model
|
||||
const getImageSizes = (provider: string, model: string) => {
|
||||
|
||||
// Get available image sizes based on provider and model (from API or fallback)
|
||||
const getImageSizes = (provider: string, model: string, imageModels: AIModelConfig[]) => {
|
||||
// First, try to find the model in the fetched models
|
||||
const modelConfig = imageModels.find(m =>
|
||||
m.model_name === model ||
|
||||
(m.provider === provider && m.model_name.includes(model))
|
||||
);
|
||||
|
||||
// If found and has valid_sizes, use them
|
||||
if (modelConfig?.valid_sizes && modelConfig.valid_sizes.length > 0) {
|
||||
return modelConfig.valid_sizes.map(size => ({
|
||||
value: size,
|
||||
label: `${size.replace('x', '×')} pixels`
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback to hardcoded sizes for backward compatibility
|
||||
if (provider === 'runware') {
|
||||
return [
|
||||
{ value: '1280x832', label: '1280×832 pixels' },
|
||||
@@ -101,6 +128,9 @@ export default function ContentSettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Image Models from API - for dynamic size options
|
||||
const [imageModels, setImageModels] = useState<AIModelConfig[]>([]);
|
||||
|
||||
// Content Generation Settings
|
||||
const [contentSettings, setContentSettings] = useState<ContentGenerationSettings>({
|
||||
appendToPrompt: '',
|
||||
@@ -141,20 +171,21 @@ export default function ContentSettingsPage() {
|
||||
};
|
||||
}, [imageQuality]);
|
||||
|
||||
// Get available sizes for current quality
|
||||
// Get available sizes for current quality (uses imageModels from API)
|
||||
const availableSizes = getImageSizes(
|
||||
getCurrentConfig().service,
|
||||
getCurrentConfig().model
|
||||
getCurrentConfig().model,
|
||||
imageModels
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Update image sizes when quality changes
|
||||
// Update image sizes when quality changes or imageModels are loaded
|
||||
useEffect(() => {
|
||||
const config = getCurrentConfig();
|
||||
const sizes = getImageSizes(config.service, config.model);
|
||||
const sizes = getImageSizes(config.service, config.model, imageModels);
|
||||
const defaultSize = sizes.length > 0 ? sizes[0].value : '1024x1024';
|
||||
|
||||
const validSizes = sizes.map(s => s.value);
|
||||
@@ -178,12 +209,24 @@ export default function ContentSettingsPage() {
|
||||
model: config.model,
|
||||
}));
|
||||
}
|
||||
}, [imageQuality, getCurrentConfig]);
|
||||
}, [imageQuality, getCurrentConfig, imageModels]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load available image models from API (for dynamic sizes)
|
||||
try {
|
||||
const modelsResponse = await fetchAPI('/v1/billing/models/?type=image');
|
||||
if (modelsResponse?.data) {
|
||||
setImageModels(modelsResponse.data);
|
||||
} else if (Array.isArray(modelsResponse)) {
|
||||
setImageModels(modelsResponse);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Image models not available, using hardcoded sizes');
|
||||
}
|
||||
|
||||
// Load image generation settings
|
||||
const imageData = await fetchAPI('/v1/system/settings/integrations/image_generation/');
|
||||
if (imageData) {
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Content, fetchImages, ImageRecord } from '../services/api';
|
||||
import { Content, fetchImages, ImageRecord, fetchAPI } from '../services/api';
|
||||
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon, PencilIcon, ImageIcon, BoltIcon } from '../icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Button from '../components/ui/button/Button';
|
||||
import { useToast } from '../components/ui/toast/ToastContainer';
|
||||
|
||||
interface ContentViewTemplateProps {
|
||||
content: Content | null;
|
||||
@@ -618,9 +619,47 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
||||
|
||||
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [imageRecords, setImageRecords] = useState<ImageRecord[]>([]);
|
||||
const [imagesLoading, setImagesLoading] = useState(false);
|
||||
const [imagesError, setImagesError] = useState<string | null>(null);
|
||||
|
||||
// Schedule editing state
|
||||
const [isEditingSchedule, setIsEditingSchedule] = useState(false);
|
||||
const [scheduleDateTime, setScheduleDateTime] = useState<string>('');
|
||||
const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false);
|
||||
|
||||
// Initialize schedule datetime when content loads
|
||||
useEffect(() => {
|
||||
if (content?.scheduled_publish_at) {
|
||||
// Convert ISO string to datetime-local format (YYYY-MM-DDTHH:mm)
|
||||
const date = new Date(content.scheduled_publish_at);
|
||||
const localDateTime = date.toISOString().slice(0, 16);
|
||||
setScheduleDateTime(localDateTime);
|
||||
}
|
||||
}, [content?.scheduled_publish_at]);
|
||||
|
||||
// Handler to update schedule
|
||||
const handleUpdateSchedule = async () => {
|
||||
if (!content?.id || !scheduleDateTime) return;
|
||||
|
||||
setIsUpdatingSchedule(true);
|
||||
try {
|
||||
const isoDateTime = new Date(scheduleDateTime).toISOString();
|
||||
await fetchAPI(`/v1/writer/content/${content.id}/schedule/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scheduled_publish_at: isoDateTime }),
|
||||
});
|
||||
toast.success('Schedule updated successfully');
|
||||
setIsEditingSchedule(false);
|
||||
// Trigger content refresh by reloading the page
|
||||
window.location.reload();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to update schedule: ${error.message}`);
|
||||
} finally {
|
||||
setIsUpdatingSchedule(false);
|
||||
}
|
||||
};
|
||||
|
||||
const metadataPrompts = useMemo(() => extractImagePromptsFromMetadata(content?.metadata), [content?.metadata]);
|
||||
|
||||
@@ -1140,11 +1179,60 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
{content.site_status === 'failed' && 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
{content.scheduled_publish_at && content.site_status === 'scheduled' && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(content.scheduled_publish_at)}
|
||||
</span>
|
||||
|
||||
{/* Schedule Date/Time Editor - Only for scheduled content */}
|
||||
{content.site_status === 'scheduled' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditingSchedule ? (
|
||||
<>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduleDateTime}
|
||||
onChange={(e) => setScheduleDateTime(e.target.value)}
|
||||
className="text-sm px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
onClick={handleUpdateSchedule}
|
||||
disabled={isUpdatingSchedule || !scheduleDateTime}
|
||||
>
|
||||
{isUpdatingSchedule ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
onClick={() => {
|
||||
setIsEditingSchedule(false);
|
||||
// Reset to original value
|
||||
if (content.scheduled_publish_at) {
|
||||
const date = new Date(content.scheduled_publish_at);
|
||||
setScheduleDateTime(date.toISOString().slice(0, 16));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{content.scheduled_publish_at ? formatDate(content.scheduled_publish_at) : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsEditingSchedule(true)}
|
||||
className="text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 p-1"
|
||||
title="Edit schedule"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.external_url && content.site_status === 'published' && (
|
||||
<a
|
||||
href={content.external_url}
|
||||
|
||||
Reference in New Issue
Block a user