Seed database with agencies, brand guidelines, and dropdown options

Backend:
- Update migration to seed agencies (OLIVER Agency, Barclays, etc.)
- Seed brand guidelines (Barclays, Barclaycard) in dropdown_options
- Seed channel/sub-channel/proof-type hierarchy
- Add /api/agencies endpoint to list all agencies
- Update DropdownOptionsResponse to include brand_guidelines
- Update dropdown repository to return brand guidelines

Frontend:
- Update DropdownOptions interface to include brandGuidelines
- CreateCampaignModal now receives brand guidelines from API
- Settings UsersTab fetches agencies from API instead of hardcoded list
- Add getAgencies() method to apiService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael 2025-12-18 17:16:23 -06:00
parent d080a20ee1
commit 6bdb02d78b
9 changed files with 151 additions and 32 deletions

View file

@ -1,4 +1,4 @@
"""Seed default dropdown options
"""Seed default dropdown options, agencies, and brand guidelines
Revision ID: 002_seed_dropdown
Revises: 001_initial
@ -20,9 +20,44 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Seed default channel/sub-channel/proof-type options."""
"""Seed default channel/sub-channel/proof-type options, agencies, and brand guidelines."""
# Default dropdown hierarchy (from original hardcoded values)
conn = op.get_bind()
# ===========================================
# 1. Seed Agencies
# ===========================================
agencies = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"]
for agency_name in agencies:
agency_id = str(uuid4())
conn.execute(
sa.text("""
INSERT INTO agencies (id, name)
VALUES (:id, :name)
ON CONFLICT (name) DO NOTHING
"""),
{"id": agency_id, "name": agency_name}
)
# ===========================================
# 2. Seed Brand Guidelines (using dropdown_options table)
# ===========================================
brand_guidelines = ["Barclays", "Barclaycard"]
for idx, brand in enumerate(brand_guidelines):
brand_id = str(uuid4())
conn.execute(
sa.text("""
INSERT INTO dropdown_options (id, option_type, parent_id, value, display_order)
VALUES (:id, 'brand_guideline', NULL, :value, :order)
"""),
{"id": brand_id, "value": brand, "order": idx}
)
# ===========================================
# 3. Seed Channel/Sub-Channel/Proof-Type Hierarchy
# ===========================================
dropdown_data = {
"Social": {
"Meta": ["In-feed 1x1", "In-feed 4x5", "In-feed 9x16", "Stories"],
@ -52,9 +87,6 @@ def upgrade() -> None:
},
}
# Get connection for raw SQL inserts
conn = op.get_bind()
for channel_name, sub_channels in dropdown_data.items():
# Insert channel
channel_id = str(uuid4())
@ -90,5 +122,6 @@ def upgrade() -> None:
def downgrade() -> None:
"""Remove all seeded dropdown options."""
op.execute("DELETE FROM dropdown_options WHERE option_type IN ('channel', 'sub_channel', 'proof_type')")
"""Remove all seeded data."""
op.execute("DELETE FROM dropdown_options WHERE option_type IN ('channel', 'sub_channel', 'proof_type', 'brand_guideline')")
op.execute("DELETE FROM agencies WHERE name IN ('OLIVER Agency', 'Barclays', 'Mindshare', 'Zenith', 'Unassigned')")

View file

@ -19,6 +19,7 @@ from app.api.schemas import (
ErrorItemResponse,
AnalyticsResponse,
DropdownOptionsResponse,
AgencyResponse,
UserResponse,
)
from app.dependencies.auth import get_current_user
@ -590,3 +591,20 @@ async def delete_proof_type(
if not success:
raise HTTPException(status_code=404, detail=f"Proof type '{proof_type}' not found")
await db.commit()
# Agency endpoints
@router.get("/agencies", response_model=list[AgencyResponse])
async def list_agencies(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""List all agencies."""
from sqlalchemy import select
from app.models.models import Agency
stmt = select(Agency).order_by(Agency.name)
result = await db.execute(stmt)
agencies = result.scalars().all()
return [AgencyResponse(id=a.id, name=a.name) for a in agencies]

View file

@ -150,6 +150,16 @@ class AnalyticsResponse(BaseModel):
class DropdownOptionsResponse(BaseModel):
campaigns: list[str]
channels: dict[str, dict[str, list[str]]]
brand_guidelines: list[str] = []
# Agency schemas
class AgencyResponse(BaseModel):
id: uuid.UUID
name: str
class Config:
from_attributes = True
# User schemas

