Files
office_translator/middleware/tier_quota.py
sepehr ce8e150a61 feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx)
- Added domain wordly.art configuration
- Added Prometheus + Grafana monitoring stack with pre-configured dashboards
- Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation)
- Added alert rules for backend, system, and Docker metrics
- Updated deployment guide for NPM + IONOS DNS homelab setup
- Added marketing plan document
- PDF translator and watermark support
- Enhanced middleware, routes, and translator modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:43:28 +02:00

157 lines
5.1 KiB
Python

"""
Tier-based monthly translation quota.
Uses Redis counter per user per month; fallback in-memory when Redis unavailable.
Coexists with IP-based rate limiting in rate_limiting.py.
Free tier: 2 translations per calendar month.
Paid tiers (starter/pro/business/enterprise): unlimited.
"""
from __future__ import annotations
import os
import logging
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Optional
logger = logging.getLogger(__name__)
# Free tier: 2 translations per calendar month.
FREE_TIER_MONTHLY_LIMIT = 2
KEY_PREFIX = "quota:monthly"
def _utc_month_str(dt: Optional[datetime] = None) -> str:
"""Current month in UTC as YYYY-MM."""
t = dt or datetime.now(timezone.utc)
return t.strftime("%Y-%m")
def _next_month_utc(dt: Optional[datetime] = None) -> datetime:
"""First second of next month UTC."""
now = dt or datetime.now(timezone.utc)
if now.month == 12:
return datetime(now.year + 1, 1, 1, tzinfo=timezone.utc)
return datetime(now.year, now.month + 1, 1, tzinfo=timezone.utc)
def _seconds_until_next_month(dt: Optional[datetime] = None) -> int:
"""Seconds until start of next month UTC."""
now = dt or datetime.now(timezone.utc)
return max(0, int((_next_month_utc(now) - now).total_seconds()))
@dataclass
class QuotaResult:
"""Result of a quota check."""
allowed: bool
remaining: int # -1 for paid tiers (unlimited)
reset_at_utc: datetime
current_usage: int = 0
limit: int = FREE_TIER_MONTHLY_LIMIT
# ---------------------------------------------------------------------------
# Redis backend
# ---------------------------------------------------------------------------
def _get_async_redis():
"""Return async Redis client or None. Uses shared client from core.redis."""
from core.redis import get_async_redis
return get_async_redis()
# ---------------------------------------------------------------------------
# In-memory fallback
# ---------------------------------------------------------------------------
_memory_usage: dict[tuple[str, str], int] = {} # (user_id, month_str) -> count
def _memory_get(user_id: str, month_str: str) -> int:
return _memory_usage.get((user_id, month_str), 0)
def _memory_incr(user_id: str, month_str: str) -> int:
key = (user_id, month_str)
_memory_usage[key] = _memory_usage.get(key, 0) + 1
return _memory_usage[key]
# ---------------------------------------------------------------------------
# TierQuotaService
# ---------------------------------------------------------------------------
class TierQuotaService:
"""
Monthly translation quota per user by tier.
Redis key pattern: quota:monthly:{user_id}:{YYYY-MM}, TTL 32 days.
"""
def __init__(self):
self._redis = None
def _redis_client(self):
if self._redis is None:
self._redis = _get_async_redis()
return self._redis
async def check_quota(self, user_id: str, tier: str) -> QuotaResult:
"""Check monthly quota. Free = 2/month; paid = unlimited."""
reset_at = _next_month_utc()
tier_lower = (tier or "free").lower()
if tier_lower in ("pro", "business", "enterprise", "starter"):
return QuotaResult(
allowed=True,
remaining=-1,
reset_at_utc=reset_at,
current_usage=0,
limit=0,
)
# Free tier — monthly counter
month_str = _utc_month_str()
redis_client = self._redis_client()
if redis_client:
try:
key = f"{KEY_PREFIX}:{user_id}:{month_str}"
count = await redis_client.get(key)
count = int(count or 0)
except Exception as e:
logger.warning("Tier quota Redis get failed: %s, using in-memory", e)
count = _memory_get(user_id, month_str)
else:
count = _memory_get(user_id, month_str)
remaining = max(0, FREE_TIER_MONTHLY_LIMIT - count)
return QuotaResult(
allowed=count < FREE_TIER_MONTHLY_LIMIT,
remaining=remaining,
reset_at_utc=reset_at,
current_usage=count,
limit=FREE_TIER_MONTHLY_LIMIT,
)
async def increment_on_success(self, user_id: str) -> None:
"""Increment monthly translation count (call after successful translation)."""
month_str = _utc_month_str()
redis_client = self._redis_client()
if redis_client:
try:
key = f"{KEY_PREFIX}:{user_id}:{month_str}"
pipe = redis_client.pipeline()
pipe.incr(key)
pipe.expire(key, 32 * 24 * 3600) # 32 days
await pipe.execute()
return
except Exception as e:
logger.warning("Tier quota Redis increment failed: %s, using in-memory", e)
_memory_incr(user_id, month_str)
def seconds_until_reset(self) -> int:
"""Seconds until start of next month UTC."""
return _seconds_until_next_month()
# Singleton
tier_quota_service = TierQuotaService()