diff --git a/Backend/app/db/db.py b/Backend/app/db/db.py index ae0f517..965d8d0 100644 --- a/Backend/app/db/db.py +++ b/Backend/app/db/db.py @@ -3,28 +3,51 @@ from sqlalchemy.exc import SQLAlchemyError import os from dotenv import load_dotenv +from urllib.parse import quote_plus +import socket # Load environment variables from .env load_dotenv() # Fetch database credentials USER = os.getenv("user") -PASSWORD = os.getenv("password") +PASSWORD = quote_plus(os.getenv("password") or "") + HOST = os.getenv("host") PORT = os.getenv("port") DBNAME = os.getenv("dbname") -# Corrected async SQLAlchemy connection string (removed `sslmode=require`) -DATABASE_URL = f"postgresql+asyncpg://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}" +# Resolve IPv4 address for HOST to avoid intermittent DNS issues on Windows +if HOST and PORT: + try: + addrinfo = socket.getaddrinfo( + HOST, + int(PORT), + socket.AF_INET, + socket.SOCK_STREAM, + ) + HOST_IP = addrinfo[0][4][0] if addrinfo else HOST + except Exception: + HOST_IP = HOST +else: + HOST_IP = HOST + +# Build async SQLAlchemy connection string +DATABASE_URL = f"postgresql+asyncpg://{USER}:{PASSWORD}@{HOST_IP}:{PORT}/{DBNAME}" +print(f"DB URL: postgresql+asyncpg://{USER}:***@{HOST_IP}:{PORT}/{DBNAME}") # Initialize async SQLAlchemy components try: engine = create_async_engine( - DATABASE_URL, echo=True, connect_args={"ssl": "require"} + DATABASE_URL, + echo=True, + connect_args={"ssl": "require"}, ) AsyncSessionLocal = sessionmaker( - bind=engine, class_=AsyncSession, expire_on_commit=False + bind=engine, + class_=AsyncSession, + expire_on_commit=False, ) Base = declarative_base() print("✅ Database connected successfully!") diff --git a/Backend/app/main.py b/Backend/app/main.py index 86d892a..5fbfc3c 100644 --- a/Backend/app/main.py +++ b/Backend/app/main.py @@ -6,12 +6,14 @@ from .routes.post import router as post_router from .routes.chat import router as chat_router from .routes.match import router as match_router +from .routes.engagement import router as engagement_router from sqlalchemy.exc import SQLAlchemyError import logging import os from dotenv import load_dotenv from contextlib import asynccontextmanager from app.routes import ai +import asyncio # Load environment variables load_dotenv() @@ -27,13 +29,26 @@ async def create_tables(): except SQLAlchemyError as e: print(f"❌ Error creating tables: {e}") +async def startup_sequence(): + await create_tables() + await seed_db() -# Lifespan context manager for startup and shutdown events +# Lifespan context manager for startup and shutdown events with retries @asynccontextmanager async def lifespan(app: FastAPI): print("App is starting...") - await create_tables() - await seed_db() + last_error = None + for attempt in range(1, 6): # up to 5 retries + try: + await startup_sequence() + last_error = None + break + except Exception as e: + last_error = e + print(f"Startup attempt {attempt} failed: {e}") + await asyncio.sleep(2) + if last_error: + print(f"⚠️ Startup continuing without DB initialization due to repeated errors: {last_error}") yield print("App is shutting down...") @@ -54,6 +69,7 @@ async def lifespan(app: FastAPI): app.include_router(post_router) app.include_router(chat_router) app.include_router(match_router) +app.include_router(engagement_router) app.include_router(ai.router) app.include_router(ai.youtube_router) diff --git a/Backend/app/routes/engagement.py b/Backend/app/routes/engagement.py new file mode 100644 index 0000000..a67e3c3 --- /dev/null +++ b/Backend/app/routes/engagement.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional + +from ..db.db import get_db +from ..models.models import UserPost +from ..schemas.engagement import EngagementRequest, EngagementMetrics +from ..services.engagement_service import compute_engagement_metrics + +router = APIRouter(prefix="/engagement", tags=["Engagement"]) + + +@router.post("/compute", response_model=EngagementMetrics) +async def compute_engagement(data: EngagementRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(UserPost).where(UserPost.user_id == data.creator_id)) + posts = result.scalars().all() + + metrics = compute_engagement_metrics(posts, data.follower_count) + return metrics \ No newline at end of file diff --git a/Backend/app/schemas/engagement.py b/Backend/app/schemas/engagement.py new file mode 100644 index 0000000..d3e1541 --- /dev/null +++ b/Backend/app/schemas/engagement.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from typing import Optional + + +class EngagementRequest(BaseModel): + creator_id: str + follower_count: Optional[int] = None + + +class EngagementMetrics(BaseModel): + likes: int + comments: int + shares: int + + avg_likes: float + avg_comments: float + avg_shares: float + + engagement_rate: Optional[float] = None + total_posts: int + follower_count: Optional[int] = None \ No newline at end of file diff --git a/Backend/app/services/engagement_service.py b/Backend/app/services/engagement_service.py new file mode 100644 index 0000000..cb24d2a --- /dev/null +++ b/Backend/app/services/engagement_service.py @@ -0,0 +1,64 @@ +from typing import List, Optional, Dict, Any + +try: + from app.models.models import UserPost # for typing clarity +except Exception: + # Fallback for type hints if import is not available at runtime + class UserPost: # type: ignore + engagement_metrics: Dict[str, Any] + + +def compute_engagement_metrics(posts: List[UserPost], follower_count: Optional[int]) -> Dict[str, Any]: + """ + Compute standardized engagement metrics using existing UserPost.engagement_metrics JSON. + + - likes, comments, shares are totals across posts + - avg_* are per-post averages (0.0 when no posts) + - engagement_rate = (likes + comments + shares) / follower_count + (None when follower_count is missing or <= 0) + - total_posts is count of posts + - follower_count echoed back in response + """ + total_likes = 0 + total_comments = 0 + total_shares = 0 + + for post in posts: + metrics = getattr(post, "engagement_metrics", {}) or {} + # Defensive defaults + likes = int(metrics.get("likes", 0) or 0) + comments = int(metrics.get("comments", 0) or 0) + shares = int(metrics.get("shares", 0) or 0) + + total_likes += likes + total_comments += comments + total_shares += shares + + total_posts = len(posts) + + if total_posts > 0: + avg_likes = total_likes / total_posts + avg_comments = total_comments / total_posts + avg_shares = total_shares / total_posts + else: + avg_likes = 0.0 + avg_comments = 0.0 + avg_shares = 0.0 + + engagement_rate: Optional[float] + if follower_count and follower_count > 0: + engagement_rate = (total_likes + total_comments + total_shares) / follower_count + else: + engagement_rate = None + + return { + "likes": total_likes, + "comments": total_comments, + "shares": total_shares, + "avg_likes": avg_likes, + "avg_comments": avg_comments, + "avg_shares": avg_shares, + "engagement_rate": engagement_rate, + "total_posts": total_posts, + "follower_count": follower_count, + } \ No newline at end of file