Files
Michele 77ca70cd48 feat: multi-user SaaS, piani Freemium/Pro, Google OAuth, admin panel
BLOCCO 1 - Multi-user data model:
- User: email, display_name, avatar_url, auth_provider, google_id
- User: subscription_plan, subscription_expires_at, is_admin, post counters
- SubscriptionCode table per redeem codes
- user_id FK su Character, Post, AffiliateLink, EditorialPlan, SocialAccount, SystemSetting
- Migrazione SQLite-safe (ALTER TABLE) + preserva dati esistenti

BLOCCO 2 - Auth completo:
- Registrazione email/password + login multi-user
- Google OAuth 2.0 (httpx, no deps esterne)
- Callback flow: Google -> /auth/callback?token=JWT -> frontend
- Backward compat login admin con username

BLOCCO 3 - Piani e abbonamenti:
- Freemium: 1 character, 15 post/mese, FB+IG only, no auto-plans
- Pro: illimitato, tutte le piattaforme, tutte le feature
- Enforcement automatico in tutti i router
- Redeem codes con durate 1/3/6/12 mesi
- Admin panel: genera codici, lista utenti

BLOCCO 4 - Frontend completo:
- Login page design Leopost (split coral/cream, Google, social coming soon)
- AuthCallback per OAuth redirect
- PlanBanner, UpgradeModal con pricing
- AdminSettings per generazione codici
- CharacterForm con tab Account Social + guide setup

Deploy:
- Dockerfile con ARG VITE_BASE_PATH/VITE_API_BASE
- docker-compose.prod.yml per leopost.it (no subpath)
- docker-compose.yml aggiornato per lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:01:07 +02:00

213 lines
6.5 KiB
Python

"""Editorial plans and scheduled posts router.
Manages editorial plans (posting schedules) and individual scheduled post entries.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import EditorialPlan, ScheduledPost, User
from ..plan_limits import get_plan
from ..schemas import (
EditorialPlanCreate,
EditorialPlanResponse,
EditorialPlanUpdate,
ScheduledPostCreate,
ScheduledPostResponse,
)
router = APIRouter(
prefix="/api/plans",
tags=["plans"],
)
# === Editorial Plans ===
@router.get("/", response_model=list[EditorialPlanResponse])
def list_plans(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all editorial plans, optionally filtered by character."""
query = db.query(EditorialPlan).filter(EditorialPlan.user_id == current_user.id)
if character_id is not None:
query = query.filter(EditorialPlan.character_id == character_id)
return query.order_by(EditorialPlan.created_at.desc()).all()
@router.get("/scheduled", response_model=list[ScheduledPostResponse])
def list_all_scheduled_posts(
platform: str | None = Query(None),
status: str | None = Query(None),
date_from: datetime | None = Query(None),
date_after: datetime | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all scheduled posts across all plans with optional filters."""
# Join with plans to filter by user
user_plan_ids = [
p.id for p in db.query(EditorialPlan.id).filter(EditorialPlan.user_id == current_user.id).all()
]
query = db.query(ScheduledPost).filter(
(ScheduledPost.plan_id.in_(user_plan_ids)) | (ScheduledPost.plan_id == None)
)
if platform is not None:
query = query.filter(ScheduledPost.platform == platform)
if status is not None:
query = query.filter(ScheduledPost.status == status)
if date_from is not None:
query = query.filter(ScheduledPost.scheduled_at >= date_from)
if date_after is not None:
query = query.filter(ScheduledPost.scheduled_at <= date_after)
return query.order_by(ScheduledPost.scheduled_at).all()
@router.get("/{plan_id}", response_model=EditorialPlanResponse)
def get_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single editorial plan by ID."""
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
return plan
@router.post("/", response_model=EditorialPlanResponse, status_code=201)
def create_plan(
data: EditorialPlanCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new editorial plan."""
plan_limits = get_plan(current_user)
if not plan_limits.get("auto_plans"):
raise HTTPException(
status_code=403,
detail={"message": "Piani automatici disponibili solo con Pro.", "upgrade_required": True},
)
plan = EditorialPlan(**data.model_dump())
plan.user_id = current_user.id
db.add(plan)
db.commit()
db.refresh(plan)
return plan
@router.put("/{plan_id}", response_model=EditorialPlanResponse)
def update_plan(
plan_id: int,
data: EditorialPlanUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update an editorial plan."""
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(plan, key, value)
plan.updated_at = datetime.utcnow()
db.commit()
db.refresh(plan)
return plan
@router.delete("/{plan_id}", status_code=204)
def delete_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete an editorial plan and its associated scheduled posts."""
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
db.query(ScheduledPost).filter(ScheduledPost.plan_id == plan_id).delete()
db.delete(plan)
db.commit()
@router.post("/{plan_id}/toggle", response_model=EditorialPlanResponse)
def toggle_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle the is_active status of an editorial plan."""
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
plan.is_active = not plan.is_active
plan.updated_at = datetime.utcnow()
db.commit()
db.refresh(plan)
return plan
# === Scheduled Posts ===
@router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse])
def get_plan_scheduled_posts(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all scheduled posts for a specific plan."""
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
return (
db.query(ScheduledPost)
.filter(ScheduledPost.plan_id == plan_id)
.order_by(ScheduledPost.scheduled_at)
.all()
)
@router.post("/schedule", response_model=ScheduledPostResponse, status_code=201)
def schedule_post(
data: ScheduledPostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually schedule a post."""
scheduled = ScheduledPost(**data.model_dump())
db.add(scheduled)
db.commit()
db.refresh(scheduled)
return scheduled