se agregaron nuevas caracteristicas

This commit is contained in:
Carlos Tello
2026-08-13 23:07:01 -03:00
parent 1bfb808c79
commit d736db982e
23 changed files with 1244 additions and 100 deletions
+133 -20
View File
@@ -10,7 +10,8 @@ from app.core.security import generate_registration_code, generate_device_token,
from app.models.models import User, Client, ClientCredential, RegistrationCode
from app.schemas.schemas import (
ClientResponse, ClientRegisterRequest, ClientRegisterResponse,
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest,
ClientUpdateRequest
)
from app.api.deps import get_current_user, require_admin, get_current_client
from app.services.event_service import log_event
@@ -78,27 +79,55 @@ async def register_client(
detail="Invalid, expired, or already used registration code."
)
# Determine next client code (e.g., CLIENT-0001)
count_res = await db.execute(select(func.count(Client.id)))
client_count = count_res.scalar() or 0
client_code = f"CLIENT-{client_count + 1:04d}"
client_ip = request.client.host if request.client else None
# Create Client
client = Client(
client_code=client_code,
name=payload.name or reg_code.client_name_hint or payload.hostname,
hostname=payload.hostname,
os_info=payload.os_info,
ip_address=client_ip,
agent_version=payload.agent_version,
status="ONLINE",
last_seen_at=now,
is_active=True
)
db.add(client)
await db.flush()
if reg_code.client_id:
# Re-registering an existing client!
client_res = await db.execute(select(Client).where(Client.id == reg_code.client_id))
client = client_res.scalar_one_or_none()
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client associated with this registration code not found."
)
# Update connection info and mark as active
client.hostname = payload.hostname
client.os_info = payload.os_info
client.ip_address = client_ip
client.agent_version = payload.agent_version
client.status = "ONLINE"
client.last_seen_at = now
client.is_active = True
else:
# Create a new Client
max_id_res = await db.execute(select(func.max(Client.id)))
max_id = max_id_res.scalar() or 0
client_code = f"CLIENT-{max_id + 1:04d}"
# Load default client quota from settings
from app.services.settings_service import get_setting
default_quota_gb_str = await get_setting(db, "default_client_quota_gb")
default_quota_bytes = 100 * 1024 * 1024 * 1024
if default_quota_gb_str:
try:
default_quota_bytes = int(default_quota_gb_str) * 1024 * 1024 * 1024
except ValueError:
pass
client = Client(
client_code=client_code,
name=payload.name or reg_code.client_name_hint or payload.hostname,
hostname=payload.hostname,
os_info=payload.os_info,
ip_address=client_ip,
agent_version=payload.agent_version,
status="ONLINE",
last_seen_at=now,
is_active=True,
storage_quota_bytes=default_quota_bytes
)
db.add(client)
await db.flush()
# Generate device unique ID and secret token
device_id = str(uuid.uuid4())
@@ -220,6 +249,90 @@ async def revoke_client_credentials(
return {"message": "Client credentials successfully revoked"}
@router.post("/{client_id}/re-register", response_model=RegistrationCodeResponse)
async def generate_client_re_registration_code(
client_id: int,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
"""Generates a registration code to re-link an existing client's agent."""
client_res = await db.execute(select(Client).where(Client.id == client_id))
client = client_res.scalar_one_or_none()
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found"
)
# Revoke old credentials to prepare for the new agent connection
cred_res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id))
creds = cred_res.scalars().all()
for cred in creds:
cred.is_revoked = True
code_str = generate_registration_code()
expires = datetime.now(timezone.utc) + timedelta(hours=24) # 24 hours to re-register
reg_code = RegistrationCode(
code=code_str,
client_id=client.id,
client_name_hint=client.name,
expires_at=expires,
is_used=False
)
db.add(reg_code)
await db.commit()
await db.refresh(reg_code)
await log_event(
db=db,
event_type="REGISTRATION_CODE_GENERATED",
message=f"Generated re-registration code {code_str} for client {client.name} ({client.client_code}).",
severity="INFO",
client_id=client_id,
user_email=admin_user.email
)
return reg_code
@router.patch("/{client_id}", response_model=ClientResponse)
async def update_client(
client_id: int,
payload: ClientUpdateRequest,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
"""Updates client details such as alias."""
res = await db.execute(select(Client).where(Client.id == client_id))
client = res.scalar_one_or_none()
if not client:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
if payload.alias is not None:
client.alias = payload.alias
if payload.storage_quota_bytes is not None:
client.storage_quota_bytes = payload.storage_quota_bytes
await db.commit()
await db.refresh(client)
message_parts = []
if payload.alias is not None:
message_parts.append(f"alias to '{client.alias}'")
if payload.storage_quota_bytes is not None:
message_parts.append(f"quota to {client.storage_quota_bytes} bytes")
await log_event(
db=db,
event_type="CLIENT_UPDATED",
message=f"Updated client {client.client_code}: {', '.join(message_parts)}.",
severity="INFO",
client_id=client.id,
user_email=admin_user.email
)
return client
@router.delete("/{client_id}")
async def delete_client(
client_id: int,
+69 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select, func, desc
from app.core.database import get_db
from app.models.models import User, Client, BackupJob
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister
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
@@ -36,6 +36,74 @@ async def get_agent_jobs(
result = await db.execute(query)
return result.scalars().all()
@router.post("/agent/register", response_model=JobResponse)
async def agent_register_job(
payload: AgentJobRegister,
db: AsyncSession = Depends(get_db),
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}"
job = BackupJob(
job_code=job_code,
client_id=current_client.id,
name=payload.name,
source_path=payload.source_path,
file_patterns=payload.file_patterns,
schedule_cron=payload.schedule_cron,
keep_daily=payload.keep_daily,
keep_weekly=payload.keep_weekly,
keep_monthly=payload.keep_monthly,
min_stable_time_seconds=payload.min_stable_time_seconds,
status="IDLE",
is_active=True
)
db.add(job)
await db.commit()
await db.refresh(job)
await log_event(
db=db,
event_type="JOB_CREATED",
message=f"Agent registered backup job '{job.name}' ({job.job_code}) from device.",
severity="INFO",
client_id=current_client.id,
job_id=job.id
)
return job
@router.delete("/agent/{job_id}")
async def agent_delete_job(
job_id: int,
db: AsyncSession = Depends(get_db),
current_client: Client = Depends(get_current_client)
):
"""Called by the Windows Agent to delete a backup job it owns."""
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 this client"
)
await db.delete(job)
await db.commit()
await log_event(
db=db,
event_type="JOB_DELETED",
message=f"Agent deleted backup job {job.job_code} from device.",
severity="WARNING",
client_id=current_client.id
)
return {"message": "Job successfully deleted by agent"}
@router.post("", response_model=JobResponse)
async def create_job(
payload: JobCreate,
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import require_admin
from app.models.models import User
from app.schemas.schemas import SystemSettingsResponse, SystemSettingsUpdate
from app.services.settings_service import get_all_settings, update_settings_service
router = APIRouter(prefix="/settings", tags=["System Settings"])
@router.get("", response_model=SystemSettingsResponse)
async def get_settings(
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
"""Retrieve global system settings."""
settings_dict = await get_all_settings(db)
return {
"storage_root": settings_dict.get("storage_root", ""),
"global_quota_gb": int(settings_dict.get("global_quota_gb", 1000)),
"default_client_quota_gb": int(settings_dict.get("default_client_quota_gb", 100)),
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
}
@router.put("", response_model=SystemSettingsResponse)
async def update_settings(
payload: SystemSettingsUpdate,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
"""Update global system settings."""
# Convert Pydantic model to a dict of values (filtering out None)
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
try:
settings_dict = await update_settings_service(db, updates)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
return {
"storage_root": settings_dict.get("storage_root", ""),
"global_quota_gb": int(settings_dict.get("global_quota_gb", 1000)),
"default_client_quota_gb": int(settings_dict.get("default_client_quota_gb", 100)),
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
}
+17
View File
@@ -67,6 +67,23 @@ async def get_dashboard_stats(
# Storage metrics
storage_stats = await storage_provider.get_storage_stats()
# Override with global virtual quota if set
from app.services.settings_service import get_setting
global_quota_gb_str = await get_setting(db, "global_quota_gb")
if global_quota_gb_str:
try:
global_quota_bytes = int(global_quota_gb_str) * 1024 * 1024 * 1024
# Sum up storage used by all clients
used_res = await db.execute(select(func.sum(Client.storage_used_bytes)))
total_used_bytes = used_res.scalar() or 0
storage_stats["total_bytes"] = global_quota_bytes
storage_stats["used_bytes"] = total_used_bytes
storage_stats["free_bytes"] = max(0, global_quota_bytes - total_used_bytes)
storage_stats["usage_percent"] = round((total_used_bytes / global_quota_bytes) * 100, 2) if global_quota_bytes > 0 else 0
except ValueError:
pass
return {
"total_clients": total_clients,