Rrestore point page created
This commit is contained in:
parent
fbc07a00e5
commit
aebaa00371
54
PlotLine/Controllers/ProjectRestorePointsController.cs
Normal file
54
PlotLine/Controllers/ProjectRestorePointsController.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Services;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
public sealed class ProjectRestorePointsController(IProjectRestorePointService restorePoints, ILogger<ProjectRestorePointsController> logger) : Controller
|
||||
{
|
||||
public async Task<IActionResult> Index(int projectId)
|
||||
{
|
||||
var model = await restorePoints.GetAsync(projectId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(int projectId, string? restorePointName, string? notes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await restorePoints.CreateManualAsync(projectId, restorePointName, notes);
|
||||
TempData["ProjectRestorePointMessage"] = created is null
|
||||
? "Restore point could not be created because the project was not found."
|
||||
: "Restore point created.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Manual restore point creation failed for project {ProjectID}.", projectId);
|
||||
TempData["ProjectRestorePointMessage"] = "Restore point could not be created. Please try again, or check the application logs if the problem continues.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index), new { projectId });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Download(int projectId, int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var download = await restorePoints.DownloadAsync(id, projectId);
|
||||
if (download is null)
|
||||
{
|
||||
TempData["ProjectRestorePointMessage"] = "Restore point could not be found.";
|
||||
return RedirectToAction(nameof(Index), new { projectId });
|
||||
}
|
||||
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(download.Json), download.ContentType, download.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Restore point download failed for restore point {ProjectRestorePointID} in project {ProjectID}.", id, projectId);
|
||||
TempData["ProjectRestorePointMessage"] = "Restore point could not be downloaded. Please try again, or check the application logs if the problem continues.";
|
||||
return RedirectToAction(nameof(Index), new { projectId });
|
||||
}
|
||||
}
|
||||
}
|
||||
50
PlotLine/Data/ProjectRestorePointRepository.cs
Normal file
50
PlotLine/Data/ProjectRestorePointRepository.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface IProjectRestorePointRepository
|
||||
{
|
||||
Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId);
|
||||
Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId);
|
||||
Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes);
|
||||
}
|
||||
|
||||
public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connectionFactory) : IProjectRestorePointRepository
|
||||
{
|
||||
public async Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<ProjectRestorePoint>(
|
||||
"dbo.ProjectRestorePoint_List",
|
||||
new { ProjectID = projectId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<ProjectRestorePoint>(
|
||||
"dbo.ProjectRestorePoint_Get",
|
||||
new { ProjectRestorePointID = projectRestorePointId, ProjectID = projectId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<ProjectRestorePoint>(
|
||||
"dbo.ProjectRestorePoint_Create",
|
||||
new
|
||||
{
|
||||
ProjectID = projectId,
|
||||
RestorePointName = restorePointName,
|
||||
RestorePointType = restorePointType,
|
||||
JsonData = jsonData,
|
||||
Notes = notes
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
21
PlotLine/Models/ProjectRestorePointModels.cs
Normal file
21
PlotLine/Models/ProjectRestorePointModels.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public sealed class ProjectRestorePoint
|
||||
{
|
||||
public int ProjectRestorePointID { get; set; }
|
||||
public int ProjectID { get; set; }
|
||||
public string ProjectName { get; set; } = string.Empty;
|
||||
public string RestorePointName { get; set; } = string.Empty;
|
||||
public string RestorePointType { get; set; } = "Manual";
|
||||
public DateTime CreatedDateUtc { get; set; }
|
||||
public string? JsonData { get; set; }
|
||||
public long JsonSizeBytes { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProjectRestorePointDownload
|
||||
{
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
public string Json { get; init; } = string.Empty;
|
||||
}
|
||||
@ -38,6 +38,7 @@ public class Program
|
||||
builder.Services.AddScoped<IWriterWorkspaceRepository, WriterWorkspaceRepository>();
|
||||
builder.Services.AddScoped<IExportRepository, ExportRepository>();
|
||||
builder.Services.AddScoped<IProjectBackupRepository, ProjectBackupRepository>();
|
||||
builder.Services.AddScoped<IProjectRestorePointRepository, ProjectRestorePointRepository>();
|
||||
builder.Services.AddScoped<IDynamicsRepository, DynamicsRepository>();
|
||||
builder.Services.AddScoped<IScenarioRepository, ScenarioRepository>();
|
||||
builder.Services.AddScoped<IArchiveRepository, ArchiveRepository>();
|
||||
@ -60,6 +61,7 @@ public class Program
|
||||
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
|
||||
builder.Services.AddScoped<IExportService, ExportService>();
|
||||
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
|
||||
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
|
||||
builder.Services.AddScoped<IDynamicsService, DynamicsService>();
|
||||
builder.Services.AddScoped<IScenarioService, ScenarioService>();
|
||||
builder.Services.AddScoped<IArchiveService, ArchiveService>();
|
||||
|
||||
97
PlotLine/Services/ProjectRestorePointService.cs
Normal file
97
PlotLine/Services/ProjectRestorePointService.cs
Normal file
@ -0,0 +1,97 @@
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IProjectRestorePointService
|
||||
{
|
||||
Task<ProjectRestorePointListViewModel?> GetAsync(int projectId);
|
||||
Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes);
|
||||
Task<ProjectRestorePointDownload?> DownloadAsync(int projectRestorePointId, int projectId);
|
||||
}
|
||||
|
||||
public sealed class ProjectRestorePointService(
|
||||
IProjectRepository projects,
|
||||
IProjectExportService projectExports,
|
||||
IProjectRestorePointRepository restorePoints,
|
||||
ILogger<ProjectRestorePointService> logger) : IProjectRestorePointService
|
||||
{
|
||||
private const string ManualRestorePointType = "Manual";
|
||||
|
||||
public async Task<ProjectRestorePointListViewModel?> GetAsync(int projectId)
|
||||
{
|
||||
var project = await projects.GetAsync(projectId);
|
||||
if (project is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProjectRestorePointListViewModel
|
||||
{
|
||||
Project = project,
|
||||
RestorePoints = await restorePoints.ListAsync(projectId)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes)
|
||||
{
|
||||
var export = await projectExports.ExportAsync(projectId);
|
||||
if (export is null)
|
||||
{
|
||||
logger.LogWarning("Manual restore point requested for missing project {ProjectID}.", projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = string.IsNullOrWhiteSpace(restorePointName)
|
||||
? $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}"
|
||||
: restorePointName.Trim();
|
||||
|
||||
var created = await restorePoints.CreateAsync(projectId, name, ManualRestorePointType, export.Json, Clean(notes));
|
||||
logger.LogInformation("Manual restore point {ProjectRestorePointID} created for project {ProjectID} with {JsonSizeBytes} JSON bytes.",
|
||||
created.ProjectRestorePointID,
|
||||
projectId,
|
||||
created.JsonSizeBytes);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<ProjectRestorePointDownload?> DownloadAsync(int projectRestorePointId, int projectId)
|
||||
{
|
||||
var restorePoint = await restorePoints.GetAsync(projectRestorePointId, projectId);
|
||||
if (restorePoint?.JsonData is null)
|
||||
{
|
||||
logger.LogWarning("Restore point download requested for missing restore point {ProjectRestorePointID} in project {ProjectID}.", projectRestorePointId, projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProjectRestorePointDownload
|
||||
{
|
||||
FileName = BuildFileName(restorePoint.ProjectName, restorePoint.CreatedDateUtc),
|
||||
ContentType = "application/json",
|
||||
Json = restorePoint.JsonData
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string BuildFileName(string projectName, DateTime createdDateUtc)
|
||||
{
|
||||
var safeTitle = new string(projectName
|
||||
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)
|
||||
.ToArray())
|
||||
.Trim();
|
||||
|
||||
safeTitle = string.Join("_", safeTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(safeTitle))
|
||||
{
|
||||
safeTitle = "PlotWeaver_Project";
|
||||
}
|
||||
|
||||
return $"{safeTitle}_RestorePoint_{createdDateUtc:yyyy-MM-dd_HHmm}.plotweaver";
|
||||
}
|
||||
}
|
||||
97
PlotLine/Sql/035_ProjectRestorePoints.sql
Normal file
97
PlotLine/Sql/035_ProjectRestorePoints.sql
Normal file
@ -0,0 +1,97 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.ProjectRestorePoints', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.ProjectRestorePoints
|
||||
(
|
||||
ProjectRestorePointID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectRestorePoints PRIMARY KEY,
|
||||
ProjectID int NOT NULL,
|
||||
RestorePointName nvarchar(255) NOT NULL,
|
||||
RestorePointType nvarchar(50) NOT NULL,
|
||||
CreatedDateUtc datetime2 NOT NULL CONSTRAINT DF_ProjectRestorePoints_CreatedDateUtc DEFAULT SYSUTCDATETIME(),
|
||||
JsonData nvarchar(max) NOT NULL,
|
||||
JsonSizeBytes bigint NOT NULL,
|
||||
Notes nvarchar(max) NULL,
|
||||
CONSTRAINT FK_ProjectRestorePoints_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectRestorePoints_ProjectCreated' AND object_id = OBJECT_ID(N'dbo.ProjectRestorePoints'))
|
||||
CREATE INDEX IX_ProjectRestorePoints_ProjectCreated ON dbo.ProjectRestorePoints(ProjectID, CreatedDateUtc DESC);
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_List
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT rp.ProjectRestorePointID, rp.ProjectID, p.ProjectName, rp.RestorePointName, rp.RestorePointType,
|
||||
rp.CreatedDateUtc, CAST(NULL AS nvarchar(max)) AS JsonData, rp.JsonSizeBytes, rp.Notes
|
||||
FROM dbo.ProjectRestorePoints rp
|
||||
INNER JOIN dbo.Projects p ON p.ProjectID = rp.ProjectID
|
||||
WHERE rp.ProjectID = @ProjectID
|
||||
ORDER BY rp.CreatedDateUtc DESC, rp.ProjectRestorePointID DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_Get
|
||||
@ProjectRestorePointID int,
|
||||
@ProjectID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT rp.ProjectRestorePointID, rp.ProjectID, p.ProjectName, rp.RestorePointName, rp.RestorePointType,
|
||||
rp.CreatedDateUtc, rp.JsonData, rp.JsonSizeBytes, rp.Notes
|
||||
FROM dbo.ProjectRestorePoints rp
|
||||
INNER JOIN dbo.Projects p ON p.ProjectID = rp.ProjectID
|
||||
WHERE rp.ProjectRestorePointID = @ProjectRestorePointID
|
||||
AND rp.ProjectID = @ProjectID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_Create
|
||||
@ProjectID int,
|
||||
@RestorePointName nvarchar(255),
|
||||
@RestorePointType nvarchar(50),
|
||||
@JsonData nvarchar(max),
|
||||
@Notes nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
INSERT INTO dbo.ProjectRestorePoints
|
||||
(
|
||||
ProjectID,
|
||||
RestorePointName,
|
||||
RestorePointType,
|
||||
CreatedDateUtc,
|
||||
JsonData,
|
||||
JsonSizeBytes,
|
||||
Notes
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@ProjectID,
|
||||
@RestorePointName,
|
||||
@RestorePointType,
|
||||
SYSUTCDATETIME(),
|
||||
@JsonData,
|
||||
DATALENGTH(@JsonData),
|
||||
@Notes
|
||||
);
|
||||
|
||||
DECLARE @ProjectRestorePointID int = SCOPE_IDENTITY();
|
||||
|
||||
SELECT rp.ProjectRestorePointID, rp.ProjectID, p.ProjectName, rp.RestorePointName, rp.RestorePointType,
|
||||
rp.CreatedDateUtc, CAST(NULL AS nvarchar(max)) AS JsonData, rp.JsonSizeBytes, rp.Notes
|
||||
FROM dbo.ProjectRestorePoints rp
|
||||
INNER JOIN dbo.Projects p ON p.ProjectID = rp.ProjectID
|
||||
WHERE rp.ProjectRestorePointID = @ProjectRestorePointID;
|
||||
END;
|
||||
GO
|
||||
@ -455,6 +455,12 @@ public sealed class ProjectSceneMetricsViewModel
|
||||
public bool HasEnabledMetrics => Metrics.Any(x => x.IsEnabledForProject && x.IsActive);
|
||||
}
|
||||
|
||||
public sealed class ProjectRestorePointListViewModel
|
||||
{
|
||||
public Project Project { get; init; } = new();
|
||||
public IReadOnlyList<ProjectRestorePoint> RestorePoints { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SceneMetricTypeEditViewModel
|
||||
{
|
||||
public int MetricTypeID { get; set; }
|
||||
|
||||
107
PlotLine/Views/ProjectRestorePoints/Index.cshtml
Normal file
107
PlotLine/Views/ProjectRestorePoints/Index.cshtml
Normal file
@ -0,0 +1,107 @@
|
||||
@model ProjectRestorePointListViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Restore Points";
|
||||
var defaultRestorePointName = $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||
}
|
||||
|
||||
@functions {
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes >= 1024 * 1024)
|
||||
{
|
||||
return $"{bytes / 1024d / 1024d:0.0} MB";
|
||||
}
|
||||
|
||||
if (bytes >= 1024)
|
||||
{
|
||||
return $"{bytes / 1024d:0.0} KB";
|
||||
}
|
||||
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail">
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<span>Restore Points</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Project backup</p>
|
||||
<h1>Restore Points</h1>
|
||||
<p class="lead-text">Create manual database-saved snapshots of the project export JSON.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Back to project</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["ProjectRestorePointMessage"] is string message)
|
||||
{
|
||||
<div class="alert alert-info">@message</div>
|
||||
}
|
||||
|
||||
<section class="story-bible-section">
|
||||
<div class="story-bible-section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Manual snapshot</p>
|
||||
<h2>Create restore point</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form asp-action="Create" method="post" class="row g-3">
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="restorePointName">Name</label>
|
||||
<input class="form-control" id="restorePointName" name="restorePointName" maxlength="255" placeholder="@defaultRestorePointName" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="notes">Notes</label>
|
||||
<input class="form-control" id="notes" name="notes" maxlength="1000" placeholder="Optional reason or reminder" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" type="submit">Create Restore Point</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Saved restore points</h2>
|
||||
@if (!Model.RestorePoints.Any())
|
||||
{
|
||||
<p class="muted">No restore points yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var restorePoint in Model.RestorePoints)
|
||||
{
|
||||
<tr>
|
||||
<td>@restorePoint.RestorePointName</td>
|
||||
<td><span class="status-pill">@restorePoint.RestorePointType</span></td>
|
||||
<td>@restorePoint.CreatedDateUtc.ToLocalTime().ToString("dd MMM yyyy HH:mm")</td>
|
||||
<td>@FormatSize(restorePoint.JsonSizeBytes)</td>
|
||||
<td>@restorePoint.Notes</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-action="Download" asp-route-projectId="@Model.Project.ProjectID" asp-route-id="@restorePoint.ProjectRestorePointID">Download</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@ -62,6 +62,7 @@
|
||||
<a class="btn btn-outline-secondary" asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Archives" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Archived Items</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="ProjectRestorePoints" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Restore Points</a>
|
||||
<a class="btn btn-primary" asp-controller="Books" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">Add book</a>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@Model.Project.ProjectID" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user