q-simplified / services /content.py
SRVCP's picture
Deploy market history and blog uploader updates
7a0d219
"""
Content Service — Blog posts
Uses Supabase if configured, falls back to demo data
"""
import logging
import os
import re
from typing import Optional, List
logger = logging.getLogger(__name__)
_supabase = None
DEMO_BLOGS = [
{
"slug": "structural-shifts-global-bond-markets",
"title": "The Structural Shifts in Global Bond Markets: What You Need to Know",
"excerpt": "As central banks pivot their strategies, we analyze the architectural changes in sovereign debt markets and how it impacts the everyday investor's portfolio balance.",
"category": "Macroeconomics",
"tags": ["bonds", "central banks", "global markets"],
"read_time_minutes": 12,
"published": True,
"featured": True,
"view_count": 2840,
"created_at": "2024-06-24T10:00:00Z",
"image_url": None,
},
{
"slug": "rbi-monetary-policy-decoded",
"title": "RBI Monetary Policy Decoded",
"excerpt": "A granular look at the latest MPC meeting outcomes and future projections.",
"category": "Strategy",
"tags": ["RBI", "monetary policy", "MPC"],
"read_time_minutes": 8,
"published": True,
"featured": False,
"view_count": 1420,
"created_at": "2024-06-22T08:00:00Z",
"image_url": None,
},
{
"slug": "tax-planning-101",
"title": "Tax Planning 101",
"excerpt": "Practical steps to optimize your financial year-end with maximum efficiency.",
"category": "Personal Finance",
"tags": ["tax", "planning", "investment"],
"read_time_minutes": 6,
"published": True,
"featured": False,
"view_count": 3210,
"created_at": "2024-06-20T09:00:00Z",
"image_url": None,
},
{
"slug": "digital-rupee-progress-pitfalls",
"title": "The Digital Rupee: Progress and Potential Pitfalls",
"excerpt": "India's CBDC journey — where we are, what's working, and what could go wrong.",
"category": "Macroeconomics",
"tags": ["CBDC", "digital rupee", "RBI"],
"read_time_minutes": 7,
"published": True,
"featured": False,
"view_count": 980,
"created_at": "2024-06-18T10:00:00Z",
"image_url": None,
},
{
"slug": "why-midcap-funds-finding-favor-2024",
"title": "Why Mid-Cap Funds are Finding Favor in 2024",
"excerpt": "The mid-cap resurgence explained with data and analyst insights.",
"category": "Investment",
"tags": ["mutual funds", "mid-cap", "equity"],
"read_time_minutes": 9,
"published": True,
"featured": False,
"view_count": 1760,
"created_at": "2024-06-15T10:00:00Z",
"image_url": None,
},
{
"slug": "understanding-yield-curves-simple-terms",
"title": "Understanding Yield Curves in Simple Terms",
"excerpt": "Bond yields, inverted curves, and what they signal for the economy.",
"category": "Education",
"tags": ["bonds", "yield curve", "education"],
"read_time_minutes": 5,
"published": True,
"featured": False,
"view_count": 2100,
"created_at": "2024-06-10T10:00:00Z",
"image_url": None,
},
]
def init_supabase():
global _supabase
url = os.getenv("SUPABASE_URL")
key = os.getenv("SUPABASE_SERVICE_KEY")
if url and key:
try:
from supabase import create_client
_supabase = create_client(url, key)
logger.info("Content service: Supabase connected")
except Exception as e:
logger.warning(f"Supabase init failed: {e} — using demo data")
else:
logger.info("Content service: No Supabase config — using demo data")
def get_blogs(limit: int = 20, offset: int = 0, category: Optional[str] = None) -> List[dict]:
if _supabase:
try:
q = _supabase.table("blog_posts").select(
"id,slug,title,excerpt,category,tags,read_time_minutes,image_url,published,created_at,featured,view_count"
).eq("published", True).order("created_at", desc=True)
if category:
q = q.eq("category", category)
return q.range(offset, offset + limit - 1).execute().data or []
except Exception as e:
logger.warning(f"Supabase blog list error: {e}")
blogs = DEMO_BLOGS
if category:
blogs = [b for b in blogs if b["category"] == category]
return blogs[offset: offset + limit]
def get_featured() -> Optional[dict]:
if _supabase:
try:
r = _supabase.table("blog_posts").select("*").eq("published", True).eq("featured", True).order("created_at", desc=True).limit(1).execute()
return (r.data or [None])[0]
except Exception:
pass
return next((b for b in DEMO_BLOGS if b.get("featured")), DEMO_BLOGS[0])
def get_recent(limit: int = 3) -> List[dict]:
if _supabase:
try:
r = _supabase.table("blog_posts").select("id,slug,title,category,created_at").eq("published", True).order("created_at", desc=True).limit(limit).execute()
return r.data or []
except Exception:
pass
return [{"slug": b["slug"], "title": b["title"], "category": b["category"], "created_at": b["created_at"]} for b in DEMO_BLOGS[:limit]]
def get_blog(slug: str) -> Optional[dict]:
if _supabase:
try:
r = _supabase.table("blog_posts").select("*").eq("slug", slug).single().execute()
return r.data
except Exception:
pass
return next((b for b in DEMO_BLOGS if b["slug"] == slug), None)
def get_categories() -> List[str]:
blogs = get_blogs(limit=100)
return sorted(set(b["category"] for b in blogs))
def create_blog(
*,
title: str,
content: str,
category: str,
tags: Optional[List[str]] = None,
excerpt: Optional[str] = None,
image_url: Optional[str] = None,
featured: bool = False,
published: bool = True,
author_id: Optional[str] = None,
author_email: Optional[str] = None,
) -> dict:
if not _supabase:
raise ValueError("Supabase is not configured")
clean_title = title.strip()
clean_content = content.strip()
clean_category = category.strip()
clean_excerpt = (excerpt or clean_content[:220]).strip()
clean_tags = [t.strip() for t in (tags or []) if t and t.strip()]
read_time_minutes = max(1, round(len(clean_content.split()) / 200))
slug_base = _slugify(clean_title)
slug = _unique_slug(slug_base)
payload = {
"slug": slug,
"title": clean_title,
"excerpt": clean_excerpt,
"content": clean_content,
"category": clean_category,
"tags": clean_tags,
"read_time_minutes": read_time_minutes,
"image_url": image_url.strip() if image_url else None,
"published": published,
"featured": featured,
"view_count": 0,
"author_id": author_id,
"author_email": author_email,
}
try:
if featured:
_supabase.table("blog_posts").update({"featured": False}).eq("featured", True).execute()
response = _supabase.table("blog_posts").insert(payload).execute()
created = (response.data or [None])[0]
if not created:
raise ValueError("Blog insert failed")
return created
except Exception as e:
logger.warning(f"Supabase blog create error: {e}")
raise ValueError("Failed to create blog post")
def get_user_blogs(author_id: str, is_admin: bool = False) -> List[dict]:
if not _supabase:
return DEMO_BLOGS # demo: no author_id on demo posts, show all
q = _supabase.table("blog_posts").select("*").order("created_at", desc=True)
if not is_admin:
q = q.eq("author_id", author_id)
return q.execute().data or []
def update_blog(slug: str, author_id: str, role: str, **fields) -> dict:
if not _supabase:
raise ValueError("Supabase is not configured")
post = get_blog(slug)
if not post:
raise ValueError("not found")
if role != "admin" and post.get("author_id") != author_id:
raise PermissionError("forbidden")
updates = {k: v for k, v in fields.items() if v is not None}
if not updates:
raise ValueError("No fields to update")
# Normalize string fields the same way create does
for f in ("title", "category", "excerpt"):
if f in updates and isinstance(updates[f], str):
updates[f] = updates[f].strip()
if "content" in updates:
updates["content"] = updates["content"].strip()
updates["read_time_minutes"] = max(1, round(len(updates["content"].split()) / 200))
if "image_url" in updates:
updates["image_url"] = updates["image_url"].strip() if updates["image_url"] else None
if "tags" in updates:
updates["tags"] = [t.strip() for t in updates["tags"] if t and t.strip()]
try:
if updates.get("featured"):
_supabase.table("blog_posts").update({"featured": False}).eq("featured", True).execute()
r = _supabase.table("blog_posts").update(updates).eq("slug", slug).execute()
updated = (r.data or [None])[0]
if not updated:
raise ValueError("Update failed")
return updated
except (ValueError, PermissionError):
raise
except Exception as e:
logger.warning(f"Supabase blog update error: {e}")
raise ValueError("Failed to update blog post")
def delete_blog(slug: str, author_id: str, role: str) -> None:
if not _supabase:
raise ValueError("Supabase is not configured")
post = get_blog(slug)
if not post:
raise ValueError("not found")
if role != "admin" and post.get("author_id") != author_id:
raise PermissionError("forbidden")
try:
_supabase.table("blog_posts").delete().eq("slug", slug).execute()
except Exception as e:
logger.warning(f"Supabase blog delete error: {e}")
raise ValueError("Failed to delete blog post")
def upload_blog_image(file_bytes: bytes, content_type: str, author_id: str) -> str:
import uuid
if not _supabase:
raise ValueError("Storage not configured")
ext = content_type.split("/")[1]
if ext == "jpeg":
ext = "jpg"
path = f"{author_id}/{uuid.uuid4()}.{ext}"
try:
_supabase.storage.from_("blog-images").upload(path, file_bytes, {"content-type": content_type})
return _supabase.storage.from_("blog-images").get_public_url(path)
except Exception as e:
logger.warning(f"Supabase storage upload error: {e}")
raise ValueError("Storage upload failed")
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "untitled-post"
def _unique_slug(base_slug: str) -> str:
slug = base_slug
counter = 2
while _slug_exists(slug):
slug = f"{base_slug}-{counter}"
counter += 1
return slug
def _slug_exists(slug: str) -> bool:
if not _supabase:
return any(b["slug"] == slug for b in DEMO_BLOGS)
try:
response = _supabase.table("blog_posts").select("slug").eq("slug", slug).limit(1).execute()
return bool(response.data)
except Exception:
return False