Section 1 & 2 - #Migration Run

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-01 06:29:13 +00:00
parent dd63403e94
commit f81fffc9a6
17 changed files with 149 additions and 44 deletions

View File

@@ -1472,13 +1472,13 @@ class AutomationService:
time.sleep(delay)
def run_stage_7(self):
"""Stage 7: Auto-Approve and Publish Review Content
"""Stage 7: Auto-Approve Review Content
This stage automatically approves content in 'review' status and
marks it as 'published' (or queues for WordPress sync).
marks it as 'approved' (ready for publishing to WordPress).
"""
stage_number = 7
stage_name = "Review → Published"
stage_name = "Review → Approved"
start_time = time.time()
# Query content ready for review
@@ -1538,7 +1538,7 @@ class AutomationService:
'review_total': total_count,
'approved_count': approved_count,
'content_ids': list(Content.objects.filter(
site=self.site, status='published', updated_at__gte=self.run.started_at
site=self.site, status='approved', updated_at__gte=self.run.started_at
).values_list('id', flat=True)),
'partial': True,
'stopped_reason': reason,
@@ -1553,8 +1553,8 @@ class AutomationService:
stage_number, f"Approving content {idx}/{total_count}: {content.title}"
)
# Approve content by changing status to 'published'
content.status = 'published'
# Approve content by changing status to 'approved' (ready for publishing)
content.status = 'approved'
content.save(update_fields=['status', 'updated_at'])
approved_count += 1
@@ -1593,7 +1593,7 @@ class AutomationService:
time_elapsed = self._format_time_elapsed(start_time)
content_ids = list(Content.objects.filter(
site=self.site,
status='published',
status='approved',
updated_at__gte=self.run.started_at
).values_list('id', flat=True))
@@ -1617,7 +1617,7 @@ class AutomationService:
# Release lock
cache.delete(f'automation_lock_{self.site.id}')
logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved and published")
logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved (ready for publishing)")
def pause_automation(self):
"""Pause current automation run"""

View File

@@ -271,7 +271,8 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
STATUS_CHOICES = [
('draft', 'Draft'),
('review', 'Review'),
('published', 'Published'),
('approved', 'Approved'), # Ready for publishing to external site
('published', 'Published'), # Actually published on external site
]
status = models.CharField(
max_length=50,

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.9 on 2026-01-01 06:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('writer', '0013_add_caption_to_images'),
]
operations = [
migrations.CreateModel(
name='ImagePrompts',
fields=[
],
options={
'verbose_name': 'Image Prompt',
'verbose_name_plural': 'Image Prompts',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('writer.images',),
),
migrations.AlterField(
model_name='content',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('review', 'Review'), ('approved', 'Approved'), ('published', 'Published')], db_index=True, default='draft', help_text='Content status', max_length=50),
),
]

View File

@@ -11,8 +11,8 @@ class TasksSerializer(serializers.ModelSerializer):
"""Serializer for Tasks model - Stage 1 refactored"""
cluster_name = serializers.SerializerMethodField()
sector_name = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
site_id = serializers.IntegerField(read_only=True)
sector_id = serializers.IntegerField(read_only=True)
class Meta:
model = Tasks
@@ -162,8 +162,8 @@ class ContentSerializer(serializers.ModelSerializer):
has_image_prompts = serializers.SerializerMethodField()
image_status = serializers.SerializerMethodField()
has_generated_images = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
site_id = serializers.IntegerField(read_only=True)
sector_id = serializers.IntegerField(read_only=True)
class Meta:
model = Content
@@ -300,8 +300,8 @@ class ContentSerializer(serializers.ModelSerializer):
class ContentTaxonomySerializer(serializers.ModelSerializer):
"""Serializer for ContentTaxonomy model - Stage 1 refactored"""
content_count = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
site_id = serializers.IntegerField(read_only=True)
sector_id = serializers.IntegerField(read_only=True)
class Meta:
model = ContentTaxonomy

View File

@@ -751,6 +751,19 @@ class ContentViewSet(SiteSectorModelViewSet):
'source',
]
def get_queryset(self):
"""Override to support status__in filtering for multiple statuses"""
queryset = super().get_queryset()
# Support status__in query param (comma-separated list of statuses)
status_in = self.request.query_params.get('status__in', None)
if status_in:
statuses = [s.strip() for s in status_in.split(',') if s.strip()]
if statuses:
queryset = queryset.filter(status__in=statuses)
return queryset
def perform_create(self, serializer):
"""Override to check monthly word limit and set account"""
user = getattr(self.request, 'user', None)

View File

@@ -15,6 +15,7 @@ import {
interface CreditAvailabilityWidgetProps {
availableCredits: number;
totalCredits: number;
usedCredits?: number; // Actual credits used this month from API
loading?: boolean;
}
@@ -29,10 +30,17 @@ const OPERATION_COSTS = {
export default function CreditAvailabilityWidget({
availableCredits,
totalCredits,
usedCredits: usedCreditsFromApi,
loading = false
}: CreditAvailabilityWidgetProps) {
const usedCredits = totalCredits - availableCredits;
const usagePercent = totalCredits > 0 ? Math.round((usedCredits / totalCredits) * 100) : 0;
// Use actual used credits from API if provided, otherwise calculate
// Note: availableCredits may include purchased credits beyond plan allocation
const usedCredits = usedCreditsFromApi ?? 0;
// Calculate usage percentage based on plan allocation
// If available > plan, user has extra credits (purchased or carried over)
const usagePercent = totalCredits > 0 ? Math.min(Math.round((usedCredits / totalCredits) * 100), 100) : 0;
const remainingPercent = Math.max(100 - usagePercent, 0);
// Calculate available operations
const availableOps = Object.entries(OPERATION_COSTS).map(([key, config]) => ({
@@ -72,11 +80,11 @@ export default function CreditAvailabilityWidget({
className={`h-2 rounded-full transition-all ${
usagePercent > 90 ? 'bg-error-500' : usagePercent > 75 ? 'bg-warning-500' : 'bg-success-500'
}`}
style={{ width: `${Math.max(100 - usagePercent, 0)}%` }}
style={{ width: `${remainingPercent}%` }}
></div>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
{totalCredits > 0 ? `${usedCredits.toLocaleString()} of ${totalCredits.toLocaleString()} used (${usagePercent}%)` : 'No credits allocated'}
{totalCredits > 0 ? `${usedCredits.toLocaleString()} of ${totalCredits.toLocaleString()} used this month (${usagePercent}%)` : 'No credits allocated'}
</p>
</div>

View File

@@ -87,8 +87,8 @@ export default function StandardizedModuleWidget({
];
// Define Writer pipeline - using correct content structure
// Content has status: draft, review, published
// totalContent = drafts + review + published
// Content has status: draft, review, approved, published
// totalContent = drafts + review + approved + published
// Get writer colors from config
const writerColors = useMemo(() => getPipelineColors('writer'), []);

View File

@@ -139,7 +139,7 @@ export default function WorkflowCompletionWidget({
];
// Define writer items - using "Content Pages" not "Articles"
// Total content = drafts + review + published
// Total content = drafts + review + approved + published
const totalContent = writer.contentDrafts + writer.contentReview + writer.contentPublished;
const writerItems = [
{ label: 'Content Pages', value: totalContent, barColor: `var(${WORKFLOW_COLORS.writer.contentPages})` },

View File

@@ -90,6 +90,27 @@ export function createApprovedPageConfig(params: {
</div>
),
},
{
key: 'status',
label: 'Status',
sortable: true,
sortField: 'status',
width: '130px',
render: (value: string, row: Content) => {
// Map internal status to user-friendly labels
const statusConfig: Record<string, { color: 'success' | 'blue' | 'amber' | 'gray'; label: string }> = {
'approved': { color: 'blue', label: 'Ready to Publish' },
'published': { color: 'success', label: row.external_id ? 'On Site' : 'Approved' },
};
const config = statusConfig[value] || { color: 'gray' as const, label: value || '-' };
return (
<Badge color={config.color} size="xs" variant="soft">
<span className="text-[11px] font-normal">{config.label}</span>
</Badge>
);
},
},
{
key: 'wordpress_status',
label: 'Site Content Status',

View File

@@ -6,7 +6,7 @@
*
* IMPORTANT: Content table structure
* - Tasks is separate table (has status: queued, processing, completed, failed)
* - Content table has status field: 'draft', 'review', 'published' (approved)
* - Content table has status field: 'draft', 'review', 'approved', 'published'
* - Images is separate table linked to content
*
* Credits data comes from /v1/billing/credits/usage/summary/ endpoint
@@ -43,7 +43,7 @@ export interface WriterModuleStats {
tasksCompleted: number; // Tasks with status='completed'
contentDrafts: number; // Content with status='draft'
contentReview: number; // Content with status='review'
contentPublished: number; // Content with status='published' (approved)
contentPublished: number; // Content with status='approved' or 'published' (ready for publishing or on site)
totalContent: number; // All content regardless of status
totalImages: number;
}
@@ -158,8 +158,8 @@ export function useModuleStats() {
fetchContent({ ...baseFilters, status: 'draft' }),
// Content with status='review'
fetchContent({ ...baseFilters, status: 'review' }),
// Content with status='published' (approved)
fetchContent({ ...baseFilters, status: 'published' }),
// Content with status='approved' or 'published' (ready for publishing or on site)
fetchContent({ ...baseFilters, status__in: 'approved,published' }),
// Total content (all statuses)
fetchContent({ ...baseFilters }),
// Total images

View File

@@ -9,7 +9,7 @@
*
* IMPORTANT: Content table structure
* - Tasks is separate table
* - Content table has status field: 'draft', 'review', 'published' (approved)
* - Content table has status field: 'draft', 'review', 'approved', 'published'
* - Images is separate table linked to content
*
* Credits data comes from /v1/billing/credits/usage/summary/ endpoint
@@ -57,7 +57,7 @@ export interface WorkflowStats {
tasksTotal: number;
contentDrafts: number; // Content with status='draft'
contentReview: number; // Content with status='review'
contentPublished: number; // Content with status='published' (approved)
contentPublished: number; // Content with status='approved' or 'published' (ready for publishing or on site)
imagesCreated: number;
};
// Credit consumption stats - detailed breakdown by operation
@@ -208,10 +208,10 @@ export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
dateFilter
? fetchAPI(`/v1/writer/content/?page_size=1&status=review${baseParams}${dateParam}`)
: fetchContent({ ...baseFilters, status: 'review' }),
// Content with status='published' (approved)
// Content with status='approved' or 'published' (ready for publishing or on site)
dateFilter
? fetchAPI(`/v1/writer/content/?page_size=1&status=published${baseParams}${dateParam}`)
: fetchContent({ ...baseFilters, status: 'published' }),
? fetchAPI(`/v1/writer/content/?page_size=1&status__in=approved,published${baseParams}${dateParam}`)
: fetchContent({ ...baseFilters, status__in: 'approved,published' }),
// Total images
dateFilter
? fetchAPI(`/v1/writer/images/?page_size=1${baseParams}${dateParam}`)

View File

@@ -139,7 +139,7 @@ const AutomationPage: React.FC = () => {
fetchContent({ page_size: 1, site_id: siteId }),
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
fetchContent({ page_size: 1, site_id: siteId, status__in: 'approved,published' }),
fetchImages({ page_size: 1 }),
fetchImages({ page_size: 1, status: 'pending' }),
]);
@@ -258,7 +258,7 @@ const AutomationPage: React.FC = () => {
fetchContent({ page_size: 1, site_id: siteId }),
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
fetchContent({ page_size: 1, site_id: siteId, status__in: 'approved,published' }),
fetchImages({ page_size: 1 }),
fetchImages({ page_size: 1, status: 'pending' }),
]);

View File

@@ -7,6 +7,8 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ComponentCard from '../../components/common/ComponentCard';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI, fetchSiteSectors } from '../../services/api';
@@ -23,6 +25,7 @@ import {
BoltIcon,
PageIcon,
ArrowRightIcon,
ArrowUpIcon,
} from '../../icons';
interface Site {
@@ -251,6 +254,7 @@ export default function SiteDashboard() {
<CreditAvailabilityWidget
availableCredits={balance?.credits_remaining ?? 0}
totalCredits={balance?.plan_credits_per_month ?? 0}
usedCredits={balance?.credits_used_this_month ?? 0}
loading={loading}
/>
</div>

View File

@@ -59,9 +59,9 @@ export default function Approved() {
// Load total metrics for footer widget and header metrics (not affected by pagination)
const loadTotalMetrics = useCallback(async () => {
try {
// Fetch all approved content to calculate totals
// Fetch all approved+published content to calculate totals
const data = await fetchContent({
status: 'published', // Backend uses 'published' for approved content
status__in: 'approved,published', // Both approved and published content
page_size: 1000, // Fetch enough to count
});
@@ -86,7 +86,7 @@ export default function Approved() {
loadTotalMetrics();
}, [loadTotalMetrics]);
// Load content - filtered for approved status (API still uses 'published' internally)
// Load content - filtered for approved+published status
const loadContent = useCallback(async () => {
setLoading(true);
setShowContent(false);
@@ -95,7 +95,7 @@ export default function Approved() {
const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }),
status: 'published', // Backend uses 'published' for approved content
status__in: 'approved,published', // Both approved and published content
page: currentPage,
page_size: pageSize,
ordering,
@@ -221,8 +221,13 @@ export default function Approved() {
toast.warning('WordPress URL not available');
}
} else if (action === 'edit') {
// Navigate to content editor (if exists) or show edit modal
navigate(`/writer/content?id=${row.id}`);
// Navigate to content editor
if (row.site_id) {
navigate(`/sites/${row.site_id}/posts/${row.id}/edit`);
} else {
// Fallback if site_id not available
toast.warning('Unable to edit: Site information not available');
}
}
}, [toast, loadContent, navigate]);

View File

@@ -81,11 +81,11 @@ export default function Content() {
});
setTotalReview(reviewRes.count || 0);
// Get content with status='published'
// Get content with status='approved' or 'published' (ready for publishing or on site)
const publishedRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'published',
status__in: 'approved,published',
});
setTotalPublished(publishedRes.count || 0);

View File

@@ -1385,7 +1385,7 @@ export interface ContentImage {
export interface ContentImagesGroup {
content_id: number;
content_title: string;
content_status: 'draft' | 'review' | 'publish';
content_status: 'draft' | 'review' | 'approved' | 'published';
featured_image: ContentImage | null;
in_article_images: ContentImage[];
overall_status: 'pending' | 'partial' | 'complete' | 'failed';
@@ -2197,6 +2197,7 @@ export async function deleteAuthorProfile(id: number): Promise<void> {
export interface ContentFilters {
search?: string;
status?: string;
status__in?: string; // Comma-separated list of statuses (e.g., 'approved,published')
content_type?: string;
content_structure?: string;
source?: string;
@@ -2215,9 +2216,11 @@ export interface Content {
content_html: string;
content_type: string;
content_structure: string;
status: 'draft' | 'published';
status: 'draft' | 'review' | 'approved' | 'published';
source: 'igny8' | 'wordpress';
// Relations
site_id?: number;
sector_id?: number;
cluster_id: number;
cluster_name?: string | null;
sector_name?: string | null;
@@ -2282,6 +2285,7 @@ export async function fetchContent(filters: ContentFilters = {}): Promise<Conten
if (filters.search) params.append('search', filters.search);
if (filters.status) params.append('status', filters.status);
if (filters.status__in) params.append('status__in', filters.status__in);
if (filters.task_id) params.append('task_id', filters.task_id.toString());
if (filters.site_id) params.append('site_id', filters.site_id.toString());
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());

View File

@@ -792,6 +792,12 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
if (statusLower === 'generated' || statusLower === 'published' || statusLower === 'complete') {
return 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400';
}
if (statusLower === 'approved') {
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
}
if (statusLower === 'review') {
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
}
if (statusLower === 'pending' || statusLower === 'draft') {
return 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400';
}
@@ -801,6 +807,18 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
};
const getStatusLabel = (status: string, hasExternalId?: boolean) => {
const statusLower = status.toLowerCase();
// Map status to user-friendly labels
const statusLabels: Record<string, string> = {
'draft': 'Draft',
'review': 'In Review',
'approved': 'Ready to Publish',
'published': hasExternalId ? 'On Site' : 'Approved',
};
return statusLabels[statusLower] || status.charAt(0).toUpperCase() + status.slice(1);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
@@ -824,7 +842,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
<div className="flex items-center gap-3 mb-3">
<FileTextIcon className="w-6 h-6" />
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(content.status)}`}>
{content.status}
{getStatusLabel(content.status, !!content.external_id)}
</span>
</div>
<h1 className="text-3xl font-bold mb-2">