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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user