feat: Add API endpoints for project backups and folder management

This commit is contained in:
Miguel 2025-09-03 10:16:06 +02:00
parent 958d8ac994
commit 92f8ff8f62
4 changed files with 434 additions and 33 deletions

45
debug_project_id.py Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env python
"""Debug script to check project IDs"""
import sys
from pathlib import Path
# Add src directory to path
current_dir = Path(__file__).parent
src_dir = current_dir / "src"
sys.path.insert(0, str(src_dir))
from models.config_model import Config
from models.project_model import ProjectManager
from services.project_discovery_service import ProjectDiscoveryService
# Initialize services
config = Config()
config.load_config()
project_manager = ProjectManager()
project_discovery_service = ProjectDiscoveryService(config, project_manager)
# Discover projects
projects = project_discovery_service.discover_all_projects()
print("Available project IDs:")
found = False
for project_id, project in project_manager.projects.items():
if "Ssae0452 Last Version Walter" in project.name:
print(f"Found project:")
print(f" ID: {project_id}")
print(f" Name: {project.name}")
print(f" Path: {project.path}")
found = True
break
if not found:
print('Project "Ssae0452 Last Version Walter" not found!')
print('Available projects containing "Ssae":')
count = 0
for project_id, project in project_manager.projects.items():
if "Ssae" in project.name:
print(f" ID: {project_id}, Name: {project.name}")
count += 1
if count >= 10: # Limit output
break

View File

@ -3,6 +3,8 @@ API Routes - AutoBackups
Rutas de la API REST para operaciones AJAX Rutas de la API REST para operaciones AJAX
""" """
import os
from datetime import datetime
from flask import request, jsonify from flask import request, jsonify
@ -543,3 +545,134 @@ def register_api_routes(app, autobackups_instance):
f"Error in Everything-only discovery: {e}" f"Error in Everything-only discovery: {e}"
) )
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/api/projects/<project_id>/backups")
def get_project_backups(project_id):
"""Get list of backup files for a specific project"""
try:
# Get the project
project = autobackups_instance.project_manager.get_project(project_id)
if not project:
return jsonify({"error": f"Project {project_id} not found"}), 404
# Get backup destination path for this project
backup_destination = autobackups_instance.config.backup_destination
backup_path = project.backup_path
full_backup_path = os.path.join(backup_destination, backup_path)
backups = []
if os.path.exists(full_backup_path):
# List date directories (format: YYYY-MM-DD)
try:
date_dirs = [
d
for d in os.listdir(full_backup_path)
if os.path.isdir(os.path.join(full_backup_path, d))
and len(d) == 10
and d.count("-") == 2
]
for date_dir in sorted(date_dirs, reverse=True):
date_path = os.path.join(full_backup_path, date_dir)
# List backup files in date directory
try:
backup_files = [
f for f in os.listdir(date_path) if f.endswith(".zip")
]
for backup_file in sorted(backup_files, reverse=True):
backup_file_path = os.path.join(date_path, backup_file)
try:
stat = os.stat(backup_file_path)
backups.append(
{
"filename": backup_file,
"date": date_dir,
"size": stat.st_size,
"size_mb": round(
stat.st_size / (1024 * 1024), 2
),
"created": datetime.fromtimestamp(
stat.st_ctime
).isoformat(),
"modified": datetime.fromtimestamp(
stat.st_mtime
).isoformat(),
"full_path": backup_file_path,
}
)
except OSError:
continue
except OSError:
continue
except OSError:
pass
return jsonify(
{
"project_id": project_id,
"project_name": project.name,
"project_path": project.path,
"backup_destination": full_backup_path,
"backup_count": len(backups),
"backups": backups,
}
)
except Exception as e:
autobackups_instance.logger.error(
f"Error getting backups for project {project_id}: {e}"
)
return jsonify({"error": str(e)}), 500
@app.route("/api/projects/<project_id>/open-folder", methods=["POST"])
def open_project_folder(project_id):
"""Open project source or backup folder in Windows Explorer"""
try:
data = request.get_json()
folder_type = data.get("type", "source") # 'source' or 'backup'
# Get the project
project = autobackups_instance.project_manager.get_project(project_id)
if not project:
return jsonify({"error": f"Project {project_id} not found"}), 404
if folder_type == "source":
folder_path = project.path
if not os.path.exists(folder_path):
return jsonify({"error": "Source folder does not exist"}), 404
elif folder_type == "backup":
backup_destination = autobackups_instance.config.backup_destination
backup_path = project.backup_path
folder_path = os.path.join(backup_destination, backup_path)
if not os.path.exists(folder_path):
return jsonify({"error": "Backup folder does not exist"}), 404
else:
return (
jsonify(
{"error": "Invalid folder type. Must be 'source' or 'backup'"}
),
400,
)
# Open folder in Windows Explorer
import subprocess
subprocess.run(["explorer", folder_path], shell=True)
return jsonify(
{
"success": True,
"folder_path": folder_path,
"folder_type": folder_type,
}
)
except Exception as e:
autobackups_instance.logger.error(
f"Error opening folder for project {project_id}: {e}"
)
return jsonify({"error": str(e)}), 500

