feat(subscriptions): add SaaS subscription tiers, pricing page, and enforced upload quotas
- Add Free / Starter / Professional / Business tiers with lifetime, daily, and monthly file limits (app/utils/subscription.py) - Add subscription_tier column to UserProfile model + migration 014 - Enforce quotas at upload time (HTTP 402 on violation) in /api/ui-upload - New REST API: GET /api/subscriptions/tiers, /my, /platform (admin) - New pages: /pricing (marketing, public) and /subscription (per-user status) - Enhanced dashboard: SaaS stats (files today/month, OCR count, active users) in multi-user mode; original single-user layout preserved - Admin users page: show Plan badge, allow tier editing via dropdown - Navigation: add Pricing link + subscription icon in user header - Tests: 23 unit tests for subscription tier logic - Docs: docs/SubscriptionTiers.md Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -23,6 +23,7 @@ from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.similarity import router as similarity_router
|
||||
from app.api.subscriptions import router as subscriptions_router
|
||||
from app.api.url_upload import router as url_upload_router
|
||||
|
||||
# Import all the individual routers
|
||||
@@ -56,3 +57,4 @@ router.include_router(similarity_router)
|
||||
router.include_router(duplicates_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(database_router)
|
||||
router.include_router(subscriptions_router)
|
||||
|
||||
@@ -51,6 +51,10 @@ class UserProfileUpsert(BaseModel):
|
||||
)
|
||||
notes: str | None = Field(default=None, max_length=4096, description="Admin notes about this user")
|
||||
is_blocked: bool = Field(default=False, description="Block this user from uploading")
|
||||
subscription_tier: str | None = Field(
|
||||
default="free",
|
||||
description="Subscription tier: free | starter | professional | business",
|
||||
)
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
@@ -62,6 +66,7 @@ class UserProfileResponse(BaseModel):
|
||||
daily_upload_limit: int | None
|
||||
notes: str | None
|
||||
is_blocked: bool
|
||||
subscription_tier: str | None
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
@@ -76,6 +81,7 @@ class UserSummary(BaseModel):
|
||||
daily_upload_limit: int | None
|
||||
notes: str | None
|
||||
is_blocked: bool
|
||||
subscription_tier: str | None
|
||||
profile_id: int | None
|
||||
document_count: int
|
||||
last_upload: str | None
|
||||
@@ -99,6 +105,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
"daily_upload_limit": profile.daily_upload_limit,
|
||||
"notes": profile.notes,
|
||||
"is_blocked": profile.is_blocked,
|
||||
"subscription_tier": profile.subscription_tier or "free",
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
@@ -164,6 +171,7 @@ def list_users(
|
||||
"daily_upload_limit": profile.daily_upload_limit if profile else None,
|
||||
"notes": profile.notes if profile else None,
|
||||
"is_blocked": profile.is_blocked if profile else False,
|
||||
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_row.doc_count if doc_row else 0,
|
||||
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
|
||||
@@ -199,6 +207,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"daily_upload_limit": profile.daily_upload_limit if profile else None,
|
||||
"notes": profile.notes if profile else None,
|
||||
"is_blocked": profile.is_blocked if profile else False,
|
||||
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_count,
|
||||
"last_upload": last_upload,
|
||||
@@ -226,6 +235,15 @@ def upsert_user_profile(
|
||||
profile.daily_upload_limit = body.daily_upload_limit
|
||||
profile.notes = body.notes
|
||||
profile.is_blocked = body.is_blocked
|
||||
if body.subscription_tier is not None:
|
||||
from app.utils.subscription import TIERS
|
||||
|
||||
if body.subscription_tier not in TIERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
|
||||
)
|
||||
profile.subscription_tier = body.subscription_tier
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
|
||||
+17
-1
@@ -11,7 +11,7 @@ import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import asc, desc
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -1295,6 +1295,22 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
||||
# Determine the owner_id for multi-user document isolation
|
||||
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
|
||||
|
||||
# Enforce subscription tier upload quotas (multi-user mode only)
|
||||
if settings.multi_user_enabled and upload_owner_id:
|
||||
from app.utils.subscription import QuotaExceeded, check_upload_allowed, get_user_tier_id
|
||||
|
||||
tier_id = get_user_tier_id(db, upload_owner_id)
|
||||
try:
|
||||
check_upload_allowed(db, upload_owner_id, tier_id)
|
||||
except QuotaExceeded as qe:
|
||||
# Clean up the already-saved file before rejecting
|
||||
if os.path.exists(target_path):
|
||||
os.remove(target_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=str(qe),
|
||||
)
|
||||
|
||||
# Check if it's a PDF by extension or MIME type
|
||||
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""API endpoints for subscription tiers and usage statistics.
|
||||
|
||||
Public endpoints:
|
||||
GET /api/subscriptions/tiers — list all available plans
|
||||
GET /api/subscriptions/my — current user's plan + usage (auth required)
|
||||
GET /api/subscriptions/platform — platform-wide stats (admin only)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.utils.subscription import (
|
||||
TIER_ORDER,
|
||||
TIERS,
|
||||
get_all_tiers,
|
||||
get_tier,
|
||||
get_user_tier_id,
|
||||
get_user_usage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/tiers", summary="List all subscription tiers")
|
||||
def list_tiers() -> dict[str, Any]:
|
||||
"""Return the full list of subscription plans in display order."""
|
||||
return {
|
||||
"tiers": get_all_tiers(),
|
||||
"order": TIER_ORDER,
|
||||
"default": "free",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my", summary="Get current user's subscription and usage")
|
||||
def my_subscription(request: Request, db: DbSession) -> dict[str, Any]:
|
||||
"""Return the authenticated user's subscription tier and current usage counts."""
|
||||
from app.config import settings
|
||||
|
||||
user = _get_current_user(request)
|
||||
|
||||
if not settings.multi_user_enabled:
|
||||
# In single-user mode there is no concept of a subscription plan
|
||||
return {
|
||||
"multi_user_mode": False,
|
||||
"tier": TIERS["business"], # unrestricted
|
||||
"usage": None,
|
||||
}
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
|
||||
tier_id = get_user_tier_id(db, owner_id)
|
||||
tier = get_tier(tier_id)
|
||||
usage = get_user_usage(db, owner_id)
|
||||
|
||||
return {
|
||||
"multi_user_mode": True,
|
||||
"owner_id": owner_id,
|
||||
"tier": tier,
|
||||
"usage": usage,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/platform", summary="Platform-wide usage statistics (admin only)")
|
||||
def platform_stats(request: Request, db: DbSession) -> dict[str, Any]:
|
||||
"""Return aggregate statistics across all users and tiers (admin only)."""
|
||||
_require_admin(request)
|
||||
|
||||
from app.models import FileRecord, UserProfile
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
# Total files
|
||||
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
||||
|
||||
# Files today
|
||||
files_today: int = (
|
||||
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
|
||||
)
|
||||
|
||||
# Files this month
|
||||
files_this_month: int = (
|
||||
db.query(func.count(FileRecord.id))
|
||||
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Files with OCR text (proxy for pages OCRed — approximation)
|
||||
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
|
||||
|
||||
# Unique active users (ever uploaded)
|
||||
unique_users: int = (
|
||||
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar() or 0
|
||||
)
|
||||
|
||||
# Users per subscription tier
|
||||
profiles = (
|
||||
db.query(UserProfile.subscription_tier, func.count(UserProfile.id))
|
||||
.group_by(UserProfile.subscription_tier)
|
||||
.all()
|
||||
)
|
||||
tier_distribution: dict[str, int] = {row[0] or "free": row[1] for row in profiles}
|
||||
|
||||
# Fill in zeros for tiers with no users
|
||||
for tid in TIER_ORDER:
|
||||
tier_distribution.setdefault(tid, 0)
|
||||
|
||||
return {
|
||||
"files": {
|
||||
"total": total_files,
|
||||
"today": files_today,
|
||||
"this_month": files_this_month,
|
||||
"with_ocr": files_with_ocr,
|
||||
},
|
||||
"users": {
|
||||
"unique_uploaders": unique_users,
|
||||
"tier_distribution": tier_distribution,
|
||||
},
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -200,5 +200,9 @@ class UserProfile(Base):
|
||||
# When True the user is prevented from uploading new documents
|
||||
is_blocked = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Subscription tier: "free" | "starter" | "professional" | "business"
|
||||
# NULL is treated as "free" by the subscription utility.
|
||||
subscription_tier = Column(String(50), nullable=True, default="free")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
|
||||
|
||||
Four tiers:
|
||||
- free $0/mo — 25 lifetime files, 1 destination, 50 OCR pages/mo
|
||||
- starter $9/mo — 10/day, 100/mo, 3 destinations, 500 OCR pages/mo
|
||||
- professional $29/mo — 50/day, 500/mo, 10 destinations, 2 500 OCR pages/mo
|
||||
- business $79/mo — unlimited, unlimited destinations, unlimited OCR
|
||||
|
||||
Limits use 0 to represent "unlimited".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIERS: dict[str, dict[str, Any]] = {
|
||||
"free": {
|
||||
"id": "free",
|
||||
"name": "Free",
|
||||
"tagline": "Explore DocuElevate at no cost",
|
||||
"price_monthly": 0,
|
||||
"price_yearly": 0,
|
||||
"highlight": False,
|
||||
# Hard caps — 0 = unlimited
|
||||
"lifetime_file_limit": 25, # total files ever processed
|
||||
"daily_upload_limit": 0, # no per-day cap (capped by lifetime)
|
||||
"monthly_upload_limit": 0, # no per-month cap (capped by lifetime)
|
||||
"max_storage_destinations": 1,
|
||||
"max_ocr_pages_monthly": 50,
|
||||
"max_file_size_mb": 10,
|
||||
"api_access": False,
|
||||
# Marketing feature list (shown on pricing page)
|
||||
"features": [
|
||||
"25 documents – lifetime total",
|
||||
"1 storage destination",
|
||||
"50 OCR pages / month",
|
||||
"10 MB max file size",
|
||||
"Basic AI metadata extraction",
|
||||
"Community support",
|
||||
],
|
||||
"cta": "Get started free",
|
||||
"badge": None,
|
||||
},
|
||||
"starter": {
|
||||
"id": "starter",
|
||||
"name": "Starter",
|
||||
"tagline": "Perfect for individuals & small teams",
|
||||
"price_monthly": 9,
|
||||
"price_yearly": 90,
|
||||
"highlight": False,
|
||||
"lifetime_file_limit": 0,
|
||||
"daily_upload_limit": 10,
|
||||
"monthly_upload_limit": 100,
|
||||
"max_storage_destinations": 3,
|
||||
"max_ocr_pages_monthly": 500,
|
||||
"max_file_size_mb": 50,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"10 documents / day",
|
||||
"100 documents / month",
|
||||
"3 storage destinations",
|
||||
"500 OCR pages / month",
|
||||
"50 MB max file size",
|
||||
"Full AI metadata extraction",
|
||||
"Email ingestion",
|
||||
"API access",
|
||||
"Email support",
|
||||
],
|
||||
"cta": "Start with Starter",
|
||||
"badge": None,
|
||||
},
|
||||
"professional": {
|
||||
"id": "professional",
|
||||
"name": "Professional",
|
||||
"tagline": "For growing teams that need more power",
|
||||
"price_monthly": 29,
|
||||
"price_yearly": 290,
|
||||
"highlight": True, # shown as "Most popular"
|
||||
"lifetime_file_limit": 0,
|
||||
"daily_upload_limit": 50,
|
||||
"monthly_upload_limit": 500,
|
||||
"max_storage_destinations": 10,
|
||||
"max_ocr_pages_monthly": 2500,
|
||||
"max_file_size_mb": 200,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"50 documents / day",
|
||||
"500 documents / month",
|
||||
"10 storage destinations",
|
||||
"2 500 OCR pages / month",
|
||||
"200 MB max file size",
|
||||
"Advanced AI workflows",
|
||||
"Email & URL ingestion",
|
||||
"Webhooks",
|
||||
"Priority email support",
|
||||
],
|
||||
"cta": "Go Professional",
|
||||
"badge": "Most Popular",
|
||||
},
|
||||
"business": {
|
||||
"id": "business",
|
||||
"name": "Business",
|
||||
"tagline": "Unlimited processing for organisations",
|
||||
"price_monthly": 79,
|
||||
"price_yearly": 790,
|
||||
"highlight": False,
|
||||
"lifetime_file_limit": 0,
|
||||
"daily_upload_limit": 0,
|
||||
"monthly_upload_limit": 0,
|
||||
"max_storage_destinations": 0,
|
||||
"max_ocr_pages_monthly": 0,
|
||||
"max_file_size_mb": 0,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"Unlimited documents",
|
||||
"Unlimited storage destinations",
|
||||
"Unlimited OCR pages",
|
||||
"Unlimited file size",
|
||||
"All AI processing steps",
|
||||
"All ingestion methods",
|
||||
"Webhooks & full API access",
|
||||
"Custom integrations",
|
||||
"Dedicated support",
|
||||
],
|
||||
"cta": "Contact Sales",
|
||||
"badge": "Best Value",
|
||||
},
|
||||
}
|
||||
|
||||
# Display order for the pricing page
|
||||
TIER_ORDER = ["free", "starter", "professional", "business"]
|
||||
|
||||
# Default tier assigned to new users
|
||||
DEFAULT_TIER = "free"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Getters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_tier(tier_id: str) -> dict[str, Any]:
|
||||
"""Return tier config dict; falls back to *free* for unknown ids."""
|
||||
return TIERS.get(tier_id, TIERS["free"])
|
||||
|
||||
|
||||
def get_all_tiers() -> list[dict[str, Any]]:
|
||||
"""Return tiers in display order."""
|
||||
return [TIERS[tid] for tid in TIER_ORDER]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _today_utc() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def get_lifetime_file_count(db: Session, owner_id: str) -> int:
|
||||
"""Total files ever processed by this user (not counting duplicates)."""
|
||||
from app.models import FileRecord
|
||||
|
||||
return (
|
||||
db.query(func.count(FileRecord.id))
|
||||
.filter(FileRecord.owner_id == owner_id, FileRecord.is_duplicate.is_(False))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def get_today_file_count(db: Session, owner_id: str) -> int:
|
||||
"""Files processed by this user today (UTC, not counting duplicates)."""
|
||||
from app.models import FileRecord
|
||||
|
||||
today = _today_utc()
|
||||
return (
|
||||
db.query(func.count(FileRecord.id))
|
||||
.filter(
|
||||
FileRecord.owner_id == owner_id,
|
||||
FileRecord.is_duplicate.is_(False),
|
||||
func.date(FileRecord.created_at) == today,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def get_month_file_count(db: Session, owner_id: str) -> int:
|
||||
"""Files processed by this user this calendar month (UTC, not counting duplicates)."""
|
||||
from app.models import FileRecord
|
||||
|
||||
today = _today_utc()
|
||||
return (
|
||||
db.query(func.count(FileRecord.id))
|
||||
.filter(
|
||||
FileRecord.owner_id == owner_id,
|
||||
FileRecord.is_duplicate.is_(False),
|
||||
func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Limit enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
"""Raised when a user has hit a subscription limit."""
|
||||
|
||||
def __init__(self, message: str, limit_type: str, limit_value: int, current_value: int) -> None:
|
||||
super().__init__(message)
|
||||
self.limit_type = limit_type
|
||||
self.limit_value = limit_value
|
||||
self.current_value = current_value
|
||||
|
||||
|
||||
def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) -> None:
|
||||
"""Raise :class:`QuotaExceeded` if this user is not allowed to upload another file.
|
||||
|
||||
When *owner_id* or *tier_id* is ``None`` (e.g. single-user mode) the check
|
||||
is skipped entirely.
|
||||
"""
|
||||
if owner_id is None or tier_id is None:
|
||||
return
|
||||
|
||||
tier = get_tier(tier_id)
|
||||
|
||||
# 1. Lifetime file cap (free tier)
|
||||
lifetime_limit = tier["lifetime_file_limit"]
|
||||
if lifetime_limit > 0:
|
||||
count = get_lifetime_file_count(db, owner_id)
|
||||
if count >= lifetime_limit:
|
||||
raise QuotaExceeded(
|
||||
f"Lifetime file limit of {lifetime_limit} reached for the {tier['name']} plan. "
|
||||
"Please upgrade to continue processing documents.",
|
||||
limit_type="lifetime",
|
||||
limit_value=lifetime_limit,
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
# 2. Daily cap
|
||||
daily_limit = tier["daily_upload_limit"]
|
||||
if daily_limit > 0:
|
||||
count = get_today_file_count(db, owner_id)
|
||||
if count >= daily_limit:
|
||||
raise QuotaExceeded(
|
||||
f"Daily file limit of {daily_limit} reached for the {tier['name']} plan. "
|
||||
"Please try again tomorrow or upgrade your plan.",
|
||||
limit_type="daily",
|
||||
limit_value=daily_limit,
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
# 3. Monthly cap
|
||||
monthly_limit = tier["monthly_upload_limit"]
|
||||
if monthly_limit > 0:
|
||||
count = get_month_file_count(db, owner_id)
|
||||
if count >= monthly_limit:
|
||||
raise QuotaExceeded(
|
||||
f"Monthly file limit of {monthly_limit} reached for the {tier['name']} plan. "
|
||||
"Please upgrade your plan for more documents this month.",
|
||||
limit_type="monthly",
|
||||
limit_value=monthly_limit,
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
|
||||
def get_user_tier_id(db: Session, owner_id: str | None) -> str:
|
||||
"""Return the subscription tier id for *owner_id*, defaulting to 'free'."""
|
||||
if owner_id is None:
|
||||
return DEFAULT_TIER
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
|
||||
if profile and profile.subscription_tier:
|
||||
return profile.subscription_tier
|
||||
return DEFAULT_TIER
|
||||
|
||||
|
||||
def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
|
||||
"""Return a dict with lifetime / daily / monthly file counts for *owner_id*."""
|
||||
return {
|
||||
"lifetime": get_lifetime_file_count(db, owner_id),
|
||||
"today": get_today_file_count(db, owner_id),
|
||||
"month": get_month_file_count(db, owner_id),
|
||||
}
|
||||
@@ -18,6 +18,7 @@ from app.views.queue import router as queue_router
|
||||
from app.views.search import router as search_router
|
||||
from app.views.settings import router as settings_router
|
||||
from app.views.status import router as status_router
|
||||
from app.views.subscriptions import router as subscriptions_router
|
||||
from app.views.wizard import router as wizard_router
|
||||
|
||||
# Create a main router that includes all the view routers
|
||||
@@ -35,3 +36,4 @@ router.include_router(settings_router)
|
||||
router.include_router(filemanager_router)
|
||||
router.include_router(search_router)
|
||||
router.include_router(queue_router)
|
||||
router.include_router(subscriptions_router) # Pricing + subscription pages
|
||||
|
||||
+63
-11
@@ -2,11 +2,12 @@
|
||||
General routes for the application homepage and basic pages.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.config_validator import get_provider_status, validate_storage_configs
|
||||
@@ -54,25 +55,76 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
|
||||
and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"]
|
||||
)
|
||||
|
||||
# Query the actual file count from the database
|
||||
processed_files = 0
|
||||
from app.models import FileRecord
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
# Global file counts (or per-user in multi-user mode)
|
||||
from app.config import settings
|
||||
|
||||
user = request.session.get("user") or {}
|
||||
is_admin = user.get("is_admin", False)
|
||||
|
||||
try:
|
||||
# Import the model here to avoid circular imports
|
||||
from app.models import FileRecord
|
||||
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
||||
|
||||
processed_files = db.query(FileRecord.id).count()
|
||||
files_today: int = (
|
||||
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
|
||||
)
|
||||
|
||||
files_month: int = (
|
||||
db.query(func.count(FileRecord.id))
|
||||
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
|
||||
|
||||
unique_users: int = (
|
||||
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but continue (don't break the page if DB query fails)
|
||||
logger.error(f"Error counting files: {str(e)}")
|
||||
logger.error(f"Error computing dashboard stats: {e}")
|
||||
total_files = files_today = files_month = files_with_ocr = unique_users = 0
|
||||
|
||||
# Per-user usage for the subscription widget (multi-user only)
|
||||
user_usage = None
|
||||
user_tier = None
|
||||
if settings.multi_user_enabled:
|
||||
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
|
||||
if owner_id:
|
||||
try:
|
||||
from app.utils.subscription import get_tier, get_user_tier_id, get_user_usage
|
||||
|
||||
tier_id = get_user_tier_id(db, owner_id)
|
||||
user_tier = get_tier(tier_id)
|
||||
user_usage = get_user_usage(db, owner_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching subscription info: {e}")
|
||||
|
||||
# Create stats object to pass to the template
|
||||
stats = {
|
||||
"processed_files": processed_files,
|
||||
"processed_files": total_files,
|
||||
"files_today": files_today,
|
||||
"files_month": files_month,
|
||||
"files_with_ocr": files_with_ocr,
|
||||
"unique_users": unique_users,
|
||||
"active_integrations": configured_providers,
|
||||
"storage_targets": configured_storage_targets,
|
||||
}
|
||||
|
||||
return templates.TemplateResponse("index.html", {"request": request, "stats": stats})
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"user_usage": user_usage,
|
||||
"user_tier": user_tier,
|
||||
"multi_user_enabled": settings.multi_user_enabled,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", include_in_schema=False)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""View routes for subscription-related pages.
|
||||
|
||||
Routes:
|
||||
GET /pricing — public marketing pricing page
|
||||
GET /subscription — authenticated user's current plan & usage
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.subscription import TIER_ORDER, get_all_tiers, get_tier, get_user_tier_id, get_user_usage
|
||||
from app.views.base import APIRouter, get_db, require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/pricing", include_in_schema=False)
|
||||
async def pricing_page(request: Request):
|
||||
"""Public-facing pricing and plans page."""
|
||||
tiers = get_all_tiers()
|
||||
return templates.TemplateResponse(
|
||||
"pricing.html",
|
||||
{
|
||||
"request": request,
|
||||
"tiers": tiers,
|
||||
"tier_order": TIER_ORDER,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscription", include_in_schema=False)
|
||||
@require_login
|
||||
async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Authenticated user's subscription status and usage page."""
|
||||
from app.config import settings
|
||||
|
||||
user = request.session.get("user") or {}
|
||||
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
|
||||
|
||||
if settings.multi_user_enabled and owner_id:
|
||||
tier_id = get_user_tier_id(db, owner_id)
|
||||
usage = get_user_usage(db, owner_id)
|
||||
else:
|
||||
tier_id = "business"
|
||||
usage = None
|
||||
|
||||
tier = get_tier(tier_id)
|
||||
all_tiers = get_all_tiers()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"subscription.html",
|
||||
{
|
||||
"request": request,
|
||||
"tier": tier,
|
||||
"tier_id": tier_id,
|
||||
"usage": usage,
|
||||
"all_tiers": all_tiers,
|
||||
"multi_user_enabled": settings.multi_user_enabled,
|
||||
"owner_id": owner_id,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user