relayers
This commit is contained in:
1 parent
21964fa986
commit
797f379648
68 files changed
+23871
-1271
No files matched your search
+276
-2
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user