Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |