This commit is contained in:
user committed 2025-07-02 19:25:20 +03:00
1 parent 21964fa986
commit 797f379648
68 files changed
+23871 -1271

No files matched your search

+276 -2
View File
@@ -1,3 +1,277 @@
from sqlalchemy.ext.declarative import declarative_base
"""
Base model classes with async SQLAlchemy support
"""
import uuid
from datetime import datetime
from typing import Any, Dict, Optional, Type, TypeVar, Union
AlchemyBase = declarative_base()
from sqlalchemy import Column, DateTime, String, Boolean, Integer, Text, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.future import select
from sqlalchemy.orm import sessionmaker
from pydantic import BaseModel
import structlog
logger = structlog.get_logger(__name__)
# Create declarative base
Base = declarative_base()
# Type variable for model classes
ModelType = TypeVar("ModelType", bound="BaseModel")
class TimestampMixin:
"""Mixin for automatic timestamp fields"""
created_at = Column(
DateTime,
nullable=False,
default=datetime.utcnow,
comment="Record creation timestamp"
)
updated_at = Column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="Record last update timestamp"
)
class UUIDMixin:
"""Mixin for UUID primary key"""
id = Column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
comment="Unique identifier"
)
class SoftDeleteMixin:
"""Mixin for soft delete functionality"""
deleted_at = Column(
DateTime,
nullable=True,
comment="Soft delete timestamp"
)
@property
def is_deleted(self) -> bool:
"""Check if record is soft deleted"""
return self.deleted_at is not None
def soft_delete(self):
"""Mark record as soft deleted"""
self.deleted_at = datetime.utcnow()
def restore(self):
"""Restore soft deleted record"""
self.deleted_at = None
class MetadataMixin:
"""Mixin for flexible metadata storage"""
metadata = Column(
JSON,
nullable=False,
default=dict,
comment="Flexible metadata storage"
)
def set_meta(self, key: str, value: Any) -> None:
"""Set metadata value"""
if self.metadata is None:
self.metadata = {}
self.metadata[key] = value
def get_meta(self, key: str, default: Any = None) -> Any:
"""Get metadata value"""
if self.metadata is None:
return default
return self.metadata.get(key, default)
def update_meta(self, updates: Dict[str, Any]) -> None:
"""Update multiple metadata values"""
if self.metadata is None:
self.metadata = {}
self.metadata.update(updates)
class StatusMixin:
"""Mixin for status tracking"""
status = Column(
String(64),
nullable=False,
default="active",
index=True,
comment="Record status"
)
def set_status(self, status: str, reason: Optional[str] = None):
"""Set status with optional reason"""
self.status = status
if reason:
self.set_meta("status_reason", reason)
self.set_meta("status_changed_at", datetime.utcnow().isoformat())
class BaseModelMixin:
"""Base mixin with common functionality"""
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary"""
result = {}
for column in self.__table__.columns:
value = getattr(self, column.name)
if isinstance(value, datetime):
value = value.isoformat()
elif hasattr(value, '__dict__'):
value = str(value)
result[column.name] = value
return result
def update_from_dict(self, data: Dict[str, Any]) -> None:
"""Update model from dictionary"""
for key, value in data.items():
if hasattr(self, key):
setattr(self, key, value)
@classmethod
async def get_by_id(
cls: Type[ModelType],
session: AsyncSession,
id_value: Union[int, str, uuid.UUID]
) -> Optional[ModelType]:
"""Get record by ID"""
try:
stmt = select(cls).where(cls.id == id_value)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting record by ID", model=cls.__name__, id=id_value, error=str(e))
return None
@classmethod
async def get_all(
cls: Type[ModelType],
session: AsyncSession,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> list[ModelType]:
"""Get all records with optional pagination"""
try:
stmt = select(cls)
if offset:
stmt = stmt.offset(offset)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error getting all records", model=cls.__name__, error=str(e))
return []
@classmethod
async def count(cls: Type[ModelType], session: AsyncSession) -> int:
"""Get total count of records"""
try:
from sqlalchemy import func
stmt = select(func.count(cls.id))
result = await session.execute(stmt)
return result.scalar() or 0
except Exception as e:
logger.error("Error counting records", model=cls.__name__, error=str(e))
return 0
async def save(self, session: AsyncSession) -> None:
"""Save model to database"""
try:
session.add(self)
await session.commit()
await session.refresh(self)
except Exception as e:
await session.rollback()
logger.error("Error saving model", model=self.__class__.__name__, error=str(e))
raise
async def delete(self, session: AsyncSession) -> None:
"""Delete model from database"""
try:
await session.delete(self)
await session.commit()
except Exception as e:
await session.rollback()
logger.error("Error deleting model", model=self.__class__.__name__, error=str(e))
raise
class AuditMixin:
"""Mixin for audit trail"""
created_by = Column(
UUID(as_uuid=True),
nullable=True,
comment="User who created the record"
)
updated_by = Column(
UUID(as_uuid=True),
nullable=True,
comment="User who last updated the record"
)
def set_audit_info(self, user_id: Optional[uuid.UUID] = None):
"""Set audit information"""
if user_id:
if not hasattr(self, 'created_at') or not self.created_at:
self.created_by = user_id
self.updated_by = user_id
class CacheableMixin:
"""Mixin for cacheable models"""
@property
def cache_key(self) -> str:
"""Generate cache key for this model"""
return f"{self.__class__.__name__.lower()}:{self.id}"
@property
def cache_ttl(self) -> int:
"""Default cache TTL in seconds"""
return 3600 # 1 hour
def get_cache_data(self) -> Dict[str, Any]:
"""Get data for caching"""
return self.to_dict()
# Combined base model class
class BaseModel(
Base,
BaseModelMixin,
TimestampMixin,
UUIDMixin,
SoftDeleteMixin,
MetadataMixin,
StatusMixin,
AuditMixin,
CacheableMixin
):
"""Base model with all mixins"""
__abstract__ = True
def __repr__(self) -> str:
"""String representation of model"""
return f"<{self.__class__.__name__}(id={self.id})>"
# Compatibility with old model base
AlchemyBase = Base
+88
View File
@@ -0,0 +1,88 @@
"""Compatible SQLAlchemy base models for MariaDB."""
from datetime import datetime
from typing import Optional, Dict, Any
from sqlalchemy import Column, Integer, DateTime, text
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import sessionmaker
# Create base class
Base = declarative_base()
class TimestampMixin:
"""Mixin for adding timestamp fields."""
@declared_attr
def created_at(cls):
return Column(
DateTime,
nullable=False,
default=datetime.utcnow,
server_default=text('CURRENT_TIMESTAMP')
)
@declared_attr
def updated_at(cls):
return Column(
DateTime,
nullable=False,
default=datetime.utcnow,
onupdate=datetime.utcnow,
server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')
)
class BaseModel(Base, TimestampMixin):
"""Base model with common fields for all entities."""
__abstract__ = True
id = Column(Integer, primary_key=True, autoincrement=True)
def to_dict(self, exclude: Optional[set] = None) -> Dict[str, Any]:
"""Convert model instance to dictionary."""
exclude = exclude or set()
result = {}
for column in self.__table__.columns:
if column.name not in exclude:
value = getattr(self, column.name)
# Handle datetime serialization
if isinstance(value, datetime):
result[column.name] = value.isoformat()
else:
result[column.name] = value
return result
def update_from_dict(self, data: Dict[str, Any], exclude: Optional[set] = None) -> None:
"""Update model instance from dictionary."""
exclude = exclude or {"id", "created_at", "updated_at"}
for key, value in data.items():
if key not in exclude and hasattr(self, key):
setattr(self, key, value)
@classmethod
def get_table_name(cls) -> str:
"""Get table name."""
return cls.__tablename__
@classmethod
def get_columns(cls) -> list:
"""Get list of column names."""
return [column.name for column in cls.__table__.columns]
def __repr__(self) -> str:
"""String representation of model."""
return f"<{self.__class__.__name__}(id={getattr(self, 'id', None)})>"
# Legacy session factory for backward compatibility
SessionLocal = sessionmaker()
def get_session():
"""Get database session (legacy function for compatibility)."""
return SessionLocal()
+445
View File
@@ -0,0 +1,445 @@
"""
Blockchain-related models for TON network integration.
Handles transaction records, wallet management, and smart contract interactions.
"""
from datetime import datetime
from decimal import Decimal
from typing import Dict, List, Optional, Any
from uuid import UUID
import sqlalchemy as sa
from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, JSON, ForeignKey, Index
from sqlalchemy.orm import relationship, validates
from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID
from app.core.models.base import Base, TimestampMixin, UUIDMixin
class BlockchainTransaction(Base, UUIDMixin, TimestampMixin):
"""Model for storing blockchain transaction records."""
__tablename__ = "blockchain_transactions"
# User relationship
user_id = Column(PostgreSQLUUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="blockchain_transactions")
# Transaction details
transaction_hash = Column(String(64), unique=True, nullable=False, index=True)
transaction_type = Column(String(20), nullable=False) # transfer, mint, burn, stake, etc.
status = Column(String(20), nullable=False, default="pending") # pending, confirmed, failed
# Amount and fees
amount = Column(sa.BIGINT, nullable=False, default=0) # Amount in nanotons
network_fee = Column(sa.BIGINT, nullable=False, default=0) # Network fee in nanotons
# Addresses
sender_address = Column(String(48), nullable=True, index=True)
recipient_address = Column(String(48), nullable=True, index=True)
# Message and metadata
message = Column(Text, nullable=True)
metadata = Column(JSON, nullable=True)
# Blockchain specific fields
block_hash = Column(String(64), nullable=True)
logical_time = Column(sa.BIGINT, nullable=True) # TON logical time
confirmations = Column(Integer, nullable=False, default=0)
# Timing
confirmed_at = Column(DateTime, nullable=True)
failed_at = Column(DateTime, nullable=True)
# Smart contract interaction
contract_address = Column(String(48), nullable=True)
contract_method = Column(String(100), nullable=True)
contract_data = Column(JSON, nullable=True)
# Internal tracking
retry_count = Column(Integer, nullable=False, default=0)
last_retry_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
# Indexes for performance
__table_args__ = (
Index("idx_blockchain_tx_user_status", "user_id", "status"),
Index("idx_blockchain_tx_hash", "transaction_hash"),
Index("idx_blockchain_tx_addresses", "sender_address", "recipient_address"),
Index("idx_blockchain_tx_created", "created_at"),
Index("idx_blockchain_tx_type_status", "transaction_type", "status"),
)
@validates('transaction_type')
def validate_transaction_type(self, key, transaction_type):
"""Validate transaction type."""
allowed_types = {
'transfer', 'mint', 'burn', 'stake', 'unstake',
'contract_call', 'deploy', 'withdraw', 'deposit'
}
if transaction_type not in allowed_types:
raise ValueError(f"Invalid transaction type: {transaction_type}")
return transaction_type
@validates('status')
def validate_status(self, key, status):
"""Validate transaction status."""
allowed_statuses = {'pending', 'confirmed', 'failed', 'cancelled'}
if status not in allowed_statuses:
raise ValueError(f"Invalid status: {status}")
return status
@property
def amount_tons(self) -> Decimal:
"""Convert nanotons to TON."""
return Decimal(self.amount) / Decimal("1000000000")
@property
def fee_tons(self) -> Decimal:
"""Convert fee nanotons to TON."""
return Decimal(self.network_fee) / Decimal("1000000000")
@property
def is_incoming(self) -> bool:
"""Check if transaction is incoming to user's wallet."""
return self.transaction_type in {'transfer', 'mint', 'deposit'} and self.recipient_address
@property
def is_outgoing(self) -> bool:
"""Check if transaction is outgoing from user's wallet."""
return self.transaction_type in {'transfer', 'burn', 'withdraw'} and self.sender_address
def to_dict(self) -> Dict[str, Any]:
"""Convert transaction to dictionary."""
return {
"id": str(self.id),
"hash": self.transaction_hash,
"type": self.transaction_type,
"status": self.status,
"amount": self.amount,
"amount_tons": str(self.amount_tons),
"fee": self.network_fee,
"fee_tons": str(self.fee_tons),
"sender": self.sender_address,
"recipient": self.recipient_address,
"message": self.message,
"block_hash": self.block_hash,
"confirmations": self.confirmations,
"created_at": self.created_at.isoformat() if self.created_at else None,
"confirmed_at": self.confirmed_at.isoformat() if self.confirmed_at else None,
"is_incoming": self.is_incoming,
"is_outgoing": self.is_outgoing
}
class SmartContract(Base, UUIDMixin, TimestampMixin):
"""Model for smart contract management."""
__tablename__ = "smart_contracts"
# Contract details
address = Column(String(48), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
contract_type = Column(String(50), nullable=False) # nft, token, defi, etc.
# Contract metadata
abi = Column(JSON, nullable=True) # Contract ABI if available
source_code = Column(Text, nullable=True)
compiler_version = Column(String(20), nullable=True)
# Deployment info
deployer_address = Column(String(48), nullable=True)
deployment_tx_hash = Column(String(64), nullable=True)
deployment_block = Column(sa.BIGINT, nullable=True)
# Status and verification
is_verified = Column(Boolean, nullable=False, default=False)
is_active = Column(Boolean, nullable=False, default=True)
verification_date = Column(DateTime, nullable=True)
# Usage statistics
interaction_count = Column(Integer, nullable=False, default=0)
last_interaction_at = Column(DateTime, nullable=True)
# Relationships
transactions = relationship(
"BlockchainTransaction",
foreign_keys="BlockchainTransaction.contract_address",
primaryjoin="SmartContract.address == BlockchainTransaction.contract_address",
back_populates=None
)
__table_args__ = (
Index("idx_smart_contract_address", "address"),
Index("idx_smart_contract_type", "contract_type"),
Index("idx_smart_contract_active", "is_active"),
)
@validates('contract_type')
def validate_contract_type(self, key, contract_type):
"""Validate contract type."""
allowed_types = {
'nft', 'token', 'defi', 'game', 'dao', 'bridge',
'oracle', 'multisig', 'custom'
}
if contract_type not in allowed_types:
raise ValueError(f"Invalid contract type: {contract_type}")
return contract_type
class TokenBalance(Base, UUIDMixin, TimestampMixin):
"""Model for tracking user token balances."""
__tablename__ = "token_balances"
# User relationship
user_id = Column(PostgreSQLUUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="token_balances")
# Token details
token_address = Column(String(48), nullable=False, index=True)
token_name = Column(String(100), nullable=True)
token_symbol = Column(String(10), nullable=True)
token_decimals = Column(Integer, nullable=False, default=9)
# Balance information
balance = Column(sa.BIGINT, nullable=False, default=0) # Raw balance
locked_balance = Column(sa.BIGINT, nullable=False, default=0) # Locked in contracts
# Metadata
last_update_block = Column(sa.BIGINT, nullable=True)
last_update_tx = Column(String(64), nullable=True)
# Unique constraint
__table_args__ = (
sa.UniqueConstraint("user_id", "token_address", name="uq_user_token"),
Index("idx_token_balance_user", "user_id"),
Index("idx_token_balance_token", "token_address"),
Index("idx_token_balance_updated", "updated_at"),
)
@property
def available_balance(self) -> int:
"""Get available (unlocked) balance."""
return max(0, self.balance - self.locked_balance)
@property
def formatted_balance(self) -> Decimal:
"""Get balance formatted with decimals."""
return Decimal(self.balance) / Decimal(10 ** self.token_decimals)
@property
def formatted_available_balance(self) -> Decimal:
"""Get available balance formatted with decimals."""
return Decimal(self.available_balance) / Decimal(10 ** self.token_decimals)
class StakingPosition(Base, UUIDMixin, TimestampMixin):
"""Model for staking positions."""
__tablename__ = "staking_positions"
# User relationship
user_id = Column(PostgreSQLUUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="staking_positions")
# Staking details
validator_address = Column(String(48), nullable=False, index=True)
pool_address = Column(String(48), nullable=True)
# Amount and timing
staked_amount = Column(sa.BIGINT, nullable=False) # Amount in nanotons
stake_tx_hash = Column(String(64), nullable=False)
stake_block = Column(sa.BIGINT, nullable=True)
# Status
status = Column(String(20), nullable=False, default="active") # active, unstaking, withdrawn
unstake_tx_hash = Column(String(64), nullable=True)
unstake_requested_at = Column(DateTime, nullable=True)
withdrawn_at = Column(DateTime, nullable=True)
# Rewards
rewards_earned = Column(sa.BIGINT, nullable=False, default=0)
last_reward_claim = Column(DateTime, nullable=True)
last_reward_tx = Column(String(64), nullable=True)
# Lock period
lock_period_days = Column(Integer, nullable=False, default=0)
unlock_date = Column(DateTime, nullable=True)
__table_args__ = (
Index("idx_staking_user_status", "user_id", "status"),
Index("idx_staking_validator", "validator_address"),
Index("idx_staking_unlock", "unlock_date"),
)
@validates('status')
def validate_status(self, key, status):
"""Validate staking status."""
allowed_statuses = {'active', 'unstaking', 'withdrawn', 'slashed'}
if status not in allowed_statuses:
raise ValueError(f"Invalid staking status: {status}")
return status
@property
def staked_tons(self) -> Decimal:
"""Get staked amount in TON."""
return Decimal(self.staked_amount) / Decimal("1000000000")
@property
def rewards_tons(self) -> Decimal:
"""Get rewards amount in TON."""
return Decimal(self.rewards_earned) / Decimal("1000000000")
@property
def is_locked(self) -> bool:
"""Check if staking position is still locked."""
if not self.unlock_date:
return False
return datetime.utcnow() < self.unlock_date
class NFTCollection(Base, UUIDMixin, TimestampMixin):
"""Model for NFT collections."""
__tablename__ = "nft_collections"
# Collection details
contract_address = Column(String(48), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
symbol = Column(String(10), nullable=True)
# Creator and metadata
creator_address = Column(String(48), nullable=False)
metadata_uri = Column(String(500), nullable=True)
base_uri = Column(String(500), nullable=True)
# Collection stats
total_supply = Column(Integer, nullable=False, default=0)
max_supply = Column(Integer, nullable=True)
floor_price = Column(sa.BIGINT, nullable=True) # In nanotons
# Status
is_verified = Column(Boolean, nullable=False, default=False)
is_active = Column(Boolean, nullable=False, default=True)
# Relationships
nfts = relationship("NFTToken", back_populates="collection")
__table_args__ = (
Index("idx_nft_collection_address", "contract_address"),
Index("idx_nft_collection_creator", "creator_address"),
Index("idx_nft_collection_verified", "is_verified"),
)
class NFTToken(Base, UUIDMixin, TimestampMixin):
"""Model for individual NFT tokens."""
__tablename__ = "nft_tokens"
# Token identification
collection_id = Column(PostgreSQLUUID(as_uuid=True), ForeignKey("nft_collections.id"), nullable=False)
collection = relationship("NFTCollection", back_populates="nfts")
token_id = Column(String(100), nullable=False) # Token ID within collection
token_address = Column(String(48), unique=True, nullable=False, index=True)
# Ownership
owner_address = Column(String(48), nullable=False, index=True)
# Metadata
name = Column(String(200), nullable=True)
description = Column(Text, nullable=True)
image_uri = Column(String(500), nullable=True)
metadata_uri = Column(String(500), nullable=True)
attributes = Column(JSON, nullable=True)
# Trading
last_sale_price = Column(sa.BIGINT, nullable=True) # In nanotons
last_sale_tx = Column(String(64), nullable=True)
last_sale_date = Column(DateTime, nullable=True)
# Status
is_burned = Column(Boolean, nullable=False, default=False)
burned_at = Column(DateTime, nullable=True)
__table_args__ = (
sa.UniqueConstraint("collection_id", "token_id", name="uq_collection_token"),
Index("idx_nft_token_address", "token_address"),
Index("idx_nft_token_owner", "owner_address"),
Index("idx_nft_token_collection", "collection_id"),
)
@property
def last_sale_tons(self) -> Optional[Decimal]:
"""Get last sale price in TON."""
if self.last_sale_price is None:
return None
return Decimal(self.last_sale_price) / Decimal("1000000000")
class DeFiPosition(Base, UUIDMixin, TimestampMixin):
"""Model for DeFi protocol positions."""
__tablename__ = "defi_positions"
# User relationship
user_id = Column(PostgreSQLUUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="defi_positions")
# Protocol details
protocol_name = Column(String(100), nullable=False)
protocol_address = Column(String(48), nullable=False)
position_type = Column(String(50), nullable=False) # liquidity, lending, borrowing, etc.
# Position details
token_a_address = Column(String(48), nullable=True)
token_a_amount = Column(sa.BIGINT, nullable=False, default=0)
token_b_address = Column(String(48), nullable=True)
token_b_amount = Column(sa.BIGINT, nullable=False, default=0)
# Value tracking
initial_value = Column(sa.BIGINT, nullable=False, default=0) # In nanotons
current_value = Column(sa.BIGINT, nullable=False, default=0)
last_value_update = Column(DateTime, nullable=True)
# Rewards and fees
rewards_earned = Column(sa.BIGINT, nullable=False, default=0)
fees_paid = Column(sa.BIGINT, nullable=False, default=0)
# Status
status = Column(String(20), nullable=False, default="active") # active, closed, liquidated
opened_tx = Column(String(64), nullable=False)
closed_tx = Column(String(64), nullable=True)
closed_at = Column(DateTime, nullable=True)
__table_args__ = (
Index("idx_defi_user_protocol", "user_id", "protocol_name"),
Index("idx_defi_position_type", "position_type"),
Index("idx_defi_status", "status"),
)
@validates('position_type')
def validate_position_type(self, key, position_type):
"""Validate position type."""
allowed_types = {
'liquidity', 'lending', 'borrowing', 'farming',
'staking', 'options', 'futures', 'insurance'
}
if position_type not in allowed_types:
raise ValueError(f"Invalid position type: {position_type}")
return position_type
@validates('status')
def validate_status(self, key, status):
"""Validate position status."""
allowed_statuses = {'active', 'closed', 'liquidated', 'expired'}
if status not in allowed_statuses:
raise ValueError(f"Invalid position status: {status}")
return status
@property
def current_value_tons(self) -> Decimal:
"""Get current value in TON."""
return Decimal(self.current_value) / Decimal("1000000000")
@property
def pnl_tons(self) -> Decimal:
"""Get profit/loss in TON."""
return Decimal(self.current_value - self.initial_value) / Decimal("1000000000")
@property
def pnl_percentage(self) -> Decimal:
"""Get profit/loss percentage."""
if self.initial_value == 0:
return Decimal("0")
return (Decimal(self.current_value - self.initial_value) / Decimal(self.initial_value)) * 100
+731
View File
@@ -0,0 +1,731 @@
"""
Content models with async support and enhanced features
"""
import hashlib
import mimetypes
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional, List, Dict, Any, Union
from urllib.parse import urljoin
from sqlalchemy import Column, String, Integer, BigInteger, Boolean, Text, ForeignKey, Index, text
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import relationship
import structlog
from app.core.models.base import BaseModel
from app.core.config import settings, PROJECT_HOST
logger = structlog.get_logger(__name__)
class ContentType(str, Enum):
"""Content type enumeration"""
AUDIO = "audio"
VIDEO = "video"
IMAGE = "image"
TEXT = "text"
DOCUMENT = "document"
UNKNOWN = "unknown"
class ContentStatus(str, Enum):
"""Content status enumeration"""
UPLOADING = "uploading"
PROCESSING = "processing"
READY = "ready"
FAILED = "failed"
DISABLED = "disabled"
DELETED = "deleted"
class StorageType(str, Enum):
"""Storage type enumeration"""
LOCAL = "local"
ONCHAIN = "onchain"
IPFS = "ipfs"
HYBRID = "hybrid"
class LicenseType(str, Enum):
"""License type enumeration"""
LISTEN = "listen"
USE = "use"
RESALE = "resale"
EXCLUSIVE = "exclusive"
class StoredContent(BaseModel):
"""Enhanced content storage model"""
__tablename__ = 'stored_content'
# Content identification
hash = Column(
String(128),
nullable=False,
unique=True,
index=True,
comment="Content hash (SHA-256 or custom)"
)
content_id = Column(
String(256),
nullable=True,
index=True,
comment="Content identifier (CID for IPFS)"
)
# File information
filename = Column(
String(512),
nullable=False,
comment="Original filename"
)
file_size = Column(
BigInteger,
nullable=False,
default=0,
comment="File size in bytes"
)
mime_type = Column(
String(128),
nullable=True,
comment="MIME type of the content"
)
# Content type and storage
content_type = Column(
String(32),
nullable=False,
default=ContentType.UNKNOWN.value,
index=True,
comment="Content type category"
)
storage_type = Column(
String(32),
nullable=False,
default=StorageType.LOCAL.value,
index=True,
comment="Storage type"
)
# File path and URLs
file_path = Column(
String(1024),
nullable=True,
comment="Local file path"
)
external_url = Column(
String(2048),
nullable=True,
comment="External URL for remote content"
)
# Blockchain related
onchain_index = Column(
Integer,
nullable=True,
index=True,
comment="On-chain index number"
)
owner_address = Column(
String(256),
nullable=True,
index=True,
comment="Blockchain owner address"
)
# User and access
user_id = Column(
String(36), # UUID
ForeignKey('users.id'),
nullable=True,
index=True,
comment="User who uploaded the content"
)
# Encryption and security
encrypted = Column(
Boolean,
nullable=False,
default=False,
comment="Whether content is encrypted"
)
encryption_key_id = Column(
String(36), # UUID
ForeignKey('encryption_keys.id'),
nullable=True,
comment="Encryption key reference"
)
# Processing status
disabled = Column(
Boolean,
nullable=False,
default=False,
index=True,
comment="Whether content is disabled"
)
# Content metadata
title = Column(
String(512),
nullable=True,
comment="Content title"
)
description = Column(
Text,
nullable=True,
comment="Content description"
)
tags = Column(
ARRAY(String),
nullable=False,
default=list,
comment="Content tags"
)
# Media-specific metadata
duration = Column(
Integer,
nullable=True,
comment="Duration in seconds (for audio/video)"
)
width = Column(
Integer,
nullable=True,
comment="Width in pixels (for images/video)"
)
height = Column(
Integer,
nullable=True,
comment="Height in pixels (for images/video)"
)
bitrate = Column(
Integer,
nullable=True,
comment="Bitrate (for audio/video)"
)
# Conversion and processing
processing_status = Column(
String(32),
nullable=False,
default=ContentStatus.READY.value,
index=True,
comment="Processing status"
)
conversion_data = Column(
JSONB,
nullable=False,
default=dict,
comment="Conversion and processing data"
)
# Statistics
download_count = Column(
Integer,
nullable=False,
default=0,
comment="Number of downloads"
)
view_count = Column(
Integer,
nullable=False,
default=0,
comment="Number of views"
)
# Relationships
user = relationship('User', back_populates='content_items')
encryption_key = relationship('EncryptionKey', back_populates='content_items')
user_contents = relationship('UserContent', back_populates='content')
user_actions = relationship('UserAction', back_populates='content')
# Indexes for performance
__table_args__ = (
Index('idx_content_hash', 'hash'),
Index('idx_content_user_type', 'user_id', 'content_type'),
Index('idx_content_storage_status', 'storage_type', 'status'),
Index('idx_content_onchain', 'onchain_index'),
Index('idx_content_created', 'created_at'),
Index('idx_content_disabled', 'disabled'),
)
def __str__(self) -> str:
"""String representation"""
return f"StoredContent({self.id}, hash={self.hash[:8]}..., filename={self.filename})"
@property
def file_extension(self) -> str:
"""Get file extension"""
return Path(self.filename).suffix.lower()
@property
def web_url(self) -> str:
"""Get web accessible URL"""
if self.external_url:
return self.external_url
if self.hash:
return urljoin(str(PROJECT_HOST), f"/api/v1.5/storage/{self.hash}")
return ""
@property
def download_url(self) -> str:
"""Get download URL"""
if self.hash:
return urljoin(str(PROJECT_HOST), f"/api/v1/storage/{self.hash}")
return ""
@property
def is_media(self) -> bool:
"""Check if content is media (audio/video/image)"""
return self.content_type in [ContentType.AUDIO, ContentType.VIDEO, ContentType.IMAGE]
@property
def is_processed(self) -> bool:
"""Check if content is fully processed"""
return self.processing_status == ContentStatus.READY.value
@property
def cache_key(self) -> str:
"""Override cache key to use hash"""
return f"content:hash:{self.hash}"
def detect_content_type(self) -> ContentType:
"""Detect content type from MIME type"""
if not self.mime_type:
# Try to guess from extension
mime_type, _ = mimetypes.guess_type(self.filename)
self.mime_type = mime_type
if self.mime_type:
if self.mime_type.startswith('audio/'):
return ContentType.AUDIO
elif self.mime_type.startswith('video/'):
return ContentType.VIDEO
elif self.mime_type.startswith('image/'):
return ContentType.IMAGE
elif self.mime_type.startswith('text/'):
return ContentType.TEXT
elif 'application/' in self.mime_type:
return ContentType.DOCUMENT
return ContentType.UNKNOWN
def calculate_hash(self, file_data: bytes) -> str:
"""Calculate hash for file data"""
return hashlib.sha256(file_data).hexdigest()
def set_conversion_data(self, key: str, value: Any) -> None:
"""Set conversion data"""
if not self.conversion_data:
self.conversion_data = {}
self.conversion_data[key] = value
def get_conversion_data(self, key: str, default: Any = None) -> Any:
"""Get conversion data"""
if not self.conversion_data:
return default
return self.conversion_data.get(key, default)
def add_tag(self, tag: str) -> None:
"""Add tag to content"""
if not self.tags:
self.tags = []
tag = tag.strip().lower()
if tag and tag not in self.tags:
self.tags.append(tag)
def remove_tag(self, tag: str) -> None:
"""Remove tag from content"""
if self.tags:
tag = tag.strip().lower()
if tag in self.tags:
self.tags.remove(tag)
def increment_download_count(self) -> None:
"""Increment download counter"""
self.download_count += 1
def increment_view_count(self) -> None:
"""Increment view counter"""
self.view_count += 1
@classmethod
async def get_by_hash(
cls,
session: AsyncSession,
content_hash: str
) -> Optional['StoredContent']:
"""Get content by hash"""
try:
stmt = select(cls).where(cls.hash == content_hash)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting content by hash", hash=content_hash, error=str(e))
return None
@classmethod
async def get_by_user(
cls,
session: AsyncSession,
user_id: str,
content_type: Optional[ContentType] = None,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List['StoredContent']:
"""Get content by user"""
try:
stmt = select(cls).where(cls.user_id == user_id)
if content_type:
stmt = stmt.where(cls.content_type == content_type.value)
stmt = stmt.order_by(cls.created_at.desc())
if offset:
stmt = stmt.offset(offset)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error getting content by user", user_id=user_id, error=str(e))
return []
@classmethod
async def get_recent(
cls,
session: AsyncSession,
days: int = 7,
content_type: Optional[ContentType] = None,
limit: Optional[int] = None
) -> List['StoredContent']:
"""Get recent content"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days)
stmt = select(cls).where(
cls.created_at >= cutoff_date,
cls.disabled == False,
cls.processing_status == ContentStatus.READY.value
)
if content_type:
stmt = stmt.where(cls.content_type == content_type.value)
stmt = stmt.order_by(cls.created_at.desc())
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error getting recent content", days=days, error=str(e))
return []
@classmethod
async def search(
cls,
session: AsyncSession,
query: str,
content_type: Optional[ContentType] = None,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List['StoredContent']:
"""Search content by title and description"""
try:
search_pattern = f"%{query.lower()}%"
stmt = select(cls).where(
(cls.title.ilike(search_pattern)) |
(cls.description.ilike(search_pattern)) |
(cls.filename.ilike(search_pattern)),
cls.disabled == False,
cls.processing_status == ContentStatus.READY.value
)
if content_type:
stmt = stmt.where(cls.content_type == content_type.value)
stmt = stmt.order_by(cls.created_at.desc())
if offset:
stmt = stmt.offset(offset)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error searching content", query=query, error=str(e))
return []
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary with additional computed fields"""
data = super().to_dict()
data.update({
'web_url': self.web_url,
'download_url': self.download_url,
'file_extension': self.file_extension,
'is_media': self.is_media,
'is_processed': self.is_processed
})
return data
class UserContent(BaseModel):
"""User content ownership and licensing"""
__tablename__ = 'user_content'
# Content relationship
content_id = Column(
String(36), # UUID
ForeignKey('stored_content.id'),
nullable=False,
index=True,
comment="Reference to stored content"
)
user_id = Column(
String(36), # UUID
ForeignKey('users.id'),
nullable=False,
index=True,
comment="User who owns this content"
)
# License information
license_type = Column(
String(32),
nullable=False,
default=LicenseType.LISTEN.value,
comment="Type of license"
)
# Blockchain data
onchain_address = Column(
String(256),
nullable=True,
index=True,
comment="On-chain contract address"
)
owner_address = Column(
String(256),
nullable=True,
index=True,
comment="Blockchain owner address"
)
# Transaction data
purchase_transaction = Column(
String(128),
nullable=True,
comment="Purchase transaction hash"
)
purchase_amount = Column(
BigInteger,
nullable=True,
comment="Purchase amount in minimal units"
)
# Wallet connection
wallet_connection_id = Column(
String(36), # UUID
ForeignKey('wallet_connections.id'),
nullable=True,
comment="Wallet connection used for purchase"
)
# Access control
access_granted = Column(
Boolean,
nullable=False,
default=False,
comment="Whether access is granted"
)
access_expires_at = Column(
DateTime,
nullable=True,
comment="When access expires (for temporary licenses)"
)
# Usage tracking
download_count = Column(
Integer,
nullable=False,
default=0,
comment="Number of downloads by this user"
)
last_accessed = Column(
DateTime,
nullable=True,
comment="Last access timestamp"
)
# Relationships
user = relationship('User', back_populates='content_items')
content = relationship('StoredContent', back_populates='user_contents')
wallet_connection = relationship('WalletConnection', back_populates='user_contents')
# Indexes
__table_args__ = (
Index('idx_user_content_user', 'user_id'),
Index('idx_user_content_content', 'content_id'),
Index('idx_user_content_onchain', 'onchain_address'),
Index('idx_user_content_owner', 'owner_address'),
Index('idx_user_content_status', 'status'),
)
def __str__(self) -> str:
"""String representation"""
return f"UserContent({self.id}, user={self.user_id}, content={self.content_id})"
@property
def is_expired(self) -> bool:
"""Check if access has expired"""
if not self.access_expires_at:
return False
return datetime.utcnow() > self.access_expires_at
@property
def is_accessible(self) -> bool:
"""Check if content is accessible"""
return self.access_granted and not self.is_expired and self.status == 'active'
def grant_access(self, expires_at: Optional[datetime] = None) -> None:
"""Grant access to content"""
self.access_granted = True
self.access_expires_at = expires_at
self.last_accessed = datetime.utcnow()
def revoke_access(self) -> None:
"""Revoke access to content"""
self.access_granted = False
def record_download(self) -> None:
"""Record a download"""
self.download_count += 1
self.last_accessed = datetime.utcnow()
@classmethod
async def get_user_access(
cls,
session: AsyncSession,
user_id: str,
content_id: str
) -> Optional['UserContent']:
"""Get user access to specific content"""
try:
stmt = select(cls).where(
cls.user_id == user_id,
cls.content_id == content_id,
cls.status == 'active'
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting user access", user_id=user_id, content_id=content_id, error=str(e))
return None
@classmethod
async def get_user_content(
cls,
session: AsyncSession,
user_id: str,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List['UserContent']:
"""Get all content accessible by user"""
try:
stmt = select(cls).where(
cls.user_id == user_id,
cls.status == 'active',
cls.access_granted == True
).order_by(cls.created_at.desc())
if offset:
stmt = stmt.offset(offset)
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error getting user content", user_id=user_id, error=str(e))
return []
class EncryptionKey(BaseModel):
"""Encryption key management"""
__tablename__ = 'encryption_keys'
# Key identification
key_hash = Column(
String(128),
nullable=False,
unique=True,
index=True,
comment="Hash of the encryption key"
)
algorithm = Column(
String(32),
nullable=False,
default="AES-256-GCM",
comment="Encryption algorithm used"
)
# Key metadata
purpose = Column(
String(64),
nullable=False,
comment="Purpose of the key (content, user_data, etc.)"
)
# Access control
owner_id = Column(
String(36), # UUID
ForeignKey('users.id'),
nullable=True,
comment="Key owner (if user-specific)"
)
# Key lifecycle
expires_at = Column(
DateTime,
nullable=True,
comment="Key expiration timestamp"
)
revoked_at = Column(
DateTime,
nullable=True,
comment="Key revocation timestamp"
)
# Relationships
owner = relationship('User', back_populates='encryption_keys')
content_items = relationship('StoredContent', back_populates='encryption_key')
def __str__(self) -> str:
"""String representation"""
return f"EncryptionKey({self.id}, hash={self.key_hash[:8]}...)"
@property
def is_valid(self) -> bool:
"""Check if key is valid (not expired or revoked)"""
now = datetime.utcnow()
if self.revoked_at and self.revoked_at <= now:
return False
if self.expires_at and self.expires_at <= now:
return False
return True
def revoke(self) -> None:
"""Revoke the key"""
self.revoked_at = datetime.utcnow()
+388
View File
@@ -0,0 +1,388 @@
"""Compatible content models for MariaDB."""
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy import Column, String, Boolean, Text, Integer, DateTime, BigInteger, Index, ForeignKey
from sqlalchemy.orm import relationship
from app.core.models.base_compatible import BaseModel
class Content(BaseModel):
"""Content model compatible with existing MariaDB schema."""
__tablename__ = "content"
# Basic content information
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False)
# File metadata
file_size = Column(BigInteger, nullable=False) # bytes
file_type = Column(String(100), nullable=False)
mime_type = Column(String(100), nullable=False)
file_extension = Column(String(10), nullable=False)
# Content metadata
title = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
tags = Column(Text, nullable=True) # JSON or comma-separated
# Status and visibility
is_public = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_indexed = Column(Boolean, default=False, nullable=False)
is_converted = Column(Boolean, default=False, nullable=False)
# Access and security
access_password = Column(String(255), nullable=True)
download_count = Column(Integer, default=0, nullable=False)
view_count = Column(Integer, default=0, nullable=False)
# Processing status
processing_status = Column(String(50), default="pending", nullable=False)
processing_error = Column(Text, nullable=True)
processing_started = Column(DateTime, nullable=True)
processing_completed = Column(DateTime, nullable=True)
# File hashes for integrity
md5_hash = Column(String(32), nullable=True, index=True)
sha256_hash = Column(String(64), nullable=True, index=True)
# Thumbnails and previews
thumbnail_path = Column(String(500), nullable=True)
preview_path = Column(String(500), nullable=True)
# TON Blockchain integration
ton_transaction_hash = Column(String(100), nullable=True, index=True)
ton_storage_proof = Column(Text, nullable=True)
ton_storage_fee = Column(BigInteger, default=0, nullable=False) # nanotons
# Expiration and cleanup
expires_at = Column(DateTime, nullable=True)
auto_delete = Column(Boolean, default=False, nullable=False)
# Relationships
user = relationship("User", back_populates="content")
# Table indexes for performance
__table_args__ = (
Index('idx_content_user_active', 'user_id', 'is_active'),
Index('idx_content_public_indexed', 'is_public', 'is_indexed'),
Index('idx_content_file_type', 'file_type', 'mime_type'),
Index('idx_content_created', 'created_at'),
Index('idx_content_size', 'file_size'),
Index('idx_content_processing', 'processing_status'),
Index('idx_content_ton_tx', 'ton_transaction_hash'),
Index('idx_content_expires', 'expires_at', 'auto_delete'),
)
def is_expired(self) -> bool:
"""Check if content is expired."""
if not self.expires_at:
return False
return datetime.utcnow() > self.expires_at
def is_image(self) -> bool:
"""Check if content is an image."""
return self.file_type.lower() in ['image', 'img'] or \
self.mime_type.startswith('image/')
def is_video(self) -> bool:
"""Check if content is a video."""
return self.file_type.lower() == 'video' or \
self.mime_type.startswith('video/')
def is_document(self) -> bool:
"""Check if content is a document."""
return self.file_type.lower() in ['document', 'doc', 'pdf'] or \
self.mime_type in ['application/pdf', 'application/msword', 'text/plain']
def get_file_size_human(self) -> str:
"""Get human-readable file size."""
size = self.file_size
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} PB"
def increment_download_count(self) -> None:
"""Increment download counter."""
self.download_count += 1
def increment_view_count(self) -> None:
"""Increment view counter."""
self.view_count += 1
def mark_as_indexed(self) -> None:
"""Mark content as indexed."""
self.is_indexed = True
def mark_as_converted(self) -> None:
"""Mark content as converted."""
self.is_converted = True
self.processing_status = "completed"
self.processing_completed = datetime.utcnow()
def set_processing_error(self, error: str) -> None:
"""Set processing error."""
self.processing_status = "error"
self.processing_error = error
self.processing_completed = datetime.utcnow()
def start_processing(self) -> None:
"""Mark processing as started."""
self.processing_status = "processing"
self.processing_started = datetime.utcnow()
self.processing_error = None
def get_tags_list(self) -> List[str]:
"""Get tags as list."""
if not self.tags:
return []
# Try to parse as JSON first, fallback to comma-separated
try:
import json
return json.loads(self.tags)
except:
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def set_tags_list(self, tags: List[str]) -> None:
"""Set tags from list."""
import json
self.tags = json.dumps(tags) if tags else None
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
"""Convert to dictionary with option to exclude sensitive data."""
exclude = set()
if not include_sensitive:
exclude.update({"access_password", "file_path", "processing_error"})
data = super().to_dict(exclude=exclude)
# Add computed fields
data.update({
"file_size_human": self.get_file_size_human(),
"is_image": self.is_image(),
"is_video": self.is_video(),
"is_document": self.is_document(),
"is_expired": self.is_expired(),
"tags_list": self.get_tags_list(),
})
return data
def to_public_dict(self) -> Dict[str, Any]:
"""Convert to public dictionary (minimal content info)."""
return {
"id": self.id,
"filename": self.filename,
"title": self.title,
"description": self.description,
"file_type": self.file_type,
"file_size": self.file_size,
"file_size_human": self.get_file_size_human(),
"is_image": self.is_image(),
"is_video": self.is_video(),
"is_document": self.is_document(),
"download_count": self.download_count,
"view_count": self.view_count,
"tags_list": self.get_tags_list(),
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class ContentShare(BaseModel):
"""Content sharing model for tracking shared content."""
__tablename__ = "content_shares"
content_id = Column(Integer, ForeignKey('content.id'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True) # Can be null for anonymous shares
# Share metadata
share_token = Column(String(100), unique=True, nullable=False, index=True)
share_url = Column(String(500), nullable=False)
# Share settings
is_active = Column(Boolean, default=True, nullable=False)
is_password_protected = Column(Boolean, default=False, nullable=False)
share_password = Column(String(255), nullable=True)
# Access control
max_downloads = Column(Integer, nullable=True) # Null = unlimited
download_count = Column(Integer, default=0, nullable=False)
view_count = Column(Integer, default=0, nullable=False)
# Time limits
expires_at = Column(DateTime, nullable=True)
# Tracking
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
# Relationships
content = relationship("Content")
user = relationship("User")
__table_args__ = (
Index('idx_shares_content_active', 'content_id', 'is_active'),
Index('idx_shares_token', 'share_token'),
Index('idx_shares_expires', 'expires_at'),
)
def is_expired(self) -> bool:
"""Check if share is expired."""
if not self.expires_at:
return False
return datetime.utcnow() > self.expires_at
def is_download_limit_reached(self) -> bool:
"""Check if download limit is reached."""
if not self.max_downloads:
return False
return self.download_count >= self.max_downloads
def is_valid(self) -> bool:
"""Check if share is valid."""
return (self.is_active and
not self.is_expired() and
not self.is_download_limit_reached())
def increment_download(self) -> bool:
"""Increment download count and return if still valid."""
if not self.is_valid():
return False
self.download_count += 1
return self.is_valid()
def increment_view(self) -> None:
"""Increment view count."""
self.view_count += 1
class ContentMetadata(BaseModel):
"""Extended metadata for content files."""
__tablename__ = "content_metadata"
content_id = Column(Integer, ForeignKey('content.id'), unique=True, nullable=False, index=True)
# Image metadata
image_width = Column(Integer, nullable=True)
image_height = Column(Integer, nullable=True)
image_dpi = Column(Integer, nullable=True)
image_color_space = Column(String(50), nullable=True)
# Video metadata
video_duration = Column(Integer, nullable=True) # seconds
video_bitrate = Column(Integer, nullable=True)
video_fps = Column(Integer, nullable=True)
video_resolution = Column(String(20), nullable=True) # e.g., "1920x1080"
video_codec = Column(String(50), nullable=True)
# Audio metadata
audio_duration = Column(Integer, nullable=True) # seconds
audio_bitrate = Column(Integer, nullable=True)
audio_sample_rate = Column(Integer, nullable=True)
audio_channels = Column(Integer, nullable=True)
audio_codec = Column(String(50), nullable=True)
# Document metadata
document_pages = Column(Integer, nullable=True)
document_words = Column(Integer, nullable=True)
document_language = Column(String(10), nullable=True)
document_author = Column(String(255), nullable=True)
# EXIF data (JSON)
exif_data = Column(Text, nullable=True)
# GPS coordinates
gps_latitude = Column(String(50), nullable=True)
gps_longitude = Column(String(50), nullable=True)
gps_altitude = Column(String(50), nullable=True)
# Technical metadata
compression_ratio = Column(String(20), nullable=True)
quality_score = Column(Integer, nullable=True) # 0-100
# Relationships
content = relationship("Content")
def to_dict(self) -> Dict[str, Any]:
"""Convert metadata to dictionary."""
data = super().to_dict(exclude={"content_id"})
# Parse EXIF data if present
if self.exif_data:
try:
import json
data["exif_data"] = json.loads(self.exif_data)
except:
data["exif_data"] = None
return data
def set_exif_data(self, exif_dict: Dict[str, Any]) -> None:
"""Set EXIF data from dictionary."""
if exif_dict:
import json
self.exif_data = json.dumps(exif_dict)
else:
self.exif_data = None
def get_exif_data(self) -> Optional[Dict[str, Any]]:
"""Get EXIF data as dictionary."""
if not self.exif_data:
return None
try:
import json
return json.loads(self.exif_data)
except:
return None
class ContentVersion(BaseModel):
"""Content version history for tracking changes."""
__tablename__ = "content_versions"
content_id = Column(Integer, ForeignKey('content.id'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# Version information
version_number = Column(Integer, nullable=False)
version_name = Column(String(100), nullable=True)
change_description = Column(Text, nullable=True)
# File information
file_path = Column(String(500), nullable=False)
file_size = Column(BigInteger, nullable=False)
file_hash = Column(String(64), nullable=False)
# Status
is_current = Column(Boolean, default=False, nullable=False)
# Relationships
content = relationship("Content")
user = relationship("User")
__table_args__ = (
Index('idx_versions_content_number', 'content_id', 'version_number'),
Index('idx_versions_current', 'content_id', 'is_current'),
)
def mark_as_current(self) -> None:
"""Mark this version as current."""
self.is_current = True
# Add relationship to User model
# This would be added to the User model:
# content = relationship("Content", back_populates="user")
+420
View File
@@ -0,0 +1,420 @@
"""
User model with async support and enhanced security
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from enum import Enum
from sqlalchemy import Column, String, BigInteger, Boolean, Integer, Index, text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import relationship
import structlog
from app.core.models.base import BaseModel
from app.core.config import settings
logger = structlog.get_logger(__name__)
class UserRole(str, Enum):
"""User role enumeration"""
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
SUPER_ADMIN = "super_admin"
class UserStatus(str, Enum):
"""User status enumeration"""
ACTIVE = "active"
SUSPENDED = "suspended"
BANNED = "banned"
PENDING = "pending"
class User(BaseModel):
"""Enhanced User model with security and async support"""
__tablename__ = 'users'
# Telegram specific fields
telegram_id = Column(
BigInteger,
nullable=False,
unique=True,
index=True,
comment="Telegram user ID"
)
username = Column(
String(512),
nullable=True,
index=True,
comment="Telegram username"
)
first_name = Column(
String(256),
nullable=True,
comment="User first name"
)
last_name = Column(
String(256),
nullable=True,
comment="User last name"
)
# Localization
language_code = Column(
String(8),
nullable=False,
default="en",
comment="User language code"
)
# Security and access control
role = Column(
String(32),
nullable=False,
default=UserRole.USER.value,
index=True,
comment="User role"
)
permissions = Column(
ARRAY(String),
nullable=False,
default=list,
comment="User permissions list"
)
# Activity tracking
last_activity = Column(
"last_use", # Keep old column name for compatibility
DateTime,
nullable=False,
default=datetime.utcnow,
index=True,
comment="Last user activity timestamp"
)
login_count = Column(
Integer,
nullable=False,
default=0,
comment="Total login count"
)
# Account status
is_verified = Column(
Boolean,
nullable=False,
default=False,
comment="Whether user is verified"
)
is_premium = Column(
Boolean,
nullable=False,
default=False,
comment="Whether user has premium access"
)
# Security settings
two_factor_enabled = Column(
Boolean,
nullable=False,
default=False,
comment="Whether 2FA is enabled"
)
security_settings = Column(
JSONB,
nullable=False,
default=dict,
comment="User security settings"
)
# Preferences
preferences = Column(
JSONB,
nullable=False,
default=dict,
comment="User preferences and settings"
)
# Statistics
content_uploaded_count = Column(
Integer,
nullable=False,
default=0,
comment="Number of content items uploaded"
)
content_purchased_count = Column(
Integer,
nullable=False,
default=0,
comment="Number of content items purchased"
)
# Rate limiting
rate_limit_reset = Column(
DateTime,
nullable=True,
comment="Rate limit reset timestamp"
)
rate_limit_count = Column(
Integer,
nullable=False,
default=0,
comment="Current rate limit count"
)
# Relationships
balances = relationship('UserBalance', back_populates='user', cascade="all, delete-orphan")
transactions = relationship('InternalTransaction', back_populates='user', cascade="all, delete-orphan")
wallet_connections = relationship('WalletConnection', back_populates='user', cascade="all, delete-orphan")
content_items = relationship('UserContent', back_populates='user', cascade="all, delete-orphan")
actions = relationship('UserAction', back_populates='user', cascade="all, delete-orphan")
activities = relationship('UserActivity', back_populates='user', cascade="all, delete-orphan")
# Indexes for performance
__table_args__ = (
Index('idx_users_telegram_id', 'telegram_id'),
Index('idx_users_username', 'username'),
Index('idx_users_role_status', 'role', 'status'),
Index('idx_users_last_activity', 'last_activity'),
Index('idx_users_created_at', 'created_at'),
)
def __str__(self) -> str:
"""String representation"""
return f"User({self.id}, telegram_id={self.telegram_id}, username={self.username})"
@property
def full_name(self) -> str:
"""Get user's full name"""
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"User_{self.telegram_id}"
@property
def display_name(self) -> str:
"""Get user's display name"""
return self.username or self.full_name
@property
def is_admin(self) -> bool:
"""Check if user is admin"""
return self.role in [UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value]
@property
def is_moderator(self) -> bool:
"""Check if user is moderator or higher"""
return self.role in [UserRole.MODERATOR.value, UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value]
@property
def cache_key(self) -> str:
"""Override cache key to include telegram_id"""
return f"user:telegram:{self.telegram_id}"
def has_permission(self, permission: str) -> bool:
"""Check if user has specific permission"""
if self.is_admin:
return True
return permission in (self.permissions or [])
def add_permission(self, permission: str) -> None:
"""Add permission to user"""
if not self.permissions:
self.permissions = []
if permission not in self.permissions:
self.permissions.append(permission)
def remove_permission(self, permission: str) -> None:
"""Remove permission from user"""
if self.permissions and permission in self.permissions:
self.permissions.remove(permission)
def update_activity(self) -> None:
"""Update user activity timestamp"""
self.last_activity = datetime.utcnow()
self.login_count += 1
def check_rate_limit(self, limit: int = None, window: int = None) -> bool:
"""Check if user is within rate limits"""
if self.is_admin:
return True
limit = limit or settings.RATE_LIMIT_REQUESTS
window = window or settings.RATE_LIMIT_WINDOW
now = datetime.utcnow()
# Reset counter if window has passed
if not self.rate_limit_reset or now > self.rate_limit_reset:
self.rate_limit_reset = now + timedelta(seconds=window)
self.rate_limit_count = 0
return self.rate_limit_count < limit
def increment_rate_limit(self) -> None:
"""Increment rate limit counter"""
if not self.is_admin:
self.rate_limit_count += 1
def set_preference(self, key: str, value: Any) -> None:
"""Set user preference"""
if not self.preferences:
self.preferences = {}
self.preferences[key] = value
def get_preference(self, key: str, default: Any = None) -> Any:
"""Get user preference"""
if not self.preferences:
return default
return self.preferences.get(key, default)
def set_security_setting(self, key: str, value: Any) -> None:
"""Set security setting"""
if not self.security_settings:
self.security_settings = {}
self.security_settings[key] = value
def get_security_setting(self, key: str, default: Any = None) -> Any:
"""Get security setting"""
if not self.security_settings:
return default
return self.security_settings.get(key, default)
def generate_api_token(self) -> str:
"""Generate secure API token for user"""
token_data = f"{self.id}:{self.telegram_id}:{datetime.utcnow().timestamp()}:{secrets.token_hex(16)}"
return hashlib.sha256(token_data.encode()).hexdigest()
def can_upload_content(self) -> bool:
"""Check if user can upload content"""
if self.status != UserStatus.ACTIVE.value:
return False
if not self.check_rate_limit(limit=10, window=3600): # 10 uploads per hour
return False
return True
def can_purchase_content(self) -> bool:
"""Check if user can purchase content"""
return self.status == UserStatus.ACTIVE.value
@classmethod
async def get_by_telegram_id(
cls,
session: AsyncSession,
telegram_id: int
) -> Optional['User']:
"""Get user by Telegram ID"""
try:
stmt = select(cls).where(cls.telegram_id == telegram_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting user by telegram_id", telegram_id=telegram_id, error=str(e))
return None
@classmethod
async def get_by_username(
cls,
session: AsyncSession,
username: str
) -> Optional['User']:
"""Get user by username"""
try:
stmt = select(cls).where(cls.username == username)
result = await session.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Error getting user by username", username=username, error=str(e))
return None
@classmethod
async def get_active_users(
cls,
session: AsyncSession,
days: int = 30,
limit: Optional[int] = None
) -> List['User']:
"""Get active users within specified days"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days)
stmt = select(cls).where(
cls.last_activity >= cutoff_date,
cls.status == UserStatus.ACTIVE.value
).order_by(cls.last_activity.desc())
if limit:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error getting active users", days=days, error=str(e))
return []
@classmethod
async def get_admins(cls, session: AsyncSession) -> List['User']:
"""Get all admin users"""
try:
stmt = select(cls).where(
cls.role.in_([UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value])
)
result = await session.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error("Error getting admin users", error=str(e))
return []
@classmethod
async def create_from_telegram(
cls,
session: AsyncSession,
telegram_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
language_code: str = "en"
) -> 'User':
"""Create user from Telegram data"""
user = cls(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
status=UserStatus.ACTIVE.value
)
session.add(user)
await session.commit()
await session.refresh(user)
logger.info("User created from Telegram", telegram_id=telegram_id, user_id=user.id)
return user
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary with safe data"""
data = super().to_dict()
# Remove sensitive fields
sensitive_fields = ['security_settings', 'permissions']
for field in sensitive_fields:
data.pop(field, None)
return data
def to_public_dict(self) -> Dict[str, Any]:
"""Convert to public dictionary with minimal data"""
return {
'id': str(self.id),
'username': self.username,
'display_name': self.display_name,
'is_verified': self.is_verified,
'is_premium': self.is_premium,
'created_at': self.created_at.isoformat() if self.created_at else None
}
+247
View File
@@ -0,0 +1,247 @@
"""Compatible user models for MariaDB."""
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy import Column, String, Boolean, Text, Integer, DateTime, Index
from sqlalchemy.orm import relationship
from app.core.models.base_compatible import BaseModel
class User(BaseModel):
"""User model compatible with existing MariaDB schema."""
__tablename__ = "users"
# Basic user information
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=True, index=True)
password_hash = Column(String(255), nullable=False)
# User status and flags
is_active = Column(Boolean, default=True, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
# Profile information
first_name = Column(String(50), nullable=True)
last_name = Column(String(50), nullable=True)
bio = Column(Text, nullable=True)
avatar_url = Column(String(255), nullable=True)
# System tracking
last_login = Column(DateTime, nullable=True)
login_count = Column(Integer, default=0, nullable=False)
# Storage and limits
storage_used = Column(Integer, default=0, nullable=False) # bytes
storage_limit = Column(Integer, default=100*1024*1024, nullable=False) # 100MB default
# TON Blockchain integration
ton_wallet_address = Column(String(100), nullable=True, index=True)
ton_balance = Column(Integer, default=0, nullable=False) # nanotons
# License and subscription
license_key = Column(String(100), nullable=True, index=True)
license_expires = Column(DateTime, nullable=True)
subscription_level = Column(String(20), default="free", nullable=False)
# API access
api_key = Column(String(100), nullable=True, unique=True, index=True)
api_calls_count = Column(Integer, default=0, nullable=False)
api_calls_limit = Column(Integer, default=1000, nullable=False)
# Relationships will be defined when we create content models
# Table indexes for performance
__table_args__ = (
Index('idx_users_username_active', 'username', 'is_active'),
Index('idx_users_email_verified', 'email', 'is_verified'),
Index('idx_users_ton_wallet', 'ton_wallet_address'),
Index('idx_users_license', 'license_key', 'license_expires'),
)
def check_storage_limit(self, file_size: int) -> bool:
"""Check if user can upload file of given size."""
return (self.storage_used + file_size) <= self.storage_limit
def update_storage_usage(self, size_change: int) -> None:
"""Update user's storage usage."""
self.storage_used = max(0, self.storage_used + size_change)
def is_license_valid(self) -> bool:
"""Check if user's license is valid."""
if not self.license_key or not self.license_expires:
return False
return self.license_expires > datetime.utcnow()
def can_make_api_call(self) -> bool:
"""Check if user can make API call."""
return self.api_calls_count < self.api_calls_limit
def increment_api_calls(self) -> None:
"""Increment API calls counter."""
self.api_calls_count += 1
def reset_api_calls(self) -> None:
"""Reset API calls counter (for monthly reset)."""
self.api_calls_count = 0
def get_storage_usage_percent(self) -> float:
"""Get storage usage as percentage."""
if self.storage_limit == 0:
return 0.0
return (self.storage_used / self.storage_limit) * 100
def get_api_usage_percent(self) -> float:
"""Get API usage as percentage."""
if self.api_calls_limit == 0:
return 0.0
return (self.api_calls_count / self.api_calls_limit) * 100
def get_display_name(self) -> str:
"""Get user's display name."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
elif self.first_name:
return self.first_name
return self.username
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
"""Convert to dictionary with option to exclude sensitive data."""
exclude = set()
if not include_sensitive:
exclude.update({"password_hash", "api_key", "license_key"})
data = super().to_dict(exclude=exclude)
# Add computed fields
data.update({
"display_name": self.get_display_name(),
"storage_usage_percent": self.get_storage_usage_percent(),
"api_usage_percent": self.get_api_usage_percent(),
"license_valid": self.is_license_valid(),
})
return data
def to_public_dict(self) -> Dict[str, Any]:
"""Convert to public dictionary (minimal user info)."""
return {
"id": self.id,
"username": self.username,
"display_name": self.get_display_name(),
"avatar_url": self.avatar_url,
"is_verified": self.is_verified,
"subscription_level": self.subscription_level,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class UserSession(BaseModel):
"""User session model for tracking active sessions."""
__tablename__ = "user_sessions"
user_id = Column(Integer, nullable=False, index=True)
session_token = Column(String(255), unique=True, nullable=False, index=True)
refresh_token = Column(String(255), unique=True, nullable=True, index=True)
# Session metadata
ip_address = Column(String(45), nullable=True) # IPv6 support
user_agent = Column(Text, nullable=True)
device_info = Column(Text, nullable=True)
# Session status
is_active = Column(Boolean, default=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
last_activity = Column(DateTime, default=datetime.utcnow, nullable=False)
# Security flags
is_suspicious = Column(Boolean, default=False, nullable=False)
failed_attempts = Column(Integer, default=0, nullable=False)
__table_args__ = (
Index('idx_sessions_user_active', 'user_id', 'is_active'),
Index('idx_sessions_token', 'session_token'),
Index('idx_sessions_expires', 'expires_at'),
)
def is_expired(self) -> bool:
"""Check if session is expired."""
return datetime.utcnow() > self.expires_at
def is_valid(self) -> bool:
"""Check if session is valid."""
return self.is_active and not self.is_expired()
def extend_session(self, hours: int = 24) -> None:
"""Extend session expiration."""
from datetime import timedelta
self.expires_at = datetime.utcnow() + timedelta(hours=hours)
self.last_activity = datetime.utcnow()
def mark_suspicious(self) -> None:
"""Mark session as suspicious."""
self.is_suspicious = True
self.failed_attempts += 1
def deactivate(self) -> None:
"""Deactivate session."""
self.is_active = False
class UserPreferences(BaseModel):
"""User preferences and settings."""
__tablename__ = "user_preferences"
user_id = Column(Integer, unique=True, nullable=False, index=True)
# UI preferences
theme = Column(String(20), default="light", nullable=False)
language = Column(String(10), default="en", nullable=False)
timezone = Column(String(50), default="UTC", nullable=False)
# Notification preferences
email_notifications = Column(Boolean, default=True, nullable=False)
upload_notifications = Column(Boolean, default=True, nullable=False)
storage_alerts = Column(Boolean, default=True, nullable=False)
# Privacy settings
public_profile = Column(Boolean, default=False, nullable=False)
show_email = Column(Boolean, default=False, nullable=False)
allow_indexing = Column(Boolean, default=True, nullable=False)
# Upload preferences
auto_optimize_images = Column(Boolean, default=True, nullable=False)
default_privacy = Column(String(20), default="private", nullable=False)
max_file_size_mb = Column(Integer, default=10, nullable=False)
# Cache and performance
cache_thumbnails = Column(Boolean, default=True, nullable=False)
preload_content = Column(Boolean, default=False, nullable=False)
def to_dict(self) -> Dict[str, Any]:
"""Convert preferences to dictionary."""
return super().to_dict(exclude={"user_id"})
@classmethod
def get_default_preferences(cls) -> Dict[str, Any]:
"""Get default user preferences."""
return {
"theme": "light",
"language": "en",
"timezone": "UTC",
"email_notifications": True,
"upload_notifications": True,
"storage_alerts": True,
"public_profile": False,
"show_email": False,
"allow_indexing": True,
"auto_optimize_images": True,
"default_privacy": "private",
"max_file_size_mb": 10,
"cache_thumbnails": True,
"preload_content": False,
}