View file

@ -17,7 +17,11 @@ class DropdownRepository:
async def get_all_hierarchical(self) -> dict:
"""
Get all dropdown options as a hierarchical structure.
Returns: { channels: { channel_name: { sub_channel_name: [proof_types] } } }
Returns: {
channels: { channel_name: { sub_channel_name: [proof_types] } },
brand_guidelines: [brand_names],
campaigns: []
}
"""
# Get all channels (top-level, no parent)
stmt = select(DropdownOption).where(
@ -28,7 +32,7 @@ class DropdownRepository:
result = await self.session.execute(stmt)
channels = result.scalars().all()
# Build hierarchical structure
# Build hierarchical structure for channels
hierarchy: dict[str, dict[str, list[str]]] = {}
for channel in channels:
@ -41,7 +45,19 @@ class DropdownRepository:
pt.value for pt in sub_channel.children
]
return {"channels": hierarchy, "campaigns": []} # campaigns not used from dropdown options
# Get brand guidelines
stmt = select(DropdownOption).where(
DropdownOption.option_type == "brand_guideline"
).order_by(DropdownOption.display_order)
result = await self.session.execute(stmt)
brand_guidelines = [bg.value for bg in result.scalars().all()]
return {
"channels": hierarchy,
"brand_guidelines": brand_guidelines,
"campaigns": [] # campaigns not used from dropdown options
}
async def add_channel(self, name: str) -> DropdownOption:
"""Add a new channel (top-level option)."""

View file

