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>
195 lines
7.8 KiB
C#
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);
|
|
}
|
|
}
|