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 uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_
from adapters.db.session import get_db 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.permissions import require_permission
from app.core.responses import success_response from app.core.responses import success_response
from app.core.responses import ApiError 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 app.services.file_storage_service import FileStorageService
from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository
from adapters.db.models.user import User from adapters.db.models.user import User
from adapters.db.models.file_storage import StorageConfig
from adapters.api.v1.schema_models.file_storage import ( from adapters.api.v1.schema_models.file_storage import (
StorageConfigCreateRequest, StorageConfigCreateRequest,
StorageConfigUpdateRequest, StorageConfigUpdateRequest,
@ -36,19 +38,85 @@ async def list_all_files(
is_temporary: Optional[bool] = Query(None), is_temporary: Optional[bool] = Query(None),
is_verified: Optional[bool] = Query(None), is_verified: Optional[bool] = Query(None),
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""لیست تمام فایل‌ها با فیلتر""" """لیست تمام فایل‌ها با فیلتر"""
try: 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 و فیلترها file_repo = FileStorageRepository(db)
statistics = await file_service.get_storage_statistics()
# محاسبه 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 = { data = {
"statistics": statistics, "files": files_data,
"message": translator.t("FILE_LIST_NOT_IMPLEMENTED", "File list endpoint - to be implemented") "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) return success_response(data, request)
@ -65,7 +133,7 @@ async def list_all_files(
async def get_unverified_files( async def get_unverified_files(
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""فایل‌های تایید نشده""" """فایل‌های تایید نشده"""
@ -102,7 +170,7 @@ async def get_unverified_files(
async def cleanup_temporary_files( async def cleanup_temporary_files(
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""پاکسازی فایل‌های موقت""" """پاکسازی فایل‌های موقت"""
@ -130,7 +198,7 @@ async def force_delete_file(
file_id: UUID, file_id: UUID,
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""حذف اجباری فایل""" """حذف اجباری فایل"""
@ -164,7 +232,7 @@ async def restore_file(
file_id: UUID, file_id: UUID,
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""بازیابی فایل حذف شده""" """بازیابی فایل حذف شده"""
@ -197,7 +265,7 @@ async def restore_file(
async def get_file_statistics( async def get_file_statistics(
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""آمار استفاده از فضای ذخیره‌سازی""" """آمار استفاده از فضای ذخیره‌سازی"""
@ -220,13 +288,22 @@ async def get_file_statistics(
async def get_storage_configs( async def get_storage_configs(
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""لیست تنظیمات ذخیره‌سازی""" """لیست تنظیمات ذخیره‌سازی"""
try: 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) config_repo = StorageConfigRepository(db)
configs = await config_repo.get_all_configs() configs = config_repo.get_all_configs()
data = { data = {
"configs": [ "configs": [
@ -236,6 +313,7 @@ async def get_storage_configs(
"storage_type": config.storage_type, "storage_type": config.storage_type,
"is_default": config.is_default, "is_default": config.is_default,
"is_active": config.is_active, "is_active": config.is_active,
"config_data": config.config_data,
"created_at": config.created_at.isoformat() "created_at": config.created_at.isoformat()
} }
for config in configs for config in configs
@ -257,7 +335,7 @@ async def create_storage_config(
request: Request, request: Request,
config_request: StorageConfigCreateRequest, config_request: StorageConfigCreateRequest,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""ایجاد تنظیمات ذخیره‌سازی جدید""" """ایجاد تنظیمات ذخیره‌سازی جدید"""
@ -268,8 +346,9 @@ async def create_storage_config(
name=config_request.name, name=config_request.name,
storage_type=config_request.storage_type, storage_type=config_request.storage_type,
config_data=config_request.config_data, config_data=config_request.config_data,
created_by=current_user.id, created_by=current_user.get_user_id(),
is_default=config_request.is_default is_default=config_request.is_default,
is_active=config_request.is_active
) )
data = { data = {
@ -293,7 +372,7 @@ async def update_storage_config(
request: Request, request: Request,
config_request: StorageConfigUpdateRequest, config_request: StorageConfigUpdateRequest,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""بروزرسانی تنظیمات ذخیره‌سازی""" """بروزرسانی تنظیمات ذخیره‌سازی"""
@ -317,7 +396,7 @@ async def set_default_storage_config(
config_id: UUID, config_id: UUID,
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""تنظیم به عنوان پیش‌فرض""" """تنظیم به عنوان پیش‌فرض"""
@ -351,7 +430,7 @@ async def delete_storage_config(
config_id: UUID, config_id: UUID,
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""حذف تنظیمات ذخیره‌سازی""" """حذف تنظیمات ذخیره‌سازی"""
@ -382,17 +461,42 @@ async def delete_storage_config(
@router.post("/storage-configs/{config_id}/test", response_model=dict) @router.post("/storage-configs/{config_id}/test", response_model=dict)
async def test_storage_config( async def test_storage_config(
config_id: UUID, config_id: str,
request: Request, request: Request,
db: Session = Depends(get_db), 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) translator = Depends(locale_dependency)
): ):
"""تست اتصال به storage""" """تست اتصال به storage"""
try: try:
# TODO: پیاده‌سازی تست اتصال config_repo = StorageConfigRepository(db)
data = {"message": translator.t("STORAGE_CONNECTION_TEST_NOT_IMPLEMENTED", "Storage connection test - to be implemented")} 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) return success_response(data, request)
except ApiError:
raise
except Exception as e: except Exception as e:
raise ApiError( raise ApiError(
code="TEST_STORAGE_CONFIG_ERROR", code="TEST_STORAGE_CONFIG_ERROR",
@ -400,3 +504,107 @@ async def test_storage_config(
http_status=500, http_status=500,
translator=translator 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="نوع ذخیره‌سازی") storage_type: str = Field(..., description="نوع ذخیره‌سازی")
config_data: Dict[str, Any] = Field(..., description="داده‌های پیکربندی") config_data: Dict[str, Any] = Field(..., description="داده‌های پیکربندی")
is_default: bool = Field(default=False, description="آیا پیش‌فرض است") is_default: bool = Field(default=False, description="آیا پیش‌فرض است")
is_active: bool = Field(default=True, description="آیا فعال است")
class StorageConfigUpdateRequest(BaseModel): class StorageConfigUpdateRequest(BaseModel):

View file

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

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart';
class FileManagementWidget extends StatefulWidget { class FileManagementWidget extends StatefulWidget {
const FileManagementWidget({super.key}); const FileManagementWidget({super.key});
@ -36,49 +37,26 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
}); });
try { try {
// TODO: Call API to load files final api = ApiClient();
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
setState(() { // Call API to load files
_allFiles = [ final response = await api.get('/api/v1/admin/files/');
{ final unverifiedResponse = await api.get('/api/v1/admin/files/unverified');
'id': '1',
'original_name': 'document.pdf', if (response.data != null && response.data['success'] == true) {
'file_size': 1024000, final files = response.data['data']['files'] as List<dynamic>;
'mime_type': 'application/pdf', final unverifiedFiles = unverifiedResponse.data != null && unverifiedResponse.data['success'] == true
'module_context': 'tickets', ? unverifiedResponse.data['data']['unverified_files'] as List<dynamic>
'created_at': '2024-01-01T10:00:00Z', : <dynamic>[];
'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,
},
];
_unverifiedFiles = _allFiles.where((file) => file['is_verified'] == false).toList(); setState(() {
_isLoading = false; _allFiles = files.cast<Map<String, dynamic>>();
}); _unverifiedFiles = unverifiedFiles.cast<Map<String, dynamic>>();
_isLoading = false;
});
} else {
throw Exception(response.data?['message'] ?? 'خطا در دریافت فایل‌ها');
}
} catch (e) { } catch (e) {
setState(() { setState(() {
_error = e.toString(); _error = e.toString();
@ -87,8 +65,9 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
} }
} }
Future<void> _forceDeleteFile(String fileId) async { Future<void> _forceDeleteFile(String fileId) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -112,17 +91,21 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
if (confirmed == true) { if (confirmed == true) {
try { try {
// TODO: Call API to force delete file final api = ApiClient();
await Future.delayed(const Duration(seconds: 1)); // Simulate API call final response = await api.delete('/api/v1/admin/files/$fileId');
ScaffoldMessenger.of(context).showSnackBar( if (response.data != null && response.data['success'] == true) {
SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text(l10n.fileDeleted), SnackBar(
backgroundColor: Colors.green, content: Text(l10n.fileDeleted),
), backgroundColor: Colors.green,
); ),
);
_loadFiles();
_loadFiles();
} else {
throw Exception(response.data?['message'] ?? 'خطا در حذف فایل');
}
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -135,7 +118,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
} }
Future<void> _restoreFile(String fileId) async { Future<void> _restoreFile(String fileId) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -156,17 +139,21 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
if (confirmed == true) { if (confirmed == true) {
try { try {
// TODO: Call API to restore file final api = ApiClient();
await Future.delayed(const Duration(seconds: 1)); // Simulate API call final response = await api.put('/api/v1/admin/files/$fileId/restore');
ScaffoldMessenger.of(context).showSnackBar( if (response.data != null && response.data['success'] == true) {
SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text(l10n.fileRestored), SnackBar(
backgroundColor: Colors.green, content: Text(l10n.fileRestored),
), backgroundColor: Colors.green,
); ),
);
_loadFiles();
_loadFiles();
} else {
throw Exception(response.data?['message'] ?? 'خطا در بازیابی فایل');
}
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -192,8 +179,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Column( return Column(
children: [ children: [
@ -224,7 +210,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
} }
Widget _buildFilesList(List<Map<String, dynamic>> files) { Widget _buildFilesList(List<Map<String, dynamic>> files) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (_isLoading) { if (_isLoading) {
@ -337,7 +323,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
children: [ children: [
Icon(Icons.delete, color: theme.colorScheme.error), Icon(Icons.delete, color: theme.colorScheme.error),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.forceDelete), Text(AppLocalizations.of(context).forceDelete),
], ],
), ),
), ),
@ -347,7 +333,7 @@ class _FileManagementWidgetState extends State<FileManagementWidget>
children: [ children: [
Icon(Icons.restore, color: theme.colorScheme.primary), Icon(Icons.restore, color: theme.colorScheme.primary),
const SizedBox(width: 8), 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:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart';
class FileStatisticsWidget extends StatefulWidget { class FileStatisticsWidget extends StatefulWidget {
const FileStatisticsWidget({super.key}); const FileStatisticsWidget({super.key});
@ -26,18 +27,17 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
}); });
try { try {
// TODO: Call API to load statistics final api = ApiClient();
await Future.delayed(const Duration(seconds: 1)); // Simulate API call final response = await api.get('/api/v1/admin/files/statistics');
setState(() { if (response.data != null && response.data['success'] == true) {
_statistics = { setState(() {
'total_files': 1250, _statistics = response.data['data'];
'total_size': 2048576000, // 2GB in bytes _isLoading = false;
'temporary_files': 45, });
'unverified_files': 12, } else {
}; throw Exception(response.data?['message'] ?? 'خطا در دریافت آمار');
_isLoading = false; }
});
} catch (e) { } catch (e) {
setState(() { setState(() {
_error = e.toString(); _error = e.toString();
@ -46,8 +46,9 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
} }
} }
Future<void> _cleanupTemporaryFiles() async { Future<void> _cleanupTemporaryFiles() async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -68,17 +69,21 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
if (confirmed == true) { if (confirmed == true) {
try { try {
// TODO: Call API to cleanup temporary files final api = ApiClient();
await Future.delayed(const Duration(seconds: 2)); // Simulate API call final response = await api.post('/api/v1/admin/files/cleanup-temporary');
ScaffoldMessenger.of(context).showSnackBar( if (response.data != null && response.data['success'] == true) {
SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text(l10n.cleanupCompleted), SnackBar(
backgroundColor: Colors.green, content: Text(l10n.cleanupCompleted),
), backgroundColor: Colors.green,
); ),
);
_loadStatistics();
_loadStatistics();
} else {
throw Exception(response.data?['message'] ?? 'خطا در پاکسازی فایل‌های موقت');
}
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -90,6 +95,7 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
} }
} }
String _formatFileSize(int bytes) { String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B'; if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
@ -99,7 +105,7 @@ class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (_isLoading) { if (_isLoading) {

View file

@ -19,165 +19,129 @@ class StorageConfigCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final isDefault = config['is_default'] == true; final isDefault = config['is_default'] == true;
final isActive = config['is_active'] == true; final isActive = config['is_active'] == true;
final storageType = config['storage_type'] as String;
return Card( return Card(
elevation: 2,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header
Row( Row(
children: [ children: [
Icon( Icon(
storageType == 'local' ? Icons.storage : Icons.cloud_upload, _getStorageIcon(config['storage_type']),
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
size: 24,
), ),
const SizedBox(width: 8), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Column(
config['name'] ?? '', crossAxisAlignment: CrossAxisAlignment.start,
style: theme.textTheme.titleMedium?.copyWith( children: [
fontWeight: FontWeight.bold, Text(
), config['name'] ?? 'Unknown',
), 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,
), ),
), const SizedBox(height: 4),
), Text(
if (!isActive) _getStorageTypeName(config['storage_type']),
Container( style: theme.textTheme.bodyMedium?.copyWith(
margin: const EdgeInsets.only(left: 8), color: theme.colorScheme.onSurface.withOpacity(0.7),
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,
), ),
],
),
),
// 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') ...[ const SizedBox(height: 16),
Row(
children: [ // Configuration details
Icon( _buildConfigDetails(context, config),
Icons.folder_outlined,
size: 16, const SizedBox(height: 16),
color: theme.colorScheme.onSurface.withOpacity(0.6),
), // Actions
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),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (onEdit != null)
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16),
label: Text(l10n.edit),
),
if (onTestConnection != null) if (onTestConnection != null)
TextButton.icon( TextButton.icon(
onPressed: onTestConnection, onPressed: onTestConnection,
icon: const Icon(Icons.wifi_protected_setup, size: 16), icon: const Icon(Icons.wifi_protected_setup, size: 16),
label: Text(l10n.testConnection), 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( TextButton.icon(
onPressed: onSetDefault, onPressed: onSetDefault,
icon: const Icon(Icons.star, size: 16), icon: const Icon(Icons.star, size: 16),
label: Text(l10n.setAsDefault), label: Text(l10n.setAsDefault),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
),
), ),
if (onDelete != null) ],
if (onDelete != null) ...[
const SizedBox(width: 8),
TextButton.icon( TextButton.icon(
onPressed: onDelete, onPressed: onDelete,
icon: const Icon(Icons.delete, size: 16), icon: const Icon(Icons.delete, size: 16),
@ -186,6 +150,7 @@ class StorageConfigCard extends StatelessWidget {
foregroundColor: theme.colorScheme.error, 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:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart';
class StorageConfigFormDialog extends StatefulWidget { class StorageConfigFormDialog extends StatefulWidget {
final Map<String, dynamic>? config; final Map<String, dynamic>? config;
@ -94,19 +95,24 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
}); });
try { try {
// TODO: Call API to save configuration final api = ApiClient();
await Future.delayed(const Duration(seconds: 1)); // Simulate API call 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 = { if (response.data != null && response.data['success'] == true) {
'name': _nameController.text, if (mounted) {
'storage_type': _selectedStorageType, Navigator.of(context).pop(response.data['data']);
'is_default': _isDefault, }
'is_active': _isActive, } else {
'config_data': _buildConfigData(), throw Exception(response.data?['message'] ?? 'خطا در ذخیره تنظیمات');
};
if (mounted) {
Navigator.of(context).pop(configData);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -128,7 +134,7 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final isEditing = widget.config != null; 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/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_form_dialog.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_card.dart'; import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_card.dart';
import '../../../core/api_client.dart';
class StorageConfigListWidget extends StatefulWidget { class StorageConfigListWidget extends StatefulWidget {
const StorageConfigListWidget({super.key}); const StorageConfigListWidget({super.key});
@ -28,41 +29,18 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
}); });
try { try {
// TODO: Call API to load storage configurations final api = ApiClient();
await Future.delayed(const Duration(seconds: 1)); // Simulate API call final response = await api.get('/api/v1/admin/files/storage-configs/');
// Mock data for now if (response.data != null && response.data['success'] == true) {
setState(() { setState(() {
_storageConfigs = [ _storageConfigs = (response.data['data']['configs'] as List<dynamic>)
{ .cast<Map<String, dynamic>>();
'id': '1', _isLoading = false;
'name': 'Local Storage Default', });
'storage_type': 'local', } else {
'is_default': true, throw Exception(response.data?['message'] ?? 'خطا در دریافت تنظیمات ذخیره‌سازی');
'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;
});
} catch (e) { } catch (e) {
setState(() { setState(() {
_error = e.toString(); _error = e.toString();
@ -71,6 +49,7 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
} }
} }
Future<void> _addStorageConfig() async { Future<void> _addStorageConfig() async {
final result = await showDialog<Map<String, dynamic>>( final result = await showDialog<Map<String, dynamic>>(
context: context, context: context,
@ -94,7 +73,7 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
} }
Future<void> _setAsDefault(String configId) async { Future<void> _setAsDefault(String configId) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
try { try {
// TODO: Call API to set as default // TODO: Call API to set as default
await Future.delayed(const Duration(seconds: 1)); // Simulate API call 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 { Future<void> _testConnection(String configId) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
try { try {
// TODO: Call API to test connection final api = ApiClient();
await Future.delayed(const Duration(seconds: 2)); // Simulate API call final response = await api.post('/api/v1/admin/files/storage-configs/$configId/test');
ScaffoldMessenger.of(context).showSnackBar( if (response.data != null && response.data['success'] == true) {
SnackBar( final testResult = response.data['data']['test_result'];
content: Text(l10n.connectionSuccessful), if (testResult['success'] == true) {
backgroundColor: Colors.green, 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) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.connectionFailed), content: Text('${l10n.connectionFailed}: $e'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
} }
} }
Future<void> _deleteConfig(String configId) async { Future<void> _deleteConfig(String configId) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -185,7 +179,7 @@ class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (_isLoading) { if (_isLoading) {