feat: add a Tools page printer for note merge and print presets
Chapter officers can merge a markdown note onto filtered students, teams, or events and save the recipe. Extra student-note columns are additional fields (any ## … fields heading) so they work as print tokens whether imported or typed by hand. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
using Core.Printing;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
public class NotePrintRequest
|
||||
{
|
||||
public required PrintEntityType EntityType { get; init; }
|
||||
|
||||
public required string TemplateMarkdown { get; init; }
|
||||
|
||||
public required PrintPresetFilters Filters { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> ImportedFieldCatalog { get; init; }
|
||||
}
|
||||
|
||||
public class NotePrintPage
|
||||
{
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
public required string Html { get; init; }
|
||||
}
|
||||
|
||||
public interface INotePrintService
|
||||
{
|
||||
Task<IReadOnlyList<NotePrintPage>> PreviewAsync(
|
||||
NotePrintRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public interface INotesService
|
||||
Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct Field names from student note <c>## Imported fields</c> tables.
|
||||
/// Distinct Field names from student note <c>## Additional fields</c> tables.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
public interface IPrintPresetService
|
||||
{
|
||||
Task<IReadOnlyList<PrintPreset>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> NameExistsAsync(string name, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PrintPreset> CreateAsync(PrintPreset preset, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PrintPreset> UpdateAsync(PrintPreset preset, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using Core.Entities;
|
||||
using Core.Notes;
|
||||
using Core.Printing;
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WebApp.Models;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
public class NotePrintService : INotePrintService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly INotesService _notesService;
|
||||
|
||||
public NotePrintService(
|
||||
AppDbContext context,
|
||||
IConfiguration configuration,
|
||||
INotesService notesService)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration;
|
||||
_notesService = notesService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotePrintPage>> PreviewAsync(
|
||||
NotePrintRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var chapter = ChapterTokens();
|
||||
var template = request.TemplateMarkdown;
|
||||
|
||||
return request.EntityType switch
|
||||
{
|
||||
PrintEntityType.Student => await PreviewStudentsAsync(request, chapter, template, cancellationToken),
|
||||
PrintEntityType.Team => await PreviewTeamsAsync(request, chapter, template, cancellationToken),
|
||||
PrintEntityType.Event => await PreviewEventsAsync(request, chapter, template, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<NotePrintPage>> PreviewStudentsAsync(
|
||||
NotePrintRequest request,
|
||||
Dictionary<string, string?> chapter,
|
||||
string template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filters = request.Filters;
|
||||
var query = _context.Students.AsNoTracking().AsQueryable();
|
||||
|
||||
if (filters.Grade.HasValue)
|
||||
query = query.Where(s => s.Grade == filters.Grade.Value);
|
||||
if (filters.TsaYear.HasValue)
|
||||
query = query.Where(s => s.TsaYear == filters.TsaYear.Value);
|
||||
if (filters.IsOfficer == true)
|
||||
query = query.Where(s => s.OfficerRole != null);
|
||||
else if (filters.IsOfficer == false)
|
||||
query = query.Where(s => s.OfficerRole == null);
|
||||
|
||||
var students = await query
|
||||
.OrderBy(s => s.LastName)
|
||||
.ThenBy(s => s.FirstName)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var notes = await _notesService.GetStudentNotesAsync(students.Select(s => s.Id));
|
||||
|
||||
List<(Student Student, Dictionary<string, string?> Imported)> rows = [];
|
||||
foreach (var student in students)
|
||||
{
|
||||
notes.TryGetValue(student.Id, out var note);
|
||||
var parsed = ImportedFieldsTable.ParseFields(note?.Content);
|
||||
rows.Add((student, ImportedTokens(parsed, request.ImportedFieldCatalog)));
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
.. rows.Select(row => MergePage(
|
||||
row.Student.LastNameFirstName,
|
||||
template,
|
||||
row.Imported,
|
||||
StudentTokens(row.Student),
|
||||
chapter))
|
||||
];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<NotePrintPage>> PreviewTeamsAsync(
|
||||
NotePrintRequest request,
|
||||
Dictionary<string, string?> chapter,
|
||||
string template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filters = request.Filters;
|
||||
var query = _context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Captain)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filters.TeamIdentifierContains))
|
||||
{
|
||||
var term = filters.TeamIdentifierContains.Trim();
|
||||
query = query.Where(t => t.Identifier != null && t.Identifier.Contains(term));
|
||||
}
|
||||
|
||||
var teams = await query
|
||||
.OrderBy(t => t.Event.Name)
|
||||
.ThenBy(t => t.Identifier)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return
|
||||
[
|
||||
.. teams.Select(team => MergePage(team.ToString(), template, null, TeamTokens(team), chapter))
|
||||
];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<NotePrintPage>> PreviewEventsAsync(
|
||||
NotePrintRequest request,
|
||||
Dictionary<string, string?> chapter,
|
||||
string template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filters = request.Filters;
|
||||
var query = _context.Events.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filters.EventNameContains))
|
||||
{
|
||||
var term = filters.EventNameContains.Trim();
|
||||
query = query.Where(e => e.Name.Contains(term));
|
||||
}
|
||||
|
||||
if (filters.EventFormat.HasValue)
|
||||
query = query.Where(e => e.EventFormat == filters.EventFormat.Value);
|
||||
|
||||
if (filters.RegionalOnly == true)
|
||||
query = query.Where(e => e.ChapterEligibilityCountRegionals > 0);
|
||||
else if (filters.RegionalOnly == false)
|
||||
query = query.Where(e => e.ChapterEligibilityCountRegionals <= 0);
|
||||
|
||||
var events = await query
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return
|
||||
[
|
||||
.. events.Select(evt => MergePage(evt.Name, template, null, EventTokens(evt), chapter))
|
||||
];
|
||||
}
|
||||
|
||||
private static NotePrintPage MergePage(
|
||||
string displayName,
|
||||
string template,
|
||||
Dictionary<string, string?>? imported,
|
||||
Dictionary<string, string?> entity,
|
||||
Dictionary<string, string?> chapter)
|
||||
{
|
||||
var map = PrintTokenMap.Build(imported, entity, chapter);
|
||||
return new NotePrintPage
|
||||
{
|
||||
DisplayName = displayName,
|
||||
Html = NoteTemplateMerger.ApplyLayout(MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)))
|
||||
};
|
||||
}
|
||||
|
||||
private Dictionary<string, string?> ChapterTokens()
|
||||
{
|
||||
var settings = ChapterSettings.FromConfiguration(_configuration);
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Chapter.Name"] = settings.Name,
|
||||
["Chapter.ShortName"] = settings.ShortName,
|
||||
["Chapter.CompetitionYear"] = settings.CompetitionYear,
|
||||
["Chapter.YearlyTheme"] = settings.YearlyTheme,
|
||||
["Chapter.StateAbbrev"] = settings.StateAbbrev
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> StudentTokens(Student student) =>
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["FirstName"] = student.FirstName,
|
||||
["LastName"] = student.LastName,
|
||||
["Name"] = student.Name,
|
||||
["LastNameFirstName"] = student.LastNameFirstName,
|
||||
["Grade"] = student.Grade.ToString(),
|
||||
["TsaYear"] = student.TsaYear.ToString(),
|
||||
["Email"] = student.Email,
|
||||
["PhoneNumber"] = student.PhoneNumber,
|
||||
["StateId"] = student.StateId,
|
||||
["RegionalId"] = student.RegionalId,
|
||||
["NationalId"] = student.NationalId,
|
||||
["OfficerRole"] = student.OfficerRole?.ToString()
|
||||
};
|
||||
|
||||
private static Dictionary<string, string?> TeamTokens(Team team)
|
||||
{
|
||||
var evt = team.Event;
|
||||
var tokens = EventSharedTokens(evt);
|
||||
tokens["Identifier"] = team.Identifier;
|
||||
tokens["Name"] = team.ToString();
|
||||
tokens["EventName"] = evt?.Name;
|
||||
tokens["EventShortName"] = evt?.ShortName;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> EventTokens(EventDefinition evt)
|
||||
{
|
||||
var tokens = EventSharedTokens(evt);
|
||||
tokens["Name"] = evt.Name;
|
||||
tokens["ShortName"] = evt.ShortName;
|
||||
tokens["LevelOfEffort"] = evt.LevelOfEffort?.ToString();
|
||||
tokens["SemifinalistActivity"] = evt.SemifinalistActivity;
|
||||
tokens["RegionalEvent"] = evt.RegionalEvent ? "Yes" : "No";
|
||||
tokens["Documentation"] = evt.Documentation;
|
||||
tokens["Notes"] = evt.Notes;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> EventSharedTokens(EventDefinition? evt) =>
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["EventFormat"] = evt?.EventFormat.ToString(),
|
||||
["TeamSize"] = evt?.TeamSize,
|
||||
["Eligibility"] = evt?.Eligibility,
|
||||
["Description"] = evt?.Description,
|
||||
["Theme"] = evt?.Theme
|
||||
};
|
||||
|
||||
private static Dictionary<string, string?> ImportedTokens(
|
||||
IReadOnlyList<ImportedField> parsed,
|
||||
IReadOnlyList<string> catalog)
|
||||
{
|
||||
var imported = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var name in catalog)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
imported[name] = parsed
|
||||
.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
?.Value;
|
||||
}
|
||||
|
||||
foreach (var field in parsed)
|
||||
imported.TryAdd(field.Name, field.Value);
|
||||
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Core.Entities;
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WebApp.Services;
|
||||
|
||||
public class PrintPresetService : IPrintPresetService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<PrintPresetService> _logger;
|
||||
|
||||
public PrintPresetService(AppDbContext context, ILogger<PrintPresetService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PrintPreset>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.PrintPresets
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Note)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.PrintPresets
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Note)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> NameExistsAsync(
|
||||
string name,
|
||||
int? excludeId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trimmed = name.Trim();
|
||||
var query = _context.PrintPresets.AsNoTracking().Where(p => p.Name == trimmed);
|
||||
if (excludeId.HasValue)
|
||||
query = query.Where(p => p.Id != excludeId.Value);
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PrintPreset> CreateAsync(PrintPreset preset, CancellationToken cancellationToken = default)
|
||||
{
|
||||
preset.Name = preset.Name.Trim();
|
||||
preset.UpdatedAt = DateTime.UtcNow;
|
||||
preset.Note = null!;
|
||||
|
||||
if (await NameExistsAsync(preset.Name, null, cancellationToken))
|
||||
throw new InvalidOperationException($"A print preset named '{preset.Name}' already exists.");
|
||||
|
||||
_context.PrintPresets.Add(preset);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("Print preset created: {PresetId} {Name}", preset.Id, preset.Name);
|
||||
return preset;
|
||||
}
|
||||
|
||||
public async Task<PrintPreset> UpdateAsync(PrintPreset preset, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _context.PrintPresets
|
||||
.FirstOrDefaultAsync(p => p.Id == preset.Id, cancellationToken);
|
||||
|
||||
if (existing is null)
|
||||
throw new InvalidOperationException($"Print preset {preset.Id} was not found.");
|
||||
|
||||
var name = preset.Name.Trim();
|
||||
if (await NameExistsAsync(name, preset.Id, cancellationToken))
|
||||
throw new InvalidOperationException($"A print preset named '{name}' already exists.");
|
||||
|
||||
existing.Name = name;
|
||||
existing.NoteId = preset.NoteId;
|
||||
existing.EntityType = preset.EntityType;
|
||||
existing.FiltersJson = preset.FiltersJson;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("Print preset updated: {PresetId} {Name}", existing.Id, existing.Name);
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _context.PrintPresets
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
|
||||
if (existing is null)
|
||||
return;
|
||||
|
||||
_context.PrintPresets.Remove(existing);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("Print preset deleted: {PresetId} {Name}", id, existing.Name);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Core.Services;
|
||||
namespace WebApp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates #Student:{id} notes when imported fields actually change.
|
||||
/// Creates or updates #Student:{id} notes when additional fields actually change.
|
||||
/// </summary>
|
||||
public class StudentNotesImportSaveService : IStudentNotesImportSaveService
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user