Files
sports-division/src/SportsDivision.Web/Reports/ReportExcelExporter.cs
warringtond 810f721e48 Fix all 45 audited bugs from bug-fixes.md
Verified every finding against the code, then fixed correctness (redirects,
advancement reset, duplicate heats, high-jump attempt entry, placement
ranking with tie handling, delete-dependency 500s), security (secrets out of
config, config-driven admin seed, login lockout, role authorization with
school scoping, heat-time IDOR, forwarded headers, last-admin guards),
schema integrity (nullable+filtered ExistingStudentId, unique indexes for
rounds/heats/bar heights via SchemaIntegrityFixes migration), performance
(N+1 removal in high jump/reports/standings/dashboard, SQL-side student
paging), and hygiene (duplicate notifications, auto-dismiss scope, local
bootstrap-icons, orphaned files, test-data.sql tournament creation).

FluentValidation is now registered; AutoMapper bumped to 14.0.0 (advisory
fully patched only in licence-changed 15.1.1 — documented). 11 new tests;
63/63 passing. Credential rotation and deploy-time DB_CONNECTION_STRING are
required manual follow-ups, documented in bug-fixes.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:30:53 -04:00

195 lines
7.8 KiB
C#

using ClosedXML.Excel;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.DTOs.ReportDtos;
namespace SportsDivision.Web.Reports;
/// <summary>
/// Builds Microsoft Excel (.xlsx) workbooks for each report. One worksheet per
/// report, with a title, subtitle (tournament name), a styled header row, and
/// flattened data rows for the nested reports.
/// </summary>
public static class ReportExcelExporter
{
public const string MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static int WriteHeader(IXLWorksheet ws, string title, string subtitle, string[] columns)
{
ws.Cell(1, 1).Value = title;
ws.Cell(1, 1).Style.Font.Bold = true;
ws.Cell(1, 1).Style.Font.FontSize = 14;
ws.Range(1, 1, 1, Math.Max(1, columns.Length)).Merge();
ws.Cell(2, 1).Value = subtitle;
ws.Cell(2, 1).Style.Font.Italic = true;
ws.Range(2, 1, 2, Math.Max(1, columns.Length)).Merge();
const int headerRow = 4;
for (int i = 0; i < columns.Length; i++)
{
var c = ws.Cell(headerRow, i + 1);
c.Value = columns[i];
c.Style.Font.Bold = true;
c.Style.Fill.BackgroundColor = XLColor.FromHtml("#003366");
c.Style.Font.FontColor = XLColor.White;
}
return headerRow + 1;
}
private static byte[] Finish(XLWorkbook wb, params IXLWorksheet[] sheets)
{
foreach (var ws in sheets)
{
ws.SheetView.FreezeRows(4);
ws.Columns().AdjustToContents();
}
using var ms = new MemoryStream();
wb.SaveAs(ms);
return ms.ToArray();
}
// Missing values stay blank so the column keeps a uniform numeric type —
// writing "-" would make Excel treat the column as text for sorting/aggregation.
private static void SetInt(IXLCell cell, int? v) { if (v.HasValue) cell.Value = v.Value; }
private static void SetDec(IXLCell cell, decimal? v) { if (v.HasValue) cell.Value = v.Value; }
public static byte[] PopularEvents(IEnumerable<PopularEventsReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Popular Events");
int r = WriteHeader(ws, "Popular Events Report", tournamentName,
new[] { "Event", "Category", "Total", "Male", "Female" });
foreach (var e in data)
{
ws.Cell(r, 1).Value = e.EventName;
ws.Cell(r, 2).Value = e.Category;
ws.Cell(r, 3).Value = e.RegistrationCount;
ws.Cell(r, 4).Value = e.MaleCount;
ws.Cell(r, 5).Value = e.FemaleCount;
r++;
}
return Finish(wb, ws);
}
public static byte[] RegistrationByGender(IEnumerable<RegistrationByGenderReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Registration by Gender");
int r = WriteHeader(ws, "Registration by Gender", tournamentName,
new[] { "Event", "Level", "Male", "Female", "Total" });
foreach (var e in data)
{
ws.Cell(r, 1).Value = e.EventName;
ws.Cell(r, 2).Value = e.EventLevelName;
ws.Cell(r, 3).Value = e.MaleCount;
ws.Cell(r, 4).Value = e.FemaleCount;
ws.Cell(r, 5).Value = e.TotalCount;
r++;
}
return Finish(wb, ws);
}
public static byte[] EventSchoolReport(IEnumerable<EventSchoolReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Event School Report");
int r = WriteHeader(ws, "Event School Report", tournamentName,
new[] { "Event", "Level", "School", "Student", "Performance", "Placement", "Points" });
foreach (var ev in data)
foreach (var school in ev.Schools)
foreach (var s in school.Students)
{
ws.Cell(r, 1).Value = ev.EventName;
ws.Cell(r, 2).Value = ev.EventLevelName;
ws.Cell(r, 3).Value = school.SchoolName;
ws.Cell(r, 4).Value = s.StudentName;
SetDec(ws.Cell(r, 5), s.RawPerformance);
SetInt(ws.Cell(r, 6), s.Placement);
ws.Cell(r, 7).Value = s.PlacementPoints;
r++;
}
return Finish(wb, ws);
}
public static byte[] StudentsBySchool(IEnumerable<StudentsBySchoolReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Students by School");
int r = WriteHeader(ws, "Students by School", tournamentName,
new[] { "School", "Zone", "Student", "Sex", "Events" });
foreach (var school in data)
foreach (var s in school.Students)
{
ws.Cell(r, 1).Value = school.SchoolName;
ws.Cell(r, 2).Value = school.ZoneName;
ws.Cell(r, 3).Value = s.StudentName;
ws.Cell(r, 4).Value = s.Sex;
ws.Cell(r, 5).Value = string.Join(", ", s.Events);
r++;
}
return Finish(wb, ws);
}
public static byte[] ScoresByEvent(IEnumerable<ScoresByEventReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Scores by Event");
int r = WriteHeader(ws, "Scores by Event", tournamentName,
new[] { "Event", "Level", "Category", "Placement", "Student", "School", "Performance", "WA Points", "Placement Points" });
foreach (var ev in data)
foreach (var s in ev.Scores)
{
ws.Cell(r, 1).Value = ev.EventName;
ws.Cell(r, 2).Value = ev.EventLevelName;
ws.Cell(r, 3).Value = ev.Category;
SetInt(ws.Cell(r, 4), s.Placement);
ws.Cell(r, 5).Value = s.StudentName;
ws.Cell(r, 6).Value = s.SchoolName;
ws.Cell(r, 7).Value = s.RawPerformance;
ws.Cell(r, 8).Value = s.CalculatedPoints;
ws.Cell(r, 9).Value = s.PlacementPoints;
r++;
}
return Finish(wb, ws);
}
public static byte[] StudentPoints(IEnumerable<StudentPointsReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Student Points");
int r = WriteHeader(ws, "Student Points", tournamentName,
new[] { "Student", "School", "Sex", "Total Placement Points", "Events" });
foreach (var s in data)
{
ws.Cell(r, 1).Value = s.StudentName;
ws.Cell(r, 2).Value = s.SchoolName;
ws.Cell(r, 3).Value = s.Sex;
ws.Cell(r, 4).Value = s.TotalPlacementPoints;
ws.Cell(r, 5).Value = s.EventCount;
r++;
}
return Finish(wb, ws);
}
public static byte[] SchoolStandings(IEnumerable<SchoolPointsSummaryDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("School Standings");
int r = WriteHeader(ws, "School Standings", tournamentName,
new[] { "Rank", "School", "Short Name", "Total Points", "1st Place", "2nd Place", "3rd Place" });
int rank = 1;
foreach (var s in data)
{
ws.Cell(r, 1).Value = rank++;
ws.Cell(r, 2).Value = s.SchoolName;
ws.Cell(r, 3).Value = s.ShortName ?? string.Empty;
ws.Cell(r, 4).Value = s.TotalPoints;
ws.Cell(r, 5).Value = s.FirstPlaceCount;
ws.Cell(r, 6).Value = s.SecondPlaceCount;
ws.Cell(r, 7).Value = s.ThirdPlaceCount;
r++;
}
return Finish(wb, ws);
}
}