View File

@ -475,26 +475,27 @@ body {
.directory-header { .directory-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.375rem; border-radius: 0.25rem;
background-color: var(--hover-bg); background-color: var(--hover-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
margin-bottom: 0.25rem;
} }
.observation-header { .observation-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.375rem;
background-color: rgba(13, 110, 253, 0.1); background-color: rgba(13, 110, 253, 0.1);
border: 2px solid var(--primary-color); border: 2px solid var(--primary-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
font-weight: 600; font-weight: 600;
color: var(--primary-color); color: var(--primary-color);
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
} }
.observation-header:hover { .observation-header:hover {
@ -505,15 +506,15 @@ body {
.collapsed-header { .collapsed-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.25rem 0.5rem; padding: 0.2rem 0.4rem;
border-radius: 0.25rem; border-radius: 0.2rem;
background-color: rgba(108, 117, 125, 0.1); background-color: rgba(108, 117, 125, 0.1);
border: 1px dashed var(--text-muted); border: 1px dashed var(--text-muted);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
font-style: italic; font-style: italic;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0.25rem; margin-bottom: 0.2rem;
} }
.collapsed-header:hover { .collapsed-header:hover {
@ -538,8 +539,8 @@ body {
} }
.directory-content { .directory-content {
margin-top: 0.5rem; margin-top: 0.25rem;
padding-left: 1rem; padding-left: 0.75rem;
border-left: 2px solid var(--border-color); border-left: 2px solid var(--border-color);
} }

View File

@ -115,7 +115,6 @@
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>Proyecto</th> <th>Proyecto</th>
<th>Ruta</th>
<th>Tipo</th> <th>Tipo</th>
<th>Estado</th> <th>Estado</th>
<th>Último Backup</th> <th>Último Backup</th>
@ -133,12 +132,7 @@
<td> <td>
<strong>{{ project.name or 'Sin nombre' }}</strong> <strong>{{ project.name or 'Sin nombre' }}</strong>
<br> <br>
<small class="text-muted">ID: {{ project.id }}</small> <small class="text-muted">{{ project.path }}</small>
</td>
<td>
<span title="{{ project.path }}">
{{ project.path[:50] }}{% if project.path|length > 50 %}...{% endif %}
</span>
</td> </td>
<td> <td>
<span class="badge bg-secondary">{{ project.type or 'N/A' }}</span> <span class="badge bg-secondary">{{ project.type or 'N/A' }}</span>
@ -180,8 +174,10 @@
data-project-id="{{ project.id }}"> data-project-id="{{ project.id }}">
<i class="bi bi-gear"></i> <i class="bi bi-gear"></i>
</button> </button>
<button class="btn btn-outline-info view-project-btn" <button class="btn btn-outline-info view-backups-btn"
data-project-id="{{ project.id }}"> data-project-id="{{ project.id }}"
data-project-name="{{ project.name or 'Sin nombre' }}"
data-project-path="{{ project.path }}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </div>
@ -884,14 +880,17 @@ function renderProjectCard(project, level) {
return ` return `
<div class="project-card card mb-2" style="margin-left: ${level * 20}px;"> <div class="project-card card mb-2" style="margin-left: ${level * 20}px;">
<div class="card-body p-3"> <div class="card-body p-2">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-md-6"> <div class="col-md-6">
<h6 class="card-title mb-1"> <h6 class="card-title mb-1">
<i class="bi bi-file-earmark-code text-primary me-2"></i> <i class="bi bi-file-earmark-code text-primary me-2"></i>
${projectName} ${projectName}
</h6> </h6>
<small class="text-muted">ID: ${projectId}</small> <small class="text-muted">
<i class="bi bi-folder me-1"></i>
${projectPath}
</small>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<span class="badge bg-secondary">${projectType}</span> <span class="badge bg-secondary">${projectType}</span>
@ -902,28 +901,25 @@ function renderProjectCard(project, level) {
<div class="col-md-2 text-end"> <div class="col-md-2 text-end">
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary backup-project-btn" <button class="btn btn-outline-primary backup-project-btn"
data-project-id="${projectId}"> data-project-id="${projectId}"
title="Hacer backup">
<i class="bi bi-archive"></i> <i class="bi bi-archive"></i>
</button> </button>
<button class="btn btn-outline-secondary config-project-btn" <button class="btn btn-outline-secondary config-project-btn"
data-project-id="${projectId}"> data-project-id="${projectId}"
title="Configurar">
<i class="bi bi-gear"></i> <i class="bi bi-gear"></i>
</button> </button>
<button class="btn btn-outline-info view-project-btn" <button class="btn btn-outline-info view-backups-btn"
data-project-id="${projectId}"> data-project-id="${projectId}"
data-project-name="${projectName}"
data-project-path="${projectPath}"
title="Ver backups y carpetas">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="row mt-2">
<div class="col-12">
<small class="text-muted">
<i class="bi bi-folder me-1"></i>
${projectPath}
</small>
</div>
</div>
</div> </div>
</div> </div>
`; `;
@ -976,6 +972,46 @@ function displayFlatProjects(filteredProjects) {
row.style.display = (matchesSearch && matchesStatus) ? '' : 'none'; row.style.display = (matchesSearch && matchesStatus) ? '' : 'none';
}); });
// Bind events for flat view buttons
bindFlatViewEvents();
}
function bindFlatViewEvents() {
// Bind backup buttons
document.querySelectorAll('.backup-project-btn').forEach(btn => {
btn.replaceWith(btn.cloneNode(true)); // Remove existing listeners
});
document.querySelectorAll('.backup-project-btn').forEach(btn => {
btn.addEventListener('click', function() {
const projectId = this.dataset.projectId;
backupProject(projectId);
});
});
// Bind config buttons
document.querySelectorAll('.config-project-btn').forEach(btn => {
btn.replaceWith(btn.cloneNode(true)); // Remove existing listeners
});
document.querySelectorAll('.config-project-btn').forEach(btn => {
btn.addEventListener('click', function() {
const projectId = this.dataset.projectId;
openProjectConfig(projectId);
});
});
// Bind view backups buttons
document.querySelectorAll('.view-backups-btn').forEach(btn => {
btn.replaceWith(btn.cloneNode(true)); // Remove existing listeners
});
document.querySelectorAll('.view-backups-btn').forEach(btn => {
btn.addEventListener('click', function() {
const projectId = this.dataset.projectId;
const projectName = this.dataset.projectName;
const projectPath = this.dataset.projectPath;
showBackupsModal(projectId, projectName, projectPath);
});
});
} }
function bindHierarchicalEvents() { function bindHierarchicalEvents() {
@ -1012,6 +1048,16 @@ function bindHierarchicalEvents() {
openProjectConfig(projectId); openProjectConfig(projectId);
}); });
}); });
// Re-bind view backups buttons
document.querySelectorAll('.view-backups-btn').forEach(btn => {
btn.addEventListener('click', function() {
const projectId = this.dataset.projectId;
const projectName = this.dataset.projectName;
const projectPath = this.dataset.projectPath;
showBackupsModal(projectId, projectName, projectPath);
});
});
} }
// Global expand/collapse functionality // Global expand/collapse functionality
@ -1052,6 +1098,182 @@ function initializeExpandCollapseButton() {
} }
} }
// Show backups modal with backup list and folder actions
function showBackupsModal(projectId, projectName, projectPath) {
// Create modal if it doesn't exist
let modal = document.getElementById('backupsModal');
if (!modal) {
modal = createBackupsModal();
document.body.appendChild(modal);
}
// Update modal content
document.getElementById('backupsModalTitle').textContent = `Backups de ${projectName}`;
document.getElementById('backupsProjectPath').textContent = projectPath;
// Set up folder buttons
const openSourceBtn = document.getElementById('openSourceFolderBtn');
const openBackupBtn = document.getElementById('openBackupFolderBtn');
openSourceBtn.onclick = () => openProjectFolder(projectId, 'source');
openBackupBtn.onclick = () => openProjectFolder(projectId, 'backup');
// Load backup list
loadBackupList(projectId);
// Show modal
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
}
function createBackupsModal() {
const modalHTML = `
<div class="modal fade" id="backupsModal" tabindex="-1" aria-labelledby="backupsModalTitle" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="backupsModalTitle">Backups del Proyecto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<strong>Ruta del proyecto:</strong>
<p class="text-muted mb-2" id="backupsProjectPath"></p>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary btn-sm" id="openSourceFolderBtn">
<i class="bi bi-folder-fill me-1"></i>Abrir Carpeta Origen
</button>
<button type="button" class="btn btn-outline-info btn-sm" id="openBackupFolderBtn">
<i class="bi bi-archive-fill me-1"></i>Abrir Carpeta Backups
</button>
</div>
</div>
<hr>
<div id="backupListContainer">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span>
</div>
<p class="mt-2">Cargando lista de backups...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
`;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = modalHTML;
return tempDiv.firstElementChild;
}
function loadBackupList(projectId) {
const container = document.getElementById('backupListContainer');
fetch(`/api/projects/${projectId}/backups`)
.then(response => response.json())
.then(data => {
if (data.error) {
container.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
Error: ${data.error}
</div>
`;
return;
}
if (data.backup_count === 0) {
container.innerHTML = `
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
No se encontraron backups para este proyecto.
</div>
`;
return;
}
// Build backup list
let html = `
<h6>Backups encontrados (${data.backup_count})</h6>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead class="table-dark">
<tr>
<th>Fecha</th>
<th>Archivo</th>
<th>Tamaño</th>
<th>Creado</th>
</tr>
</thead>
<tbody>
`;
data.backups.forEach(backup => {
const createdDate = new Date(backup.created).toLocaleString('es-ES');
html += `
<tr>
<td>${backup.date}</td>
<td>
<i class="bi bi-file-zip me-1"></i>
${backup.filename}
</td>
<td>${backup.size_mb} MB</td>
<td>${createdDate}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
<small class="text-muted">
<i class="bi bi-folder me-1"></i>
Ubicación: ${data.backup_destination}
</small>
`;
container.innerHTML = html;
})
.catch(error => {
console.error('Error loading backup list:', error);
container.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
Error cargando la lista de backups: ${error.message}
</div>
`;
});
}
function openProjectFolder(projectId, folderType) {
fetch(`/api/projects/${projectId}/open-folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ type: folderType })
})
.then(response => response.json())
.then(data => {
if (data.error) {
showNotification(`Error: ${data.error}`, 'error');
} else {
const folderName = folderType === 'source' ? 'origen' : 'backups';
showNotification(`Carpeta ${folderName} abierta en el explorador`, 'success');
}
})
.catch(error => {
console.error('Error opening folder:', error);
showNotification(`Error abriendo la carpeta: ${error.message}`, 'error');
});
}
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initializeProjectsView(); initializeProjectsView();