Initial commit: VoIdeaAI - voice-first AI idea assistant

This commit is contained in:
2026-05-13 12:51:42 +03:00
commit 688d043dad
421 changed files with 47915 additions and 0 deletions
+16
View File
@@ -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 |
+28
View File
@@ -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",
]
+97
View File
@@ -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
+204
View File
@@ -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()
+46
View File
@@ -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()
+175
View File
@@ -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
+138
View File
@@ -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
+93
View File
@@ -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)
+6
View File
@@ -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)
+48
View File
@@ -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
+28
View File
@@ -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
+168
View File
@@ -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
+126
View File
@@ -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)