feat: implementar Fases 3, 4 y 5 del roadmap (Planificacion Avanzada, Historial de Corridas y Copia Local Duplicada)
This commit is contained in:
+14
-2
@@ -10,12 +10,24 @@ from app.services.event_service import log_event
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == credentials.email.strip().lower()))
|
||||
email_clean = credentials.email.strip().lower()
|
||||
result = await db.execute(select(User).where(User.email == email_clean))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(credentials.password, user.hashed_password):
|
||||
if not user:
|
||||
logger.warning(f"Login failed: User not found with email '{email_clean}'")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
if not verify_password(credentials.password, user.hashed_password):
|
||||
logger.warning(f"Login failed: Password mismatch for email '{email_clean}' (sent password length: {len(credentials.password)})")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
|
||||
+91
-8
@@ -5,8 +5,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Client, BackupJob, BackupFile
|
||||
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister, PurgeOrphansRequest
|
||||
from app.models.models import User, Client, BackupJob, BackupFile, JobRun
|
||||
from app.schemas.schemas import (
|
||||
JobCreate, JobUpdate, JobResponse, AgentJobRegister, PurgeOrphansRequest,
|
||||
JobRunResponse, JobRunStartRequest, JobRunCompleteRequest
|
||||
)
|
||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||
from app.services.event_service import log_event
|
||||
from app.ws.manager import ws_manager
|
||||
@@ -46,9 +49,9 @@ async def agent_register_job(
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""Called by the Windows Agent to register a new local job/folder on the server."""
|
||||
count_res = await db.execute(select(func.count(BackupJob.id)))
|
||||
job_count = count_res.scalar() or 0
|
||||
job_code = f"JOB-{job_count + 1:03d}"
|
||||
max_id_res = await db.execute(select(func.max(BackupJob.id)))
|
||||
max_id = max_id_res.scalar() or 0
|
||||
job_code = f"JOB-{max_id + 1:03d}"
|
||||
|
||||
job = BackupJob(
|
||||
job_code=job_code,
|
||||
@@ -61,6 +64,7 @@ async def agent_register_job(
|
||||
skip_system=payload.skip_system,
|
||||
skip_readonly=payload.skip_readonly,
|
||||
sync_deletions=payload.sync_deletions,
|
||||
local_destination_path=payload.local_destination_path,
|
||||
schedule_cron=payload.schedule_cron,
|
||||
keep_daily=payload.keep_daily,
|
||||
keep_weekly=payload.keep_weekly,
|
||||
@@ -124,9 +128,9 @@ async def create_job(
|
||||
if not client:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
||||
|
||||
count_res = await db.execute(select(func.count(BackupJob.id)))
|
||||
job_count = count_res.scalar() or 0
|
||||
job_code = f"JOB-{job_count + 1:03d}"
|
||||
max_id_res = await db.execute(select(func.max(BackupJob.id)))
|
||||
max_id = max_id_res.scalar() or 0
|
||||
job_code = f"JOB-{max_id + 1:03d}"
|
||||
|
||||
job = BackupJob(
|
||||
job_code=job_code,
|
||||
@@ -139,6 +143,7 @@ async def create_job(
|
||||
skip_system=payload.skip_system,
|
||||
skip_readonly=payload.skip_readonly,
|
||||
sync_deletions=payload.sync_deletions,
|
||||
local_destination_path=payload.local_destination_path,
|
||||
schedule_cron=payload.schedule_cron,
|
||||
keep_daily=payload.keep_daily,
|
||||
keep_weekly=payload.keep_weekly,
|
||||
@@ -322,3 +327,81 @@ async def purge_orphans(
|
||||
"freed_bytes": total_freed_bytes,
|
||||
"message": f"Successfully purged {purged_count} orphan files from server."
|
||||
}
|
||||
|
||||
@router.post("/{job_id}/runs/start", response_model=JobRunResponse)
|
||||
async def start_job_run(
|
||||
job_id: int,
|
||||
payload: JobRunStartRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""Called by the Windows Agent to register the start of a scheduled or manual job run."""
|
||||
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id, BackupJob.client_id == current_client.id))
|
||||
job = res.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found or not owned by client")
|
||||
|
||||
job.status = "RUNNING"
|
||||
job.last_run_at = datetime.now(timezone.utc)
|
||||
|
||||
run = JobRun(
|
||||
job_id=job.id,
|
||||
client_id=current_client.id,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
status="RUNNING"
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
|
||||
return run
|
||||
|
||||
@router.post("/{job_id}/runs/{run_id}/complete", response_model=JobRunResponse)
|
||||
async def complete_job_run(
|
||||
job_id: int,
|
||||
run_id: int,
|
||||
payload: JobRunCompleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""Called by the Windows Agent to record completion metrics for a run."""
|
||||
res = await db.execute(select(JobRun).where(JobRun.id == run_id, JobRun.job_id == job_id, JobRun.client_id == current_client.id))
|
||||
run = res.scalar_one_or_none()
|
||||
if not run:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job run not found")
|
||||
|
||||
job_res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
||||
job = job_res.scalar_one_or_none()
|
||||
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.status = payload.status
|
||||
run.files_scanned = payload.files_scanned
|
||||
run.files_copied = payload.files_copied
|
||||
run.files_skipped = payload.files_skipped
|
||||
run.errors_count = payload.errors_count
|
||||
run.bytes_transferred = payload.bytes_transferred
|
||||
run.error_summary = payload.error_summary
|
||||
|
||||
if job:
|
||||
job.status = payload.status
|
||||
job.last_run_at = run.completed_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
|
||||
return run
|
||||
|
||||
@router.get("/{job_id}/runs", response_model=List[JobRunResponse])
|
||||
async def list_job_runs(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Called by the Web UI to retrieve execution history for a job."""
|
||||
result = await db.execute(
|
||||
select(JobRun)
|
||||
.where(JobRun.job_id == job_id)
|
||||
.order_by(desc(JobRun.started_at))
|
||||
.limit(50)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -98,11 +98,34 @@ class BackupJob(Base):
|
||||
status = Column(String(50), default="IDLE", nullable=False) # IDLE, RUNNING, ERROR, SUCCESS
|
||||
last_run_at = Column(DateTime(timezone=True), nullable=True)
|
||||
next_run_at = Column(DateTime(timezone=True), nullable=True)
|
||||
local_destination_path = Column(String(1024), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
||||
|
||||
client = relationship("Client", back_populates="jobs")
|
||||
backup_files = relationship("BackupFile", back_populates="job", cascade="all, delete-orphan")
|
||||
backup_sessions = relationship("BackupSession", back_populates="job", cascade="all, delete-orphan")
|
||||
runs = relationship("JobRun", back_populates="job", cascade="all, delete-orphan")
|
||||
|
||||
class JobRun(Base):
|
||||
__tablename__ = "job_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
job_id = Column(Integer, ForeignKey("backup_jobs.id", ondelete="CASCADE"), nullable=False)
|
||||
client_id = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False)
|
||||
started_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
status = Column(String(50), default="RUNNING", nullable=False) # RUNNING, SUCCESS, FAILED
|
||||
|
||||
# Detailed execution metrics
|
||||
files_scanned = Column(Integer, default=0, nullable=False)
|
||||
files_copied = Column(Integer, default=0, nullable=False)
|
||||
files_skipped = Column(Integer, default=0, nullable=False)
|
||||
errors_count = Column(Integer, default=0, nullable=False)
|
||||
bytes_transferred = Column(BigInteger, default=0, nullable=False)
|
||||
error_summary = Column(Text, nullable=True)
|
||||
|
||||
job = relationship("BackupJob", back_populates="runs")
|
||||
client = relationship("Client")
|
||||
|
||||
class BackupSession(Base):
|
||||
__tablename__ = "backup_sessions"
|
||||
|
||||
@@ -87,6 +87,7 @@ class JobCreate(BaseModel):
|
||||
skip_system: bool = False
|
||||
skip_readonly: bool = False
|
||||
sync_deletions: bool = False
|
||||
local_destination_path: Optional[str] = None
|
||||
schedule_cron: str = "0 2 * * *"
|
||||
keep_daily: int = 7
|
||||
keep_weekly: int = 4
|
||||
@@ -102,6 +103,7 @@ class JobUpdate(BaseModel):
|
||||
skip_system: Optional[bool] = None
|
||||
skip_readonly: Optional[bool] = None
|
||||
sync_deletions: Optional[bool] = None
|
||||
local_destination_path: Optional[str] = None
|
||||
schedule_cron: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
keep_daily: Optional[int] = None
|
||||
@@ -121,6 +123,7 @@ class JobResponse(BaseModel):
|
||||
skip_system: bool
|
||||
skip_readonly: bool
|
||||
sync_deletions: bool
|
||||
local_destination_path: Optional[str]
|
||||
schedule_cron: str
|
||||
is_active: bool
|
||||
keep_daily: int
|
||||
@@ -255,6 +258,7 @@ class AgentJobRegister(BaseModel):
|
||||
skip_system: bool = False
|
||||
skip_readonly: bool = False
|
||||
sync_deletions: bool = False
|
||||
local_destination_path: Optional[str] = None
|
||||
schedule_cron: str = "daily"
|
||||
keep_daily: int = 7
|
||||
keep_weekly: int = 4
|
||||
@@ -263,3 +267,32 @@ class AgentJobRegister(BaseModel):
|
||||
|
||||
class PurgeOrphansRequest(BaseModel):
|
||||
active_relative_paths: List[str]
|
||||
|
||||
# --- Job Run Schemas ---
|
||||
class JobRunStartRequest(BaseModel):
|
||||
client_id: int
|
||||
|
||||
class JobRunCompleteRequest(BaseModel):
|
||||
status: str # SUCCESS, FAILED
|
||||
files_scanned: int
|
||||
files_copied: int
|
||||
files_skipped: int
|
||||
errors_count: int
|
||||
bytes_transferred: int
|
||||
error_summary: Optional[str] = None
|
||||
|
||||
class JobRunResponse(BaseModel):
|
||||
id: int
|
||||
job_id: int
|
||||
client_id: int
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime]
|
||||
status: str
|
||||
files_scanned: int
|
||||
files_copied: int
|
||||
files_skipped: int
|
||||
errors_count: int
|
||||
bytes_transferred: int
|
||||
error_summary: Optional[str]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
|
||||
|
||||
def migrate():
|
||||
print(f"Connecting to database at {DB_PATH}...")
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. Add local_destination_path column to backup_jobs table
|
||||
try:
|
||||
cursor.execute("ALTER TABLE backup_jobs ADD COLUMN local_destination_path VARCHAR(1024) NULL")
|
||||
print("Column 'local_destination_path' added successfully to backup_jobs.")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"Column 'local_destination_path' could not be added (maybe it already exists?): {e}")
|
||||
|
||||
# 2. Create job_runs table
|
||||
try:
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS job_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
client_id INTEGER NOT NULL,
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'RUNNING',
|
||||
files_scanned INTEGER NOT NULL DEFAULT 0,
|
||||
files_copied INTEGER NOT NULL DEFAULT 0,
|
||||
files_skipped INTEGER NOT NULL DEFAULT 0,
|
||||
errors_count INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_transferred BIGINT NOT NULL DEFAULT 0,
|
||||
error_summary TEXT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES backup_jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(client_id) REFERENCES clients(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
print("Table 'job_runs' created successfully.")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"Table 'job_runs' could not be created: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Migration finished.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
Reference in New Issue
Block a user