init commit
This commit is contained in:
3
.history/app/__init___20251210201602.py
Normal file
3
.history/app/__init___20251210201602.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Finance Bot Application Package"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
3
.history/app/__init___20251210202255.py
Normal file
3
.history/app/__init___20251210202255.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Finance Bot Application Package"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
.history/app/api/__init___20251210201724.py
Normal file
1
.history/app/api/__init___20251210201724.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes"""
|
||||
1
.history/app/api/__init___20251210202255.py
Normal file
1
.history/app/api/__init___20251210202255.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes"""
|
||||
279
.history/app/api/auth_20251210210440.py
Normal file
279
.history/app/api/auth_20251210210440.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.auth_service import AuthService
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
user_id: int
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingStartRequest(BaseModel):
|
||||
chat_id: int
|
||||
|
||||
|
||||
class TelegramBindingStartResponse(BaseModel):
|
||||
code: str
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingConfirmRequest(BaseModel):
|
||||
code: str
|
||||
chat_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramBindingConfirmResponse(BaseModel):
|
||||
success: bool
|
||||
user_id: int
|
||||
jwt_token: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class TokenRefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenRefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginResponse,
|
||||
summary="User login with email & password",
|
||||
)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginResponse:
|
||||
"""
|
||||
Authenticate user and create session.
|
||||
|
||||
**Returns:**
|
||||
- access_token: Short-lived JWT (15 min)
|
||||
- refresh_token: Long-lived refresh token (30 days)
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
X-Device-Id: device_uuid # For tracking
|
||||
```
|
||||
"""
|
||||
|
||||
# TODO: Verify email + password
|
||||
# For MVP: Assume credentials are valid
|
||||
|
||||
from app.db.models import User
|
||||
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
service = AuthService(db)
|
||||
access_token, refresh_token = await service.create_session(
|
||||
user_id=user.id,
|
||||
device_id=request.__dict__.get("device_id"),
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user_id=user.id,
|
||||
expires_in=15 * 60, # 15 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenRefreshResponse,
|
||||
summary="Refresh access token",
|
||||
)
|
||||
async def refresh_token(
|
||||
request: TokenRefreshRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenRefreshResponse:
|
||||
"""
|
||||
Issue new access token using refresh token.
|
||||
|
||||
**Flow:**
|
||||
1. Access token expires
|
||||
2. Send refresh_token to this endpoint
|
||||
3. Receive new access_token (without creating new session)
|
||||
"""
|
||||
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
|
||||
service = AuthService(db)
|
||||
new_access_token = await service.refresh_access_token(
|
||||
refresh_token=request.refresh_token,
|
||||
user_id=token_payload.sub,
|
||||
)
|
||||
|
||||
return TokenRefreshResponse(
|
||||
access_token=new_access_token,
|
||||
expires_in=15 * 60,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/start",
|
||||
response_model=TelegramBindingStartResponse,
|
||||
summary="Start Telegram binding flow",
|
||||
)
|
||||
async def telegram_binding_start(
|
||||
request: TelegramBindingStartRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate binding code for Telegram user.
|
||||
|
||||
**Bot Flow:**
|
||||
1. User sends /start
|
||||
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||
3. Bot receives code and generates link
|
||||
4. Bot sends message with link to user
|
||||
5. User clicks link (goes to confirm endpoint)
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||
|
||||
return TelegramBindingStartResponse(
|
||||
code=code,
|
||||
expires_in=600, # 10 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/confirm",
|
||||
response_model=TelegramBindingConfirmResponse,
|
||||
summary="Confirm Telegram binding",
|
||||
)
|
||||
async def telegram_binding_confirm(
|
||||
request: TelegramBindingConfirmRequest,
|
||||
current_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Confirm Telegram binding and issue JWT.
|
||||
|
||||
**Flow:**
|
||||
1. User logs in or creates account
|
||||
2. User clicks binding link with code
|
||||
3. Frontend calls this endpoint with code + user context
|
||||
4. Backend creates TelegramIdentity record
|
||||
5. Backend returns JWT for bot to use
|
||||
|
||||
**Bot Usage:**
|
||||
```python
|
||||
# Bot stores JWT for user
|
||||
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||
|
||||
# Bot makes API calls
|
||||
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||
```
|
||||
"""
|
||||
|
||||
# Get authenticated user from JWT
|
||||
user_id = getattr(current_request.state, "user_id", None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.confirm_telegram_binding(
|
||||
user_id=user_id,
|
||||
chat_id=request.chat_id,
|
||||
code=request.code,
|
||||
username=request.username,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail="Binding failed")
|
||||
|
||||
return TelegramBindingConfirmResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/authenticate",
|
||||
response_model=dict,
|
||||
summary="Authenticate by Telegram chat_id",
|
||||
)
|
||||
async def telegram_authenticate(
|
||||
chat_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get JWT token for Telegram user.
|
||||
|
||||
**Usage in Bot:**
|
||||
```python
|
||||
# After user binding is confirmed
|
||||
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||
jwt_token = response["jwt_token"]
|
||||
```
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
summary="Logout user",
|
||||
)
|
||||
async def logout(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Revoke session and blacklist tokens.
|
||||
|
||||
**TODO:** Implement token blacklisting in Redis
|
||||
"""
|
||||
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# TODO: Add token to Redis blacklist
|
||||
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
279
.history/app/api/auth_20251210210906.py
Normal file
279
.history/app/api/auth_20251210210906.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.auth_service import AuthService
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
user_id: int
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingStartRequest(BaseModel):
|
||||
chat_id: int
|
||||
|
||||
|
||||
class TelegramBindingStartResponse(BaseModel):
|
||||
code: str
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingConfirmRequest(BaseModel):
|
||||
code: str
|
||||
chat_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramBindingConfirmResponse(BaseModel):
|
||||
success: bool
|
||||
user_id: int
|
||||
jwt_token: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class TokenRefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenRefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginResponse,
|
||||
summary="User login with email & password",
|
||||
)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginResponse:
|
||||
"""
|
||||
Authenticate user and create session.
|
||||
|
||||
**Returns:**
|
||||
- access_token: Short-lived JWT (15 min)
|
||||
- refresh_token: Long-lived refresh token (30 days)
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
X-Device-Id: device_uuid # For tracking
|
||||
```
|
||||
"""
|
||||
|
||||
# TODO: Verify email + password
|
||||
# For MVP: Assume credentials are valid
|
||||
|
||||
from app.db.models import User
|
||||
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
service = AuthService(db)
|
||||
access_token, refresh_token = await service.create_session(
|
||||
user_id=user.id,
|
||||
device_id=request.__dict__.get("device_id"),
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user_id=user.id,
|
||||
expires_in=15 * 60, # 15 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenRefreshResponse,
|
||||
summary="Refresh access token",
|
||||
)
|
||||
async def refresh_token(
|
||||
request: TokenRefreshRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenRefreshResponse:
|
||||
"""
|
||||
Issue new access token using refresh token.
|
||||
|
||||
**Flow:**
|
||||
1. Access token expires
|
||||
2. Send refresh_token to this endpoint
|
||||
3. Receive new access_token (without creating new session)
|
||||
"""
|
||||
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
|
||||
service = AuthService(db)
|
||||
new_access_token = await service.refresh_access_token(
|
||||
refresh_token=request.refresh_token,
|
||||
user_id=token_payload.sub,
|
||||
)
|
||||
|
||||
return TokenRefreshResponse(
|
||||
access_token=new_access_token,
|
||||
expires_in=15 * 60,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/start",
|
||||
response_model=TelegramBindingStartResponse,
|
||||
summary="Start Telegram binding flow",
|
||||
)
|
||||
async def telegram_binding_start(
|
||||
request: TelegramBindingStartRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate binding code for Telegram user.
|
||||
|
||||
**Bot Flow:**
|
||||
1. User sends /start
|
||||
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||
3. Bot receives code and generates link
|
||||
4. Bot sends message with link to user
|
||||
5. User clicks link (goes to confirm endpoint)
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||
|
||||
return TelegramBindingStartResponse(
|
||||
code=code,
|
||||
expires_in=600, # 10 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/confirm",
|
||||
response_model=TelegramBindingConfirmResponse,
|
||||
summary="Confirm Telegram binding",
|
||||
)
|
||||
async def telegram_binding_confirm(
|
||||
request: TelegramBindingConfirmRequest,
|
||||
current_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Confirm Telegram binding and issue JWT.
|
||||
|
||||
**Flow:**
|
||||
1. User logs in or creates account
|
||||
2. User clicks binding link with code
|
||||
3. Frontend calls this endpoint with code + user context
|
||||
4. Backend creates TelegramIdentity record
|
||||
5. Backend returns JWT for bot to use
|
||||
|
||||
**Bot Usage:**
|
||||
```python
|
||||
# Bot stores JWT for user
|
||||
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||
|
||||
# Bot makes API calls
|
||||
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||
```
|
||||
"""
|
||||
|
||||
# Get authenticated user from JWT
|
||||
user_id = getattr(current_request.state, "user_id", None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.confirm_telegram_binding(
|
||||
user_id=user_id,
|
||||
chat_id=request.chat_id,
|
||||
code=request.code,
|
||||
username=request.username,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail="Binding failed")
|
||||
|
||||
return TelegramBindingConfirmResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/authenticate",
|
||||
response_model=dict,
|
||||
summary="Authenticate by Telegram chat_id",
|
||||
)
|
||||
async def telegram_authenticate(
|
||||
chat_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get JWT token for Telegram user.
|
||||
|
||||
**Usage in Bot:**
|
||||
```python
|
||||
# After user binding is confirmed
|
||||
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||
jwt_token = response["jwt_token"]
|
||||
```
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
summary="Logout user",
|
||||
)
|
||||
async def logout(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Revoke session and blacklist tokens.
|
||||
|
||||
**TODO:** Implement token blacklisting in Redis
|
||||
"""
|
||||
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# TODO: Add token to Redis blacklist
|
||||
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
41
.history/app/api/main_20251210201725.py
Normal file
41
.history/app/api/main_20251210201725.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""FastAPI application"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="REST API for family finance management",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Finance Bot API",
|
||||
"docs": "/docs",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
41
.history/app/api/main_20251210202255.py
Normal file
41
.history/app/api/main_20251210202255.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""FastAPI application"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="REST API for family finance management",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Finance Bot API",
|
||||
"docs": "/docs",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
275
.history/app/api/transactions_20251210210425.py
Normal file
275
.history/app/api/transactions_20251210210425.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Transaction API Endpoints - CRUD + Approval Workflow
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.transaction_service import TransactionService
|
||||
from app.security.rbac import UserContext, RBACEngine, MemberRole, Permission
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class TransactionCreateRequest(BaseModel):
|
||||
family_id: int
|
||||
from_wallet_id: Optional[int] = None
|
||||
to_wallet_id: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
amount: Decimal
|
||||
description: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"to_wallet_id": 11,
|
||||
"category_id": 5,
|
||||
"amount": 50.00,
|
||||
"description": "Rent payment",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
status: str # draft, pending_approval, executed, reversed
|
||||
amount: Decimal
|
||||
description: str
|
||||
confirmation_required: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionConfirmRequest(BaseModel):
|
||||
confirmation_token: Optional[str] = None
|
||||
|
||||
|
||||
class TransactionReverseRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
# Dependency to extract user context
|
||||
async def get_user_context(request: Request) -> UserContext:
|
||||
"""Extract user context from JWT"""
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
family_id = getattr(request.state, "family_id", None)
|
||||
|
||||
if not user_id or not family_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||
|
||||
# Load user role from DB (simplified for MVP)
|
||||
# In production: Load from users->family_members join
|
||||
role = MemberRole.OWNER # TODO: Load from DB
|
||||
permissions = RBACEngine.get_permissions(role)
|
||||
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
family_id=family_id,
|
||||
role=role,
|
||||
permissions=permissions,
|
||||
family_ids=[family_id],
|
||||
device_id=getattr(request.state, "device_id", None),
|
||||
client_id=getattr(request.state, "client_id", None),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TransactionResponse,
|
||||
status_code=201,
|
||||
summary="Create new transaction",
|
||||
)
|
||||
async def create_transaction(
|
||||
request: TransactionCreateRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TransactionResponse:
|
||||
"""
|
||||
Create a new financial transaction.
|
||||
|
||||
**Request Headers Required:**
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot | web_frontend | ios_app
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
|
||||
**Response:**
|
||||
- If amount ≤ threshold: status="executed" immediately
|
||||
- If amount > threshold: status="pending_approval", requires confirmation
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.created
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.create_transaction(
|
||||
user_context=user_context,
|
||||
family_id=request.family_id,
|
||||
from_wallet_id=request.from_wallet_id,
|
||||
to_wallet_id=request.to_wallet_id,
|
||||
amount=request.amount,
|
||||
category_id=request.category_id,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.warning(f"Permission denied: {e} (user: {user_context.user_id})")
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating transaction: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transaction_id}/confirm",
|
||||
response_model=TransactionResponse,
|
||||
summary="Confirm pending transaction",
|
||||
)
|
||||
async def confirm_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionConfirmRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Approve a pending transaction for execution.
|
||||
|
||||
Only owner or designated approver can confirm.
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.confirmed
|
||||
- transaction.executed
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.confirm_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
confirmation_token=request.confirmation_token,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{transaction_id}",
|
||||
response_model=dict,
|
||||
summary="Reverse (cancel) transaction",
|
||||
)
|
||||
async def reverse_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionReverseRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Reverse (cancel) executed transaction.
|
||||
|
||||
Creates a compensation (reverse) transaction instead of deletion.
|
||||
Original transaction status changes to "reversed".
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.reversed
|
||||
- transaction.created (compensation)
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.reverse_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=List[TransactionResponse],
|
||||
summary="List transactions",
|
||||
)
|
||||
async def list_transactions(
|
||||
family_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all transactions for family.
|
||||
|
||||
**Filtering:**
|
||||
- ?family_id=1
|
||||
- ?wallet_id=10
|
||||
- ?category_id=5
|
||||
- ?status=executed
|
||||
- ?from_date=2023-12-01&to_date=2023-12-31
|
||||
|
||||
**Pagination:**
|
||||
- ?skip=0&limit=20
|
||||
"""
|
||||
|
||||
# Verify family access
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transactions = db.query(Transaction).filter(
|
||||
Transaction.family_id == family_id,
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return [TransactionResponse.from_orm(t) for t in transactions]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{transaction_id}",
|
||||
response_model=TransactionResponse,
|
||||
summary="Get transaction details",
|
||||
)
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed transaction information"""
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transaction = db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
|
||||
return TransactionResponse.from_orm(transaction)
|
||||
275
.history/app/api/transactions_20251210210906.py
Normal file
275
.history/app/api/transactions_20251210210906.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Transaction API Endpoints - CRUD + Approval Workflow
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.transaction_service import TransactionService
|
||||
from app.security.rbac import UserContext, RBACEngine, MemberRole, Permission
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class TransactionCreateRequest(BaseModel):
|
||||
family_id: int
|
||||
from_wallet_id: Optional[int] = None
|
||||
to_wallet_id: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
amount: Decimal
|
||||
description: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"to_wallet_id": 11,
|
||||
"category_id": 5,
|
||||
"amount": 50.00,
|
||||
"description": "Rent payment",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
status: str # draft, pending_approval, executed, reversed
|
||||
amount: Decimal
|
||||
description: str
|
||||
confirmation_required: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionConfirmRequest(BaseModel):
|
||||
confirmation_token: Optional[str] = None
|
||||
|
||||
|
||||
class TransactionReverseRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
# Dependency to extract user context
|
||||
async def get_user_context(request: Request) -> UserContext:
|
||||
"""Extract user context from JWT"""
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
family_id = getattr(request.state, "family_id", None)
|
||||
|
||||
if not user_id or not family_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||
|
||||
# Load user role from DB (simplified for MVP)
|
||||
# In production: Load from users->family_members join
|
||||
role = MemberRole.OWNER # TODO: Load from DB
|
||||
permissions = RBACEngine.get_permissions(role)
|
||||
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
family_id=family_id,
|
||||
role=role,
|
||||
permissions=permissions,
|
||||
family_ids=[family_id],
|
||||
device_id=getattr(request.state, "device_id", None),
|
||||
client_id=getattr(request.state, "client_id", None),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TransactionResponse,
|
||||
status_code=201,
|
||||
summary="Create new transaction",
|
||||
)
|
||||
async def create_transaction(
|
||||
request: TransactionCreateRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TransactionResponse:
|
||||
"""
|
||||
Create a new financial transaction.
|
||||
|
||||
**Request Headers Required:**
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot | web_frontend | ios_app
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
|
||||
**Response:**
|
||||
- If amount ≤ threshold: status="executed" immediately
|
||||
- If amount > threshold: status="pending_approval", requires confirmation
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.created
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.create_transaction(
|
||||
user_context=user_context,
|
||||
family_id=request.family_id,
|
||||
from_wallet_id=request.from_wallet_id,
|
||||
to_wallet_id=request.to_wallet_id,
|
||||
amount=request.amount,
|
||||
category_id=request.category_id,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.warning(f"Permission denied: {e} (user: {user_context.user_id})")
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating transaction: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transaction_id}/confirm",
|
||||
response_model=TransactionResponse,
|
||||
summary="Confirm pending transaction",
|
||||
)
|
||||
async def confirm_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionConfirmRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Approve a pending transaction for execution.
|
||||
|
||||
Only owner or designated approver can confirm.
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.confirmed
|
||||
- transaction.executed
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.confirm_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
confirmation_token=request.confirmation_token,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{transaction_id}",
|
||||
response_model=dict,
|
||||
summary="Reverse (cancel) transaction",
|
||||
)
|
||||
async def reverse_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionReverseRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Reverse (cancel) executed transaction.
|
||||
|
||||
Creates a compensation (reverse) transaction instead of deletion.
|
||||
Original transaction status changes to "reversed".
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.reversed
|
||||
- transaction.created (compensation)
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.reverse_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=List[TransactionResponse],
|
||||
summary="List transactions",
|
||||
)
|
||||
async def list_transactions(
|
||||
family_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all transactions for family.
|
||||
|
||||
**Filtering:**
|
||||
- ?family_id=1
|
||||
- ?wallet_id=10
|
||||
- ?category_id=5
|
||||
- ?status=executed
|
||||
- ?from_date=2023-12-01&to_date=2023-12-31
|
||||
|
||||
**Pagination:**
|
||||
- ?skip=0&limit=20
|
||||
"""
|
||||
|
||||
# Verify family access
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transactions = db.query(Transaction).filter(
|
||||
Transaction.family_id == family_id,
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return [TransactionResponse.from_orm(t) for t in transactions]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{transaction_id}",
|
||||
response_model=TransactionResponse,
|
||||
summary="Get transaction details",
|
||||
)
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed transaction information"""
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transaction = db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
|
||||
return TransactionResponse.from_orm(transaction)
|
||||
275
.history/app/api/transactions_20251210212821.py
Normal file
275
.history/app/api/transactions_20251210212821.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Transaction API Endpoints - CRUD + Approval Workflow
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.transaction_service import TransactionService
|
||||
from app.security.rbac import UserContext, RBACEngine, MemberRole, Permission
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class TransactionCreateRequest(BaseModel):
|
||||
family_id: int
|
||||
from_wallet_id: Optional[int] = None
|
||||
to_wallet_id: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
amount: Decimal
|
||||
description: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"to_wallet_id": 11,
|
||||
"category_id": 5,
|
||||
"amount": 50.00,
|
||||
"description": "Rent payment",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
status: str # draft, pending_approval, executed, reversed
|
||||
amount: Decimal
|
||||
description: str
|
||||
confirmation_required: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionConfirmRequest(BaseModel):
|
||||
confirmation_token: Optional[str] = None
|
||||
|
||||
|
||||
class TransactionReverseRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
# Dependency to extract user context
|
||||
async def get_user_context(request: Request) -> UserContext:
|
||||
"""Extract user context from JWT"""
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
family_id = getattr(request.state, "family_id", None)
|
||||
|
||||
if not user_id or not family_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||
|
||||
# Load user role from DB (simplified for MVP)
|
||||
# In production: Load from users->family_members join
|
||||
role = MemberRole.OWNER # TODO: Load from DB
|
||||
permissions = RBACEngine.get_permissions(role)
|
||||
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
family_id=family_id,
|
||||
role=role,
|
||||
permissions=permissions,
|
||||
family_ids=[family_id],
|
||||
device_id=getattr(request.state, "device_id", None),
|
||||
client_id=getattr(request.state, "client_id", None),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TransactionResponse,
|
||||
status_code=201,
|
||||
summary="Create new transaction",
|
||||
)
|
||||
async def create_transaction(
|
||||
request: TransactionCreateRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TransactionResponse:
|
||||
"""
|
||||
Create a new financial transaction.
|
||||
|
||||
**Request Headers Required:**
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot | web_frontend | ios_app
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
|
||||
**Response:**
|
||||
- If amount ≤ threshold: status="executed" immediately
|
||||
- If amount > threshold: status="pending_approval", requires confirmation
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.created
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.create_transaction(
|
||||
user_context=user_context,
|
||||
family_id=request.family_id,
|
||||
from_wallet_id=request.from_wallet_id,
|
||||
to_wallet_id=request.to_wallet_id,
|
||||
amount=request.amount,
|
||||
category_id=request.category_id,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.warning(f"Permission denied: {e} (user: {user_context.user_id})")
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating transaction: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transaction_id}/confirm",
|
||||
response_model=TransactionResponse,
|
||||
summary="Confirm pending transaction",
|
||||
)
|
||||
async def confirm_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionConfirmRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Approve a pending transaction for execution.
|
||||
|
||||
Only owner or designated approver can confirm.
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.confirmed
|
||||
- transaction.executed
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.confirm_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
confirmation_token=request.confirmation_token,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{transaction_id}",
|
||||
response_model=dict,
|
||||
summary="Reverse (cancel) transaction",
|
||||
)
|
||||
async def reverse_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionReverseRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Reverse (cancel) executed transaction.
|
||||
|
||||
Creates a compensation (reverse) transaction instead of deletion.
|
||||
Original transaction status changes to "reversed".
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.reversed
|
||||
- transaction.created (compensation)
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.reverse_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=List[TransactionResponse],
|
||||
summary="List transactions",
|
||||
)
|
||||
async def list_transactions(
|
||||
family_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all transactions for family.
|
||||
|
||||
**Filtering:**
|
||||
- ?family_id=1
|
||||
- ?wallet_id=10
|
||||
- ?category_id=5
|
||||
- ?status=executed
|
||||
- ?from_date=2023-12-01&to_date=2023-12-31
|
||||
|
||||
**Pagination:**
|
||||
- ?skip=0&limit=20
|
||||
"""
|
||||
|
||||
# Verify family access
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transactions = db.query(Transaction).filter(
|
||||
Transaction.family_id == family_id,
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return [TransactionResponse.from_orm(t) for t in transactions]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{transaction_id}",
|
||||
response_model=TransactionResponse,
|
||||
summary="Get transaction details",
|
||||
)
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed transaction information"""
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transaction = db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
|
||||
return TransactionResponse.from_orm(transaction)
|
||||
275
.history/app/api/transactions_20251210212833.py
Normal file
275
.history/app/api/transactions_20251210212833.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Transaction API Endpoints - CRUD + Approval Workflow
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.transaction_service import TransactionService
|
||||
from app.security.rbac import UserContext, RBACEngine, MemberRole, Permission
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class TransactionCreateRequest(BaseModel):
|
||||
family_id: int
|
||||
from_wallet_id: Optional[int] = None
|
||||
to_wallet_id: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
amount: Decimal
|
||||
description: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"to_wallet_id": 11,
|
||||
"category_id": 5,
|
||||
"amount": 50.00,
|
||||
"description": "Rent payment",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
status: str # draft, pending_approval, executed, reversed
|
||||
amount: Decimal
|
||||
description: str
|
||||
confirmation_required: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionConfirmRequest(BaseModel):
|
||||
confirmation_token: Optional[str] = None
|
||||
|
||||
|
||||
class TransactionReverseRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
# Dependency to extract user context
|
||||
async def get_user_context(request: Request) -> UserContext:
|
||||
"""Extract user context from JWT"""
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
family_id = getattr(request.state, "family_id", None)
|
||||
|
||||
if not user_id or not family_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||
|
||||
# Load user role from DB (simplified for MVP)
|
||||
# In production: Load from users->family_members join
|
||||
role = MemberRole.OWNER # TODO: Load from DB
|
||||
permissions = RBACEngine.get_permissions(role)
|
||||
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
family_id=family_id,
|
||||
role=role,
|
||||
permissions=permissions,
|
||||
family_ids=[family_id],
|
||||
device_id=getattr(request.state, "device_id", None),
|
||||
client_id=getattr(request.state, "client_id", None),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TransactionResponse,
|
||||
status_code=201,
|
||||
summary="Create new transaction",
|
||||
)
|
||||
async def create_transaction(
|
||||
request: TransactionCreateRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TransactionResponse:
|
||||
"""
|
||||
Create a new financial transaction.
|
||||
|
||||
**Request Headers Required:**
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot | web_frontend | ios_app
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
|
||||
**Response:**
|
||||
- If amount ≤ threshold: status="executed" immediately
|
||||
- If amount > threshold: status="pending_approval", requires confirmation
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.created
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.create_transaction(
|
||||
user_context=user_context,
|
||||
family_id=request.family_id,
|
||||
from_wallet_id=request.from_wallet_id,
|
||||
to_wallet_id=request.to_wallet_id,
|
||||
amount=request.amount,
|
||||
category_id=request.category_id,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.warning(f"Permission denied: {e} (user: {user_context.user_id})")
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating transaction: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transaction_id}/confirm",
|
||||
response_model=TransactionResponse,
|
||||
summary="Confirm pending transaction",
|
||||
)
|
||||
async def confirm_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionConfirmRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Approve a pending transaction for execution.
|
||||
|
||||
Only owner or designated approver can confirm.
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.confirmed
|
||||
- transaction.executed
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.confirm_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
confirmation_token=request.confirmation_token,
|
||||
)
|
||||
|
||||
return TransactionResponse(**result)
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{transaction_id}",
|
||||
response_model=dict,
|
||||
summary="Reverse (cancel) transaction",
|
||||
)
|
||||
async def reverse_transaction(
|
||||
transaction_id: int,
|
||||
request: TransactionReverseRequest,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Reverse (cancel) executed transaction.
|
||||
|
||||
Creates a compensation (reverse) transaction instead of deletion.
|
||||
Original transaction status changes to "reversed".
|
||||
|
||||
**Events Emitted:**
|
||||
- transaction.reversed
|
||||
- transaction.created (compensation)
|
||||
"""
|
||||
|
||||
try:
|
||||
service = TransactionService(db)
|
||||
result = await service.reverse_transaction(
|
||||
user_context=user_context,
|
||||
transaction_id=transaction_id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except (PermissionError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=List[TransactionResponse],
|
||||
summary="List transactions",
|
||||
)
|
||||
async def list_transactions(
|
||||
family_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List all transactions for family.
|
||||
|
||||
**Filtering:**
|
||||
- ?family_id=1
|
||||
- ?wallet_id=10
|
||||
- ?category_id=5
|
||||
- ?status=executed
|
||||
- ?from_date=2023-12-01&to_date=2023-12-31
|
||||
|
||||
**Pagination:**
|
||||
- ?skip=0&limit=20
|
||||
"""
|
||||
|
||||
# Verify family access
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transactions = db.query(Transaction).filter(
|
||||
Transaction.family_id == family_id,
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return [TransactionResponse.from_orm(t) for t in transactions]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{transaction_id}",
|
||||
response_model=TransactionResponse,
|
||||
summary="Get transaction details",
|
||||
)
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
user_context: UserContext = Depends(get_user_context),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed transaction information"""
|
||||
|
||||
from app.db.models import Transaction
|
||||
|
||||
transaction = db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
|
||||
return TransactionResponse.from_orm(transaction)
|
||||
6
.history/app/bot/__init___20251210201700.py
Normal file
6
.history/app/bot/__init___20251210201700.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Bot module"""
|
||||
|
||||
from app.bot.handlers import register_handlers
|
||||
from app.bot.keyboards import *
|
||||
|
||||
__all__ = ["register_handlers"]
|
||||
6
.history/app/bot/__init___20251210202255.py
Normal file
6
.history/app/bot/__init___20251210202255.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Bot module"""
|
||||
|
||||
from app.bot.handlers import register_handlers
|
||||
from app.bot.keyboards import *
|
||||
|
||||
__all__ = ["register_handlers"]
|
||||
329
.history/app/bot/client_20251210210501.py
Normal file
329
.history/app/bot/client_20251210210501.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Telegram Bot - API-First Client
|
||||
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotClient:
|
||||
"""
|
||||
Telegram Bot that communicates exclusively via API calls.
|
||||
|
||||
Features:
|
||||
- User authentication via JWT tokens stored in Redis
|
||||
- All operations through API (no direct DB access)
|
||||
- Async HTTP requests with aiohttp
|
||||
- Event listening via Redis Streams
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||
self.bot = Bot(token=bot_token)
|
||||
self.dp = Dispatcher()
|
||||
self.api_base_url = api_base_url
|
||||
self.redis_client = redis_client
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Register handlers
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Register message handlers"""
|
||||
self.dp.message.register(self.cmd_start, Command("start"))
|
||||
self.dp.message.register(self.cmd_help, Command("help"))
|
||||
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
# Start polling
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
finally:
|
||||
await self.session.close()
|
||||
|
||||
# ========== Handler: /start (Binding) ==========
|
||||
async def cmd_start(self, message: Message):
|
||||
"""
|
||||
/start - Begin Telegram binding process.
|
||||
|
||||
Flow:
|
||||
1. Check if user already bound
|
||||
2. If not: Generate binding code
|
||||
3. Send link to user
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Check if already bound
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
existing_token = self.redis_client.get(jwt_key)
|
||||
|
||||
if existing_token:
|
||||
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
|
||||
return
|
||||
|
||||
# Generate binding code
|
||||
try:
|
||||
code = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/start",
|
||||
data={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
binding_code = code.get("code")
|
||||
|
||||
# Send binding link to user
|
||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
||||
|
||||
await message.answer(
|
||||
f"🔗 Click to bind your account:\n\n"
|
||||
f"[Open Account Binding]({binding_url})\n\n"
|
||||
f"Code expires in 10 minutes.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Binding start error: {e}")
|
||||
await message.answer("❌ Binding failed. Try again later.")
|
||||
|
||||
# ========== Handler: /balance ==========
|
||||
async def cmd_balance(self, message: Message):
|
||||
"""
|
||||
/balance - Show wallet balances.
|
||||
|
||||
Requires:
|
||||
- User must be bound (JWT token in Redis)
|
||||
- API call with JWT auth
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Get JWT token
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||
wallets = await self._api_call(
|
||||
method="GET",
|
||||
endpoint="/api/v1/wallets/summary",
|
||||
jwt_token=jwt_token,
|
||||
params={"family_id": 1}, # TODO: Get from context
|
||||
)
|
||||
|
||||
# Format response
|
||||
response = "💰 **Your Wallets:**\n\n"
|
||||
for wallet in wallets:
|
||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||
|
||||
await message.answer(response, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance fetch error: {e}")
|
||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||
|
||||
# ========== Handler: /add (Create Transaction) ==========
|
||||
async def cmd_add_transaction(self, message: Message):
|
||||
"""
|
||||
/add - Create new transaction (interactive).
|
||||
|
||||
Flow:
|
||||
1. Ask for amount
|
||||
2. Ask for category
|
||||
3. Ask for wallet (from/to)
|
||||
4. Create transaction via API
|
||||
"""
|
||||
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start first.")
|
||||
return
|
||||
|
||||
# Store conversation state in Redis
|
||||
state_key = f"chat_id:{chat_id}:state"
|
||||
self.redis_client.setex(state_key, 300, json.dumps({
|
||||
"action": "add_transaction",
|
||||
"step": 1, # Waiting for amount
|
||||
}))
|
||||
|
||||
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||
|
||||
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||
"""Handle transaction creation in steps"""
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
step = state.get("step", 1)
|
||||
|
||||
if step == 1:
|
||||
# Amount entered
|
||||
try:
|
||||
amount = Decimal(message.text)
|
||||
except:
|
||||
await message.answer("❌ Invalid amount. Try again.")
|
||||
return
|
||||
|
||||
state["amount"] = float(amount)
|
||||
state["step"] = 2
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||
|
||||
elif step == 2:
|
||||
# Category selected
|
||||
state["category"] = message.text
|
||||
state["step"] = 3
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||
|
||||
elif step == 3:
|
||||
# Notes entered (or skipped)
|
||||
state["notes"] = message.text if message.text != "/skip" else ""
|
||||
|
||||
# Create transaction via API
|
||||
try:
|
||||
result = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/transactions",
|
||||
jwt_token=jwt_token,
|
||||
data={
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"amount": state["amount"],
|
||||
"category_id": 5, # TODO: Map category
|
||||
"description": state["category"],
|
||||
"notes": state["notes"],
|
||||
}
|
||||
)
|
||||
|
||||
tx_id = result.get("id")
|
||||
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transaction creation error: {e}")
|
||||
await message.answer("❌ Creation failed. Try again.")
|
||||
|
||||
finally:
|
||||
# Clean up state
|
||||
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||
|
||||
# ========== Handler: /help ==========
|
||||
async def cmd_help(self, message: Message):
|
||||
"""Show available commands"""
|
||||
help_text = """
|
||||
🤖 **Finance Bot Commands:**
|
||||
|
||||
/start - Bind your Telegram account
|
||||
/balance - Show wallet balances
|
||||
/add - Add new transaction
|
||||
/reports - View reports (daily/weekly/monthly)
|
||||
/help - This message
|
||||
"""
|
||||
await message.answer(help_text, parse_mode="Markdown")
|
||||
|
||||
# ========== API Communication Methods ==========
|
||||
async def _api_call(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Dict = None,
|
||||
params: Dict = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
use_jwt: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API with proper auth headers.
|
||||
|
||||
Headers:
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
"""
|
||||
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized")
|
||||
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"X-Client-Id": "telegram_bot",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add JWT if provided
|
||||
if use_jwt and jwt_token:
|
||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||
|
||||
# Add HMAC signature
|
||||
timestamp = int(time.time())
|
||||
headers["X-Timestamp"] = str(timestamp)
|
||||
|
||||
signature = hmac_manager.create_signature(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
timestamp=timestamp,
|
||||
body=data,
|
||||
)
|
||||
headers["X-Signature"] = signature
|
||||
|
||||
# Make request
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"API error {response.status}: {error_text}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||
"""Get JWT token for chat_id from Redis"""
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
token = self.redis_client.get(jwt_key)
|
||||
return token.decode() if token else None
|
||||
|
||||
async def send_notification(self, chat_id: int, message: str):
|
||||
"""Send notification to user"""
|
||||
try:
|
||||
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||
|
||||
|
||||
# Bot factory
|
||||
async def create_telegram_bot(
|
||||
bot_token: str,
|
||||
api_base_url: str,
|
||||
redis_client: redis.Redis,
|
||||
) -> TelegramBotClient:
|
||||
"""Create and start Telegram bot"""
|
||||
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||
return bot
|
||||
329
.history/app/bot/client_20251210210906.py
Normal file
329
.history/app/bot/client_20251210210906.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Telegram Bot - API-First Client
|
||||
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotClient:
|
||||
"""
|
||||
Telegram Bot that communicates exclusively via API calls.
|
||||
|
||||
Features:
|
||||
- User authentication via JWT tokens stored in Redis
|
||||
- All operations through API (no direct DB access)
|
||||
- Async HTTP requests with aiohttp
|
||||
- Event listening via Redis Streams
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||
self.bot = Bot(token=bot_token)
|
||||
self.dp = Dispatcher()
|
||||
self.api_base_url = api_base_url
|
||||
self.redis_client = redis_client
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Register handlers
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Register message handlers"""
|
||||
self.dp.message.register(self.cmd_start, Command("start"))
|
||||
self.dp.message.register(self.cmd_help, Command("help"))
|
||||
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
# Start polling
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
finally:
|
||||
await self.session.close()
|
||||
|
||||
# ========== Handler: /start (Binding) ==========
|
||||
async def cmd_start(self, message: Message):
|
||||
"""
|
||||
/start - Begin Telegram binding process.
|
||||
|
||||
Flow:
|
||||
1. Check if user already bound
|
||||
2. If not: Generate binding code
|
||||
3. Send link to user
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Check if already bound
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
existing_token = self.redis_client.get(jwt_key)
|
||||
|
||||
if existing_token:
|
||||
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
|
||||
return
|
||||
|
||||
# Generate binding code
|
||||
try:
|
||||
code = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/start",
|
||||
data={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
binding_code = code.get("code")
|
||||
|
||||
# Send binding link to user
|
||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
||||
|
||||
await message.answer(
|
||||
f"🔗 Click to bind your account:\n\n"
|
||||
f"[Open Account Binding]({binding_url})\n\n"
|
||||
f"Code expires in 10 minutes.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Binding start error: {e}")
|
||||
await message.answer("❌ Binding failed. Try again later.")
|
||||
|
||||
# ========== Handler: /balance ==========
|
||||
async def cmd_balance(self, message: Message):
|
||||
"""
|
||||
/balance - Show wallet balances.
|
||||
|
||||
Requires:
|
||||
- User must be bound (JWT token in Redis)
|
||||
- API call with JWT auth
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Get JWT token
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||
wallets = await self._api_call(
|
||||
method="GET",
|
||||
endpoint="/api/v1/wallets/summary",
|
||||
jwt_token=jwt_token,
|
||||
params={"family_id": 1}, # TODO: Get from context
|
||||
)
|
||||
|
||||
# Format response
|
||||
response = "💰 **Your Wallets:**\n\n"
|
||||
for wallet in wallets:
|
||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||
|
||||
await message.answer(response, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance fetch error: {e}")
|
||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||
|
||||
# ========== Handler: /add (Create Transaction) ==========
|
||||
async def cmd_add_transaction(self, message: Message):
|
||||
"""
|
||||
/add - Create new transaction (interactive).
|
||||
|
||||
Flow:
|
||||
1. Ask for amount
|
||||
2. Ask for category
|
||||
3. Ask for wallet (from/to)
|
||||
4. Create transaction via API
|
||||
"""
|
||||
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start first.")
|
||||
return
|
||||
|
||||
# Store conversation state in Redis
|
||||
state_key = f"chat_id:{chat_id}:state"
|
||||
self.redis_client.setex(state_key, 300, json.dumps({
|
||||
"action": "add_transaction",
|
||||
"step": 1, # Waiting for amount
|
||||
}))
|
||||
|
||||
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||
|
||||
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||
"""Handle transaction creation in steps"""
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
step = state.get("step", 1)
|
||||
|
||||
if step == 1:
|
||||
# Amount entered
|
||||
try:
|
||||
amount = Decimal(message.text)
|
||||
except:
|
||||
await message.answer("❌ Invalid amount. Try again.")
|
||||
return
|
||||
|
||||
state["amount"] = float(amount)
|
||||
state["step"] = 2
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||
|
||||
elif step == 2:
|
||||
# Category selected
|
||||
state["category"] = message.text
|
||||
state["step"] = 3
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||
|
||||
elif step == 3:
|
||||
# Notes entered (or skipped)
|
||||
state["notes"] = message.text if message.text != "/skip" else ""
|
||||
|
||||
# Create transaction via API
|
||||
try:
|
||||
result = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/transactions",
|
||||
jwt_token=jwt_token,
|
||||
data={
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"amount": state["amount"],
|
||||
"category_id": 5, # TODO: Map category
|
||||
"description": state["category"],
|
||||
"notes": state["notes"],
|
||||
}
|
||||
)
|
||||
|
||||
tx_id = result.get("id")
|
||||
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transaction creation error: {e}")
|
||||
await message.answer("❌ Creation failed. Try again.")
|
||||
|
||||
finally:
|
||||
# Clean up state
|
||||
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||
|
||||
# ========== Handler: /help ==========
|
||||
async def cmd_help(self, message: Message):
|
||||
"""Show available commands"""
|
||||
help_text = """
|
||||
🤖 **Finance Bot Commands:**
|
||||
|
||||
/start - Bind your Telegram account
|
||||
/balance - Show wallet balances
|
||||
/add - Add new transaction
|
||||
/reports - View reports (daily/weekly/monthly)
|
||||
/help - This message
|
||||
"""
|
||||
await message.answer(help_text, parse_mode="Markdown")
|
||||
|
||||
# ========== API Communication Methods ==========
|
||||
async def _api_call(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Dict = None,
|
||||
params: Dict = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
use_jwt: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API with proper auth headers.
|
||||
|
||||
Headers:
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
"""
|
||||
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized")
|
||||
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"X-Client-Id": "telegram_bot",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add JWT if provided
|
||||
if use_jwt and jwt_token:
|
||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||
|
||||
# Add HMAC signature
|
||||
timestamp = int(time.time())
|
||||
headers["X-Timestamp"] = str(timestamp)
|
||||
|
||||
signature = hmac_manager.create_signature(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
timestamp=timestamp,
|
||||
body=data,
|
||||
)
|
||||
headers["X-Signature"] = signature
|
||||
|
||||
# Make request
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"API error {response.status}: {error_text}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||
"""Get JWT token for chat_id from Redis"""
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
token = self.redis_client.get(jwt_key)
|
||||
return token.decode() if token else None
|
||||
|
||||
async def send_notification(self, chat_id: int, message: str):
|
||||
"""Send notification to user"""
|
||||
try:
|
||||
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||
|
||||
|
||||
# Bot factory
|
||||
async def create_telegram_bot(
|
||||
bot_token: str,
|
||||
api_base_url: str,
|
||||
redis_client: redis.Redis,
|
||||
) -> TelegramBotClient:
|
||||
"""Create and start Telegram bot"""
|
||||
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||
return bot
|
||||
332
.history/app/bot/client_20251210215954.py
Normal file
332
.history/app/bot/client_20251210215954.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Telegram Bot - API-First Client
|
||||
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
import time
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotClient:
|
||||
"""
|
||||
Telegram Bot that communicates exclusively via API calls.
|
||||
|
||||
Features:
|
||||
- User authentication via JWT tokens stored in Redis
|
||||
- All operations through API (no direct DB access)
|
||||
- Async HTTP requests with aiohttp
|
||||
- Event listening via Redis Streams
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||
self.bot = Bot(token=bot_token)
|
||||
self.dp = Dispatcher()
|
||||
self.api_base_url = api_base_url
|
||||
self.redis_client = redis_client
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Register handlers
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Register message handlers"""
|
||||
self.dp.message.register(self.cmd_start, Command("start"))
|
||||
self.dp.message.register(self.cmd_help, Command("help"))
|
||||
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
# Start polling
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
finally:
|
||||
await self.session.close()
|
||||
|
||||
# ========== Handler: /start (Binding) ==========
|
||||
async def cmd_start(self, message: Message):
|
||||
"""
|
||||
/start - Begin Telegram binding process.
|
||||
|
||||
Flow:
|
||||
1. Check if user already bound
|
||||
2. If not: Generate binding code
|
||||
3. Send link to user
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Check if already bound
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
existing_token = self.redis_client.get(jwt_key)
|
||||
|
||||
if existing_token:
|
||||
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
|
||||
return
|
||||
|
||||
# Generate binding code
|
||||
try:
|
||||
code = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/start",
|
||||
data={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
binding_code = code.get("code")
|
||||
|
||||
# Send binding link to user
|
||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
||||
|
||||
await message.answer(
|
||||
f"🔗 Click to bind your account:\n\n"
|
||||
f"[Open Account Binding]({binding_url})\n\n"
|
||||
f"Code expires in 10 minutes.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Binding start error: {e}")
|
||||
await message.answer("❌ Binding failed. Try again later.")
|
||||
|
||||
# ========== Handler: /balance ==========
|
||||
async def cmd_balance(self, message: Message):
|
||||
"""
|
||||
/balance - Show wallet balances.
|
||||
|
||||
Requires:
|
||||
- User must be bound (JWT token in Redis)
|
||||
- API call with JWT auth
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Get JWT token
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||
wallets = await self._api_call(
|
||||
method="GET",
|
||||
endpoint="/api/v1/wallets/summary",
|
||||
jwt_token=jwt_token,
|
||||
params={"family_id": 1}, # TODO: Get from context
|
||||
)
|
||||
|
||||
# Format response
|
||||
response = "💰 **Your Wallets:**\n\n"
|
||||
for wallet in wallets:
|
||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||
|
||||
await message.answer(response, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance fetch error: {e}")
|
||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||
|
||||
# ========== Handler: /add (Create Transaction) ==========
|
||||
async def cmd_add_transaction(self, message: Message):
|
||||
"""
|
||||
/add - Create new transaction (interactive).
|
||||
|
||||
Flow:
|
||||
1. Ask for amount
|
||||
2. Ask for category
|
||||
3. Ask for wallet (from/to)
|
||||
4. Create transaction via API
|
||||
"""
|
||||
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start first.")
|
||||
return
|
||||
|
||||
# Store conversation state in Redis
|
||||
state_key = f"chat_id:{chat_id}:state"
|
||||
self.redis_client.setex(state_key, 300, json.dumps({
|
||||
"action": "add_transaction",
|
||||
"step": 1, # Waiting for amount
|
||||
}))
|
||||
|
||||
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||
|
||||
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||
"""Handle transaction creation in steps"""
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
step = state.get("step", 1)
|
||||
|
||||
if step == 1:
|
||||
# Amount entered
|
||||
try:
|
||||
amount = Decimal(message.text)
|
||||
except:
|
||||
await message.answer("❌ Invalid amount. Try again.")
|
||||
return
|
||||
|
||||
state["amount"] = float(amount)
|
||||
state["step"] = 2
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||
|
||||
elif step == 2:
|
||||
# Category selected
|
||||
state["category"] = message.text
|
||||
state["step"] = 3
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||
|
||||
elif step == 3:
|
||||
# Notes entered (or skipped)
|
||||
state["notes"] = message.text if message.text != "/skip" else ""
|
||||
|
||||
# Create transaction via API
|
||||
try:
|
||||
result = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/transactions",
|
||||
jwt_token=jwt_token,
|
||||
data={
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"amount": state["amount"],
|
||||
"category_id": 5, # TODO: Map category
|
||||
"description": state["category"],
|
||||
"notes": state["notes"],
|
||||
}
|
||||
)
|
||||
|
||||
tx_id = result.get("id")
|
||||
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transaction creation error: {e}")
|
||||
await message.answer("❌ Creation failed. Try again.")
|
||||
|
||||
finally:
|
||||
# Clean up state
|
||||
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||
|
||||
# ========== Handler: /help ==========
|
||||
async def cmd_help(self, message: Message):
|
||||
"""Show available commands"""
|
||||
help_text = """
|
||||
🤖 **Finance Bot Commands:**
|
||||
|
||||
/start - Bind your Telegram account
|
||||
/balance - Show wallet balances
|
||||
/add - Add new transaction
|
||||
/reports - View reports (daily/weekly/monthly)
|
||||
/help - This message
|
||||
"""
|
||||
await message.answer(help_text, parse_mode="Markdown")
|
||||
|
||||
# ========== API Communication Methods ==========
|
||||
async def _api_call(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Dict = None,
|
||||
params: Dict = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
use_jwt: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API with proper auth headers.
|
||||
|
||||
Headers:
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
"""
|
||||
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized")
|
||||
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"X-Client-Id": "telegram_bot",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add JWT if provided
|
||||
if use_jwt and jwt_token:
|
||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||
|
||||
# Add HMAC signature
|
||||
timestamp = int(time.time())
|
||||
headers["X-Timestamp"] = str(timestamp)
|
||||
|
||||
signature = hmac_manager.create_signature(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
timestamp=timestamp,
|
||||
body=data,
|
||||
)
|
||||
headers["X-Signature"] = signature
|
||||
|
||||
# Make request
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"API error {response.status}: {error_text}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||
"""Get JWT token for chat_id from Redis"""
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
token = self.redis_client.get(jwt_key)
|
||||
return token.decode() if token else None
|
||||
|
||||
async def send_notification(self, chat_id: int, message: str):
|
||||
"""Send notification to user"""
|
||||
try:
|
||||
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||
|
||||
|
||||
# Bot factory
|
||||
async def create_telegram_bot(
|
||||
bot_token: str,
|
||||
api_base_url: str,
|
||||
redis_client: redis.Redis,
|
||||
) -> TelegramBotClient:
|
||||
"""Create and start Telegram bot"""
|
||||
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||
return bot
|
||||
328
.history/app/bot/client_20251210215958.py
Normal file
328
.history/app/bot/client_20251210215958.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Telegram Bot - API-First Client
|
||||
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
import time
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotClient:
|
||||
"""
|
||||
Telegram Bot that communicates exclusively via API calls.
|
||||
|
||||
Features:
|
||||
- User authentication via JWT tokens stored in Redis
|
||||
- All operations through API (no direct DB access)
|
||||
- Async HTTP requests with aiohttp
|
||||
- Event listening via Redis Streams
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||
self.bot = Bot(token=bot_token)
|
||||
self.dp = Dispatcher()
|
||||
self.api_base_url = api_base_url
|
||||
self.redis_client = redis_client
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Register handlers
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Register message handlers"""
|
||||
self.dp.message.register(self.cmd_start, Command("start"))
|
||||
self.dp.message.register(self.cmd_help, Command("help"))
|
||||
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
# Start polling
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
finally:
|
||||
await self.session.close()
|
||||
|
||||
# ========== Handler: /start (Binding) ==========
|
||||
async def cmd_start(self, message: Message):
|
||||
"""
|
||||
/start - Begin Telegram binding process.
|
||||
|
||||
Flow:
|
||||
1. Check if user already bound
|
||||
2. If not: Generate binding code
|
||||
3. Send link to user
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Check if already bound
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
existing_token = self.redis_client.get(jwt_key)
|
||||
|
||||
if existing_token:
|
||||
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
|
||||
return
|
||||
|
||||
# Generate binding code
|
||||
try:
|
||||
code = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/start",
|
||||
data={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
binding_code = code.get("code")
|
||||
|
||||
# Send binding link to user
|
||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
||||
|
||||
await message.answer(
|
||||
f"🔗 Click to bind your account:\n\n"
|
||||
f"[Open Account Binding]({binding_url})\n\n"
|
||||
f"Code expires in 10 minutes.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Binding start error: {e}")
|
||||
await message.answer("❌ Binding failed. Try again later.")
|
||||
|
||||
# ========== Handler: /balance ==========
|
||||
async def cmd_balance(self, message: Message):
|
||||
"""
|
||||
/balance - Show wallet balances.
|
||||
|
||||
Requires:
|
||||
- User must be bound (JWT token in Redis)
|
||||
- API call with JWT auth
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Get JWT token
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||
wallets = await self._api_call(
|
||||
method="GET",
|
||||
endpoint="/api/v1/wallets/summary",
|
||||
jwt_token=jwt_token,
|
||||
params={"family_id": 1}, # TODO: Get from context
|
||||
)
|
||||
|
||||
# Format response
|
||||
response = "💰 **Your Wallets:**\n\n"
|
||||
for wallet in wallets:
|
||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||
|
||||
await message.answer(response, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance fetch error: {e}")
|
||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||
|
||||
# ========== Handler: /add (Create Transaction) ==========
|
||||
async def cmd_add_transaction(self, message: Message):
|
||||
"""
|
||||
/add - Create new transaction (interactive).
|
||||
|
||||
Flow:
|
||||
1. Ask for amount
|
||||
2. Ask for category
|
||||
3. Ask for wallet (from/to)
|
||||
4. Create transaction via API
|
||||
"""
|
||||
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start first.")
|
||||
return
|
||||
|
||||
# Store conversation state in Redis
|
||||
state_key = f"chat_id:{chat_id}:state"
|
||||
self.redis_client.setex(state_key, 300, json.dumps({
|
||||
"action": "add_transaction",
|
||||
"step": 1, # Waiting for amount
|
||||
}))
|
||||
|
||||
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||
|
||||
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||
"""Handle transaction creation in steps"""
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
step = state.get("step", 1)
|
||||
|
||||
if step == 1:
|
||||
# Amount entered
|
||||
try:
|
||||
amount = Decimal(message.text)
|
||||
except:
|
||||
await message.answer("❌ Invalid amount. Try again.")
|
||||
return
|
||||
|
||||
state["amount"] = float(amount)
|
||||
state["step"] = 2
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||
|
||||
elif step == 2:
|
||||
# Category selected
|
||||
state["category"] = message.text
|
||||
state["step"] = 3
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||
|
||||
elif step == 3:
|
||||
# Notes entered (or skipped)
|
||||
state["notes"] = message.text if message.text != "/skip" else ""
|
||||
|
||||
# Create transaction via API
|
||||
try:
|
||||
result = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/transactions",
|
||||
jwt_token=jwt_token,
|
||||
data={
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"amount": state["amount"],
|
||||
"category_id": 5, # TODO: Map category
|
||||
"description": state["category"],
|
||||
"notes": state["notes"],
|
||||
}
|
||||
)
|
||||
|
||||
tx_id = result.get("id")
|
||||
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transaction creation error: {e}")
|
||||
await message.answer("❌ Creation failed. Try again.")
|
||||
|
||||
finally:
|
||||
# Clean up state
|
||||
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||
|
||||
# ========== Handler: /help ==========
|
||||
async def cmd_help(self, message: Message):
|
||||
"""Show available commands"""
|
||||
help_text = """
|
||||
🤖 **Finance Bot Commands:**
|
||||
|
||||
/start - Bind your Telegram account
|
||||
/balance - Show wallet balances
|
||||
/add - Add new transaction
|
||||
/reports - View reports (daily/weekly/monthly)
|
||||
/help - This message
|
||||
"""
|
||||
await message.answer(help_text, parse_mode="Markdown")
|
||||
|
||||
# ========== API Communication Methods ==========
|
||||
async def _api_call(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Dict = None,
|
||||
params: Dict = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
use_jwt: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API with proper auth headers.
|
||||
|
||||
Headers:
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
"""
|
||||
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized")
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"X-Client-Id": "telegram_bot",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add JWT if provided
|
||||
if use_jwt and jwt_token:
|
||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||
|
||||
# Add HMAC signature
|
||||
timestamp = int(time.time())
|
||||
headers["X-Timestamp"] = str(timestamp)
|
||||
|
||||
signature = hmac_manager.create_signature(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
timestamp=timestamp,
|
||||
body=data,
|
||||
)
|
||||
headers["X-Signature"] = signature
|
||||
|
||||
# Make request
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"API error {response.status}: {error_text}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||
"""Get JWT token for chat_id from Redis"""
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
token = self.redis_client.get(jwt_key)
|
||||
return token.decode() if token else None
|
||||
|
||||
async def send_notification(self, chat_id: int, message: str):
|
||||
"""Send notification to user"""
|
||||
try:
|
||||
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||
|
||||
|
||||
# Bot factory
|
||||
async def create_telegram_bot(
|
||||
bot_token: str,
|
||||
api_base_url: str,
|
||||
redis_client: redis.Redis,
|
||||
) -> TelegramBotClient:
|
||||
"""Create and start Telegram bot"""
|
||||
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||
return bot
|
||||
328
.history/app/bot/client_20251210220144.py
Normal file
328
.history/app/bot/client_20251210220144.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Telegram Bot - API-First Client
|
||||
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
import time
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotClient:
|
||||
"""
|
||||
Telegram Bot that communicates exclusively via API calls.
|
||||
|
||||
Features:
|
||||
- User authentication via JWT tokens stored in Redis
|
||||
- All operations through API (no direct DB access)
|
||||
- Async HTTP requests with aiohttp
|
||||
- Event listening via Redis Streams
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||
self.bot = Bot(token=bot_token)
|
||||
self.dp = Dispatcher()
|
||||
self.api_base_url = api_base_url
|
||||
self.redis_client = redis_client
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Register handlers
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Register message handlers"""
|
||||
self.dp.message.register(self.cmd_start, Command("start"))
|
||||
self.dp.message.register(self.cmd_help, Command("help"))
|
||||
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
# Start polling
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
finally:
|
||||
await self.session.close()
|
||||
|
||||
# ========== Handler: /start (Binding) ==========
|
||||
async def cmd_start(self, message: Message):
|
||||
"""
|
||||
/start - Begin Telegram binding process.
|
||||
|
||||
Flow:
|
||||
1. Check if user already bound
|
||||
2. If not: Generate binding code
|
||||
3. Send link to user
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Check if already bound
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
existing_token = self.redis_client.get(jwt_key)
|
||||
|
||||
if existing_token:
|
||||
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
|
||||
return
|
||||
|
||||
# Generate binding code
|
||||
try:
|
||||
code = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/start",
|
||||
data={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
binding_code = code.get("code")
|
||||
|
||||
# Send binding link to user
|
||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
||||
|
||||
await message.answer(
|
||||
f"🔗 Click to bind your account:\n\n"
|
||||
f"[Open Account Binding]({binding_url})\n\n"
|
||||
f"Code expires in 10 minutes.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Binding start error: {e}")
|
||||
await message.answer("❌ Binding failed. Try again later.")
|
||||
|
||||
# ========== Handler: /balance ==========
|
||||
async def cmd_balance(self, message: Message):
|
||||
"""
|
||||
/balance - Show wallet balances.
|
||||
|
||||
Requires:
|
||||
- User must be bound (JWT token in Redis)
|
||||
- API call with JWT auth
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Get JWT token
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||
wallets = await self._api_call(
|
||||
method="GET",
|
||||
endpoint="/api/v1/wallets/summary",
|
||||
jwt_token=jwt_token,
|
||||
params={"family_id": 1}, # TODO: Get from context
|
||||
)
|
||||
|
||||
# Format response
|
||||
response = "💰 **Your Wallets:**\n\n"
|
||||
for wallet in wallets:
|
||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||
|
||||
await message.answer(response, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance fetch error: {e}")
|
||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||
|
||||
# ========== Handler: /add (Create Transaction) ==========
|
||||
async def cmd_add_transaction(self, message: Message):
|
||||
"""
|
||||
/add - Create new transaction (interactive).
|
||||
|
||||
Flow:
|
||||
1. Ask for amount
|
||||
2. Ask for category
|
||||
3. Ask for wallet (from/to)
|
||||
4. Create transaction via API
|
||||
"""
|
||||
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start first.")
|
||||
return
|
||||
|
||||
# Store conversation state in Redis
|
||||
state_key = f"chat_id:{chat_id}:state"
|
||||
self.redis_client.setex(state_key, 300, json.dumps({
|
||||
"action": "add_transaction",
|
||||
"step": 1, # Waiting for amount
|
||||
}))
|
||||
|
||||
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||
|
||||
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||
"""Handle transaction creation in steps"""
|
||||
chat_id = message.chat.id
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
step = state.get("step", 1)
|
||||
|
||||
if step == 1:
|
||||
# Amount entered
|
||||
try:
|
||||
amount = Decimal(message.text)
|
||||
except:
|
||||
await message.answer("❌ Invalid amount. Try again.")
|
||||
return
|
||||
|
||||
state["amount"] = float(amount)
|
||||
state["step"] = 2
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||
|
||||
elif step == 2:
|
||||
# Category selected
|
||||
state["category"] = message.text
|
||||
state["step"] = 3
|
||||
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||
|
||||
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||
|
||||
elif step == 3:
|
||||
# Notes entered (or skipped)
|
||||
state["notes"] = message.text if message.text != "/skip" else ""
|
||||
|
||||
# Create transaction via API
|
||||
try:
|
||||
result = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/transactions",
|
||||
jwt_token=jwt_token,
|
||||
data={
|
||||
"family_id": 1,
|
||||
"from_wallet_id": 10,
|
||||
"amount": state["amount"],
|
||||
"category_id": 5, # TODO: Map category
|
||||
"description": state["category"],
|
||||
"notes": state["notes"],
|
||||
}
|
||||
)
|
||||
|
||||
tx_id = result.get("id")
|
||||
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transaction creation error: {e}")
|
||||
await message.answer("❌ Creation failed. Try again.")
|
||||
|
||||
finally:
|
||||
# Clean up state
|
||||
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||
|
||||
# ========== Handler: /help ==========
|
||||
async def cmd_help(self, message: Message):
|
||||
"""Show available commands"""
|
||||
help_text = """
|
||||
🤖 **Finance Bot Commands:**
|
||||
|
||||
/start - Bind your Telegram account
|
||||
/balance - Show wallet balances
|
||||
/add - Add new transaction
|
||||
/reports - View reports (daily/weekly/monthly)
|
||||
/help - This message
|
||||
"""
|
||||
await message.answer(help_text, parse_mode="Markdown")
|
||||
|
||||
# ========== API Communication Methods ==========
|
||||
async def _api_call(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Dict = None,
|
||||
params: Dict = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
use_jwt: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API with proper auth headers.
|
||||
|
||||
Headers:
|
||||
- Authorization: Bearer <jwt_token>
|
||||
- X-Client-Id: telegram_bot
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Timestamp: unix timestamp
|
||||
"""
|
||||
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized")
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"X-Client-Id": "telegram_bot",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add JWT if provided
|
||||
if use_jwt and jwt_token:
|
||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||
|
||||
# Add HMAC signature
|
||||
timestamp = int(time.time())
|
||||
headers["X-Timestamp"] = str(timestamp)
|
||||
|
||||
signature = hmac_manager.create_signature(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
timestamp=timestamp,
|
||||
body=data,
|
||||
)
|
||||
headers["X-Signature"] = signature
|
||||
|
||||
# Make request
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"API error {response.status}: {error_text}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||
"""Get JWT token for chat_id from Redis"""
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
token = self.redis_client.get(jwt_key)
|
||||
return token.decode() if token else None
|
||||
|
||||
async def send_notification(self, chat_id: int, message: str):
|
||||
"""Send notification to user"""
|
||||
try:
|
||||
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||
|
||||
|
||||
# Bot factory
|
||||
async def create_telegram_bot(
|
||||
bot_token: str,
|
||||
api_base_url: str,
|
||||
redis_client: redis.Redis,
|
||||
) -> TelegramBotClient:
|
||||
"""Create and start Telegram bot"""
|
||||
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||
return bot
|
||||
14
.history/app/bot/handlers/__init___20251210201701.py
Normal file
14
.history/app/bot/handlers/__init___20251210201701.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Bot handlers"""
|
||||
|
||||
from app.bot.handlers.start import register_start_handlers
|
||||
from app.bot.handlers.user import register_user_handlers
|
||||
from app.bot.handlers.family import register_family_handlers
|
||||
from app.bot.handlers.transaction import register_transaction_handlers
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Register all bot handlers"""
|
||||
register_start_handlers(dp)
|
||||
register_user_handlers(dp)
|
||||
register_family_handlers(dp)
|
||||
register_transaction_handlers(dp)
|
||||
14
.history/app/bot/handlers/__init___20251210202255.py
Normal file
14
.history/app/bot/handlers/__init___20251210202255.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Bot handlers"""
|
||||
|
||||
from app.bot.handlers.start import register_start_handlers
|
||||
from app.bot.handlers.user import register_user_handlers
|
||||
from app.bot.handlers.family import register_family_handlers
|
||||
from app.bot.handlers.transaction import register_transaction_handlers
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Register all bot handlers"""
|
||||
register_start_handlers(dp)
|
||||
register_user_handlers(dp)
|
||||
register_family_handlers(dp)
|
||||
register_transaction_handlers(dp)
|
||||
18
.history/app/bot/handlers/family_20251210201701.py
Normal file
18
.history/app/bot/handlers/family_20251210201701.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Family-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def family_menu(message: Message):
|
||||
"""Handle family menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_family_handlers(dp):
|
||||
"""Register family handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/family_20251210202255.py
Normal file
18
.history/app/bot/handlers/family_20251210202255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Family-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def family_menu(message: Message):
|
||||
"""Handle family menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_family_handlers(dp):
|
||||
"""Register family handlers"""
|
||||
dp.include_router(router)
|
||||
60
.history/app/bot/handlers/start_20251210201701.py
Normal file
60
.history/app/bot/handlers/start_20251210201701.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Start and help handlers"""
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.repositories import UserRepository, FamilyRepository
|
||||
from app.bot.keyboards import main_menu_keyboard
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message):
|
||||
"""Handle /start command"""
|
||||
user_repo = UserRepository(SessionLocal())
|
||||
|
||||
# Create or update user
|
||||
user = user_repo.get_or_create(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
)
|
||||
|
||||
welcome_text = (
|
||||
"👋 Добро пожаловать в Finance Bot!\n\n"
|
||||
"Я помогу вам управлять семейными финансами:\n"
|
||||
"💰 Отслеживать доходы и расходы\n"
|
||||
"👨👩👧👦 Управлять семейной группой\n"
|
||||
"📊 Видеть аналитику\n"
|
||||
"🎯 Ставить финансовые цели\n\n"
|
||||
"Выберите действие:"
|
||||
)
|
||||
|
||||
await message.answer(welcome_text, reply_markup=main_menu_keyboard())
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_help(message: Message):
|
||||
"""Handle /help command"""
|
||||
help_text = (
|
||||
"📚 **Справка по командам:**\n\n"
|
||||
"/start - Главное меню\n"
|
||||
"/help - Эта справка\n"
|
||||
"/account - Мои счета\n"
|
||||
"/transaction - Новая операция\n"
|
||||
"/budget - Управление бюджетом\n"
|
||||
"/analytics - Аналитика\n"
|
||||
"/family - Управление семьей\n"
|
||||
"/settings - Параметры\n"
|
||||
)
|
||||
await message.answer(help_text)
|
||||
|
||||
|
||||
def register_start_handlers(dp):
|
||||
"""Register start handlers"""
|
||||
dp.include_router(router)
|
||||
60
.history/app/bot/handlers/start_20251210202255.py
Normal file
60
.history/app/bot/handlers/start_20251210202255.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Start and help handlers"""
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.repositories import UserRepository, FamilyRepository
|
||||
from app.bot.keyboards import main_menu_keyboard
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message):
|
||||
"""Handle /start command"""
|
||||
user_repo = UserRepository(SessionLocal())
|
||||
|
||||
# Create or update user
|
||||
user = user_repo.get_or_create(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
)
|
||||
|
||||
welcome_text = (
|
||||
"👋 Добро пожаловать в Finance Bot!\n\n"
|
||||
"Я помогу вам управлять семейными финансами:\n"
|
||||
"💰 Отслеживать доходы и расходы\n"
|
||||
"👨👩👧👦 Управлять семейной группой\n"
|
||||
"📊 Видеть аналитику\n"
|
||||
"🎯 Ставить финансовые цели\n\n"
|
||||
"Выберите действие:"
|
||||
)
|
||||
|
||||
await message.answer(welcome_text, reply_markup=main_menu_keyboard())
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_help(message: Message):
|
||||
"""Handle /help command"""
|
||||
help_text = (
|
||||
"📚 **Справка по командам:**\n\n"
|
||||
"/start - Главное меню\n"
|
||||
"/help - Эта справка\n"
|
||||
"/account - Мои счета\n"
|
||||
"/transaction - Новая операция\n"
|
||||
"/budget - Управление бюджетом\n"
|
||||
"/analytics - Аналитика\n"
|
||||
"/family - Управление семьей\n"
|
||||
"/settings - Параметры\n"
|
||||
)
|
||||
await message.answer(help_text)
|
||||
|
||||
|
||||
def register_start_handlers(dp):
|
||||
"""Register start handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/transaction_20251210201701.py
Normal file
18
.history/app/bot/handlers/transaction_20251210201701.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Transaction-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def transaction_menu(message: Message):
|
||||
"""Handle transaction operations"""
|
||||
pass
|
||||
|
||||
|
||||
def register_transaction_handlers(dp):
|
||||
"""Register transaction handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/transaction_20251210202255.py
Normal file
18
.history/app/bot/handlers/transaction_20251210202255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Transaction-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def transaction_menu(message: Message):
|
||||
"""Handle transaction operations"""
|
||||
pass
|
||||
|
||||
|
||||
def register_transaction_handlers(dp):
|
||||
"""Register transaction handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/user_20251210201701.py
Normal file
18
.history/app/bot/handlers/user_20251210201701.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""User-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def user_menu(message: Message):
|
||||
"""Handle user menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_user_handlers(dp):
|
||||
"""Register user handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/user_20251210202255.py
Normal file
18
.history/app/bot/handlers/user_20251210202255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""User-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def user_menu(message: Message):
|
||||
"""Handle user menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_user_handlers(dp):
|
||||
"""Register user handlers"""
|
||||
dp.include_router(router)
|
||||
56
.history/app/bot/keyboards/__init___20251210201702.py
Normal file
56
.history/app/bot/keyboards/__init___20251210201702.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Bot keyboards"""
|
||||
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
def main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
"""Main menu keyboard"""
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="💰 Новая операция"),
|
||||
KeyboardButton(text="📊 Аналитика"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="👨👩👧👦 Семья"),
|
||||
KeyboardButton(text="🎯 Цели"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="💳 Счета"),
|
||||
KeyboardButton(text="⚙️ Параметры"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📞 Помощь"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Выберите действие...",
|
||||
)
|
||||
|
||||
|
||||
def transaction_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Transaction type selection"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Расход", callback_data="tx_expense")],
|
||||
[InlineKeyboardButton(text="💵 Доход", callback_data="tx_income")],
|
||||
[InlineKeyboardButton(text="🔄 Перевод", callback_data="tx_transfer")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def cancel_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Cancel button"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"main_menu_keyboard",
|
||||
"transaction_type_keyboard",
|
||||
"cancel_keyboard",
|
||||
]
|
||||
56
.history/app/bot/keyboards/__init___20251210202255.py
Normal file
56
.history/app/bot/keyboards/__init___20251210202255.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Bot keyboards"""
|
||||
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
def main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
"""Main menu keyboard"""
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="💰 Новая операция"),
|
||||
KeyboardButton(text="📊 Аналитика"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="👨👩👧👦 Семья"),
|
||||
KeyboardButton(text="🎯 Цели"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="💳 Счета"),
|
||||
KeyboardButton(text="⚙️ Параметры"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📞 Помощь"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Выберите действие...",
|
||||
)
|
||||
|
||||
|
||||
def transaction_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Transaction type selection"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Расход", callback_data="tx_expense")],
|
||||
[InlineKeyboardButton(text="💵 Доход", callback_data="tx_income")],
|
||||
[InlineKeyboardButton(text="🔄 Перевод", callback_data="tx_transfer")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def cancel_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Cancel button"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"main_menu_keyboard",
|
||||
"transaction_type_keyboard",
|
||||
"cancel_keyboard",
|
||||
]
|
||||
36
.history/app/bot_main_20251210215926.py
Normal file
36
.history/app/bot_main_20251210215926.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Telegram Bot Entry Point
|
||||
Runs the bot polling service
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from app.bot.client import TelegramBotClient
|
||||
from app.core.config import settings
|
||||
import redis
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Start Telegram bot"""
|
||||
try:
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
bot = TelegramBotClient(
|
||||
bot_token=settings.bot_token,
|
||||
api_base_url="http://web:8000",
|
||||
redis_client=redis_client
|
||||
)
|
||||
|
||||
logger.info("Starting Telegram bot...")
|
||||
await bot.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bot error: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
36
.history/app/bot_main_20251210220144.py
Normal file
36
.history/app/bot_main_20251210220144.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Telegram Bot Entry Point
|
||||
Runs the bot polling service
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from app.bot.client import TelegramBotClient
|
||||
from app.core.config import settings
|
||||
import redis
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Start Telegram bot"""
|
||||
try:
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
bot = TelegramBotClient(
|
||||
bot_token=settings.bot_token,
|
||||
api_base_url="http://web:8000",
|
||||
redis_client=redis_client
|
||||
)
|
||||
|
||||
logger.info("Starting Telegram bot...")
|
||||
await bot.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bot error: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
5
.history/app/core/__init___20251210201602.py
Normal file
5
.history/app/core/__init___20251210201602.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Core module - configuration and utilities"""
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
__all__ = ["Settings"]
|
||||
5
.history/app/core/__init___20251210202255.py
Normal file
5
.history/app/core/__init___20251210202255.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Core module - configuration and utilities"""
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
__all__ = ["Settings"]
|
||||
43
.history/app/core/config_20251210201604.py
Normal file
43
.history/app/core/config_20251210201604.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
43
.history/app/core/config_20251210202255.py
Normal file
43
.history/app/core/config_20251210202255.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
48
.history/app/core/config_20251210203345.py
Normal file
48
.history/app/core/config_20251210203345.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Database Credentials (for Docker)
|
||||
db_password: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
48
.history/app/core/config_20251210203358.py
Normal file
48
.history/app/core/config_20251210203358.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Database Credentials (for Docker)
|
||||
db_password: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
66
.history/app/core/config_20251210210332.py
Normal file
66
.history/app/core/config_20251210210332.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Database Credentials (for Docker)
|
||||
db_password: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
# Security Configuration
|
||||
jwt_secret_key: str = "your-secret-key-change-in-production"
|
||||
hmac_secret_key: str = "your-hmac-secret-change-in-production"
|
||||
require_hmac_verification: bool = False # Disabled by default in MVP
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
# CORS Configuration
|
||||
cors_allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8081"]
|
||||
cors_allow_credentials: bool = True
|
||||
cors_allow_methods: list[str] = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers: list[str] = ["*"]
|
||||
|
||||
# Feature Flags
|
||||
feature_telegram_bot_enabled: bool = True
|
||||
feature_transaction_approval: bool = True
|
||||
feature_event_logging: bool = True
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
66
.history/app/core/config_20251210210906.py
Normal file
66
.history/app/core/config_20251210210906.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Database Credentials (for Docker)
|
||||
db_password: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
# Security Configuration
|
||||
jwt_secret_key: str = "your-secret-key-change-in-production"
|
||||
hmac_secret_key: str = "your-hmac-secret-change-in-production"
|
||||
require_hmac_verification: bool = False # Disabled by default in MVP
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
# CORS Configuration
|
||||
cors_allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8081"]
|
||||
cors_allow_credentials: bool = True
|
||||
cors_allow_methods: list[str] = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers: list[str] = ["*"]
|
||||
|
||||
# Feature Flags
|
||||
feature_telegram_bot_enabled: bool = True
|
||||
feature_transaction_approval: bool = True
|
||||
feature_event_logging: bool = True
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
70
.history/app/core/config_20251210211749.py
Normal file
70
.history/app/core/config_20251210211749.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Database Credentials (for Docker)
|
||||
db_password: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
# Security Configuration
|
||||
jwt_secret_key: str = "your-secret-key-change-in-production"
|
||||
hmac_secret_key: str = "your-hmac-secret-change-in-production"
|
||||
require_hmac_verification: bool = False # Disabled by default in MVP
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
# CORS Configuration
|
||||
cors_allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8081"]
|
||||
cors_allow_credentials: bool = True
|
||||
cors_allow_methods: list[str] = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers: list[str] = ["*"]
|
||||
|
||||
# Feature Flags
|
||||
feature_telegram_bot_enabled: bool = True
|
||||
feature_transaction_approval: bool = True
|
||||
feature_event_logging: bool = True
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Global settings instance for direct imports
|
||||
settings = get_settings()
|
||||
70
.history/app/core/config_20251210211818.py
Normal file
70
.history/app/core/config_20251210211818.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Application configuration using pydantic-settings"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings"""
|
||||
|
||||
# Bot Configuration
|
||||
bot_token: str
|
||||
bot_admin_id: int
|
||||
|
||||
# Database Configuration
|
||||
database_url: str
|
||||
database_echo: bool = False
|
||||
|
||||
# Database Credentials (for Docker)
|
||||
db_password: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
# Redis Configuration
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Application Configuration
|
||||
app_debug: bool = False
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# API Configuration
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
|
||||
# Timezone
|
||||
tz: str = "Europe/Moscow"
|
||||
|
||||
# Security Configuration
|
||||
jwt_secret_key: str = "your-secret-key-change-in-production"
|
||||
hmac_secret_key: str = "your-hmac-secret-change-in-production"
|
||||
require_hmac_verification: bool = False # Disabled by default in MVP
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
# CORS Configuration
|
||||
cors_allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8081"]
|
||||
cors_allow_credentials: bool = True
|
||||
cors_allow_methods: list[str] = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers: list[str] = ["*"]
|
||||
|
||||
# Feature Flags
|
||||
feature_telegram_bot_enabled: bool = True
|
||||
feature_transaction_approval: bool = True
|
||||
feature_event_logging: bool = True
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance"""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Global settings instance for direct imports
|
||||
settings = get_settings()
|
||||
5
.history/app/db/__init___20251210201602.py
Normal file
5
.history/app/db/__init___20251210201602.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Database module - models, repositories, and session management"""
|
||||
|
||||
from app.db.database import SessionLocal, engine, Base
|
||||
|
||||
__all__ = ["SessionLocal", "engine", "Base"]
|
||||
5
.history/app/db/__init___20251210202255.py
Normal file
5
.history/app/db/__init___20251210202255.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Database module - models, repositories, and session management"""
|
||||
|
||||
from app.db.database import SessionLocal, engine, Base
|
||||
|
||||
__all__ = ["SessionLocal", "engine", "Base"]
|
||||
36
.history/app/db/database_20251210201604.py
Normal file
36
.history/app/db/database_20251210201604.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Database connection and session management"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create database engine
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
echo=settings.database_echo,
|
||||
pool_pre_ping=True, # Verify connections before using them
|
||||
pool_recycle=3600, # Recycle connections every hour
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# Create declarative base for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for FastAPI to get database session"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
36
.history/app/db/database_20251210202255.py
Normal file
36
.history/app/db/database_20251210202255.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Database connection and session management"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create database engine
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
echo=settings.database_echo,
|
||||
pool_pre_ping=True, # Verify connections before using them
|
||||
pool_recycle=3600, # Recycle connections every hour
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# Create declarative base for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for FastAPI to get database session"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
21
.history/app/db/models/__init___20251210201603.py
Normal file
21
.history/app/db/models/__init___20251210201603.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Database models"""
|
||||
|
||||
from app.db.models.user import User
|
||||
from app.db.models.family import Family, FamilyMember, FamilyInvite
|
||||
from app.db.models.account import Account
|
||||
from app.db.models.category import Category
|
||||
from app.db.models.transaction import Transaction
|
||||
from app.db.models.budget import Budget
|
||||
from app.db.models.goal import Goal
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Family",
|
||||
"FamilyMember",
|
||||
"FamilyInvite",
|
||||
"Account",
|
||||
"Category",
|
||||
"Transaction",
|
||||
"Budget",
|
||||
"Goal",
|
||||
]
|
||||
21
.history/app/db/models/__init___20251210202255.py
Normal file
21
.history/app/db/models/__init___20251210202255.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Database models"""
|
||||
|
||||
from app.db.models.user import User
|
||||
from app.db.models.family import Family, FamilyMember, FamilyInvite
|
||||
from app.db.models.account import Account
|
||||
from app.db.models.category import Category
|
||||
from app.db.models.transaction import Transaction
|
||||
from app.db.models.budget import Budget
|
||||
from app.db.models.goal import Goal
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Family",
|
||||
"FamilyMember",
|
||||
"FamilyInvite",
|
||||
"Account",
|
||||
"Category",
|
||||
"Transaction",
|
||||
"Budget",
|
||||
"Goal",
|
||||
]
|
||||
28
.history/app/db/models/__init___20251210203708.py
Normal file
28
.history/app/db/models/__init___20251210203708.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Database models"""
|
||||
|
||||
from app.db.models.user import User
|
||||
from app.db.models.family import Family, FamilyMember, FamilyInvite, FamilyRole
|
||||
from app.db.models.account import Account, AccountType
|
||||
from app.db.models.category import Category, CategoryType
|
||||
from app.db.models.transaction import Transaction, TransactionType
|
||||
from app.db.models.budget import Budget, BudgetPeriod
|
||||
from app.db.models.goal import Goal
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"User",
|
||||
"Family",
|
||||
"FamilyMember",
|
||||
"FamilyInvite",
|
||||
"Account",
|
||||
"Category",
|
||||
"Transaction",
|
||||
"Budget",
|
||||
"Goal",
|
||||
# Enums
|
||||
"FamilyRole",
|
||||
"AccountType",
|
||||
"CategoryType",
|
||||
"TransactionType",
|
||||
"BudgetPeriod",
|
||||
]
|
||||
28
.history/app/db/models/__init___20251210203716.py
Normal file
28
.history/app/db/models/__init___20251210203716.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Database models"""
|
||||
|
||||
from app.db.models.user import User
|
||||
from app.db.models.family import Family, FamilyMember, FamilyInvite, FamilyRole
|
||||
from app.db.models.account import Account, AccountType
|
||||
from app.db.models.category import Category, CategoryType
|
||||
from app.db.models.transaction import Transaction, TransactionType
|
||||
from app.db.models.budget import Budget, BudgetPeriod
|
||||
from app.db.models.goal import Goal
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"User",
|
||||
"Family",
|
||||
"FamilyMember",
|
||||
"FamilyInvite",
|
||||
"Account",
|
||||
"Category",
|
||||
"Transaction",
|
||||
"Budget",
|
||||
"Goal",
|
||||
# Enums
|
||||
"FamilyRole",
|
||||
"AccountType",
|
||||
"CategoryType",
|
||||
"TransactionType",
|
||||
"BudgetPeriod",
|
||||
]
|
||||
50
.history/app/db/models/account_20251210201605.py
Normal file
50
.history/app/db/models/account_20251210201605.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Account (wallet) model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class AccountType(str, PyEnum):
|
||||
"""Types of accounts"""
|
||||
CARD = "card"
|
||||
CASH = "cash"
|
||||
DEPOSIT = "deposit"
|
||||
GOAL = "goal"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class Account(Base):
|
||||
"""Account model - represents a user's wallet or account"""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
name = Column(String(255), nullable=False)
|
||||
account_type = Column(Enum(AccountType), default=AccountType.CARD)
|
||||
description = Column(String(500), nullable=True)
|
||||
|
||||
# Balance
|
||||
balance = Column(Float, default=0.0)
|
||||
initial_balance = Column(Float, default=0.0)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="accounts")
|
||||
owner = relationship("User", back_populates="accounts")
|
||||
transactions = relationship("Transaction", back_populates="account")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Account(id={self.id}, name={self.name}, balance={self.balance})>"
|
||||
50
.history/app/db/models/account_20251210202255.py
Normal file
50
.history/app/db/models/account_20251210202255.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Account (wallet) model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class AccountType(str, PyEnum):
|
||||
"""Types of accounts"""
|
||||
CARD = "card"
|
||||
CASH = "cash"
|
||||
DEPOSIT = "deposit"
|
||||
GOAL = "goal"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class Account(Base):
|
||||
"""Account model - represents a user's wallet or account"""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
name = Column(String(255), nullable=False)
|
||||
account_type = Column(Enum(AccountType), default=AccountType.CARD)
|
||||
description = Column(String(500), nullable=True)
|
||||
|
||||
# Balance
|
||||
balance = Column(Float, default=0.0)
|
||||
initial_balance = Column(Float, default=0.0)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="accounts")
|
||||
owner = relationship("User", back_populates="accounts")
|
||||
transactions = relationship("Transaction", back_populates="account")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Account(id={self.id}, name={self.name}, balance={self.balance})>"
|
||||
50
.history/app/db/models/budget_20251210201605.py
Normal file
50
.history/app/db/models/budget_20251210201605.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Budget model for budget tracking"""
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, String, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class BudgetPeriod(str, PyEnum):
|
||||
"""Budget periods"""
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
class Budget(Base):
|
||||
"""Budget model - spending limits"""
|
||||
|
||||
__tablename__ = "budgets"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
# Budget details
|
||||
name = Column(String(255), nullable=False)
|
||||
limit_amount = Column(Float, nullable=False)
|
||||
spent_amount = Column(Float, default=0.0)
|
||||
period = Column(Enum(BudgetPeriod), default=BudgetPeriod.MONTHLY)
|
||||
|
||||
# Alert threshold (percentage)
|
||||
alert_threshold = Column(Float, default=80.0)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
start_date = Column(DateTime, nullable=False)
|
||||
end_date = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="budgets")
|
||||
category = relationship("Category", back_populates="budgets")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Budget(id={self.id}, name={self.name}, limit={self.limit_amount})>"
|
||||
50
.history/app/db/models/budget_20251210202255.py
Normal file
50
.history/app/db/models/budget_20251210202255.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Budget model for budget tracking"""
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, String, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class BudgetPeriod(str, PyEnum):
|
||||
"""Budget periods"""
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
class Budget(Base):
|
||||
"""Budget model - spending limits"""
|
||||
|
||||
__tablename__ = "budgets"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
# Budget details
|
||||
name = Column(String(255), nullable=False)
|
||||
limit_amount = Column(Float, nullable=False)
|
||||
spent_amount = Column(Float, default=0.0)
|
||||
period = Column(Enum(BudgetPeriod), default=BudgetPeriod.MONTHLY)
|
||||
|
||||
# Alert threshold (percentage)
|
||||
alert_threshold = Column(Float, default=80.0)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
start_date = Column(DateTime, nullable=False)
|
||||
end_date = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="budgets")
|
||||
category = relationship("Category", back_populates="budgets")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Budget(id={self.id}, name={self.name}, limit={self.limit_amount})>"
|
||||
47
.history/app/db/models/category_20251210201605.py
Normal file
47
.history/app/db/models/category_20251210201605.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Category model for income/expense categories"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class CategoryType(str, PyEnum):
|
||||
"""Types of categories"""
|
||||
EXPENSE = "expense"
|
||||
INCOME = "income"
|
||||
|
||||
|
||||
class Category(Base):
|
||||
"""Category model - income/expense categories"""
|
||||
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
|
||||
name = Column(String(255), nullable=False)
|
||||
category_type = Column(Enum(CategoryType), nullable=False)
|
||||
emoji = Column(String(10), nullable=True)
|
||||
color = Column(String(7), nullable=True) # Hex color
|
||||
description = Column(String(500), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
|
||||
# Order for UI
|
||||
order = Column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="categories")
|
||||
transactions = relationship("Transaction", back_populates="category")
|
||||
budgets = relationship("Budget", back_populates="category")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Category(id={self.id}, name={self.name}, type={self.category_type})>"
|
||||
47
.history/app/db/models/category_20251210202255.py
Normal file
47
.history/app/db/models/category_20251210202255.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Category model for income/expense categories"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class CategoryType(str, PyEnum):
|
||||
"""Types of categories"""
|
||||
EXPENSE = "expense"
|
||||
INCOME = "income"
|
||||
|
||||
|
||||
class Category(Base):
|
||||
"""Category model - income/expense categories"""
|
||||
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
|
||||
name = Column(String(255), nullable=False)
|
||||
category_type = Column(Enum(CategoryType), nullable=False)
|
||||
emoji = Column(String(10), nullable=True)
|
||||
color = Column(String(7), nullable=True) # Hex color
|
||||
description = Column(String(500), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
|
||||
# Order for UI
|
||||
order = Column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="categories")
|
||||
transactions = relationship("Transaction", back_populates="category")
|
||||
budgets = relationship("Budget", back_populates="category")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Category(id={self.id}, name={self.name}, type={self.category_type})>"
|
||||
98
.history/app/db/models/family_20251210201605.py
Normal file
98
.history/app/db/models/family_20251210201605.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Family and Family-related models"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class FamilyRole(str, PyEnum):
|
||||
"""Roles in family"""
|
||||
OWNER = "owner"
|
||||
MEMBER = "member"
|
||||
RESTRICTED = "restricted"
|
||||
|
||||
|
||||
class Family(Base):
|
||||
"""Family model - represents a family group"""
|
||||
|
||||
__tablename__ = "families"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(String(500), nullable=True)
|
||||
currency = Column(String(3), default="RUB") # ISO 4217 code
|
||||
invite_code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
|
||||
# Settings
|
||||
notification_level = Column(String(50), default="all") # all, important, none
|
||||
accounting_period = Column(String(20), default="month") # week, month, year
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
members = relationship("FamilyMember", back_populates="family", cascade="all, delete-orphan")
|
||||
invites = relationship("FamilyInvite", back_populates="family", cascade="all, delete-orphan")
|
||||
accounts = relationship("Account", back_populates="family", cascade="all, delete-orphan")
|
||||
categories = relationship("Category", back_populates="family", cascade="all, delete-orphan")
|
||||
budgets = relationship("Budget", back_populates="family", cascade="all, delete-orphan")
|
||||
goals = relationship("Goal", back_populates="family", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Family(id={self.id}, name={self.name}, currency={self.currency})>"
|
||||
|
||||
|
||||
class FamilyMember(Base):
|
||||
"""Family member model - user membership in family"""
|
||||
|
||||
__tablename__ = "family_members"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
role = Column(Enum(FamilyRole), default=FamilyRole.MEMBER)
|
||||
|
||||
# Permissions
|
||||
can_edit_budget = Column(Boolean, default=True)
|
||||
can_manage_members = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="members")
|
||||
user = relationship("User", back_populates="family_members")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FamilyMember(family_id={self.family_id}, user_id={self.user_id}, role={self.role})>"
|
||||
|
||||
|
||||
class FamilyInvite(Base):
|
||||
"""Family invite model - pending invitations"""
|
||||
|
||||
__tablename__ = "family_invites"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
invite_code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Invite validity
|
||||
is_active = Column(Boolean, default=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="invites")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FamilyInvite(id={self.id}, family_id={self.family_id})>"
|
||||
98
.history/app/db/models/family_20251210202255.py
Normal file
98
.history/app/db/models/family_20251210202255.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Family and Family-related models"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class FamilyRole(str, PyEnum):
|
||||
"""Roles in family"""
|
||||
OWNER = "owner"
|
||||
MEMBER = "member"
|
||||
RESTRICTED = "restricted"
|
||||
|
||||
|
||||
class Family(Base):
|
||||
"""Family model - represents a family group"""
|
||||
|
||||
__tablename__ = "families"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(String(500), nullable=True)
|
||||
currency = Column(String(3), default="RUB") # ISO 4217 code
|
||||
invite_code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
|
||||
# Settings
|
||||
notification_level = Column(String(50), default="all") # all, important, none
|
||||
accounting_period = Column(String(20), default="month") # week, month, year
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
members = relationship("FamilyMember", back_populates="family", cascade="all, delete-orphan")
|
||||
invites = relationship("FamilyInvite", back_populates="family", cascade="all, delete-orphan")
|
||||
accounts = relationship("Account", back_populates="family", cascade="all, delete-orphan")
|
||||
categories = relationship("Category", back_populates="family", cascade="all, delete-orphan")
|
||||
budgets = relationship("Budget", back_populates="family", cascade="all, delete-orphan")
|
||||
goals = relationship("Goal", back_populates="family", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Family(id={self.id}, name={self.name}, currency={self.currency})>"
|
||||
|
||||
|
||||
class FamilyMember(Base):
|
||||
"""Family member model - user membership in family"""
|
||||
|
||||
__tablename__ = "family_members"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
role = Column(Enum(FamilyRole), default=FamilyRole.MEMBER)
|
||||
|
||||
# Permissions
|
||||
can_edit_budget = Column(Boolean, default=True)
|
||||
can_manage_members = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="members")
|
||||
user = relationship("User", back_populates="family_members")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FamilyMember(family_id={self.family_id}, user_id={self.user_id}, role={self.role})>"
|
||||
|
||||
|
||||
class FamilyInvite(Base):
|
||||
"""Family invite model - pending invitations"""
|
||||
|
||||
__tablename__ = "family_invites"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
invite_code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Invite validity
|
||||
is_active = Column(Boolean, default=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="invites")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FamilyInvite(id={self.id}, family_id={self.family_id})>"
|
||||
44
.history/app/db/models/goal_20251210201605.py
Normal file
44
.history/app/db/models/goal_20251210201605.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Savings goal model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, String, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Goal(Base):
|
||||
"""Goal model - savings goals with progress tracking"""
|
||||
|
||||
__tablename__ = "goals"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
|
||||
|
||||
# Goal details
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(String(500), nullable=True)
|
||||
target_amount = Column(Float, nullable=False)
|
||||
current_amount = Column(Float, default=0.0)
|
||||
|
||||
# Priority
|
||||
priority = Column(Integer, default=0)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
|
||||
# Deadlines
|
||||
target_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="goals")
|
||||
account = relationship("Account")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Goal(id={self.id}, name={self.name}, target={self.target_amount})>"
|
||||
44
.history/app/db/models/goal_20251210202255.py
Normal file
44
.history/app/db/models/goal_20251210202255.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Savings goal model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, String, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Goal(Base):
|
||||
"""Goal model - savings goals with progress tracking"""
|
||||
|
||||
__tablename__ = "goals"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
|
||||
|
||||
# Goal details
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(String(500), nullable=True)
|
||||
target_amount = Column(Float, nullable=False)
|
||||
current_amount = Column(Float, default=0.0)
|
||||
|
||||
# Priority
|
||||
priority = Column(Integer, default=0)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
|
||||
# Deadlines
|
||||
target_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family", back_populates="goals")
|
||||
account = relationship("Account")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Goal(id={self.id}, name={self.name}, target={self.target_amount})>"
|
||||
57
.history/app/db/models/transaction_20251210201606.py
Normal file
57
.history/app/db/models/transaction_20251210201606.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Transaction model for income/expense records"""
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, String, DateTime, Boolean, ForeignKey, Text, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class TransactionType(str, PyEnum):
|
||||
"""Types of transactions"""
|
||||
EXPENSE = "expense"
|
||||
INCOME = "income"
|
||||
TRANSFER = "transfer"
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
"""Transaction model - represents income/expense transaction"""
|
||||
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
# Transaction details
|
||||
amount = Column(Float, nullable=False)
|
||||
transaction_type = Column(Enum(TransactionType), nullable=False)
|
||||
description = Column(String(500), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
tags = Column(String(500), nullable=True) # Comma-separated tags
|
||||
|
||||
# Receipt
|
||||
receipt_photo_url = Column(String(500), nullable=True)
|
||||
|
||||
# Recurring transaction
|
||||
is_recurring = Column(Boolean, default=False)
|
||||
recurrence_pattern = Column(String(50), nullable=True) # daily, weekly, monthly, etc.
|
||||
|
||||
# Status
|
||||
is_confirmed = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
transaction_date = Column(DateTime, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family")
|
||||
user = relationship("User", back_populates="transactions")
|
||||
account = relationship("Account", back_populates="transactions")
|
||||
category = relationship("Category", back_populates="transactions")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Transaction(id={self.id}, amount={self.amount}, type={self.transaction_type})>"
|
||||
57
.history/app/db/models/transaction_20251210202255.py
Normal file
57
.history/app/db/models/transaction_20251210202255.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Transaction model for income/expense records"""
|
||||
|
||||
from sqlalchemy import Column, Integer, Float, String, DateTime, Boolean, ForeignKey, Text, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class TransactionType(str, PyEnum):
|
||||
"""Types of transactions"""
|
||||
EXPENSE = "expense"
|
||||
INCOME = "income"
|
||||
TRANSFER = "transfer"
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
"""Transaction model - represents income/expense transaction"""
|
||||
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
family_id = Column(Integer, ForeignKey("families.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
# Transaction details
|
||||
amount = Column(Float, nullable=False)
|
||||
transaction_type = Column(Enum(TransactionType), nullable=False)
|
||||
description = Column(String(500), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
tags = Column(String(500), nullable=True) # Comma-separated tags
|
||||
|
||||
# Receipt
|
||||
receipt_photo_url = Column(String(500), nullable=True)
|
||||
|
||||
# Recurring transaction
|
||||
is_recurring = Column(Boolean, default=False)
|
||||
recurrence_pattern = Column(String(50), nullable=True) # daily, weekly, monthly, etc.
|
||||
|
||||
# Status
|
||||
is_confirmed = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
transaction_date = Column(DateTime, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
family = relationship("Family")
|
||||
user = relationship("User", back_populates="transactions")
|
||||
account = relationship("Account", back_populates="transactions")
|
||||
category = relationship("Category", back_populates="transactions")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Transaction(id={self.id}, amount={self.amount}, type={self.transaction_type})>"
|
||||
35
.history/app/db/models/user_20251210201604.py
Normal file
35
.history/app/db/models/user_20251210201604.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""User model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model - represents a Telegram user"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
telegram_id = Column(Integer, unique=True, nullable=False, index=True)
|
||||
username = Column(String(255), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
phone = Column(String(20), nullable=True)
|
||||
|
||||
# Account status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
family_members = relationship("FamilyMember", back_populates="user")
|
||||
accounts = relationship("Account", back_populates="owner")
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, telegram_id={self.telegram_id}, username={self.username})>"
|
||||
35
.history/app/db/models/user_20251210202255.py
Normal file
35
.history/app/db/models/user_20251210202255.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""User model"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model - represents a Telegram user"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
telegram_id = Column(Integer, unique=True, nullable=False, index=True)
|
||||
username = Column(String(255), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
phone = Column(String(20), nullable=True)
|
||||
|
||||
# Account status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
family_members = relationship("FamilyMember", back_populates="user")
|
||||
accounts = relationship("Account", back_populates="owner")
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, telegram_id={self.telegram_id}, username={self.username})>"
|
||||
21
.history/app/db/repositories/__init___20251210201605.py
Normal file
21
.history/app/db/repositories/__init___20251210201605.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Repository layer for database access"""
|
||||
|
||||
from app.db.repositories.base import BaseRepository
|
||||
from app.db.repositories.user import UserRepository
|
||||
from app.db.repositories.family import FamilyRepository
|
||||
from app.db.repositories.account import AccountRepository
|
||||
from app.db.repositories.category import CategoryRepository
|
||||
from app.db.repositories.transaction import TransactionRepository
|
||||
from app.db.repositories.budget import BudgetRepository
|
||||
from app.db.repositories.goal import GoalRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"UserRepository",
|
||||
"FamilyRepository",
|
||||
"AccountRepository",
|
||||
"CategoryRepository",
|
||||
"TransactionRepository",
|
||||
"BudgetRepository",
|
||||
"GoalRepository",
|
||||
]
|
||||
21
.history/app/db/repositories/__init___20251210202255.py
Normal file
21
.history/app/db/repositories/__init___20251210202255.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Repository layer for database access"""
|
||||
|
||||
from app.db.repositories.base import BaseRepository
|
||||
from app.db.repositories.user import UserRepository
|
||||
from app.db.repositories.family import FamilyRepository
|
||||
from app.db.repositories.account import AccountRepository
|
||||
from app.db.repositories.category import CategoryRepository
|
||||
from app.db.repositories.transaction import TransactionRepository
|
||||
from app.db.repositories.budget import BudgetRepository
|
||||
from app.db.repositories.goal import GoalRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"UserRepository",
|
||||
"FamilyRepository",
|
||||
"AccountRepository",
|
||||
"CategoryRepository",
|
||||
"TransactionRepository",
|
||||
"BudgetRepository",
|
||||
"GoalRepository",
|
||||
]
|
||||
54
.history/app/db/repositories/account_20251210201606.py
Normal file
54
.history/app/db/repositories/account_20251210201606.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Account repository"""
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Account
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class AccountRepository(BaseRepository[Account]):
|
||||
"""Account data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Account)
|
||||
|
||||
def get_family_accounts(self, family_id: int) -> List[Account]:
|
||||
"""Get all accounts for a family"""
|
||||
return (
|
||||
self.session.query(Account)
|
||||
.filter(Account.family_id == family_id, Account.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_user_accounts(self, user_id: int) -> List[Account]:
|
||||
"""Get all accounts owned by user"""
|
||||
return (
|
||||
self.session.query(Account)
|
||||
.filter(Account.owner_id == user_id, Account.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_account_if_accessible(self, account_id: int, family_id: int) -> Optional[Account]:
|
||||
"""Get account only if it belongs to family"""
|
||||
return (
|
||||
self.session.query(Account)
|
||||
.filter(
|
||||
Account.id == account_id,
|
||||
Account.family_id == family_id,
|
||||
Account.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def update_balance(self, account_id: int, amount: float) -> Optional[Account]:
|
||||
"""Update account balance by delta"""
|
||||
account = self.get_by_id(account_id)
|
||||
if account:
|
||||
account.balance += amount
|
||||
self.session.commit()
|
||||
self.session.refresh(account)
|
||||
return account
|
||||
|
||||
def archive_account(self, account_id: int) -> Optional[Account]:
|
||||
"""Archive account"""
|
||||
return self.update(account_id, is_archived=True)
|
||||
54
.history/app/db/repositories/account_20251210202255.py
Normal file
54
.history/app/db/repositories/account_20251210202255.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Account repository"""
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Account
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class AccountRepository(BaseRepository[Account]):
|
||||
"""Account data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Account)
|
||||
|
||||
def get_family_accounts(self, family_id: int) -> List[Account]:
|
||||
"""Get all accounts for a family"""
|
||||
return (
|
||||
self.session.query(Account)
|
||||
.filter(Account.family_id == family_id, Account.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_user_accounts(self, user_id: int) -> List[Account]:
|
||||
"""Get all accounts owned by user"""
|
||||
return (
|
||||
self.session.query(Account)
|
||||
.filter(Account.owner_id == user_id, Account.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_account_if_accessible(self, account_id: int, family_id: int) -> Optional[Account]:
|
||||
"""Get account only if it belongs to family"""
|
||||
return (
|
||||
self.session.query(Account)
|
||||
.filter(
|
||||
Account.id == account_id,
|
||||
Account.family_id == family_id,
|
||||
Account.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def update_balance(self, account_id: int, amount: float) -> Optional[Account]:
|
||||
"""Update account balance by delta"""
|
||||
account = self.get_by_id(account_id)
|
||||
if account:
|
||||
account.balance += amount
|
||||
self.session.commit()
|
||||
self.session.refresh(account)
|
||||
return account
|
||||
|
||||
def archive_account(self, account_id: int) -> Optional[Account]:
|
||||
"""Archive account"""
|
||||
return self.update(account_id, is_archived=True)
|
||||
64
.history/app/db/repositories/base_20251210201606.py
Normal file
64
.history/app/db/repositories/base_20251210201606.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Base repository with generic CRUD operations"""
|
||||
|
||||
from typing import TypeVar, Generic, Type, List, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.db.database import Base as SQLAlchemyBase
|
||||
|
||||
T = TypeVar("T", bound=SQLAlchemyBase)
|
||||
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Generic repository for CRUD operations"""
|
||||
|
||||
def __init__(self, session: Session, model: Type[T]):
|
||||
self.session = session
|
||||
self.model = model
|
||||
|
||||
def create(self, **kwargs) -> T:
|
||||
"""Create and return new instance"""
|
||||
instance = self.model(**kwargs)
|
||||
self.session.add(instance)
|
||||
self.session.commit()
|
||||
self.session.refresh(instance)
|
||||
return instance
|
||||
|
||||
def get_by_id(self, id: Any) -> Optional[T]:
|
||||
"""Get instance by primary key"""
|
||||
return self.session.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
def get_all(self, skip: int = 0, limit: int = 100) -> List[T]:
|
||||
"""Get all instances with pagination"""
|
||||
return (
|
||||
self.session.query(self.model)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update(self, id: Any, **kwargs) -> Optional[T]:
|
||||
"""Update instance by id"""
|
||||
instance = self.get_by_id(id)
|
||||
if instance:
|
||||
for key, value in kwargs.items():
|
||||
setattr(instance, key, value)
|
||||
self.session.commit()
|
||||
self.session.refresh(instance)
|
||||
return instance
|
||||
|
||||
def delete(self, id: Any) -> bool:
|
||||
"""Delete instance by id"""
|
||||
instance = self.get_by_id(id)
|
||||
if instance:
|
||||
self.session.delete(instance)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def exists(self, **kwargs) -> bool:
|
||||
"""Check if instance exists with given filters"""
|
||||
return self.session.query(self.model).filter_by(**kwargs).first() is not None
|
||||
|
||||
def count(self, **kwargs) -> int:
|
||||
"""Count instances with given filters"""
|
||||
return self.session.query(self.model).filter_by(**kwargs).count()
|
||||
64
.history/app/db/repositories/base_20251210202255.py
Normal file
64
.history/app/db/repositories/base_20251210202255.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Base repository with generic CRUD operations"""
|
||||
|
||||
from typing import TypeVar, Generic, Type, List, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.db.database import Base as SQLAlchemyBase
|
||||
|
||||
T = TypeVar("T", bound=SQLAlchemyBase)
|
||||
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Generic repository for CRUD operations"""
|
||||
|
||||
def __init__(self, session: Session, model: Type[T]):
|
||||
self.session = session
|
||||
self.model = model
|
||||
|
||||
def create(self, **kwargs) -> T:
|
||||
"""Create and return new instance"""
|
||||
instance = self.model(**kwargs)
|
||||
self.session.add(instance)
|
||||
self.session.commit()
|
||||
self.session.refresh(instance)
|
||||
return instance
|
||||
|
||||
def get_by_id(self, id: Any) -> Optional[T]:
|
||||
"""Get instance by primary key"""
|
||||
return self.session.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
def get_all(self, skip: int = 0, limit: int = 100) -> List[T]:
|
||||
"""Get all instances with pagination"""
|
||||
return (
|
||||
self.session.query(self.model)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update(self, id: Any, **kwargs) -> Optional[T]:
|
||||
"""Update instance by id"""
|
||||
instance = self.get_by_id(id)
|
||||
if instance:
|
||||
for key, value in kwargs.items():
|
||||
setattr(instance, key, value)
|
||||
self.session.commit()
|
||||
self.session.refresh(instance)
|
||||
return instance
|
||||
|
||||
def delete(self, id: Any) -> bool:
|
||||
"""Delete instance by id"""
|
||||
instance = self.get_by_id(id)
|
||||
if instance:
|
||||
self.session.delete(instance)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def exists(self, **kwargs) -> bool:
|
||||
"""Check if instance exists with given filters"""
|
||||
return self.session.query(self.model).filter_by(**kwargs).first() is not None
|
||||
|
||||
def count(self, **kwargs) -> int:
|
||||
"""Count instances with given filters"""
|
||||
return self.session.query(self.model).filter_by(**kwargs).count()
|
||||
54
.history/app/db/repositories/budget_20251210201606.py
Normal file
54
.history/app/db/repositories/budget_20251210201606.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Budget repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Budget
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class BudgetRepository(BaseRepository[Budget]):
|
||||
"""Budget data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Budget)
|
||||
|
||||
def get_family_budgets(self, family_id: int) -> List[Budget]:
|
||||
"""Get all active budgets for family"""
|
||||
return (
|
||||
self.session.query(Budget)
|
||||
.filter(Budget.family_id == family_id, Budget.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_category_budget(self, family_id: int, category_id: int) -> Optional[Budget]:
|
||||
"""Get budget for specific category"""
|
||||
return (
|
||||
self.session.query(Budget)
|
||||
.filter(
|
||||
Budget.family_id == family_id,
|
||||
Budget.category_id == category_id,
|
||||
Budget.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_general_budget(self, family_id: int) -> Optional[Budget]:
|
||||
"""Get general budget (no category)"""
|
||||
return (
|
||||
self.session.query(Budget)
|
||||
.filter(
|
||||
Budget.family_id == family_id,
|
||||
Budget.category_id == None,
|
||||
Budget.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def update_spent_amount(self, budget_id: int, amount: float) -> Optional[Budget]:
|
||||
"""Update spent amount for budget"""
|
||||
budget = self.get_by_id(budget_id)
|
||||
if budget:
|
||||
budget.spent_amount += amount
|
||||
self.session.commit()
|
||||
self.session.refresh(budget)
|
||||
return budget
|
||||
54
.history/app/db/repositories/budget_20251210202255.py
Normal file
54
.history/app/db/repositories/budget_20251210202255.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Budget repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Budget
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class BudgetRepository(BaseRepository[Budget]):
|
||||
"""Budget data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Budget)
|
||||
|
||||
def get_family_budgets(self, family_id: int) -> List[Budget]:
|
||||
"""Get all active budgets for family"""
|
||||
return (
|
||||
self.session.query(Budget)
|
||||
.filter(Budget.family_id == family_id, Budget.is_active == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_category_budget(self, family_id: int, category_id: int) -> Optional[Budget]:
|
||||
"""Get budget for specific category"""
|
||||
return (
|
||||
self.session.query(Budget)
|
||||
.filter(
|
||||
Budget.family_id == family_id,
|
||||
Budget.category_id == category_id,
|
||||
Budget.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_general_budget(self, family_id: int) -> Optional[Budget]:
|
||||
"""Get general budget (no category)"""
|
||||
return (
|
||||
self.session.query(Budget)
|
||||
.filter(
|
||||
Budget.family_id == family_id,
|
||||
Budget.category_id == None,
|
||||
Budget.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def update_spent_amount(self, budget_id: int, amount: float) -> Optional[Budget]:
|
||||
"""Update spent amount for budget"""
|
||||
budget = self.get_by_id(budget_id)
|
||||
if budget:
|
||||
budget.spent_amount += amount
|
||||
self.session.commit()
|
||||
self.session.refresh(budget)
|
||||
return budget
|
||||
50
.history/app/db/repositories/category_20251210201606.py
Normal file
50
.history/app/db/repositories/category_20251210201606.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Category repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Category, CategoryType
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class CategoryRepository(BaseRepository[Category]):
|
||||
"""Category data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Category)
|
||||
|
||||
def get_family_categories(
|
||||
self, family_id: int, category_type: Optional[CategoryType] = None
|
||||
) -> List[Category]:
|
||||
"""Get categories for family, optionally filtered by type"""
|
||||
query = self.session.query(Category).filter(
|
||||
Category.family_id == family_id,
|
||||
Category.is_active == True
|
||||
)
|
||||
if category_type:
|
||||
query = query.filter(Category.category_type == category_type)
|
||||
return query.order_by(Category.order).all()
|
||||
|
||||
def get_by_name(self, family_id: int, name: str) -> Optional[Category]:
|
||||
"""Get category by name"""
|
||||
return (
|
||||
self.session.query(Category)
|
||||
.filter(
|
||||
Category.family_id == family_id,
|
||||
Category.name == name,
|
||||
Category.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_default_categories(self, family_id: int, category_type: CategoryType) -> List[Category]:
|
||||
"""Get default categories of type"""
|
||||
return (
|
||||
self.session.query(Category)
|
||||
.filter(
|
||||
Category.family_id == family_id,
|
||||
Category.category_type == category_type,
|
||||
Category.is_default == True,
|
||||
Category.is_active == True
|
||||
)
|
||||
.all()
|
||||
)
|
||||
50
.history/app/db/repositories/category_20251210202255.py
Normal file
50
.history/app/db/repositories/category_20251210202255.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Category repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Category, CategoryType
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class CategoryRepository(BaseRepository[Category]):
|
||||
"""Category data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Category)
|
||||
|
||||
def get_family_categories(
|
||||
self, family_id: int, category_type: Optional[CategoryType] = None
|
||||
) -> List[Category]:
|
||||
"""Get categories for family, optionally filtered by type"""
|
||||
query = self.session.query(Category).filter(
|
||||
Category.family_id == family_id,
|
||||
Category.is_active == True
|
||||
)
|
||||
if category_type:
|
||||
query = query.filter(Category.category_type == category_type)
|
||||
return query.order_by(Category.order).all()
|
||||
|
||||
def get_by_name(self, family_id: int, name: str) -> Optional[Category]:
|
||||
"""Get category by name"""
|
||||
return (
|
||||
self.session.query(Category)
|
||||
.filter(
|
||||
Category.family_id == family_id,
|
||||
Category.name == name,
|
||||
Category.is_active == True
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_default_categories(self, family_id: int, category_type: CategoryType) -> List[Category]:
|
||||
"""Get default categories of type"""
|
||||
return (
|
||||
self.session.query(Category)
|
||||
.filter(
|
||||
Category.family_id == family_id,
|
||||
Category.category_type == category_type,
|
||||
Category.is_default == True,
|
||||
Category.is_active == True
|
||||
)
|
||||
.all()
|
||||
)
|
||||
69
.history/app/db/repositories/family_20251210201606.py
Normal file
69
.history/app/db/repositories/family_20251210201606.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Family repository"""
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Family, FamilyMember, FamilyInvite
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class FamilyRepository(BaseRepository[Family]):
|
||||
"""Family data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Family)
|
||||
|
||||
def get_by_invite_code(self, invite_code: str) -> Optional[Family]:
|
||||
"""Get family by invite code"""
|
||||
return self.session.query(Family).filter(Family.invite_code == invite_code).first()
|
||||
|
||||
def get_user_families(self, user_id: int) -> List[Family]:
|
||||
"""Get all families for a user"""
|
||||
return (
|
||||
self.session.query(Family)
|
||||
.join(FamilyMember)
|
||||
.filter(FamilyMember.user_id == user_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
def is_member(self, family_id: int, user_id: int) -> bool:
|
||||
"""Check if user is member of family"""
|
||||
return (
|
||||
self.session.query(FamilyMember)
|
||||
.filter(
|
||||
FamilyMember.family_id == family_id,
|
||||
FamilyMember.user_id == user_id
|
||||
)
|
||||
.first() is not None
|
||||
)
|
||||
|
||||
def add_member(self, family_id: int, user_id: int, role: str = "member") -> FamilyMember:
|
||||
"""Add user to family"""
|
||||
member = FamilyMember(family_id=family_id, user_id=user_id, role=role)
|
||||
self.session.add(member)
|
||||
self.session.commit()
|
||||
self.session.refresh(member)
|
||||
return member
|
||||
|
||||
def remove_member(self, family_id: int, user_id: int) -> bool:
|
||||
"""Remove user from family"""
|
||||
member = (
|
||||
self.session.query(FamilyMember)
|
||||
.filter(
|
||||
FamilyMember.family_id == family_id,
|
||||
FamilyMember.user_id == user_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if member:
|
||||
self.session.delete(member)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_invite(self, invite_code: str) -> Optional[FamilyInvite]:
|
||||
"""Get invite by code"""
|
||||
return (
|
||||
self.session.query(FamilyInvite)
|
||||
.filter(FamilyInvite.invite_code == invite_code)
|
||||
.first()
|
||||
)
|
||||
69
.history/app/db/repositories/family_20251210202255.py
Normal file
69
.history/app/db/repositories/family_20251210202255.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Family repository"""
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Family, FamilyMember, FamilyInvite
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class FamilyRepository(BaseRepository[Family]):
|
||||
"""Family data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Family)
|
||||
|
||||
def get_by_invite_code(self, invite_code: str) -> Optional[Family]:
|
||||
"""Get family by invite code"""
|
||||
return self.session.query(Family).filter(Family.invite_code == invite_code).first()
|
||||
|
||||
def get_user_families(self, user_id: int) -> List[Family]:
|
||||
"""Get all families for a user"""
|
||||
return (
|
||||
self.session.query(Family)
|
||||
.join(FamilyMember)
|
||||
.filter(FamilyMember.user_id == user_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
def is_member(self, family_id: int, user_id: int) -> bool:
|
||||
"""Check if user is member of family"""
|
||||
return (
|
||||
self.session.query(FamilyMember)
|
||||
.filter(
|
||||
FamilyMember.family_id == family_id,
|
||||
FamilyMember.user_id == user_id
|
||||
)
|
||||
.first() is not None
|
||||
)
|
||||
|
||||
def add_member(self, family_id: int, user_id: int, role: str = "member") -> FamilyMember:
|
||||
"""Add user to family"""
|
||||
member = FamilyMember(family_id=family_id, user_id=user_id, role=role)
|
||||
self.session.add(member)
|
||||
self.session.commit()
|
||||
self.session.refresh(member)
|
||||
return member
|
||||
|
||||
def remove_member(self, family_id: int, user_id: int) -> bool:
|
||||
"""Remove user from family"""
|
||||
member = (
|
||||
self.session.query(FamilyMember)
|
||||
.filter(
|
||||
FamilyMember.family_id == family_id,
|
||||
FamilyMember.user_id == user_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if member:
|
||||
self.session.delete(member)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_invite(self, invite_code: str) -> Optional[FamilyInvite]:
|
||||
"""Get invite by code"""
|
||||
return (
|
||||
self.session.query(FamilyInvite)
|
||||
.filter(FamilyInvite.invite_code == invite_code)
|
||||
.first()
|
||||
)
|
||||
50
.history/app/db/repositories/goal_20251210201606.py
Normal file
50
.history/app/db/repositories/goal_20251210201606.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Goal repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Goal
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class GoalRepository(BaseRepository[Goal]):
|
||||
"""Goal data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Goal)
|
||||
|
||||
def get_family_goals(self, family_id: int) -> List[Goal]:
|
||||
"""Get all active goals for family"""
|
||||
return (
|
||||
self.session.query(Goal)
|
||||
.filter(Goal.family_id == family_id, Goal.is_active == True)
|
||||
.order_by(Goal.priority.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_goals_progress(self, family_id: int) -> List[dict]:
|
||||
"""Get goals with progress info"""
|
||||
goals = self.get_family_goals(family_id)
|
||||
return [
|
||||
{
|
||||
"id": goal.id,
|
||||
"name": goal.name,
|
||||
"target": goal.target_amount,
|
||||
"current": goal.current_amount,
|
||||
"progress_percent": (goal.current_amount / goal.target_amount * 100) if goal.target_amount > 0 else 0,
|
||||
"is_completed": goal.is_completed
|
||||
}
|
||||
for goal in goals
|
||||
]
|
||||
|
||||
def update_progress(self, goal_id: int, amount: float) -> Optional[Goal]:
|
||||
"""Update goal progress"""
|
||||
goal = self.get_by_id(goal_id)
|
||||
if goal:
|
||||
goal.current_amount += amount
|
||||
if goal.current_amount >= goal.target_amount:
|
||||
goal.is_completed = True
|
||||
from datetime import datetime
|
||||
goal.completed_at = datetime.utcnow()
|
||||
self.session.commit()
|
||||
self.session.refresh(goal)
|
||||
return goal
|
||||
50
.history/app/db/repositories/goal_20251210202255.py
Normal file
50
.history/app/db/repositories/goal_20251210202255.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Goal repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Goal
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class GoalRepository(BaseRepository[Goal]):
|
||||
"""Goal data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Goal)
|
||||
|
||||
def get_family_goals(self, family_id: int) -> List[Goal]:
|
||||
"""Get all active goals for family"""
|
||||
return (
|
||||
self.session.query(Goal)
|
||||
.filter(Goal.family_id == family_id, Goal.is_active == True)
|
||||
.order_by(Goal.priority.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_goals_progress(self, family_id: int) -> List[dict]:
|
||||
"""Get goals with progress info"""
|
||||
goals = self.get_family_goals(family_id)
|
||||
return [
|
||||
{
|
||||
"id": goal.id,
|
||||
"name": goal.name,
|
||||
"target": goal.target_amount,
|
||||
"current": goal.current_amount,
|
||||
"progress_percent": (goal.current_amount / goal.target_amount * 100) if goal.target_amount > 0 else 0,
|
||||
"is_completed": goal.is_completed
|
||||
}
|
||||
for goal in goals
|
||||
]
|
||||
|
||||
def update_progress(self, goal_id: int, amount: float) -> Optional[Goal]:
|
||||
"""Update goal progress"""
|
||||
goal = self.get_by_id(goal_id)
|
||||
if goal:
|
||||
goal.current_amount += amount
|
||||
if goal.current_amount >= goal.target_amount:
|
||||
goal.is_completed = True
|
||||
from datetime import datetime
|
||||
goal.completed_at = datetime.utcnow()
|
||||
self.session.commit()
|
||||
self.session.refresh(goal)
|
||||
return goal
|
||||
94
.history/app/db/repositories/transaction_20251210201606.py
Normal file
94
.history/app/db/repositories/transaction_20251210201606.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Transaction repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
from app.db.models import Transaction, TransactionType
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class TransactionRepository(BaseRepository[Transaction]):
|
||||
"""Transaction data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Transaction)
|
||||
|
||||
def get_family_transactions(self, family_id: int, skip: int = 0, limit: int = 50) -> List[Transaction]:
|
||||
"""Get transactions for family"""
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(Transaction.family_id == family_id)
|
||||
.order_by(Transaction.transaction_date.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_transactions_by_period(
|
||||
self, family_id: int, start_date: datetime, end_date: datetime
|
||||
) -> List[Transaction]:
|
||||
"""Get transactions within date range"""
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.family_id == family_id,
|
||||
Transaction.transaction_date >= start_date,
|
||||
Transaction.transaction_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(Transaction.transaction_date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_transactions_by_category(
|
||||
self, family_id: int, category_id: int, start_date: datetime, end_date: datetime
|
||||
) -> List[Transaction]:
|
||||
"""Get transactions by category in date range"""
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.family_id == family_id,
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.transaction_date >= start_date,
|
||||
Transaction.transaction_date <= end_date
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_user_transactions(self, user_id: int, days: int = 30) -> List[Transaction]:
|
||||
"""Get user's recent transactions"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.transaction_date >= start_date
|
||||
)
|
||||
)
|
||||
.order_by(Transaction.transaction_date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def sum_by_category(
|
||||
self, family_id: int, category_id: int, start_date: datetime, end_date: datetime
|
||||
) -> float:
|
||||
"""Calculate sum of transactions by category"""
|
||||
result = (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.family_id == family_id,
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.transaction_date >= start_date,
|
||||
Transaction.transaction_date <= end_date,
|
||||
Transaction.transaction_type == TransactionType.EXPENSE
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return sum(t.amount for t in result)
|
||||
94
.history/app/db/repositories/transaction_20251210202255.py
Normal file
94
.history/app/db/repositories/transaction_20251210202255.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Transaction repository"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
from app.db.models import Transaction, TransactionType
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class TransactionRepository(BaseRepository[Transaction]):
|
||||
"""Transaction data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, Transaction)
|
||||
|
||||
def get_family_transactions(self, family_id: int, skip: int = 0, limit: int = 50) -> List[Transaction]:
|
||||
"""Get transactions for family"""
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(Transaction.family_id == family_id)
|
||||
.order_by(Transaction.transaction_date.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_transactions_by_period(
|
||||
self, family_id: int, start_date: datetime, end_date: datetime
|
||||
) -> List[Transaction]:
|
||||
"""Get transactions within date range"""
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.family_id == family_id,
|
||||
Transaction.transaction_date >= start_date,
|
||||
Transaction.transaction_date <= end_date
|
||||
)
|
||||
)
|
||||
.order_by(Transaction.transaction_date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_transactions_by_category(
|
||||
self, family_id: int, category_id: int, start_date: datetime, end_date: datetime
|
||||
) -> List[Transaction]:
|
||||
"""Get transactions by category in date range"""
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.family_id == family_id,
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.transaction_date >= start_date,
|
||||
Transaction.transaction_date <= end_date
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_user_transactions(self, user_id: int, days: int = 30) -> List[Transaction]:
|
||||
"""Get user's recent transactions"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
return (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.transaction_date >= start_date
|
||||
)
|
||||
)
|
||||
.order_by(Transaction.transaction_date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def sum_by_category(
|
||||
self, family_id: int, category_id: int, start_date: datetime, end_date: datetime
|
||||
) -> float:
|
||||
"""Calculate sum of transactions by category"""
|
||||
result = (
|
||||
self.session.query(Transaction)
|
||||
.filter(
|
||||
and_(
|
||||
Transaction.family_id == family_id,
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.transaction_date >= start_date,
|
||||
Transaction.transaction_date <= end_date,
|
||||
Transaction.transaction_type == TransactionType.EXPENSE
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return sum(t.amount for t in result)
|
||||
38
.history/app/db/repositories/user_20251210201606.py
Normal file
38
.history/app/db/repositories/user_20251210201606.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""User repository"""
|
||||
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import User
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""User data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, User)
|
||||
|
||||
def get_by_telegram_id(self, telegram_id: int) -> Optional[User]:
|
||||
"""Get user by Telegram ID"""
|
||||
return self.session.query(User).filter(User.telegram_id == telegram_id).first()
|
||||
|
||||
def get_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get user by username"""
|
||||
return self.session.query(User).filter(User.username == username).first()
|
||||
|
||||
def get_or_create(self, telegram_id: int, **kwargs) -> User:
|
||||
"""Get user or create if doesn't exist"""
|
||||
user = self.get_by_telegram_id(telegram_id)
|
||||
if not user:
|
||||
user = self.create(telegram_id=telegram_id, **kwargs)
|
||||
return user
|
||||
|
||||
def update_activity(self, telegram_id: int) -> Optional[User]:
|
||||
"""Update user's last activity timestamp"""
|
||||
from datetime import datetime
|
||||
user = self.get_by_telegram_id(telegram_id)
|
||||
if user:
|
||||
user.last_activity = datetime.utcnow()
|
||||
self.session.commit()
|
||||
self.session.refresh(user)
|
||||
return user
|
||||
38
.history/app/db/repositories/user_20251210202255.py
Normal file
38
.history/app/db/repositories/user_20251210202255.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""User repository"""
|
||||
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import User
|
||||
from app.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""User data access operations"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__(session, User)
|
||||
|
||||
def get_by_telegram_id(self, telegram_id: int) -> Optional[User]:
|
||||
"""Get user by Telegram ID"""
|
||||
return self.session.query(User).filter(User.telegram_id == telegram_id).first()
|
||||
|
||||
def get_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get user by username"""
|
||||
return self.session.query(User).filter(User.username == username).first()
|
||||
|
||||
def get_or_create(self, telegram_id: int, **kwargs) -> User:
|
||||
"""Get user or create if doesn't exist"""
|
||||
user = self.get_by_telegram_id(telegram_id)
|
||||
if not user:
|
||||
user = self.create(telegram_id=telegram_id, **kwargs)
|
||||
return user
|
||||
|
||||
def update_activity(self, telegram_id: int) -> Optional[User]:
|
||||
"""Update user's last activity timestamp"""
|
||||
from datetime import datetime
|
||||
user = self.get_by_telegram_id(telegram_id)
|
||||
if user:
|
||||
user.last_activity = datetime.utcnow()
|
||||
self.session.commit()
|
||||
self.session.refresh(user)
|
||||
return user
|
||||
45
.history/app/main_20251210201719.py
Normal file
45
.history/app/main_20251210201719.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Main application entry point"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from app.core.config import get_settings
|
||||
from app.bot import register_handlers
|
||||
from app.db.database import engine, Base
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main bot application"""
|
||||
settings = get_settings()
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables created")
|
||||
|
||||
# Initialize bot and dispatcher
|
||||
bot = Bot(token=settings.bot_token)
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
# Register handlers
|
||||
register_handlers(dp)
|
||||
logger.info("Handlers registered")
|
||||
|
||||
# Start polling
|
||||
logger.info("Bot polling started")
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
45
.history/app/main_20251210202255.py
Normal file
45
.history/app/main_20251210202255.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Main application entry point"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from app.core.config import get_settings
|
||||
from app.bot import register_handlers
|
||||
from app.db.database import engine, Base
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main bot application"""
|
||||
settings = get_settings()
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables created")
|
||||
|
||||
# Initialize bot and dispatcher
|
||||
bot = Bot(token=settings.bot_token)
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
# Register handlers
|
||||
register_handlers(dp)
|
||||
logger.info("Handlers registered")
|
||||
|
||||
# Start polling
|
||||
logger.info("Bot polling started")
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
108
.history/app/main_20251210210611.py
Normal file
108
.history/app/main_20251210210611.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get settings
|
||||
settings = get_settings()
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
108
.history/app/main_20251210210906.py
Normal file
108
.history/app/main_20251210210906.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get settings
|
||||
settings = get_settings()
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
108
.history/app/main_20251210212127.py
Normal file
108
.history/app/main_20251210212127.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get settings
|
||||
settings = get_settings()
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
105
.history/app/main_20251210212130.py
Normal file
105
.history/app/main_20251210212130.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
105
.history/app/main_20251210212154.py
Normal file
105
.history/app/main_20251210212154.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
109
.history/app/main_20251210212947.py
Normal file
109
.history/app/main_20251210212947.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Suppress Pydantic V2 migration warnings
|
||||
warnings.filterwarnings('ignore', message="Valid config keys have changed in V2")
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
109
.history/app/main_20251210212958.py
Normal file
109
.history/app/main_20251210212958.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
FastAPI Application Entry Point
|
||||
Integrated API Gateway + Telegram Bot
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.db.database import engine, Base, get_db
|
||||
from app.security.middleware import add_security_middleware
|
||||
from app.api import transactions, auth
|
||||
import redis
|
||||
|
||||
# Suppress Pydantic V2 migration warnings
|
||||
warnings.filterwarnings('ignore', message="Valid config keys have changed in V2")
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup/Shutdown events
|
||||
"""
|
||||
# === STARTUP ===
|
||||
logger.info("🚀 Application starting...")
|
||||
|
||||
# Create database tables (if not exist)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database initialized")
|
||||
|
||||
# Verify Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
logger.info("✅ Redis connected")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis connection failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# === SHUTDOWN ===
|
||||
logger.info("🛑 Application shutting down...")
|
||||
redis_client.close()
|
||||
logger.info("✅ Cleanup complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="API-First Zero-Trust Architecture for Family Finance Management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=settings.cors_allow_methods,
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
add_security_middleware(app, redis_client, next(get_db()))
|
||||
|
||||
# Include API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(transactions.router)
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ========== Graceful Shutdown ==========
|
||||
import signal
|
||||
import asyncio
|
||||
|
||||
async def shutdown_handler(sig):
|
||||
"""Handle graceful shutdown"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
# Close connections
|
||||
redis_client.close()
|
||||
|
||||
# Exit
|
||||
return 0
|
||||
27
.history/app/schemas/__init___20251210201617.py
Normal file
27
.history/app/schemas/__init___20251210201617.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Pydantic schemas for request/response validation"""
|
||||
|
||||
from app.schemas.user import UserSchema, UserCreateSchema
|
||||
from app.schemas.family import FamilySchema, FamilyCreateSchema, FamilyMemberSchema
|
||||
from app.schemas.account import AccountSchema, AccountCreateSchema
|
||||
from app.schemas.category import CategorySchema, CategoryCreateSchema
|
||||
from app.schemas.transaction import TransactionSchema, TransactionCreateSchema
|
||||
from app.schemas.budget import BudgetSchema, BudgetCreateSchema
|
||||
from app.schemas.goal import GoalSchema, GoalCreateSchema
|
||||
|
||||
__all__ = [
|
||||
"UserSchema",
|
||||
"UserCreateSchema",
|
||||
"FamilySchema",
|
||||
"FamilyCreateSchema",
|
||||
"FamilyMemberSchema",
|
||||
"AccountSchema",
|
||||
"AccountCreateSchema",
|
||||
"CategorySchema",
|
||||
"CategoryCreateSchema",
|
||||
"TransactionSchema",
|
||||
"TransactionCreateSchema",
|
||||
"BudgetSchema",
|
||||
"BudgetCreateSchema",
|
||||
"GoalSchema",
|
||||
"GoalCreateSchema",
|
||||
]
|
||||
27
.history/app/schemas/__init___20251210202255.py
Normal file
27
.history/app/schemas/__init___20251210202255.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Pydantic schemas for request/response validation"""
|
||||
|
||||
from app.schemas.user import UserSchema, UserCreateSchema
|
||||
from app.schemas.family import FamilySchema, FamilyCreateSchema, FamilyMemberSchema
|
||||
from app.schemas.account import AccountSchema, AccountCreateSchema
|
||||
from app.schemas.category import CategorySchema, CategoryCreateSchema
|
||||
from app.schemas.transaction import TransactionSchema, TransactionCreateSchema
|
||||
from app.schemas.budget import BudgetSchema, BudgetCreateSchema
|
||||
from app.schemas.goal import GoalSchema, GoalCreateSchema
|
||||
|
||||
__all__ = [
|
||||
"UserSchema",
|
||||
"UserCreateSchema",
|
||||
"FamilySchema",
|
||||
"FamilyCreateSchema",
|
||||
"FamilyMemberSchema",
|
||||
"AccountSchema",
|
||||
"AccountCreateSchema",
|
||||
"CategorySchema",
|
||||
"CategoryCreateSchema",
|
||||
"TransactionSchema",
|
||||
"TransactionCreateSchema",
|
||||
"BudgetSchema",
|
||||
"BudgetCreateSchema",
|
||||
"GoalSchema",
|
||||
"GoalCreateSchema",
|
||||
]
|
||||
28
.history/app/schemas/account_20251210201618.py
Normal file
28
.history/app/schemas/account_20251210201618.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Account schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AccountCreateSchema(BaseModel):
|
||||
"""Schema for creating account"""
|
||||
name: str
|
||||
account_type: str = "card"
|
||||
description: Optional[str] = None
|
||||
initial_balance: float = 0.0
|
||||
|
||||
|
||||
class AccountSchema(AccountCreateSchema):
|
||||
"""Account response schema"""
|
||||
id: int
|
||||
family_id: int
|
||||
owner_id: int
|
||||
balance: float
|
||||
is_active: bool
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
28
.history/app/schemas/account_20251210202255.py
Normal file
28
.history/app/schemas/account_20251210202255.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Account schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AccountCreateSchema(BaseModel):
|
||||
"""Schema for creating account"""
|
||||
name: str
|
||||
account_type: str = "card"
|
||||
description: Optional[str] = None
|
||||
initial_balance: float = 0.0
|
||||
|
||||
|
||||
class AccountSchema(AccountCreateSchema):
|
||||
"""Account response schema"""
|
||||
id: int
|
||||
family_id: int
|
||||
owner_id: int
|
||||
balance: float
|
||||
is_active: bool
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
29
.history/app/schemas/budget_20251210201618.py
Normal file
29
.history/app/schemas/budget_20251210201618.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Budget schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BudgetCreateSchema(BaseModel):
|
||||
"""Schema for creating budget"""
|
||||
name: str
|
||||
limit_amount: float
|
||||
period: str = "monthly"
|
||||
alert_threshold: float = 80.0
|
||||
category_id: Optional[int] = None
|
||||
start_date: datetime
|
||||
|
||||
|
||||
class BudgetSchema(BudgetCreateSchema):
|
||||
"""Budget response schema"""
|
||||
id: int
|
||||
family_id: int
|
||||
spent_amount: float
|
||||
is_active: bool
|
||||
end_date: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
29
.history/app/schemas/budget_20251210202255.py
Normal file
29
.history/app/schemas/budget_20251210202255.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Budget schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BudgetCreateSchema(BaseModel):
|
||||
"""Schema for creating budget"""
|
||||
name: str
|
||||
limit_amount: float
|
||||
period: str = "monthly"
|
||||
alert_threshold: float = 80.0
|
||||
category_id: Optional[int] = None
|
||||
start_date: datetime
|
||||
|
||||
|
||||
class BudgetSchema(BudgetCreateSchema):
|
||||
"""Budget response schema"""
|
||||
id: int
|
||||
family_id: int
|
||||
spent_amount: float
|
||||
is_active: bool
|
||||
end_date: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
28
.history/app/schemas/category_20251210201618.py
Normal file
28
.history/app/schemas/category_20251210201618.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Category schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CategoryCreateSchema(BaseModel):
|
||||
"""Schema for creating category"""
|
||||
name: str
|
||||
category_type: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class CategorySchema(CategoryCreateSchema):
|
||||
"""Category response schema"""
|
||||
id: int
|
||||
family_id: int
|
||||
is_active: bool
|
||||
order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
28
.history/app/schemas/category_20251210202255.py
Normal file
28
.history/app/schemas/category_20251210202255.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Category schemas"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CategoryCreateSchema(BaseModel):
|
||||
"""Schema for creating category"""
|
||||
name: str
|
||||
category_type: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class CategorySchema(CategoryCreateSchema):
|
||||
"""Category response schema"""
|
||||
id: int
|
||||
family_id: int
|
||||
is_active: bool
|
||||
order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user