Skip to content

Commit d561b40

Browse files
Clément VALENTINclaude
andcommitted
feat(admin-offers): détection dynamique des scrapers disponibles
- Backend: nouvel endpoint GET /admin/scrapers pour lister les scrapers - Backend: GET /admin/providers avec param include_missing_scrapers - Frontend: affiche tous les fournisseurs avec scraper, même non initialisés - Frontend: badge "Non initialisé" pour les fournisseurs pas encore en base - UI: centrage des onglets dans AdminTabs et ApiDocsTabs - Docs: mise à jour admin-offers.md et checklist.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent a8298fe commit d561b40

File tree

7 files changed

+233
-86
lines changed

7 files changed

+233
-86
lines changed

apps/api/src/routers/admin.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,14 +1213,47 @@ async def list_offers(
12131213
)
12141214

12151215

1216+
@router.get("/scrapers", response_model=APIResponse)
1217+
async def list_available_scrapers(
1218+
current_user: User = Depends(require_permission('offers'))
1219+
) -> APIResponse:
1220+
"""
1221+
List all available scrapers (providers with scraping support)
1222+
1223+
Returns:
1224+
APIResponse with list of scraper names
1225+
"""
1226+
try:
1227+
scraper_names = list(PriceUpdateService.SCRAPERS.keys())
1228+
1229+
return APIResponse(
1230+
success=True,
1231+
data={
1232+
"scrapers": scraper_names,
1233+
"total": len(scraper_names)
1234+
}
1235+
)
1236+
1237+
except Exception as e:
1238+
logger.error(f"Error listing scrapers: {e}", exc_info=True)
1239+
return APIResponse(
1240+
success=False,
1241+
error={"code": "LIST_ERROR", "message": str(e)}
1242+
)
1243+
1244+
12161245
@router.get("/providers", response_model=APIResponse)
12171246
async def list_providers(
1247+
include_missing_scrapers: bool = Query(False, description="Include providers with scrapers that don't exist in DB yet"),
12181248
current_user: User = Depends(require_permission('offers')),
12191249
db: AsyncSession = Depends(get_db)
12201250
) -> APIResponse:
12211251
"""
12221252
List all energy providers
12231253
1254+
Args:
1255+
include_missing_scrapers: If True, also returns providers with scrapers not yet in DB
1256+
12241257
Returns:
12251258
APIResponse with list of providers
12261259
"""
@@ -1231,7 +1264,11 @@ async def list_providers(
12311264
providers = result.scalars().all()
12321265

12331266
providers_data = []
1267+
existing_names = set()
1268+
12341269
for provider in providers:
1270+
existing_names.add(provider.name)
1271+
12351272
# Count active offers
12361273
offers_result = await db.execute(
12371274
select(func.count()).select_from(EnergyOffer).where(
@@ -1243,17 +1280,44 @@ async def list_providers(
12431280
)
12441281
offers_count = offers_result.scalar()
12451282

1283+
# Check if this provider has a scraper
1284+
has_scraper = provider.name in PriceUpdateService.SCRAPERS
1285+
12461286
providers_data.append({
12471287
"id": provider.id,
12481288
"name": provider.name,
12491289
"logo_url": provider.logo_url,
12501290
"website": provider.website,
12511291
"is_active": provider.is_active,
12521292
"active_offers_count": offers_count,
1293+
"has_scraper": has_scraper,
1294+
"scraper_urls": provider.scraper_urls,
12531295
"created_at": provider.created_at.isoformat(),
12541296
"updated_at": provider.updated_at.isoformat(),
12551297
})
12561298

1299+
# Add providers with scrapers that don't exist in DB yet
1300+
if include_missing_scrapers:
1301+
for scraper_name in PriceUpdateService.SCRAPERS.keys():
1302+
if scraper_name not in existing_names:
1303+
# Generate a placeholder ID and default values
1304+
providers_data.append({
1305+
"id": f"scraper-{scraper_name.lower().replace(' ', '-')}",
1306+
"name": scraper_name,
1307+
"logo_url": None,
1308+
"website": None,
1309+
"is_active": False,
1310+
"active_offers_count": 0,
1311+
"has_scraper": True,
1312+
"scraper_urls": None,
1313+
"created_at": None,
1314+
"updated_at": None,
1315+
"not_in_database": True, # Flag to indicate this is a placeholder
1316+
})
1317+
1318+
# Sort by name
1319+
providers_data.sort(key=lambda x: x["name"])
1320+
12571321
return APIResponse(
12581322
success=True,
12591323
data={

apps/web/src/api/energy.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export interface EnergyProvider {
88
scraper_urls?: string[]
99
active_offers_count?: number
1010
last_update?: string
11+
has_scraper?: boolean
12+
not_in_database?: boolean // True if provider exists only as scraper, not in DB
13+
is_active?: boolean
14+
created_at?: string
15+
updated_at?: string
1116
}
1217

1318
export interface EnergyOffer {
@@ -125,6 +130,16 @@ export const energyApi = {
125130
return apiClient.get<EnergyProvider[]>('energy/providers')
126131
},
127132

133+
// Admin endpoint - includes providers with scrapers not yet in DB
134+
getProvidersWithScrapers: async () => {
135+
return apiClient.get<{ providers: EnergyProvider[], total: number }>('admin/providers', { include_missing_scrapers: true })
136+
},
137+
138+
// Get list of available scrapers
139+
getAvailableScrapers: async () => {
140+
return apiClient.get<{ scrapers: string[], total: number }>('admin/scrapers')
141+
},
142+
128143
getOffers: async (providerId?: string) => {
129144
return apiClient.get<EnergyOffer[]>('energy/offers', providerId ? { provider_id: providerId } : {})
130145
},

apps/web/src/components/AdminTabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default function AdminTabs() {
2626

2727
return (
2828
<div className="w-full border-b border-gray-200 dark:border-gray-700 overflow-x-auto bg-white dark:bg-gray-800">
29-
<nav className="flex gap-1 min-w-max px-3 sm:px-4 lg:px-6" aria-label="Tabs">
29+
<nav className="flex justify-center gap-1 min-w-max px-3 sm:px-4 lg:px-6" aria-label="Tabs">
3030
{visibleTabs.map((tab) => {
3131
const isActive = location.pathname === tab.path
3232
return (

apps/web/src/components/ApiDocsTabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function ApiDocsTabs() {
1515

1616
return (
1717
<div className="w-full border-b border-gray-200 dark:border-gray-700 overflow-x-auto bg-white dark:bg-gray-800">
18-
<nav className="flex gap-1 min-w-max px-3 sm:px-4 lg:px-6" aria-label="Tabs">
18+
<nav className="flex justify-center gap-1 min-w-max px-3 sm:px-4 lg:px-6" aria-label="Tabs">
1919
{tabs.map((tab) => {
2020
const isActive = location.pathname === tab.path
2121
return (

apps/web/src/pages/AdminOffers.tsx

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,13 @@ export default function AdminOffers() {
8585
return new Date(validFrom) < sixMonthsAgo
8686
}
8787

88-
// Fetch providers
88+
// Fetch providers (includes providers with scrapers not yet in DB)
8989
const { data: providersData } = useQuery({
90-
queryKey: ['energy-providers'],
90+
queryKey: ['energy-providers-with-scrapers'],
9191
queryFn: async () => {
92-
const response = await energyApi.getProviders()
93-
if (response.success && Array.isArray(response.data)) {
94-
return response.data as EnergyProvider[]
92+
const response = await energyApi.getProvidersWithScrapers()
93+
if (response.success && response.data?.providers) {
94+
return response.data.providers as EnergyProvider[]
9595
}
9696
return []
9797
},
@@ -638,13 +638,15 @@ export default function AdminOffers() {
638638
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
639639
{Array.isArray(providersData) &&
640640
providersData
641+
// Filtrer pour n'afficher que les fournisseurs avec un scraper
642+
.filter((provider) => provider.has_scraper)
641643
.sort((a, b) => a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' }))
642644
.map((provider) => {
643645
const allProviderOffers = allOffersByProvider?.[provider.id] || []
644646
const activeCount = allProviderOffers.filter(o => o.is_active).length
645647
const isLoadingPreview = loadingPreview === provider.id
646648
const isRefreshing = refreshingProvider === provider.id
647-
const hasProvider = ['EDF', 'Enercoop', 'TotalEnergies', 'Priméo Énergie', 'Engie', 'ALPIQ', 'Alterna', 'Ekwateur'].includes(provider.name)
649+
const isNotInDatabase = provider.not_in_database === true
648650

649651
// Find the most recent tariff date
650652
const mostRecentDate = allProviderOffers
@@ -656,25 +658,38 @@ export default function AdminOffers() {
656658
<div
657659
key={provider.id}
658660
className={`bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg p-4 hover:shadow-lg transition-shadow duration-200 ${
659-
!hasProvider ? 'opacity-60 bg-gray-50 dark:bg-gray-900' : ''
661+
isNotInDatabase ? 'border-dashed border-2 border-amber-400 dark:border-amber-600' : ''
660662
}`}
661663
>
662664
<div className="flex items-start justify-between mb-3">
663665
<div className="flex-1">
664-
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
665-
{provider.name}
666-
</h3>
667-
<p className="text-sm text-gray-600 dark:text-gray-400">
668-
{activeCount} offre{activeCount > 1 ? 's' : ''} active{activeCount > 1 ? 's' : ''}
669-
</p>
666+
<div className="flex items-center gap-2 mb-1">
667+
<h3 className="font-semibold text-gray-900 dark:text-white">
668+
{provider.name}
669+
</h3>
670+
{isNotInDatabase && (
671+
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full">
672+
Non initialisé
673+
</span>
674+
)}
675+
</div>
676+
{isNotInDatabase ? (
677+
<p className="text-sm text-amber-600 dark:text-amber-400">
678+
Scraper disponible - Cliquez sur Prévisualiser pour initialiser
679+
</p>
680+
) : (
681+
<p className="text-sm text-gray-600 dark:text-gray-400">
682+
{activeCount} offre{activeCount > 1 ? 's' : ''} active{activeCount > 1 ? 's' : ''}
683+
</p>
684+
)}
670685
{mostRecentDate && (
671686
<p className="text-xs text-green-600 dark:text-green-400 font-medium mt-1">
672687
Tarif du {mostRecentDate.toLocaleDateString('fr-FR', { month: 'short', year: 'numeric' })}
673688
</p>
674689
)}
675-
{provider.last_update && (
690+
{provider.updated_at && !isNotInDatabase && (
676691
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
677-
Mise à jour : {new Date(provider.last_update).toLocaleDateString('fr-FR')}
692+
Mise à jour : {new Date(provider.updated_at).toLocaleDateString('fr-FR')}
678693
</p>
679694
)}
680695
</div>
@@ -699,9 +714,9 @@ export default function AdminOffers() {
699714
</div>
700715

701716
<div className="flex flex-col gap-2">
702-
{/* Détection si le fournisseur a un scraper - déjà défini plus haut */}
717+
{/* Boutons d'action */}
703718
{(() => {
704-
const isDisabled = !hasProvider || isLoadingPreview || isRefreshing
719+
const isDisabled = isLoadingPreview || isRefreshing
705720

706721
return (
707722
<>
@@ -710,10 +725,8 @@ export default function AdminOffers() {
710725
<button
711726
onClick={() => handlePreviewRefresh(provider.id, provider.name)}
712727
disabled={isDisabled}
713-
className={`btn btn-primary flex-1 text-sm flex items-center justify-center gap-2 ${
714-
!hasProvider ? 'opacity-50 cursor-not-allowed' : ''
715-
}`}
716-
title={!hasProvider ? 'Scraper non disponible pour ce fournisseur' : ''}
728+
className="btn btn-primary flex-1 text-sm flex items-center justify-center gap-2"
729+
title="Prévisualiser les changements avant application"
717730
>
718731
{isLoadingPreview && loadingPreview === provider.id ? (
719732
<>
@@ -728,8 +741,8 @@ export default function AdminOffers() {
728741
)}
729742
</button>
730743

731-
{/* Bouton Purger - Uniquement avec permission delete */}
732-
{hasAction('offers', 'delete') && (
744+
{/* Bouton Purger - Uniquement avec permission delete et si le fournisseur existe en base */}
745+
{hasAction('offers', 'delete') && !isNotInDatabase && (
733746
<button
734747
onClick={() => handlePurgeProvider(provider.id, provider.name)}
735748
disabled={isRefreshing}
@@ -741,15 +754,8 @@ export default function AdminOffers() {
741754
)}
742755
</div>
743756

744-
{/* Message si pas de scraper */}
745-
{!hasProvider && (
746-
<div className="text-xs text-gray-500 dark:text-gray-400 italic text-center">
747-
Scraper non disponible
748-
</div>
749-
)}
750-
751757
{/* Affichage des sources du scraper */}
752-
{hasProvider && provider.scraper_urls && provider.scraper_urls.length > 0 && (() => {
758+
{provider.scraper_urls && provider.scraper_urls.length > 0 && (() => {
753759
// Labels pour les URLs en fonction du fournisseur
754760
const urlLabels: Record<string, string[]> = {
755761
'EDF': ['Tarif Bleu (réglementé)', 'Zen Week-End (marché)'],

docs/design/checklist.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ Cette checklist garantit que chaque nouvelle page ou modification respecte les s
1515

1616
### Container Principal
1717

18-
- [ ] Container avec `pt-6 w-full`
18+
- [ ] Container avec `w-full`
1919
- [ ] Espacement cohérent entre sections (`mt-6`)
2020
- [ ] Padding latéral géré par Layout (ne pas ajouter `px-*`)
2121

2222
**Référence:** [01 - Container](./components/01-container.md)
2323

2424
### Header de Page
2525

26+
**⚠️ Important : Les pages Admin n'ont PAS besoin d'ajouter leur propre header H1.**
27+
Le header est géré automatiquement par le Layout via la configuration des routes.
28+
29+
Pour les **pages non-Admin** uniquement :
2630
- [ ] H1 avec `text-3xl font-bold mb-2 flex items-center gap-3`
2731
- [ ] Icône avec `text-primary-600 dark:text-primary-400` et `size={32}`
2832
- [ ] Sous-titre avec `text-gray-600 dark:text-gray-400`

0 commit comments

Comments
 (0)