When New page per record is off, a single-table template shares one header across records. Event and team pages can print regional/state team counts and nationals eligibility. Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
12 KiB
C#
347 lines
12 KiB
C#
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
|
|
.Include(s => s.EventRankings)
|
|
.ThenInclude(r => r.EventDefinition)
|
|
.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 FinishPreview(
|
|
request,
|
|
template,
|
|
chapter,
|
|
rows.Count == 1 ? "1 student" : $"{rows.Count} students",
|
|
rows.Select(row => new RecordMerge(
|
|
row.Student.LastNameFirstName,
|
|
row.Imported,
|
|
StudentTokens(row.Student),
|
|
StudentHtmlFragments(row.Student))));
|
|
}
|
|
|
|
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 FinishPreview(
|
|
request,
|
|
template,
|
|
chapter,
|
|
teams.Count == 1 ? "1 team" : $"{teams.Count} teams",
|
|
teams.Select(team => new RecordMerge(team.ToString(), null, TeamTokens(team), null)));
|
|
}
|
|
|
|
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);
|
|
|
|
var rankings = await _context.StudentEventRanking
|
|
.AsNoTracking()
|
|
.Include(r => r.Student)
|
|
.Include(r => r.EventDefinition)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var rankingsByEventId = rankings
|
|
.GroupBy(r => r.EventDefinition.Id)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
return FinishPreview(
|
|
request,
|
|
template,
|
|
chapter,
|
|
events.Count == 1 ? "1 event" : $"{events.Count} events",
|
|
events.Select(evt => new RecordMerge(
|
|
evt.Name,
|
|
null,
|
|
EventTokens(evt),
|
|
EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id)))));
|
|
}
|
|
|
|
private readonly record struct RecordMerge(
|
|
string DisplayName,
|
|
Dictionary<string, string?>? Imported,
|
|
Dictionary<string, string?> Entity,
|
|
IReadOnlyDictionary<string, string>? HtmlFragments);
|
|
|
|
private static IReadOnlyList<NotePrintPage> FinishPreview(
|
|
NotePrintRequest request,
|
|
string template,
|
|
Dictionary<string, string?> chapter,
|
|
string combinedDisplayName,
|
|
IEnumerable<RecordMerge> records)
|
|
{
|
|
var list = records.ToList();
|
|
if (list.Count == 0)
|
|
return [];
|
|
|
|
if (!request.Filters.NewPagePerRecord
|
|
&& MarkdownTableStencil.TryParse(template, out var stencil)
|
|
&& stencil is not null)
|
|
{
|
|
var chapterMap = PrintTokenMap.Build(null, null, chapter);
|
|
var prefix = NoteTemplateMerger.Merge(stencil.Prefix, chapterMap);
|
|
var suffix = NoteTemplateMerger.Merge(stencil.Suffix, chapterMap);
|
|
var bodies = list.Select(record =>
|
|
NoteTemplateMerger.Merge(
|
|
stencil.Body,
|
|
PrintTokenMap.Build(record.Imported, record.Entity, chapter)));
|
|
|
|
return
|
|
[
|
|
new NotePrintPage
|
|
{
|
|
DisplayName = combinedDisplayName,
|
|
Html = NoteTemplateMerger.ApplyLayout(
|
|
MarkdownHelper.ToHtml(stencil.Stitch(prefix, bodies, suffix)))
|
|
}
|
|
];
|
|
}
|
|
|
|
return
|
|
[
|
|
.. list.Select(record => MergePage(
|
|
record.DisplayName,
|
|
template,
|
|
record.Imported,
|
|
record.Entity,
|
|
chapter,
|
|
record.HtmlFragments))
|
|
];
|
|
}
|
|
|
|
private static NotePrintPage MergePage(
|
|
string displayName,
|
|
string template,
|
|
Dictionary<string, string?>? imported,
|
|
Dictionary<string, string?> entity,
|
|
Dictionary<string, string?> chapter,
|
|
IReadOnlyDictionary<string, string>? htmlFragments = null)
|
|
{
|
|
var map = PrintTokenMap.Build(imported, entity, chapter);
|
|
return new NotePrintPage
|
|
{
|
|
DisplayName = displayName,
|
|
Html = NoteTemplateMerger.ApplyLayout(
|
|
MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)),
|
|
htmlFragments)
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, string> StudentHtmlFragments(Student student) =>
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings)
|
|
};
|
|
|
|
private static Dictionary<string, string> EventHtmlFragments(IReadOnlyList<StudentEventRanking>? rankings) =>
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings)
|
|
};
|
|
|
|
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)
|
|
{
|
|
var tokens = new Dictionary<string, string?>(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()
|
|
};
|
|
|
|
foreach (var (key, value) in StudentRankTokens.FromRankings(student.EventRankings))
|
|
tokens[key] = value;
|
|
|
|
return tokens;
|
|
}
|
|
|
|
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,
|
|
["NationalEligibility"] = evt?.Eligibility,
|
|
["Eligibility"] = evt?.Eligibility,
|
|
["RegionalTeamCount"] = evt?.ChapterEligibilityCountRegionals.ToString(),
|
|
["StateTeamCount"] = evt?.ChapterEligibilityCountState.ToString(),
|
|
["Description"] = evt?.Description,
|
|
["Theme"] = evt?.Theme,
|
|
["EventAttributes"] = EventAttributeMarks.For(evt)
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|