@ -25,6 +25,8 @@ export interface DropdownOptions {
campaigns: string[];
// Hierarchy: Channel -> SubChannel -> ProofType[]
channels: Record<string, Record<string, string[]>>;
// Brand guidelines for campaign creation
brandGuidelines: string[];
}
const App: React.FC = () => {
@ -48,7 +50,8 @@ const App: React.FC = () => {
// Dropdown options now loaded from API
const [dropdownOptions, setDropdownOptions] = useState<DropdownOptions>({
campaigns: [],
channels: {}
channels: {},
brandGuidelines: []
});
// Load dropdown options from API when authenticated
@ -60,7 +63,8 @@ const App: React.FC = () => {
const options = await apiService.getDropdownOptions();
setDropdownOptions({
campaigns: options.campaigns || [],
channels: options.channels || {}
channels: options.channels || {},
brandGuidelines: options.brand_guidelines || []
});
} catch (error) {
console.error('Failed to load dropdown options:', error);
@ -70,7 +74,8 @@ const App: React.FC = () => {
channels: {
"Social": { "Meta": ["In-feed 1x1", "In-feed 4x5"] },
"Display": { "Banner": ["300x600", "300x250"] }
}
},
brandGuidelines: ["Barclays", "Barclaycard"]
});
}
};
@ -419,7 +424,8 @@ const App: React.FC = () => {
const options = await apiService.getDropdownOptions();
setDropdownOptions({
campaigns: options.campaigns || [],
channels: options.channels || {}
channels: options.channels || {},
brandGuidelines: options.brand_guidelines || []
});
} catch (error) {
console.error('Failed to refresh dropdown options:', error);

View file

@ -1311,10 +1311,11 @@ export const Campaigns: React.FC<CampaignsProps> = ({
return (
<>
<CreateCampaignModal
<CreateCampaignModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onAddCampaign={onAddNewCampaign}
brandGuidelines={dropdownOptions.brandGuidelines}
/>
<CampaignList
onSelectCampaign={onSelectCampaign}

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { XIcon } from './icons/XIcon';
import apiService from '../services/apiService';
interface CreateCampaignModalProps {
isOpen: boolean;
@ -11,11 +12,12 @@ interface CreateCampaignModalProps {
clientLead: string;
brandGuidelines: string;
}) => void;
brandGuidelines?: string[];
}
export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen, onClose, onAddCampaign }) => {
export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen, onClose, onAddCampaign, brandGuidelines: brandGuidelineOptions = [] }) => {
const [name, setName] = useState('');
const [brandGuidelines, setBrandGuidelines] = useState('');
const [selectedBrandGuideline, setSelectedBrandGuideline] = useState('');
const [workfrontId, setWorkfrontId] = useState('');
const [clientLead, setClientLead] = useState('');
const [error, setError] = useState<string | null>(null);
@ -24,7 +26,7 @@ export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen
// Reset form when modal opens
if (isOpen) {
setName('');
setBrandGuidelines('');
setSelectedBrandGuideline('');
setWorkfrontId('');
setClientLead('');
setError(null);
@ -51,7 +53,7 @@ export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen
e.preventDefault();
const isIdValidOnSubmit = /^#WF_\d+$/.test(workfrontId);
if (!name.trim() || !clientLead.trim() || !workfrontId.trim() || !brandGuidelines.trim()) {
if (!name.trim() || !clientLead.trim() || !workfrontId.trim() || !selectedBrandGuideline.trim()) {
return;
}
@ -59,15 +61,15 @@ export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen
setError("Workfront Campaign ID must be in the format '#WF_12345'");
return;
}
setError(null);
onAddCampaign({ name, workfrontId, clientLead, brandGuidelines });
onAddCampaign({ name, workfrontId, clientLead, brandGuidelines: selectedBrandGuideline });
onClose();
};
if (!isOpen) return null;
const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim() || !brandGuidelines.trim();
const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim() || !selectedBrandGuideline.trim();
return (
<div
@ -103,14 +105,15 @@ export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen
<label htmlFor="brand-guidelines" className="block text-sm font-medium text-gray-700">Brand Guidelines</label>
<select
id="brand-guidelines"
value={brandGuidelines}
onChange={(e) => setBrandGuidelines(e.target.value)}
value={selectedBrandGuideline}
onChange={(e) => setSelectedBrandGuideline(e.target.value)}
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
required
>
<option value="" disabled>Select brand guidelines</option>
<option value="Barclays">Barclays</option>
<option value="Barclaycard">Barclaycard</option>
{brandGuidelineOptions.map((brand) => (
<option key={brand} value={brand}>{brand}</option>
))}
</select>
</div>
<div>

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import type { DropdownOptions } from '../App';
import apiService from '../services/apiService';
import { TrashIcon } from './icons/TrashIcon';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
@ -95,16 +96,36 @@ const INITIAL_USERS: User[] = [
{ id: '3', name: "Sarah Jenkins", email: "sarah.jenkins@mindshare.com", agency: "Mindshare" },
];
const AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"];
// Fallback agencies if API fails
const DEFAULT_AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"];
const UsersTab: React.FC = () => {
const [users, setUsers] = useState<User[]>(() => {
const saved = localStorage.getItem('barclays_modcomms_users');
return saved ? JSON.parse(saved) : INITIAL_USERS;
});
const [agencies, setAgencies] = useState<string[]>(DEFAULT_AGENCIES);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newAgency, setNewAgency] = useState(AGENCIES[0]);
const [newAgency, setNewAgency] = useState('');
// Load agencies from API
useEffect(() => {
const loadAgencies = async () => {
try {
const response = await apiService.getAgencies();
const agencyNames = response.map(a => a.name);
setAgencies(agencyNames.length > 0 ? agencyNames : DEFAULT_AGENCIES);
if (agencyNames.length > 0 && !newAgency) {
setNewAgency(agencyNames[0]);
}
} catch (error) {
console.error('Failed to load agencies:', error);
setAgencies(DEFAULT_AGENCIES);
}
};
loadAgencies();
}, []);
useEffect(() => {
localStorage.setItem('barclays_modcomms_users', JSON.stringify(users));
@ -174,7 +195,7 @@ const UsersTab: React.FC = () => {
onChange={(e) => setNewAgency(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent appearance-none"
>
{AGENCIES.map(a => <option key={a} value={a}>{a}</option>)}
{agencies.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
</div>
@ -223,7 +244,7 @@ const UsersTab: React.FC = () => {
onChange={(e) => handleAgencyChange(user.id, e.target.value)}
className="w-full bg-white border border-gray-300 text-gray-700 py-1.5 pl-3 pr-8 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
>
{AGENCIES.map(a => <option key={a} value={a}>{a}</option>)}
{agencies.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-500 pointer-events-none" />
</div>

View file

@ -352,11 +352,22 @@ class ApiService {
method: 'DELETE',
});
}
// Agency endpoints
async getAgencies(): Promise<AgencyResponse[]> {
return this.fetch<AgencyResponse[]>('/agencies');
}
}
export interface DropdownOptionsResponse {
campaigns: string[];
channels: Record<string, Record<string, string[]>>;
brand_guidelines: string[];
}
export interface AgencyResponse {
id: string;
name: string;
}
export const apiService = new ApiService();