Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# core Module - VoIdea
|
||||
|
||||
## Overview
|
||||
|
||||
[Auto-generated documentation]
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `base.py` | Base classes |
|
||||
| `config.py` | Configuration management |
|
||||
| `database.py` | Database setup |
|
||||
| `dependencies.py` | FastAPI dependencies |
|
||||
| `exceptions.py` | Custom exceptions |
|
||||
| `security.py` | Security utilities |
|
||||
@@ -0,0 +1,28 @@
|
||||
"""VoIdea - Core module."""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.base import BaseModel, BaseService
|
||||
from app.core.exceptions import (
|
||||
AppError,
|
||||
NotFoundError,
|
||||
PermissionError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"settings",
|
||||
"BaseModel",
|
||||
"BaseService",
|
||||
"AppError",
|
||||
"NotFoundError",
|
||||
"PermissionError",
|
||||
"ValidationError",
|
||||
"create_access_token",
|
||||
"verify_password",
|
||||
"get_password_hash",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Base classes for VoIdea models and services."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, TypeVar
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class SQLBase(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
type_annotation_map = {
|
||||
datetime: DateTime(timezone=True),
|
||||
}
|
||||
|
||||
|
||||
class CoreModel(BaseModel):
|
||||
"""Base Pydantic model with common fields."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for timestamp fields."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
"""Mixin for UUID primary key."""
|
||||
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Base repository for data access."""
|
||||
|
||||
model: type[T]
|
||||
|
||||
def __init__(self, session: Any):
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, id: UUID) -> T | None:
|
||||
"""Get entity by ID."""
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def get_all(self, limit: int = 100, offset: int = 0) -> list[T]:
|
||||
"""Get all entities with pagination."""
|
||||
result = await self.session.execute(
|
||||
select(self.model).limit(limit).offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, **kwargs: Any) -> T:
|
||||
"""Create new entity."""
|
||||
entity = self.model(**kwargs)
|
||||
self.session.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def update(self, entity: T, **kwargs: Any) -> T:
|
||||
"""Update entity."""
|
||||
for key, value in kwargs.items():
|
||||
setattr(entity, key, value)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def delete(self, entity: T) -> None:
|
||||
"""Delete entity."""
|
||||
await self.session.delete(entity)
|
||||
await self.session.commit()
|
||||
|
||||
|
||||
class BaseService:
|
||||
"""Base service class."""
|
||||
|
||||
def __init__(self, repository: BaseRepository):
|
||||
self.repository = repository
|
||||
@@ -0,0 +1,204 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||
try:
|
||||
return version_file.read_text(encoding="utf-8").strip()
|
||||
except (FileNotFoundError, OSError):
|
||||
return "1.0.0"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Project
|
||||
project_name: str = Field(default="VoIdeaAI", description="Project name")
|
||||
project_version: str = Field(default_factory=_read_version, description="Project version (from VERSION file)")
|
||||
project_env: str = Field(default="local", description="Environment")
|
||||
project_owner: str = Field(default="Owner", description="Project owner name")
|
||||
project_license: str = Field(default="AGPL-3.0", description="License type")
|
||||
|
||||
# Server
|
||||
server_host: str = Field(default="0.0.0.0", description="Server host")
|
||||
server_port: int = Field(default=8020, description="Server port")
|
||||
server_external_url: str = Field(default="http://localhost:8020", description="External URL")
|
||||
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://voidea:password@localhost:5432/voidea",
|
||||
description="Database URL (async, PostgreSQL with asyncpg)",
|
||||
)
|
||||
db_echo: bool = Field(default=False, description="Echo SQL queries")
|
||||
|
||||
@property
|
||||
def sync_database_url(self) -> str:
|
||||
return self.database_url.replace("+asyncpg", "")
|
||||
|
||||
# Redis
|
||||
redis_host: str = Field(default="localhost", description="Redis host")
|
||||
redis_port: int = Field(default=6379, description="Redis port")
|
||||
redis_url: str = Field(default="redis://localhost:6379/0", description="Redis URL")
|
||||
|
||||
# JWT
|
||||
jwt_secret_key: str = Field(default="", description="JWT secret key")
|
||||
jwt_reset_secret_key: str = Field(default="", description="JWT secret for password reset tokens (separate from access)")
|
||||
jwt_algorithm: str = Field(default="HS256", description="JWT algorithm")
|
||||
jwt_access_token_expire_minutes: int = Field(default=60, description="Access token TTL")
|
||||
jwt_refresh_token_expire_days: int = Field(default=30, description="Refresh token TTL")
|
||||
|
||||
# AI Providers
|
||||
openai_api_key: str = Field(default="", description="OpenAI API key (Whisper, GPT)")
|
||||
ai_yandex_key: str = Field(default="", description="Yandex GPT API key or IAM token")
|
||||
ai_yandex_url: str = Field(default="https://llm.api.cloud.yandex.net", description="Yandex API URL")
|
||||
ai_yandex_folder_id: str = Field(default="", description="Yandex Cloud folder ID")
|
||||
ai_gigachat_client_id: str = Field(default="", description="GigaChat OAuth client ID")
|
||||
ai_gigachat_secret: str = Field(default="", description="GigaChat OAuth client secret")
|
||||
ai_gigachat_url: str = Field(default="https://gigachat.devices.sber.ru", description="GigaChat API URL")
|
||||
ai_fallback_model: str = Field(default="yandex_gpt", description="Default AI model")
|
||||
ai_timeout: int = Field(default=10, description="AI request timeout")
|
||||
ai_max_retries: int = Field(default=3, description="Max retries for AI requests")
|
||||
|
||||
# OAuth
|
||||
oauth_yandex_id: str = Field(default="", description="Yandex OAuth client ID")
|
||||
oauth_yandex_secret: str = Field(default="", description="Yandex OAuth client secret")
|
||||
oauth_yandex_redirect_uri: str = Field(default="http://localhost:3000/oauth/callback")
|
||||
oauth_google_id: str = Field(default="", description="Google OAuth client ID")
|
||||
oauth_google_secret: str = Field(default="", description="Google OAuth client secret")
|
||||
oauth_google_redirect_uri: str = Field(default="http://localhost:8020/auth/google/callback")
|
||||
oauth_apple_id: str = Field(default="", description="Apple OAuth client ID")
|
||||
oauth_apple_secret: str = Field(default="", description="Apple OAuth client secret")
|
||||
oauth_apple_redirect_uri: str = Field(default="http://localhost:8020/auth/apple/callback")
|
||||
|
||||
@property
|
||||
def google_oauth_enabled(self) -> bool:
|
||||
return bool(self.oauth_google_id)
|
||||
|
||||
@property
|
||||
def apple_oauth_enabled(self) -> bool:
|
||||
return bool(self.oauth_apple_id)
|
||||
|
||||
# Telegram Bot
|
||||
telegram_bot_token: str = Field(default="", description="Telegram bot token")
|
||||
|
||||
# Email (SMTP)
|
||||
smtp_host: str = Field(default="", description="SMTP server host")
|
||||
smtp_port: int = Field(default=587, description="SMTP server port")
|
||||
smtp_user: str = Field(default="", description="SMTP username")
|
||||
smtp_pass: str = Field(default="", description="SMTP password")
|
||||
smtp_from: str = Field(default="VoIdea <notifications@voidea.ru>")
|
||||
smtp_tls: bool = Field(default=True)
|
||||
|
||||
# Security
|
||||
enable_2fa: bool = Field(default=False)
|
||||
accepted_terms_version: str = Field(
|
||||
default="2026-05-11",
|
||||
description="Current version of Terms of Service / Privacy Policy",
|
||||
)
|
||||
|
||||
# System Owner
|
||||
system_owner_email: str = Field(
|
||||
default="",
|
||||
description="Email of the system owner (set on VPS deploy, protected from deletion/suspension)",
|
||||
)
|
||||
|
||||
# Social Networks (empty = hidden)
|
||||
social_telegram: str = Field(default="voideaai", description="Telegram account name")
|
||||
social_vk: str = Field(default="voideaai", description="VK account name")
|
||||
social_youtube: str = Field(default="voideaai", description="YouTube account name")
|
||||
social_tiktok: str = Field(default="voideaai", description="TikTok account name")
|
||||
project_slogan: str = Field(
|
||||
default="VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!",
|
||||
description="Project slogan",
|
||||
)
|
||||
|
||||
# Analytics (empty = disabled)
|
||||
yandex_metrika_id: str = Field(default="", description="Yandex Metrika counter ID")
|
||||
google_analytics_id: str = Field(default="", description="Google Analytics tracking ID")
|
||||
|
||||
# Tariffs
|
||||
tariffs_enabled: bool = Field(default=False, description="Enable tariff system (false = promo free-for-all)")
|
||||
tariffs_free_code: str = Field(default="free", description="Code of the default free tariff plan")
|
||||
|
||||
# Push Notifications (mobile-ready stubs)
|
||||
fcm_server_key: str = Field(default="", description="Firebase Cloud Messaging server key (mobile push)")
|
||||
apns_key_id: str = Field(default="", description="Apple Push Notification Service key ID (iOS push)")
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO")
|
||||
log_format: str = Field(default="json")
|
||||
log_file_path: str = Field(default="logs/app.log")
|
||||
log_max_bytes: int = Field(default=10485760)
|
||||
log_backup_count: int = Field(default=5)
|
||||
|
||||
# CORS
|
||||
cors_origins: str = Field(default="http://localhost:3000,http://localhost:8020")
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||
|
||||
# Celery
|
||||
celery_broker_url: str = Field(default="redis://localhost:6379/0")
|
||||
celery_result_backend: str = Field(default="redis://localhost:6379/0")
|
||||
celery_task_track_started: bool = Field(default=True)
|
||||
celery_task_time_limit: int = Field(default=300)
|
||||
|
||||
# Observer Agent
|
||||
observer_enabled: bool = Field(default=True)
|
||||
observer_sample_rate: float = Field(default=0.1)
|
||||
observer_store_raw_data: bool = Field(default=False)
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_enabled: bool = Field(default=True, description="Enable rate limiting")
|
||||
rate_limit_default: str = Field(default="60/minute", description="Default rate limit")
|
||||
rate_limit_auth: str = Field(default="10/minute", description="Auth endpoints rate limit")
|
||||
|
||||
# Crypto
|
||||
encryption_key: str = Field(default="", description="AES-256 encryption key (Fernet)")
|
||||
|
||||
# Development
|
||||
debug: bool = Field(default=False)
|
||||
reload: bool = Field(default=True)
|
||||
|
||||
def is_production(self) -> bool:
|
||||
return self.project_env == "production"
|
||||
|
||||
def is_development(self) -> bool:
|
||||
return self.project_env in ("development", "local")
|
||||
|
||||
@property
|
||||
def public_config(self) -> dict:
|
||||
"""Non-sensitive settings exposed via GET /api/v1/config/public."""
|
||||
return {
|
||||
"project_name": self.project_name,
|
||||
"project_version": self.project_version,
|
||||
"project_env": self.project_env,
|
||||
"project_slogan": self.project_slogan,
|
||||
"social_telegram": self.social_telegram,
|
||||
"social_vk": self.social_vk,
|
||||
"social_youtube": self.social_youtube,
|
||||
"social_tiktok": self.social_tiktok,
|
||||
"yandex_metrika_id": self.yandex_metrika_id,
|
||||
"google_analytics_id": self.google_analytics_id,
|
||||
"tariffs_enabled": self.tariffs_enabled,
|
||||
"tariffs_free_code": self.tariffs_free_code,
|
||||
"accepted_terms_version": self.accepted_terms_version,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Database configuration and session management for VoIdea."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.db_echo,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession instance
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""FastAPI dependencies for VoIdea.
|
||||
|
||||
Common dependencies used across API endpoints.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import async_session_maker
|
||||
from app.core.security import decode_token
|
||||
from app.models.user import User
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Get database session."""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: Bearer token credentials
|
||||
|
||||
Returns:
|
||||
User model instance
|
||||
|
||||
Raises:
|
||||
HTTPException: If token invalid or expired
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token missing user ID",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
async with async_session_maker() as db:
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is disabled",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Require admin or owner role."""
|
||||
if not user.is_superuser and not user.is_owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def require_owner(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Require owner role (system-level operations)."""
|
||||
if not user.is_owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Owner access required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_permission(perm_name: str):
|
||||
"""Factory: require a specific moderator permission or admin/owner.
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/feedback")
|
||||
async def list_feedback(
|
||||
user: Annotated[User, Depends(require_permission("can_manage_feedback"))],
|
||||
):
|
||||
...
|
||||
"""
|
||||
async def _check(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
if user.is_owner or user.role == "admin":
|
||||
return user
|
||||
if user.role == "moderator" and user.permissions:
|
||||
if user.permissions.get(perm_name, False):
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission '{perm_name}' required",
|
||||
)
|
||||
return _check
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(HTTPBearer(auto_error=False))],
|
||||
) -> User | None:
|
||||
"""Get current user if authenticated, None otherwise."""
|
||||
if credentials is None:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None or payload.get("type") != "access":
|
||||
return None
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
async with async_session_maker() as db:
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user and user.is_active:
|
||||
return user
|
||||
return None
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Custom exceptions for VoIdea application."""
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base application exception.
|
||||
|
||||
All business errors inherit from this class.
|
||||
Does not contain HTTP status codes (those are in API layer).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "GENERAL_ERROR",
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert exception to dictionary."""
|
||||
return {
|
||||
"error": self.code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
"""Resource not found error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Resource not found",
|
||||
code: str = "NOT_FOUND",
|
||||
resource: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if resource:
|
||||
details["resource"] = resource
|
||||
if resource_id:
|
||||
details["resource_id"] = resource_id
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class PermissionError(AppError):
|
||||
"""Permission denied error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Permission denied",
|
||||
code: str = "PERMISSION_DENIED",
|
||||
) -> None:
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
"""Validation error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Validation error",
|
||||
code: str = "VALIDATION_ERROR",
|
||||
field: str | None = None,
|
||||
value: Any = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if field:
|
||||
details["field"] = field
|
||||
if value is not None:
|
||||
details["value"] = str(value)
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class AuthenticationError(AppError):
|
||||
"""Authentication error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Authentication failed",
|
||||
code: str = "AUTH_FAILED",
|
||||
) -> None:
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class QuotaExceededError(AppError):
|
||||
"""Quota exceeded error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Quota exceeded",
|
||||
code: str = "QUOTA_EXCEEDED",
|
||||
limit: int | None = None,
|
||||
used: int | None = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if limit is not None:
|
||||
details["limit"] = limit
|
||||
if used is not None:
|
||||
details["used"] = used
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class ExternalServiceError(AppError):
|
||||
"""External service error (AI, OAuth, etc.)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "External service error",
|
||||
code: str = "EXTERNAL_SERVICE_ERROR",
|
||||
service: str | None = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if service:
|
||||
details["service"] = service
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class RateLimitError(AppError):
|
||||
"""Rate limit exceeded error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Rate limit exceeded",
|
||||
code: str = "RATE_LIMIT_EXCEEDED",
|
||||
retry_after: int | None = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if retry_after:
|
||||
details["retry_after"] = retry_after
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
# Import for type hints
|
||||
from typing import Any
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Feature gate module for VoIdea tariff-based access control.
|
||||
|
||||
Usage:
|
||||
features = await get_user_features(user, db)
|
||||
if not features.get("has_voice"):
|
||||
raise HTTPException(403, "Feature not available on your plan")
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.tariff import TariffPlan, UserSubscription
|
||||
from app.models.user import User
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
DEFAULT_FREE_FEATURES: dict[str, Any] = {
|
||||
"limit_ideas": None, # None = unlimited
|
||||
"limit_sessions_per_day": None,
|
||||
"limit_agents": None,
|
||||
"limit_team_members": None,
|
||||
"limit_storage_mb": 100,
|
||||
"has_voice": True,
|
||||
"has_voice_commands": True,
|
||||
"has_drive": True,
|
||||
"has_advanced_analytics": True,
|
||||
"has_api_access": True,
|
||||
"has_priority_support": True,
|
||||
"has_custom_branding": True,
|
||||
"_promo": True,
|
||||
}
|
||||
|
||||
|
||||
async def get_user_features(
|
||||
user: User, db: AsyncSession
|
||||
) -> dict[str, Any]:
|
||||
"""Get feature flags and limits for a user based on their tariff plan.
|
||||
|
||||
When tariffs_enabled=False (promo mode), returns all features as available.
|
||||
"""
|
||||
if not settings.tariffs_enabled:
|
||||
return dict(DEFAULT_FREE_FEATURES)
|
||||
|
||||
result = await db.execute(
|
||||
select(UserSubscription).where(UserSubscription.user_id == user.id)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
|
||||
if sub is None or sub.status != "active":
|
||||
return _get_plan_features(db, settings.tariffs_free_code)
|
||||
|
||||
result = await db.execute(
|
||||
select(TariffPlan).where(TariffPlan.id == sub.plan_id)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
|
||||
if plan is None or not plan.is_active:
|
||||
return _get_plan_features(db, settings.tariffs_free_code)
|
||||
|
||||
return plan.features or dict(DEFAULT_FREE_FEATURES)
|
||||
|
||||
|
||||
async def _get_plan_features(
|
||||
db: AsyncSession, code: str
|
||||
) -> dict[str, Any]:
|
||||
"""Get features dict for a plan by code, falling back to defaults."""
|
||||
result = await db.execute(
|
||||
select(TariffPlan).where(
|
||||
TariffPlan.code == code, TariffPlan.is_active.is_(True)
|
||||
)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
if plan and plan.features:
|
||||
return plan.features
|
||||
return dict(DEFAULT_FREE_FEATURES)
|
||||
|
||||
|
||||
def check_feature(features: dict[str, Any], key: str) -> bool:
|
||||
"""Check if a boolean feature is enabled."""
|
||||
return bool(features.get(key, False))
|
||||
|
||||
|
||||
def check_limit(
|
||||
features: dict[str, Any], key: str, current: int = 0
|
||||
) -> bool:
|
||||
"""Check if a numeric limit is not exceeded (None = unlimited)."""
|
||||
limit = features.get(key)
|
||||
if limit is None:
|
||||
return True
|
||||
return current < int(limit)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Shared rate limiter instance for VoIdeaAI."""
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""HTTP request logging middleware for debug mode."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.log import LogEntry
|
||||
from app.services.debug_service import DebugService
|
||||
|
||||
logger = logging.getLogger("voidea.http")
|
||||
|
||||
|
||||
class DebugLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start = time.time()
|
||||
|
||||
response: Response = await call_next(request)
|
||||
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
|
||||
path = request.url.path
|
||||
|
||||
# Skip health check and static noise
|
||||
if path in ("/health", "/api/v1/health") or path.startswith("/assets/") or path.startswith("/icons/") or path == "/":
|
||||
return response
|
||||
|
||||
try:
|
||||
async with async_session_maker() as db:
|
||||
svc = DebugService(db)
|
||||
if await svc.is_debug_mode():
|
||||
log = LogEntry(
|
||||
level="DEBUG",
|
||||
source="http",
|
||||
message=f"{request.method} {path} → {response.status_code} ({elapsed_ms}ms)",
|
||||
details=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Security middleware for VoIdeaAI.
|
||||
|
||||
Adds security headers to all HTTP responses.
|
||||
"""
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response: Response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=31536000; includeSubDomains"
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"connect-src 'self' https://*.openai.com https://llm.api.cloud.yandex.net https://cloud-api.yandex.net; "
|
||||
"media-src 'self' blob:; "
|
||||
"img-src 'self' data: https://avatars.yandex.net"
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Security utilities for VoIdea.
|
||||
|
||||
JWT handling, password hashing, and authentication helpers.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash password using bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password against hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create JWT access token.
|
||||
|
||||
Args:
|
||||
data: Payload data to encode in token
|
||||
expires_delta: Optional custom expiration time
|
||||
|
||||
Returns:
|
||||
Encoded JWT token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.jwt_access_token_expire_minutes
|
||||
)
|
||||
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "access",
|
||||
})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.jwt_secret_key,
|
||||
algorithm=settings.jwt_algorithm,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create JWT refresh token.
|
||||
|
||||
Args:
|
||||
data: Payload data to encode in token
|
||||
expires_delta: Optional custom expiration time
|
||||
|
||||
Returns:
|
||||
Encoded JWT refresh token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.jwt_refresh_token_expire_days
|
||||
)
|
||||
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "refresh",
|
||||
})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.jwt_secret_key,
|
||||
algorithm=settings.jwt_algorithm,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_reset_token(data: dict[str, Any]) -> str:
|
||||
"""Create JWT password reset token (1 hour expiry, separate key).
|
||||
|
||||
Args:
|
||||
data: Payload data to encode in token
|
||||
|
||||
Returns:
|
||||
Encoded JWT reset token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "reset",
|
||||
})
|
||||
secret = settings.jwt_reset_secret_key or settings.jwt_secret_key
|
||||
return jwt.encode(to_encode, secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(
|
||||
token: str,
|
||||
secret: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Decode and verify JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
secret: Optional secret key (defaults to jwt_secret_key)
|
||||
|
||||
Returns:
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret or settings.jwt_secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def decode_reset_token(token: str) -> dict[str, Any] | None:
|
||||
"""Decode and verify password reset JWT token (uses reset secret key).
|
||||
|
||||
Args:
|
||||
token: JWT reset token string
|
||||
|
||||
Returns:
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
secret = settings.jwt_reset_secret_key or settings.jwt_secret_key
|
||||
return decode_token(token, secret)
|
||||
|
||||
|
||||
def verify_token_type(token_data: dict[str, Any], expected_type: str) -> bool:
|
||||
"""Verify token type.
|
||||
|
||||
Args:
|
||||
token_data: Decoded token payload
|
||||
expected_type: Expected token type (access/refresh/reset)
|
||||
|
||||
Returns:
|
||||
True if token type matches
|
||||
"""
|
||||
return token_data.get("type") == expected_type
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Seed data for VoIdeaAI.
|
||||
|
||||
Called from app lifespan when DB tables are empty.
|
||||
Creates default tariff plans and sets system owner by email.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.agent import AgentConfig
|
||||
from app.models.tariff import TariffPlan
|
||||
from app.services.user_service import UserService
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_AGENT_VERSIONS_FILE = Path(__file__).resolve().parent.parent.parent / "AGENT_VERSIONS.json"
|
||||
|
||||
|
||||
def _load_agent_versions() -> dict[str, str]:
|
||||
try:
|
||||
return json.loads(_AGENT_VERSIONS_FILE.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
AGENT_DESCRIPTIONS: dict[str, str] = {
|
||||
"conductor": "Дирижёр — оркестратор, единственная точка входа. Маршрутизирует запросы к ролевым агентам, верифицирует ответы.",
|
||||
"business_analyst": "Бизнес-аналитик — оценивает бизнес-метрики, ROI, конкурентную среду, окупаемость идеи.",
|
||||
"task_organizer": "Организатор задач — разбивает идею на конкретные шаги реализации с оценкой сроков.",
|
||||
"lawyer": "Юрист — проверяет идею на соответствие законодательству РФ (44-ФЗ, 152-ФЗ, 223-ФЗ).",
|
||||
"financial_consultant": "Финансовый консультант — бюджет, прогноз выручки, точка безубыточности.",
|
||||
"solution_architect": "Архитектор решений — предлагает 2 варианта архитектуры: монолит и микросервисы.",
|
||||
"tester": "Тестировщик — описывает сценарии тестирования (позитивные и негативные кейсы).",
|
||||
"ui_designer": "UI-дизайнер — предлагает 2 варианта визуального дизайна интерфейса.",
|
||||
"smm_specialist": "SMM-специалист — разрабатывает контент-план для соцсетей.",
|
||||
"life_coach": "Лайф-коуч — ставит SMART-цели, поквартальные вехи развития.",
|
||||
"accessibility_expert": "Эксперт по доступности — проверяет на WCAG 2.1 AA соответствие.",
|
||||
"critic": "Критик — конструктивная критика, поиск «слепых зон» идеи.",
|
||||
"copywriter": "Копирайтер — упаковывает идею в продающий текст для разных аудиторий.",
|
||||
"keeper": "Хранитель — сохраняет проработанную идею в базу данных.",
|
||||
"doc_agent": "DocAgent — автоматическая генерация и обновление документации.",
|
||||
"backlog_agent": "BacklogAgent — управление бэклогом, приоритизация задач.",
|
||||
"spec_agent": "SpecAgent — написание формальных спецификаций и требований.",
|
||||
"audit_agent": "AuditAgent — аудит кода, архитектуры, безопасности.",
|
||||
"observer_agent": "ObserverAgent — мониторинг системы, сбор метрик, алерты.",
|
||||
"evolution_agent": "EvolutionAgent — автоматическое улучшение кода, рефакторинг.",
|
||||
"security_agent": "SecurityAgent — проверка безопасности, уязвимости, OWASP.",
|
||||
"qa_tester_agent": "QATesterAgent — автоматизированное тестирование, регресс.",
|
||||
"fix_agent": "FixAgent — автоматическое исправление ошибок на основе логов.",
|
||||
"ui_test_agent": "UITestAgent — тестирование UI/UX, скриншотные тесты.",
|
||||
"rollout_agent": "RolloutAgent — управление релизами, миграции, откаты.",
|
||||
}
|
||||
|
||||
|
||||
async def seed_database(db: AsyncSession) -> None:
|
||||
"""Seed initial data if tables are empty."""
|
||||
await _seed_tariff_plans(db)
|
||||
await _seed_agent_descriptions(db)
|
||||
await _seed_owner(db)
|
||||
|
||||
|
||||
async def _seed_tariff_plans(db: AsyncSession) -> None:
|
||||
"""Create default Free tariff plan if none exist."""
|
||||
result = await db.execute(select(TariffPlan).limit(1))
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
|
||||
free_plan = TariffPlan(
|
||||
name="Бесплатно",
|
||||
code="free",
|
||||
description="Базовый доступ ко всем функциям. Сейчас всё бесплатно 🎉",
|
||||
price_monthly=0,
|
||||
features={
|
||||
"limit_ideas": None,
|
||||
"limit_sessions_per_day": None,
|
||||
"limit_agents": None,
|
||||
"limit_team_members": None,
|
||||
"limit_storage_mb": 100,
|
||||
"has_voice": True,
|
||||
"has_voice_commands": True,
|
||||
"has_drive": True,
|
||||
"has_advanced_analytics": True,
|
||||
"has_api_access": True,
|
||||
"has_priority_support": True,
|
||||
"has_custom_branding": True,
|
||||
"_promo": True,
|
||||
},
|
||||
is_active=True,
|
||||
sort_order=0,
|
||||
)
|
||||
db.add(free_plan)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_agent_descriptions(db: AsyncSession) -> None:
|
||||
"""Fill empty agent descriptions and update versions from AGENT_VERSIONS.json."""
|
||||
agent_versions = _load_agent_versions()
|
||||
result = await db.execute(select(AgentConfig))
|
||||
agents = result.scalars().all()
|
||||
updated = False
|
||||
for agent in agents:
|
||||
name = agent.agent_name.lower()
|
||||
if name in AGENT_DESCRIPTIONS and (not agent.description or agent.description == ""):
|
||||
agent.description = AGENT_DESCRIPTIONS[name]
|
||||
updated = True
|
||||
|
||||
name_lower = agent.agent_name.lower()
|
||||
expected_version = agent_versions.get(name_lower) or agent_versions.get(agent.agent_name)
|
||||
if expected_version and agent.version != expected_version:
|
||||
agent.version = expected_version
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_owner(db: AsyncSession) -> None:
|
||||
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
|
||||
if not settings.system_owner_email:
|
||||
return
|
||||
svc = UserService(db)
|
||||
await svc.set_owner_by_email(settings.system_owner_email)
|
||||
Reference in New Issue
Block a user