Files
chapter-organizer/WebApp/Services/NotePrintService.cs
T
poprhythmandCursor 3712dba974 feat: merge event rankings and attribute badges into the page printer
Interview notes can print each student's ranked events and a shared attribute legend from the same catalog as the ranking index, without leftover table markup or collapsed answer space.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 23:03:10 -04:00

290 lines
10 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
[
.. rows.Select(row => MergePage(
row.Student.LastNameFirstName,
template,
row.Imported,
StudentTokens(row.Student),
chapter,
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
[
.. 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);
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
[
.. events.Select(evt => MergePage(
evt.Name,
template,
null,
EventTokens(evt),
chapter,
EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id))))
];
}
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,
["Eligibility"] = evt?.Eligibility,
["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;
}
}