progress in file storage

This commit is contained in:
Hesabix 2025-09-21 21:40:10 +03:30
parent 754d61e622
commit bee18daf4a
8 changed files with 586 additions and 306 deletions

View file

@ -2,9 +2,10 @@ from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request
from sqlalchemy.orm import Session
from sqlalchemy import and_
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_permission
from app.core.responses import success_response
from app.core.responses import ApiError
@ -12,6 +13,7 @@ from app.core.i18n import locale_dependency
from app.services.file_storage_service import FileStorageService
from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository
from adapters.db.models.user import User
from adapters.db.models.file_storage import StorageConfig
from adapters.api.v1.schema_models.file_storage import (
StorageConfigCreateRequest,
StorageConfigUpdateRequest,
@ -36,19 +38,85 @@ async def list_all_files(
is_temporary: Optional[bool] = Query(None),
is_verified: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.view")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""لیست تمام فایل‌ها با فیلتر"""
try:
file_service = FileStorageService(db)
# Check permission
if not current_user.has_app_permission("admin.file.view"):
raise ApiError(
code="FORBIDDEN",
message=translator.t("FORBIDDEN", "دسترسی غیرمجاز"),
http_status=403,
translator=translator
)
# TODO: پیاده‌سازی pagination و فیلترها
statistics = await file_service.get_storage_statistics()
file_repo = FileStorageRepository(db)
# محاسبه offset برای pagination
offset = (page - 1) * size
# ساخت فیلترها
filters = []
if module_context:
filters.append(FileStorage.module_context == module_context)
if is_temporary is not None:
filters.append(FileStorage.is_temporary == is_temporary)
if is_verified is not None:
filters.append(FileStorage.is_verified == is_verified)
# اضافه کردن فیلتر حذف نشده
filters.append(FileStorage.deleted_at.is_(None))
# دریافت فایل‌ها با فیلتر و pagination
files_query = db.query(FileStorage).filter(and_(*filters))
total_count = files_query.count()
files = files_query.order_by(FileStorage.created_at.desc()).offset(offset).limit(size).all()
# تبدیل به فرمت مناسب
files_data = []
for file in files:
files_data.append({
"id": str(file.id),
"original_name": file.original_name,
"stored_name": file.stored_name,
"file_size": file.file_size,
"mime_type": file.mime_type,
"storage_type": file.storage_type,
"module_context": file.module_context,
"context_id": str(file.context_id) if file.context_id else None,
"is_temporary": file.is_temporary,
"is_verified": file.is_verified,
"is_active": file.is_active,
"created_at": file.created_at.isoformat(),
"updated_at": file.updated_at.isoformat(),
"expires_at": file.expires_at.isoformat() if file.expires_at else None,
"uploaded_by": file.uploaded_by,
"checksum": file.checksum
})
# محاسبه pagination info
total_pages = (total_count + size - 1) // size
has_next = page < total_pages
has_prev = page > 1
data = {
"statistics": statistics,
"message": translator.t("FILE_LIST_NOT_IMPLEMENTED", "File list endpoint - to be implemented")
"files": files_data,
"pagination": {
"page": page,
"size": size,
"total_count": total_count,
"total_pages": total_pages,
"has_next": has_next,
"has_prev": has_prev
},
"filters": {
"module_context": module_context,
"is_temporary": is_temporary,
"is_verified": is_verified
}
}
return success_response(data, request)
@ -65,7 +133,7 @@ async def list_all_files(
async def get_unverified_files(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.view")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""فایل‌های تایید نشده"""
@ -102,7 +170,7 @@ async def get_unverified_files(
async def cleanup_temporary_files(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.cleanup")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""پاکسازی فایل‌های موقت"""
@ -130,7 +198,7 @@ async def force_delete_file(
file_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.delete")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""حذف اجباری فایل"""
@ -164,7 +232,7 @@ async def restore_file(
file_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.restore")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""بازیابی فایل حذف شده"""
@ -197,7 +265,7 @@ async def restore_file(
async def get_file_statistics(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.view")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""آمار استفاده از فضای ذخیره‌سازی"""
@ -220,13 +288,22 @@ async def get_file_statistics(
async def get_storage_configs(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.view")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""لیست تنظیمات ذخیره‌سازی"""
try:
# Check permission
if not current_user.has_app_permission("admin.storage.view"):
raise ApiError(
code="FORBIDDEN",
message=translator.t("FORBIDDEN", "دسترسی غیرمجاز"),
http_status=403,
translator=translator
)
config_repo = StorageConfigRepository(db)
configs = await config_repo.get_all_configs()
configs = config_repo.get_all_configs()
data = {
"configs": [
@ -236,6 +313,7 @@ async def get_storage_configs(
"storage_type": config.storage_type,
"is_default": config.is_default,
"is_active": config.is_active,
"config_data": config.config_data,
"created_at": config.created_at.isoformat()
}
for config in configs
@ -257,7 +335,7 @@ async def create_storage_config(
request: Request,
config_request: StorageConfigCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.create")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""ایجاد تنظیمات ذخیره‌سازی جدید"""
@ -268,8 +346,9 @@ async def create_storage_config(
name=config_request.name,
storage_type=config_request.storage_type,
config_data=config_request.config_data,
created_by=current_user.id,
is_default=config_request.is_default
created_by=current_user.get_user_id(),
is_default=config_request.is_default,
is_active=config_request.is_active
)
data = {
@ -293,7 +372,7 @@ async def update_storage_config(
request: Request,
config_request: StorageConfigUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.update")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""بروزرسانی تنظیمات ذخیره‌سازی"""
@ -317,7 +396,7 @@ async def set_default_storage_config(
config_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.update")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""تنظیم به عنوان پیش‌فرض"""
@ -351,7 +430,7 @@ async def delete_storage_config(
config_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.delete")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""حذف تنظیمات ذخیره‌سازی"""
@ -382,17 +461,42 @@ async def delete_storage_config(
@router.post("/storage-configs/{config_id}/test", response_model=dict)
async def test_storage_config(
config_id: UUID,
config_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.test")),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""تست اتصال به storage"""
try:
# TODO: پیاده‌سازی تست اتصال
data = {"message": translator.t("STORAGE_CONNECTION_TEST_NOT_IMPLEMENTED", "Storage connection test - to be implemented")}
config_repo = StorageConfigRepository(db)
config = db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
if not config:
raise ApiError(
code="STORAGE_CONFIG_NOT_FOUND",
message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"),
http_status=404,
translator=translator
)
# تست اتصال بر اساس نوع storage
test_result = await _test_storage_connection(config)
if test_result["success"]:
data = {
"message": translator.t("STORAGE_CONNECTION_SUCCESS", "اتصال به storage موفقیت‌آمیز بود"),
"test_result": test_result
}
else:
data = {
"message": translator.t("STORAGE_CONNECTION_FAILED", "اتصال به storage ناموفق بود"),
"test_result": test_result
}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="TEST_STORAGE_CONFIG_ERROR",
@ -400,3 +504,107 @@ async def test_storage_config(
http_status=500,
translator=translator
)
# Helper function for testing storage connections
async def _test_storage_connection(config: StorageConfig) -> dict:
"""تست اتصال به storage بر اساس نوع آن"""
import os
import tempfile
from datetime import datetime
try:
if config.storage_type == "local":
return await _test_local_storage(config)
elif config.storage_type == "ftp":
return await _test_ftp_storage(config)
else:
return {
"success": False,
"error": f"نوع storage پشتیبانی نشده: {config.storage_type}",
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": str(e),
"tested_at": datetime.utcnow().isoformat()
}
async def _test_local_storage(config: StorageConfig) -> dict:
"""تست اتصال به local storage"""
import os
from datetime import datetime
try:
base_path = config.config_data.get("base_path", "/tmp/hesabix_files")
# بررسی وجود مسیر
if not os.path.exists(base_path):
# تلاش برای ایجاد مسیر
os.makedirs(base_path, exist_ok=True)
# بررسی دسترسی نوشتن
test_file_path = os.path.join(base_path, f"test_connection_{datetime.utcnow().timestamp()}.txt")
# نوشتن فایل تست
with open(test_file_path, "w") as f:
f.write("Test connection file")
# خواندن فایل تست
with open(test_file_path, "r") as f:
content = f.read()
# حذف فایل تست
os.remove(test_file_path)
if content == "Test connection file":
return {
"success": True,
"message": "اتصال به local storage موفقیت‌آمیز بود",
"storage_type": "local",
"base_path": base_path,
"tested_at": datetime.utcnow().isoformat()
}
else:
return {
"success": False,
"error": "خطا در خواندن فایل تست",
"tested_at": datetime.utcnow().isoformat()
}
except PermissionError:
return {
"success": False,
"error": "دسترسی به مسیر ذخیره‌سازی وجود ندارد",
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": f"خطا در تست local storage: {str(e)}",
"tested_at": datetime.utcnow().isoformat()
}
async def _test_ftp_storage(config: StorageConfig) -> dict:
"""تست اتصال به FTP storage"""
from datetime import datetime
try:
# TODO: پیاده‌سازی تست FTP
# فعلاً فقط ساختار کلی را برمی‌گردانیم
return {
"success": False,
"error": "تست FTP هنوز پیاده‌سازی نشده است",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": f"خطا در تست FTP storage: {str(e)}",
"tested_at": datetime.utcnow().isoformat()
}

View file

@ -10,6 +10,7 @@ class StorageConfigCreateRequest(BaseModel):
storage_type: str = Field(..., description="نوع ذخیره‌سازی")
config_data: Dict[str, Any] = Field(..., description="داده‌های پیکربندی")
is_default: bool = Field(default=False, description="آیا پیش‌فرض است")
is_active: bool = Field(default=True, description="آیا فعال است")
class StorageConfigUpdateRequest(BaseModel):

View file

@ -10,7 +10,7 @@ from adapters.db.repositories.base_repo import BaseRepository
class FileStorageRepository(BaseRepository[FileStorage]):
def __init__(self, db: Session):
super().__init__(FileStorage, db)
super().__init__(db, FileStorage)
async def create_file(
self,
@ -177,15 +177,16 @@ class FileStorageRepository(BaseRepository[FileStorage]):
class StorageConfigRepository(BaseRepository[StorageConfig]):
def __init__(self, db: Session):
super().__init__(StorageConfig, db)
super().__init__(db, StorageConfig)
async def create_config(
self,
name: str,
storage_type: str,
config_data: Dict,
created_by: UUID,
is_default: bool = False
created_by: int,
is_default: bool = False,
is_active: bool = True
) -> StorageConfig:
# اگر این config به عنوان پیش‌فرض تنظیم می‌شود، بقیه را غیرفعال کن
if is_default:
@ -196,7 +197,8 @@ class StorageConfigRepository(BaseRepository[StorageConfig]):
storage_type=storage_type,
config_data=config_data,
created_by=created_by,
is_default=is_default
is_default=is_default,
is_active=is_active
)
self.db.add(storage_config)
@ -212,7 +214,7 @@ class StorageConfigRepository(BaseRepository[StorageConfig]):
)
).first()
async def get_all_configs(self) -> List[StorageConfig]:
def get_all_configs(self) -> List[StorageConfig]:
return self.db.query(StorageConfig).filter(
StorageConfig.is_active == True
).order_by(desc(StorageConfig.created_at)).all()

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart';
class FileManagementWidget extends StatefulWidget {
const FileManagementWidget({super.key});
@ -36,49 +37,26 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
});
try {
// TODO: Call API to load files
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final api = ApiClient();
setState(() {
_allFiles = [
{
'id': '1',
'original_name': 'document.pdf',
'file_size': 1024000,
'mime_type': 'application/pdf',
'module_context': 'tickets',
'created_at': '2024-01-01T10:00:00Z',
'expires_at': null,
'is_temporary': false,
'is_verified': true,
},
{
'id': '2',
'original_name': 'image.jpg',
'file_size': 512000,
'mime_type': 'image/jpeg',
'module_context': 'accounting',
'created_at': '2024-01-02T11:00:00Z',
'expires_at': '2024-01-09T11:00:00Z',
'is_temporary': true,
'is_verified': false,
},
{
'id': '3',
'original_name': 'spreadsheet.xlsx',
'file_size': 256000,
'mime_type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'module_context': 'reports',
'created_at': '2024-01-03T12:00:00Z',
'expires_at': null,
'is_temporary': false,
'is_verified': true,
},
];
// Call API to load files
final response = await api.get('/api/v1/admin/files/');
final unverifiedResponse = await api.get('/api/v1/admin/files/unverified');
_unverifiedFiles = _allFiles.where((file) => file['is_verified'] == false).toList();
_isLoading = false;
});
if (response.data != null && response.data['success'] == true) {
final files = response.data['data']['files'] as List<dynamic>;
final unverifiedFiles = unverifiedResponse.data != null && unverifiedResponse.data['success'] == true
? unverifiedResponse.data['data']['unverified_files'] as List<dynamic>
: <dynamic>[];
setState(() {
_allFiles = files.cast<Map<String, dynamic>>();
_unverifiedFiles = unverifiedFiles.cast<Map<String, dynamic>>();
_isLoading = false;
});
} else {
throw Exception(response.data?['message'] ?? 'خطا در دریافت فایل‌ها');
}
} catch (e) {
setState(() {
_error = e.toString();
@ -87,8 +65,9 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
}
}
Future<void> _forceDeleteFile(String fileId) async {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@ -112,17 +91,21 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
if (confirmed == true) {
try {
// TODO: Call API to force delete file
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final api = ApiClient();
final response = await api.delete('/api/v1/admin/files/$fileId');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileDeleted),
backgroundColor: Colors.green,
),
);
if (response.data != null && response.data['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileDeleted),
backgroundColor: Colors.green,
),
);
_loadFiles();
_loadFiles();
} else {
throw Exception(response.data?['message'] ?? 'خطا در حذف فایل');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -135,7 +118,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
}
Future<void> _restoreFile(String fileId) async {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@ -156,17 +139,21 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
if (confirmed == true) {
try {
// TODO: Call API to restore file
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final api = ApiClient();
final response = await api.put('/api/v1/admin/files/$fileId/restore');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileRestored),
backgroundColor: Colors.green,
),
);
if (response.data != null && response.data['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileRestored),
backgroundColor: Colors.green,
),
);
_loadFiles();
_loadFiles();
} else {
throw Exception(response.data?['message'] ?? 'خطا در بازیابی فایل');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -192,8 +179,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return Column(
children: [
@ -224,7 +210,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
}
Widget _buildFilesList(List<Map<String, dynamic>> files) {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (_isLoading) {
@ -337,7 +323,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
children: [
Icon(Icons.delete, color: theme.colorScheme.error),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.forceDelete),
Text(AppLocalizations.of(context).forceDelete),
],
),
),
@ -347,7 +333,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
children: [
Icon(Icons.restore, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.restoreFile),
Text(AppLocalizations.of(context).restoreFile),
],
),
),

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart';
class FileStatisticsWidget extends StatefulWidget {
const FileStatisticsWidget({super.key});
@ -26,18 +27,17 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
});
try {
// TODO: Call API to load statistics
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final api = ApiClient();
final response = await api.get('/api/v1/admin/files/statistics');
setState(() {
_statistics = {
'total_files': 1250,
'total_size': 2048576000, // 2GB in bytes
'temporary_files': 45,
'unverified_files': 12,
};
_isLoading = false;
});
if (response.data != null && response.data['success'] == true) {
setState(() {
_statistics = response.data['data'];
_isLoading = false;
});
} else {
throw Exception(response.data?['message'] ?? 'خطا در دریافت آمار');
}
} catch (e) {
setState(() {
_error = e.toString();
@ -46,8 +46,9 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
}
}
Future<void> _cleanupTemporaryFiles() async {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@ -68,17 +69,21 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
if (confirmed == true) {
try {
// TODO: Call API to cleanup temporary files
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
final api = ApiClient();
final response = await api.post('/api/v1/admin/files/cleanup-temporary');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cleanupCompleted),
backgroundColor: Colors.green,
),
);
if (response.data != null && response.data['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cleanupCompleted),
backgroundColor: Colors.green,
),
);
_loadStatistics();
_loadStatistics();
} else {
throw Exception(response.data?['message'] ?? 'خطا در پاکسازی فایل‌های موقت');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -90,6 +95,7 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
@ -99,7 +105,7 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (_isLoading) {

View file

@ -19,165 +19,129 @@ class StorageConfigCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final isDefault = config['is_default'] == true;
final isActive = config['is_active'] == true;
final storageType = config['storage_type'] as String;
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
storageType == 'local' ? Icons.storage : Icons.cloud_upload,
_getStorageIcon(config['storage_type']),
color: theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: 8),
const SizedBox(width: 12),
Expanded(
child: Text(
config['name'] ?? '',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (isDefault)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
l10n.isDefault,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config['name'] ?? 'Unknown',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (!isActive)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Inactive',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onError,
const SizedBox(height: 4),
Text(
_getStorageTypeName(config['storage_type']),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
// Status badges
Row(
children: [
if (isDefault)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
l10n.isDefault,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: isActive ? Colors.green : Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: Text(
isActive ? l10n.isActive : 'غیرفعال',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
l10n.storageType,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(width: 8),
Text(
storageType == 'local' ? l10n.localStorage : l10n.ftpStorage,
style: theme.textTheme.bodySmall,
],
),
],
),
const SizedBox(height: 4),
if (storageType == 'local') ...[
Row(
children: [
Icon(
Icons.folder_outlined,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
l10n.basePath,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
config['config_data']?['base_path'] ?? '',
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
] else if (storageType == 'ftp') ...[
Row(
children: [
Icon(
Icons.cloud_outlined,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${l10n.ftpHost}: ${config['config_data']?['host'] ?? ''}',
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.person_outline,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${l10n.ftpUsername}: ${config['config_data']?['username'] ?? ''}',
style: theme.textTheme.bodySmall,
),
],
),
],
const SizedBox(height: 12),
const SizedBox(height: 16),
// Configuration details
_buildConfigDetails(context, config),
const SizedBox(height: 16),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onEdit != null)
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16),
label: Text(l10n.edit),
),
if (onTestConnection != null)
TextButton.icon(
onPressed: onTestConnection,
icon: const Icon(Icons.wifi_protected_setup, size: 16),
label: Text(l10n.testConnection),
),
if (onSetDefault != null)
if (onEdit != null) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16),
label: Text(l10n.edit),
),
],
if (onSetDefault != null) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: onSetDefault,
icon: const Icon(Icons.star, size: 16),
label: Text(l10n.setAsDefault),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
),
),
if (onDelete != null)
],
if (onDelete != null) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete, size: 16),
@ -186,6 +150,7 @@ class StorageConfigCard extends StatelessWidget {
foregroundColor: theme.colorScheme.error,
),
),
],
],
),
],
@ -193,4 +158,116 @@ class StorageConfigCard extends StatelessWidget {
),
);
}
Widget _buildConfigDetails(BuildContext context, Map<String, dynamic> config) {
final l10n = AppLocalizations.of(context);
final configData = config['config_data'] ?? {};
final storageType = config['storage_type'];
if (storageType == 'local') {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow(
context,
l10n.basePath,
configData['base_path'] ?? 'N/A',
Icons.folder,
),
],
);
} else if (storageType == 'ftp') {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow(
context,
l10n.ftpHost,
configData['host'] ?? 'N/A',
Icons.dns,
),
const SizedBox(height: 8),
_buildDetailRow(
context,
l10n.ftpPort,
configData['port']?.toString() ?? 'N/A',
Icons.settings_ethernet,
),
const SizedBox(height: 8),
_buildDetailRow(
context,
l10n.ftpUsername,
configData['username'] ?? 'N/A',
Icons.person,
),
const SizedBox(height: 8),
_buildDetailRow(
context,
l10n.ftpDirectory,
configData['directory'] ?? 'N/A',
Icons.folder,
),
],
);
}
return const SizedBox.shrink();
}
Widget _buildDetailRow(
BuildContext context,
String label,
String value,
IconData icon,
) {
final theme = Theme.of(context);
return Row(
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 8),
Text(
'$label: ',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.8),
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
IconData _getStorageIcon(String storageType) {
switch (storageType) {
case 'local':
return Icons.storage;
case 'ftp':
return Icons.cloud_upload;
default:
return Icons.storage;
}
}
String _getStorageTypeName(String storageType) {
switch (storageType) {
case 'local':
return 'Local Storage';
case 'ftp':
return 'FTP Storage';
default:
return 'Unknown Storage';
}
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart';
class StorageConfigFormDialog extends StatefulWidget {
final Map<String, dynamic>? config;
@ -94,19 +95,24 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
});
try {
// TODO: Call API to save configuration
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final api = ApiClient();
final response = await api.post(
'/api/v1/admin/files/storage-configs/',
data: {
'name': _nameController.text,
'storage_type': _selectedStorageType,
'is_default': _isDefault,
'is_active': _isActive,
'config_data': _buildConfigData(),
},
);
final configData = {
'name': _nameController.text,
'storage_type': _selectedStorageType,
'is_default': _isDefault,
'is_active': _isActive,
'config_data': _buildConfigData(),
};
if (mounted) {
Navigator.of(context).pop(configData);
if (response.data != null && response.data['success'] == true) {
if (mounted) {
Navigator.of(context).pop(response.data['data']);
}
} else {
throw Exception(response.data?['message'] ?? 'خطا در ذخیره تنظیمات');
}
} catch (e) {
if (mounted) {
@ -128,7 +134,7 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final isEditing = widget.config != null;

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_form_dialog.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_card.dart';
import '../../../core/api_client.dart';
class StorageConfigListWidget extends StatefulWidget {
const StorageConfigListWidget({super.key});
@ -28,41 +29,18 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
});
try {
// TODO: Call API to load storage configurations
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final api = ApiClient();
final response = await api.get('/api/v1/admin/files/storage-configs/');
// Mock data for now
setState(() {
_storageConfigs = [
{
'id': '1',
'name': 'Local Storage Default',
'storage_type': 'local',
'is_default': true,
'is_active': true,
'config_data': {
'base_path': '/var/hesabix/files'
},
'created_at': '2024-01-01T00:00:00Z',
},
{
'id': '2',
'name': 'FTP Backup',
'storage_type': 'ftp',
'is_default': false,
'is_active': true,
'config_data': {
'host': 'ftp.example.com',
'port': 21,
'username': 'hesabix',
'password': '***',
'directory': '/hesabix/files'
},
'created_at': '2024-01-02T00:00:00Z',
},
];
_isLoading = false;
});
if (response.data != null && response.data['success'] == true) {
setState(() {
_storageConfigs = (response.data['data']['configs'] as List<dynamic>)
.cast<Map<String, dynamic>>();
_isLoading = false;
});
} else {
throw Exception(response.data?['message'] ?? 'خطا در دریافت تنظیمات ذخیره‌سازی');
}
} catch (e) {
setState(() {
_error = e.toString();
@ -71,6 +49,7 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
}
}
Future<void> _addStorageConfig() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
@ -94,7 +73,7 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
}
Future<void> _setAsDefault(String configId) async {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
try {
// TODO: Call API to set as default
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
@ -118,29 +97,44 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
}
Future<void> _testConnection(String configId) async {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
try {
// TODO: Call API to test connection
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
final api = ApiClient();
final response = await api.post('/api/v1/admin/files/storage-configs/$configId/test');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.connectionSuccessful),
backgroundColor: Colors.green,
),
);
if (response.data != null && response.data['success'] == true) {
final testResult = response.data['data']['test_result'];
if (testResult['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.connectionSuccessful),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${l10n.connectionFailed}: ${testResult['error']}'),
backgroundColor: Colors.red,
),
);
}
} else {
throw Exception(response.data?['message'] ?? 'خطا در تست اتصال');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.connectionFailed),
content: Text('${l10n.connectionFailed}: $e'),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _deleteConfig(String configId) async {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@ -185,7 +179,7 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (_isLoading) {