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>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ Thumbs.db
|
||||
|
||||
## Secrets
|
||||
appsettings.*.local.json
|
||||
.env
|
||||
|
||||
@@ -2,8 +2,6 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Copy project files and restore dependencies
|
||||
COPY src/SportsDivision.Domain/SportsDivision.Domain.csproj src/SportsDivision.Domain/
|
||||
COPY src/SportsDivision.Application/SportsDivision.Application.csproj src/SportsDivision.Application/
|
||||
@@ -23,6 +21,8 @@ WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:80
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
COPY --from=build /app/out .
|
||||
|
||||
ENTRYPOINT ["dotnet", "SportsDivision.Web.dll"]
|
||||
|
||||
361
bug-fixes.md
Normal file
361
bug-fixes.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Bug Findings & Fixes — SportsDivision
|
||||
|
||||
Original scan: 2026-08-11 against `master` @ `78c526e` (45 findings, two sweeps).
|
||||
**Fix pass completed 2026-08-11.** Every finding was re-verified against the code
|
||||
before changes were made; **all 45 were confirmed real and accurately described**
|
||||
(one correction of detail: the AutoMapper advisory is patched only in 15.1.1/16.1.1,
|
||||
not in any 13.x/14.x — see #21).
|
||||
|
||||
State after fixes: `dotnet build` succeeds, `dotnet test` passes **63/63**
|
||||
(52 pre-existing + 11 new tests covering the fixed logic). A new EF migration
|
||||
`SchemaIntegrityFixes` carries the schema changes (#13, #38) and is confirmed in
|
||||
sync with the model.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Required manual actions (cannot be done from the repo)
|
||||
|
||||
1. **Rotate the leaked credentials** (#15). The following were committed and are in
|
||||
git history; removing them from HEAD (done) is not sufficient:
|
||||
- Postgres superuser password for `74.50.64.180` (production)
|
||||
- the local/dev Postgres password
|
||||
- the Gmail app password for `pcgurudm@gmail.com` (revoke it — the mail feature
|
||||
doesn't even exist)
|
||||
2. **Provide secrets via environment at deploy time.** `docker-compose.yml` now
|
||||
requires `DB_CONNECTION_STRING` (and optionally `SEED_ADMIN_PASSWORD`), e.g. from
|
||||
an untracked `.env` next to the compose file on the VPS or injected by Jenkins.
|
||||
The Jenkins deploy stage runs `docker compose up` from a fresh checkout, so until
|
||||
a `.env` exists on the host path (or the pipeline exports the variable), deploys
|
||||
will fail fast with a clear compose error. Local dev: set the connection string in
|
||||
`appsettings.Development.json` (placeholder committed) or user-secrets.
|
||||
3. **Change the admin password** if the seeded `admin@sportsdivision.dm` /
|
||||
`Admin@123!` account exists in any database — the seeder no longer creates it
|
||||
with a hardcoded password (#16), but existing rows keep the old one.
|
||||
4. **Decide on git history purge** for the leaked secrets (e.g. `git filter-repo`).
|
||||
Not done here because rewriting published history must be coordinated.
|
||||
5. **Decide on AutoMapper 15.1.1** (#21): the DoS advisory is only patched in
|
||||
15.1.1+/16.1.1+, which are under the new RPL/commercial dual licence. Upgraded to
|
||||
14.0.0 (last version under the original licence); the NU1903 warning remains
|
||||
deliberately visible. Exploitability here is negligible — the app only maps its
|
||||
own bounded EF entity graphs, never attacker-controlled object graphs.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Correctness & crashes
|
||||
|
||||
### 1. "Complete Heat" navigated to the wrong round (or 404) — **FIXED** ✅
|
||||
Verified: `CompleteHeat` redirected with `roundId = heatId`.
|
||||
Fix: the hidden `complete-heat` form in `ManageRound.cshtml` now posts `roundId`
|
||||
alongside `heatId`, and `TrackEventController.CompleteHeat(int heatId, int roundId)`
|
||||
redirects with the real round id.
|
||||
|
||||
### 2. FluentValidation never registered — **FIXED** ✅
|
||||
Verified: four validators existed with zero registration; every `ModelState.IsValid`
|
||||
guard was unconditionally true.
|
||||
Fix: `AddValidatorsFromAssembly(...)` in `AddApplication()` (new
|
||||
`FluentValidation.DependencyInjectionExtensions` package reference) and
|
||||
`AddFluentValidationAutoValidation()` in `Program.cs`. All four validators
|
||||
(student, school, tournament incl. `EndDate >= StartDate`, registration) now run;
|
||||
`Register.cshtml` gained a validation summary so registration rule failures are shown.
|
||||
|
||||
### 3. NullReferenceException registering a relay team — **FIXED** ✅
|
||||
Verified: `dto.StudentId!.Value` dereferenced null for relay-only posts.
|
||||
Fix: `RegisterStudentAsync` branches on `StudentId.HasValue`; relay-only
|
||||
registrations are rejected with a clear `InvalidOperationException` ("not supported
|
||||
yet") since the relay feature has no management UI or eligibility rules anywhere in
|
||||
the app. Covered by two new tests.
|
||||
|
||||
### 4. Advancement flags never cleared before recalculation — **FIXED** ✅
|
||||
Verified. Fix: `CalculateAdvancementAsync` resets `IsAdvanced`/`AdvanceReason` on
|
||||
every lane in the round before applying the top-N and fastest-loser passes.
|
||||
Covered by a new test (`HeatAdvancementResetTests`).
|
||||
|
||||
### 5. "Send Advancers" duplicated heats when re-run — **FIXED** ✅
|
||||
Verified. Fix: `PopulateNextRoundAsync` now clears the next round's existing heats
|
||||
and lanes first (mirroring `SeedHeatsAsync`), and errors clearly when no athletes
|
||||
are marked as advancing.
|
||||
|
||||
### 6. `SeedingMethod.ByPerformance` / `.Manual` silently ignored — **FIXED** ✅
|
||||
Verified: only `Random` was implemented; the UI only ever posted `Random`.
|
||||
Fix: the unimplemented members were removed from the enum (nothing persists or
|
||||
references them), with a comment explaining what a future implementation needs.
|
||||
|
||||
### 7. `lanesPerHeat = 0` wiped a round's heats — **FIXED** ✅
|
||||
Verified (crafted-POST-only reachability also confirmed).
|
||||
Fix: `SeedHeatsAsync` validates `lanesPerHeat` (1–10) **before** any deletion and
|
||||
throws; the controller surfaces the message via the notification banner.
|
||||
|
||||
### 8. High jump attempts 2 and 3 unreachable — **FIXED** ✅
|
||||
Verified: the read-only branch rendered as soon as any attempt row existed.
|
||||
Fix: `HighJump/Index.cshtml` now always renders one dropdown per attempt slot,
|
||||
pre-selected from `Attempt1/2/3` — attempts 2/3 are recordable and mistakes are
|
||||
correctable. The controller also rejects a blank result (which would otherwise
|
||||
bind to the enum default and record a clearance), and the OUT badge still shows.
|
||||
|
||||
### 9. "Remove height" bounced to the event-level picker — **FIXED** ✅
|
||||
Verified. Fix: the form posts the missing `tournamentEventLevelId` hidden input.
|
||||
|
||||
### 10. High jump results left stale scores behind — **FIXED** ✅
|
||||
Verified. Fix: `CalculateResultsAsync` deletes `Score` rows for registrations in
|
||||
the event level that are absent from the ranked results, mirroring the track path.
|
||||
|
||||
### 11. Field-event placements meaningless without scoring constants — **FIXED** ✅
|
||||
Verified: 20 of 32 seeded events have constants; ranking was on `CalculatedPoints`.
|
||||
Fixes:
|
||||
- `CalculatePlacementsAsync` ranks on `RawPerformance` (ascending for Track,
|
||||
descending for Field), so placements are correct even without a constant.
|
||||
- Ties share a placement (competition ranking 1, 2, 2, 4) and split the pooled
|
||||
placement points — same convention as the high-jump path. New tests cover
|
||||
direction, ties and pooled points.
|
||||
- `CalculateFinalScoresAsync` now **throws a clear error** instead of silently
|
||||
returning when the event has no `ScoringConstant`; the Field Event page shows it.
|
||||
- Scores with no valid mark get their stale placement cleared (see also #27).
|
||||
- The missing constants themselves can now be added in-app (#36).
|
||||
|
||||
### 12. Deleting an in-use entity returned a 500 — **FIXED** ✅
|
||||
Verified for all four paths (`DeleteBehavior.Restrict` confirmed in configuration).
|
||||
Fix: `StudentService.DeleteAsync`, `SchoolService.DeleteAsync`,
|
||||
`EventService.DeleteAsync` and `RegistrationService.UnregisterAsync` check for
|
||||
dependants first and throw `InvalidOperationException` with an actionable message
|
||||
(which the controllers already surface). A new repository helper
|
||||
(`IHighJumpHeightRepository.HasAttemptsForRegistrationAsync`) covers the
|
||||
high-jump-attempt dependency.
|
||||
|
||||
### 13. Second student without an "existing student ID" crashed — **FIXED** ✅
|
||||
Verified: `""` collided on the unfiltered unique index.
|
||||
Fix: `Student.ExistingStudentId` is now nullable; the unique index is filtered to
|
||||
non-null (`SchemaIntegrityFixes` migration, which also normalises existing `""`
|
||||
rows to NULL); the service trims/normalises blank input to null and pre-checks
|
||||
duplicates so a repeat ID gets a friendly message instead of a 500.
|
||||
|
||||
### 14. Eligibility ignored active flags and tournament status — **FIXED** ✅
|
||||
Verified. Fix: `CheckEligibilityAsync` now rejects deactivated students,
|
||||
deactivated schools, and completed or archived tournaments, each with a clear
|
||||
reason. Covered by four new tests.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Security & silent failures
|
||||
|
||||
### 15. Live production credentials committed — **FIXED in HEAD** ⚠️ rotation required
|
||||
Verified: both appsettings files tracked, with production Postgres, dev Postgres
|
||||
and a Gmail app password.
|
||||
Fix: all secrets removed from `appsettings.json` / `appsettings.Production.json`;
|
||||
the dead `EmailSettings` block deleted (grep confirmed no code reads it);
|
||||
configuration now flows from the environment (`ConnectionStrings__DefaultConnection`)
|
||||
supplied by docker-compose; `.env` added to `.gitignore`.
|
||||
**Rotation and history purge are manual actions — see the top of this document.**
|
||||
|
||||
### 16. Hardcoded seeded admin account — **FIXED** ✅
|
||||
Verified. Fix: the seeder reads `SeedAdmin:Email` / `SeedAdmin:Password` from
|
||||
configuration; if no password is configured and no admin exists it logs a warning
|
||||
and skips (nothing is created with a known password). Forced password change on
|
||||
first login is not natively supported by ASP.NET Identity — the seeder logs a
|
||||
reminder instead; change the password after first sign-in.
|
||||
|
||||
### 17. Brute-force protection disabled + returnUrl 500 — **FIXED** ✅
|
||||
Verified. Fix: `lockoutOnFailure: true` (Identity default: 5 attempts/5 min), with
|
||||
a distinct "temporarily locked" message; `LocalRedirect` replaced with
|
||||
`Url.IsLocalUrl(...) ? Redirect(...) : RedirectToAction("Index", "Home")`.
|
||||
|
||||
### 18. Roles seeded but never enforced — **FIXED** ✅
|
||||
Verified: only 2 of 13 authorized controllers restricted by role.
|
||||
Fix (role matrix now enforced):
|
||||
- `TrackEvent`, `FieldEvent`, `HighJump`: `Admin,Official` (controller-level).
|
||||
- `Student`, `School`, `Event`, `Tournament`: viewing for any signed-in user;
|
||||
every Create/Edit/Delete/status/archive/event-level action requires `Admin,Official`.
|
||||
- `Registration`: viewing for any signed-in user; Register/Unregister for
|
||||
`Admin,Official,Coach,Principal` — and coaches/principals are **scoped to their
|
||||
own school** via `ApplicationUser.SchoolId` (previously never read outside user
|
||||
management).
|
||||
- `UserManagement`, `ScoringConfig`: `Admin` (as before).
|
||||
|
||||
### 19. Heat times writable across events (IDOR) — **FIXED** ✅
|
||||
Verified. Fix: `SaveHeatTimesAsync` loads the heat's own lanes and ignores any
|
||||
posted `HeatLaneId` outside that set. The same pattern in
|
||||
`HighJumpService.RecordAttemptAsync` is fixed too (the registration must belong to
|
||||
the bar's event level). `RemoveEventLevelAsync`/`ToggleAgeWaiverAsync` redirect
|
||||
mismatches remain cosmetic (the actions themselves operate on the posted id and
|
||||
are now role-restricted).
|
||||
|
||||
### 20. HTTPS redirect behind Caddy without forwarded headers — **FIXED** ✅
|
||||
Verified. Fix: `UseForwardedHeaders` (`X-Forwarded-Proto|For`, known-proxy lists
|
||||
cleared because the proxy's Docker-network address isn't fixed) registered before
|
||||
`UseHttpsRedirection`. `Request.IsHttps`, HSTS and secure-cookie behaviour are now
|
||||
correct behind the proxy.
|
||||
|
||||
### 21. AutoMapper NU1903 advisory — **PARTIALLY FIXED** ⚠️ licensing decision
|
||||
Verified (GHSA-rvv3-g6hj-g44x, uncontrolled-recursion DoS). Correction to the
|
||||
original finding: there is **no patched 13.x**; the fix ships in 15.1.1/16.1.1,
|
||||
which fall under AutoMapper's new commercial/RPL dual licence.
|
||||
Done: upgraded 13.0.1 → **14.0.0** (last original-licence version). The warning is
|
||||
left visible on purpose. Residual risk is negligible here (only trusted, bounded
|
||||
EF entity graphs are mapped). Full remediation = the licensing decision above.
|
||||
Related hygiene, all fixed: `EXPOSE 80` moved to the Dockerfile runtime stage;
|
||||
`${IMAGE_TAG}` defaults to `latest` so plain `docker compose up` works; the unused
|
||||
`./data` volume removed. `AllowedHosts: "*"` was left as-is deliberately — the
|
||||
public hostname isn't recorded in the repo and Caddy fronts the app; set it to the
|
||||
real domain if desired.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Performance & robustness
|
||||
|
||||
### 22. N+1 query storm in high-jump results — **FIXED** ✅
|
||||
Verified. Fix: `CalculateResultsAsync` and `IsEliminatedAsync` use the attempts
|
||||
already eager-loaded by `GetByTournamentEventLevelAsync`; the per-registration ×
|
||||
per-height re-fetch is gone (240 queries → 1 for the doc's 20×12 example).
|
||||
|
||||
### 23. Per-event-level query loops in reports/standings/dashboard — **FIXED** ✅
|
||||
Verified. Fix: new `IEventRegistrationRepository.GetByTournamentAsync` loads a
|
||||
tournament's registrations (student, school, zone, event, level, score) in one
|
||||
query; `GetSchoolStandingsAsync`, all six `ReportService` methods and
|
||||
`DashboardService` now aggregate in memory. New
|
||||
`ITournamentEventLevelRepository.GetByCategoryAsync` collapses the scoring-page
|
||||
picker's per-tournament loop to one query.
|
||||
|
||||
### 24. Pagination loaded the entire table — **FIXED (students)** ✅
|
||||
Verified. Fix: `IStudentRepository.GetPagedAsync` pushes filtering, search,
|
||||
ordering, `Skip/Take` and the true total count into SQL; `StudentController.Index`
|
||||
uses it (stale page numbers are clamped and re-queried). The silent `SearchAsync`
|
||||
`.Take(50)` is gone — search results are properly paged with a correct total.
|
||||
Schools (~70 rows) and tournaments (a handful) keep in-memory paging deliberately:
|
||||
bounded data, no measurable benefit.
|
||||
|
||||
### 25. Read-then-write race on high-jump SortOrder — **FIXED** ✅
|
||||
Verified. Fix: ordering everywhere (repository, service, view) now derives from
|
||||
`Height` itself, and #38's unique `(TournamentEventLevelId, Height)` index makes
|
||||
the countback's "highest bar" deterministic regardless of insertion races.
|
||||
|
||||
### 26. Lane assignment ignored standard seeding — **FIXED** ✅
|
||||
Verified. Fix: lanes are assigned centre-out (4, 5, 3, 6, 2, 7, 1, 8 for 8 lanes);
|
||||
`PopulateNextRoundAsync` feeds athletes fastest-first, so the fastest qualifiers
|
||||
get the middle lanes.
|
||||
|
||||
### 27. Smaller items — **ALL FIXED** ✅
|
||||
| Item | Resolution |
|
||||
|---|---|
|
||||
| Recorded score didn't recalculate points | `RecordScoreAsync` recomputes `CalculatedPoints` from the event's constant, and refreshes placements if they had already been calculated for the event |
|
||||
| `RawPerformance == 0` silently skipped | Points path no longer skips zero; placements explicitly exclude no-mark scores **and clear their stale placement** (tested) |
|
||||
| Empty events counted as "in progress" | Dashboard counts only event levels with registrations |
|
||||
| `IsAgeBased` with no `MaxAge` | "Open Boys/Girls" seeded with `IsAgeBased = false` (existing rows unaffected — seeder skips populated tables) |
|
||||
| Dead `IsEliminated` branch | Removed (`FailCount` covers it) |
|
||||
| `Repository.Update` on tracked entities | `Update` now attaches only detached entities; tracked ones rely on change detection |
|
||||
| Deactivation check after sign-in | `IsActive` checked **before** `PasswordSignInAsync`, same generic error (no account enumeration) |
|
||||
| CDN dependency for icons | Bootstrap Icons 1.11.3 vendored to `wwwroot/lib/bootstrap-icons/`; both layouts use the local copy — icons now work offline |
|
||||
| Unused volume mount | Removed from docker-compose |
|
||||
|
||||
---
|
||||
|
||||
## Second sweep findings
|
||||
|
||||
### 28. `/Student/Details/{id}` missing view — **FIXED** ✅
|
||||
Verified (only missing view in the app). Fix: `Views/Student/Details.cshtml` added
|
||||
in the style of the sibling Details pages, and a Details link added to the student
|
||||
list row actions.
|
||||
|
||||
### 29. Eight ViewBag key mismatches — **FIXED** ✅
|
||||
All eight verified. Fixes: `Student/Index` reads `SelectedSchoolId`/`SearchTerm`;
|
||||
`School/Index` reads `SelectedZoneId`; `FieldEventController`,
|
||||
`HighJumpController` and both `RegistrationController` actions now set
|
||||
`EventName`/`LevelName` the way `TrackEventController` does (officials can see
|
||||
which event and age group they are scoring); `ByStudent.cshtml` reads the
|
||||
`ViewBag.Student` DTO.
|
||||
|
||||
### 30. School level filter wrong cast — **FIXED** ✅
|
||||
Verified. Fix: `ViewBag.SelectedLevel as SchoolLevel?` with enum comparisons.
|
||||
|
||||
### 31. Every notification rendered twice — **FIXED** ✅
|
||||
Verified (exactly the 13 listed views). Fix: all per-view
|
||||
`<partial name="_Notification" />` removed; the layout's single render remains.
|
||||
|
||||
### 32. "Keep this alert visible" defeated — **FIXED** ✅
|
||||
Verified. Fix: the duplicate timer in `site.js` is gone. The remaining timer in
|
||||
`_Notification.cshtml` was also tightened: it now dismisses only alerts the
|
||||
partial itself marks `data-autodismiss` (success/error) — warnings (the jump-off
|
||||
message) and informational page content ("No heats yet", "no event levels") are
|
||||
never auto-dismissed.
|
||||
|
||||
### 33. Eligibility pre-check computed then thrown away — **FIXED** ✅
|
||||
Verified. Fix: `Register.cshtml` got `@model EventRegistrationCreateDto`,
|
||||
re-requests the page with `studentId` on selection change, renders the
|
||||
eligibility verdict (green/red panel, register button disabled when ineligible),
|
||||
and keeps the selection on a rejected submission.
|
||||
|
||||
### 34. Events couldn't be edited or deleted from the UI — **FIXED** ✅
|
||||
Verified. Fix: Edit and Delete (with confirm) buttons per event row; inactive
|
||||
events are badged.
|
||||
|
||||
### 35. Event category filter unreachable — **FIXED** ✅
|
||||
Verified. Fix: a category dropdown drives `?category=`; grouping comes from
|
||||
`Enum.GetValues<EventCategory>()` so future categories appear automatically.
|
||||
|
||||
### 36. Missing scoring constants couldn't be added in-app — **FIXED** ✅
|
||||
Verified. Fix: `ScoringConfig` now supports **add** (dropdown offers only events
|
||||
without a constant — i.e. the 12 broken ones) and **delete** for scoring
|
||||
constants, and add/delete for placement points (9th place etc.). All validation
|
||||
failures surface via the notification banner instead of a silent redirect.
|
||||
|
||||
### 37. Archiving hid a tournament's reports — **FIXED** ✅
|
||||
Verified. Fix: the report picker includes archived tournaments, labelled
|
||||
"(archived)".
|
||||
|
||||
### 38. No uniqueness on round order / heat number / bar height — **FIXED** ✅
|
||||
Verified. Fix: unique indexes on `(TournamentEventLevelId, RoundOrder)`,
|
||||
`(RoundId, HeatNumber)` and `(TournamentEventLevelId, Height)` in the
|
||||
`SchemaIntegrityFixes` migration. Friendly pre-checks added where users can hit
|
||||
them (duplicate round order, duplicate bar height) so the constraint is a backstop,
|
||||
not the error message.
|
||||
|
||||
### 39. A round couldn't be re-seeded — **FIXED** ✅
|
||||
Verified. Fix: rounds with heats show a "Re-seed" button behind an explicit
|
||||
confirm describing what will be replaced (handles late scratches/entries).
|
||||
|
||||
### 40. Admin lock-out — **FIXED** ✅
|
||||
Verified. Fix: `ToggleActive` and `Edit` refuse to deactivate/demote the current
|
||||
user and refuse any change that would leave zero active Admins.
|
||||
|
||||
### 41. `test-data.sql` targeted tournaments the seeder never creates — **FIXED** ✅
|
||||
Verified (the seeder seeds no tournaments at all). Fix: the script now creates the
|
||||
two named tournaments idempotently and resolves their IDs by name everywhere
|
||||
(temp table `_tt`); the header comment describes reality; the summary query no
|
||||
longer assumes IDs 1/2.
|
||||
|
||||
### 42. Extra DB work per page / user list — **FIXED** ✅
|
||||
Verified. Fixes: a claims principal factory stamps first/last name into the
|
||||
sign-in cookie and `_LoginPartial` reads claims — no more per-request
|
||||
`GetUserAsync` (existing sessions fall back to the email initial until next
|
||||
sign-in). `UserManagement.Index` does one query per role instead of one per user.
|
||||
|
||||
### 43. Excel exports wrote `"-"` into numeric columns — **FIXED** ✅
|
||||
Verified. Fix: missing values are left blank; columns keep a uniform numeric type.
|
||||
|
||||
### 44. Orphaned and mislabelled files — **FIXED** ✅
|
||||
Verified. Fixes: `userguide.html` deleted (duplicate of the live Help page);
|
||||
`ViewModels/Placeholder.cs` deleted with its `_ViewImports` using;
|
||||
`ScoreSheetDocument.cs` renamed to `QuestPdfLicenseInitializer.cs` to match its
|
||||
content.
|
||||
|
||||
### 45. Test suite covered only pure functions — **IMPROVED** ✅ / structural note
|
||||
Verified. Done: the wrong leap-year comment fixed, and 11 new service-level tests
|
||||
added with mocked `IUnitOfWork` covering the highest-risk fixed logic —
|
||||
eligibility (inactive student/school, completed/archived tournament, relay
|
||||
rejection), placement ranking direction, tie pooling, stale-placement clearing,
|
||||
and advancement-flag reset. The structural recommendation stands: an integration
|
||||
test suite against a real Postgres (Testcontainers) would have caught #12, #13,
|
||||
#28 and #41; it needs Docker in CI and is left as follow-up work.
|
||||
|
||||
---
|
||||
|
||||
## Summary of the fix pass
|
||||
|
||||
- **45/45 findings verified accurate** (one detail corrected in #21).
|
||||
- **43 fully fixed in code**; #15 fixed in HEAD with mandatory manual rotation;
|
||||
#21 mitigated pending a licensing decision.
|
||||
- New EF migration: `20260811121912_SchemaIntegrityFixes` (nullable + filtered
|
||||
unique `ExistingStudentId` with data normalisation; three new unique indexes).
|
||||
It applies automatically at startup (`Database.MigrateAsync`).
|
||||
- Build clean (only the deliberate NU1903 remains); tests 63/63.
|
||||
- Behavioural notes: scoring pages now require the `Official` (or `Admin`) role;
|
||||
coaches/principals can register/unregister only their own school's students;
|
||||
deploys need `DB_CONNECTION_STRING` in the environment.
|
||||
@@ -3,13 +3,16 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: registry.dwarrington.com/sportsdivision:${IMAGE_TAG}
|
||||
image: registry.dwarrington.com/sportsdivision:${IMAGE_TAG:-latest}
|
||||
container_name: sportsdivision
|
||||
restart: always
|
||||
ports:
|
||||
- "5039:80"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# Secrets are supplied via the environment (e.g. an untracked .env file),
|
||||
# never committed to the repository.
|
||||
ConnectionStrings__DefaultConnection: ${DB_CONNECTION_STRING:?set DB_CONNECTION_STRING in .env}
|
||||
SeedAdmin__Password: ${SEED_ADMIN_PASSWORD:-}
|
||||
networks:
|
||||
- caddy_network
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
namespace SportsDivision.Application.DTOs;
|
||||
public class PlacementPointConfigDto { public int PlacementPointConfigId { get; set; } public int Placement { get; set; } public int Points { get; set; } }
|
||||
public class PlacementPointConfigCreateDto { public int Placement { get; set; } public int Points { get; set; } }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace SportsDivision.Application.DTOs;
|
||||
public class ScoringConstantDto { public int ScoringConstantId { get; set; } public int EventId { get; set; } public string EventName { get; set; } = string.Empty; public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } public string Unit { get; set; } = string.Empty; }
|
||||
public class ScoringConstantUpdateDto { public int ScoringConstantId { get; set; } public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } }
|
||||
public class ScoringConstantCreateDto { public int EventId { get; set; } public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } public string Unit { get; set; } = string.Empty; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using SportsDivision.Domain.Enums;
|
||||
namespace SportsDivision.Application.DTOs;
|
||||
public class StudentDto { public int StudentId { get; set; } public string ExistingStudentId { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } public string SchoolName { get; set; } = string.Empty; public bool IsActive { get; set; } public int? Age { get; set; } }
|
||||
public class StudentCreateDto { public string ExistingStudentId { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } }
|
||||
public class StudentDto { public int StudentId { get; set; } public string? ExistingStudentId { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } public string SchoolName { get; set; } = string.Empty; public bool IsActive { get; set; } public int? Age { get; set; } }
|
||||
public class StudentCreateDto { public string? ExistingStudentId { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } }
|
||||
public class StudentUpdateDto : StudentCreateDto { public int StudentId { get; set; } public bool IsActive { get; set; } }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SportsDivision.Application.Interfaces;
|
||||
using SportsDivision.Application.Mappings;
|
||||
@@ -10,6 +11,7 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAutoMapper(typeof(MappingProfile).Assembly);
|
||||
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
|
||||
|
||||
services.AddScoped<IStudentService, StudentService>();
|
||||
services.AddScoped<ISchoolService, SchoolService>();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using SportsDivision.Application.DTOs;
|
||||
namespace SportsDivision.Application.Interfaces;
|
||||
public interface IRegistrationService { Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task<IEnumerable<EventRegistrationDto>> GetByStudentAsync(int studentId); Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy); Task UnregisterAsync(int eventRegistrationId); Task<(bool IsEligible, string? Reason)> CheckEligibilityAsync(int tournamentEventLevelId, int studentId); }
|
||||
public interface IRegistrationService { Task<EventRegistrationDto?> GetByIdAsync(int eventRegistrationId); Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task<IEnumerable<EventRegistrationDto>> GetByStudentAsync(int studentId); Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy); Task UnregisterAsync(int eventRegistrationId); Task<(bool IsEligible, string? Reason)> CheckEligibilityAsync(int tournamentEventLevelId, int studentId); }
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using SportsDivision.Application.DTOs;
|
||||
namespace SportsDivision.Application.Interfaces;
|
||||
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); }
|
||||
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task CreateScoringConstantAsync(ScoringConstantCreateDto dto); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task DeleteScoringConstantAsync(int scoringConstantId); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task CreatePlacementPointConfigAsync(PlacementPointConfigCreateDto dto); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); Task DeletePlacementPointConfigAsync(int placementPointConfigId); }
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using SportsDivision.Application.DTOs;
|
||||
namespace SportsDivision.Application.Interfaces;
|
||||
public interface IStudentService { Task<IEnumerable<StudentDto>> GetAllAsync(); Task<StudentDto?> GetByIdAsync(int id); Task<StudentDto?> GetByExistingIdAsync(string existingStudentId); Task<IEnumerable<StudentDto>> GetBySchoolAsync(int schoolId); Task<IEnumerable<StudentDto>> SearchAsync(string searchTerm); Task<StudentDto> CreateAsync(StudentCreateDto dto); Task UpdateAsync(StudentUpdateDto dto); Task DeleteAsync(int id); }
|
||||
public interface IStudentService { Task<IEnumerable<StudentDto>> GetAllAsync(); Task<StudentDto?> GetByIdAsync(int id); Task<StudentDto?> GetByExistingIdAsync(string existingStudentId); Task<IEnumerable<StudentDto>> GetBySchoolAsync(int schoolId); Task<(IReadOnlyList<StudentDto> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize); Task<StudentDto> CreateAsync(StudentCreateDto dto); Task UpdateAsync(StudentUpdateDto dto); Task DeleteAsync(int id); }
|
||||
|
||||
@@ -35,18 +35,19 @@ public class DashboardService : IDashboardService
|
||||
|
||||
if (tournamentId.HasValue)
|
||||
{
|
||||
var tels = (await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId.Value)).ToList();
|
||||
int regCount = 0;
|
||||
// One query for the tournament's registrations instead of one per event level.
|
||||
var regs = (await _uow.EventRegistrations.GetByTournamentAsync(tournamentId.Value)).ToList();
|
||||
int scoredEvents = 0;
|
||||
int eventsWithEntries = 0;
|
||||
var recent = new List<RecentScoreDto>();
|
||||
|
||||
foreach (var tel in tels)
|
||||
foreach (var telGroup in regs.GroupBy(r => r.TournamentEventLevelId))
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
regCount += regs.Count();
|
||||
eventsWithEntries++;
|
||||
var tel = telGroup.First().TournamentEventLevel;
|
||||
|
||||
bool hasResults = false;
|
||||
foreach (var reg in regs.Where(r => r.Score != null))
|
||||
foreach (var reg in telGroup.Where(r => r.Score != null))
|
||||
{
|
||||
if (reg.Score!.Placement != null) hasResults = true;
|
||||
recent.Add(new RecentScoreDto
|
||||
@@ -62,9 +63,11 @@ public class DashboardService : IDashboardService
|
||||
if (hasResults) scoredEvents++;
|
||||
}
|
||||
|
||||
dashboard.TotalRegistrations = regCount;
|
||||
dashboard.TotalRegistrations = regs.Count;
|
||||
dashboard.EventsCompleted = scoredEvents;
|
||||
dashboard.EventsInProgress = tels.Count - scoredEvents;
|
||||
// Only event levels somebody actually entered count as "in progress" —
|
||||
// empty event levels are neither in progress nor completed.
|
||||
dashboard.EventsInProgress = eventsWithEntries - scoredEvents;
|
||||
dashboard.RecentScores = recent.OrderByDescending(r => r.RecordedAt).Take(8).ToList();
|
||||
|
||||
var standings = await _scoringService.GetSchoolStandingsAsync(tournamentId.Value);
|
||||
|
||||
@@ -71,6 +71,13 @@ public class EventService : IEventService
|
||||
{
|
||||
var evt = await _uow.Events.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Event not found.");
|
||||
|
||||
// Tournament event levels reference events with DeleteBehavior.Restrict —
|
||||
// check first so the user gets a clear message instead of a database error page.
|
||||
if (await _uow.TournamentEventLevels.AnyAsync(t => t.EventId == id))
|
||||
throw new InvalidOperationException(
|
||||
"This event is used by one or more tournaments. Remove it from those tournaments first, or mark it inactive instead.");
|
||||
|
||||
_uow.Events.Remove(evt);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ public class HeatManagementService : IHeatManagementService
|
||||
|
||||
public async Task<RoundDto> CreateRoundAsync(RoundCreateDto dto)
|
||||
{
|
||||
var existing = await _uow.Rounds.GetByTournamentEventLevelAsync(dto.TournamentEventLevelId);
|
||||
if (existing.Any(r => r.RoundOrder == dto.RoundOrder))
|
||||
throw new InvalidOperationException($"A round with order {dto.RoundOrder} already exists for this event.");
|
||||
|
||||
var round = _mapper.Map<Round>(dto);
|
||||
await _uow.Rounds.AddAsync(round);
|
||||
await _uow.SaveChangesAsync();
|
||||
@@ -38,8 +42,22 @@ public class HeatManagementService : IHeatManagementService
|
||||
return round == null ? null : _mapper.Map<RoundDto>(round);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane numbers in preference order for a heat of <paramref name="laneCount"/> lanes:
|
||||
/// centre lanes first (4, 5, 3, 6, ... for 8 lanes), as is standard for track seeding,
|
||||
/// so the highest-seeded athletes in the list get the middle of the track.
|
||||
/// </summary>
|
||||
private static int[] PreferredLaneOrder(int laneCount) =>
|
||||
Enumerable.Range(1, laneCount)
|
||||
.OrderBy(l => Math.Abs(l - (laneCount + 1) / 2.0))
|
||||
.ThenBy(l => l)
|
||||
.ToArray();
|
||||
|
||||
public async Task SeedHeatsAsync(int roundId, SeedingMethod method, int lanesPerHeat = 8)
|
||||
{
|
||||
if (lanesPerHeat < 1 || lanesPerHeat > 10)
|
||||
throw new InvalidOperationException("Lanes per heat must be between 1 and 10.");
|
||||
|
||||
var round = await _uow.Rounds.GetWithHeatsAsync(roundId)
|
||||
?? throw new KeyNotFoundException("Round not found.");
|
||||
|
||||
@@ -64,7 +82,12 @@ public class HeatManagementService : IHeatManagementService
|
||||
}
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
int heatCount = (int)Math.Ceiling((double)registrations.Count / lanesPerHeat);
|
||||
await CreateHeatsAsync(roundId, registrations.Select(r => r.EventRegistrationId).ToList(), lanesPerHeat);
|
||||
}
|
||||
|
||||
private async Task CreateHeatsAsync(int roundId, IReadOnlyList<int> registrationIds, int lanesPerHeat)
|
||||
{
|
||||
int heatCount = (int)Math.Ceiling((double)registrationIds.Count / lanesPerHeat);
|
||||
for (int h = 0; h < heatCount; h++)
|
||||
{
|
||||
var heat = new Heat
|
||||
@@ -76,14 +99,15 @@ public class HeatManagementService : IHeatManagementService
|
||||
await _uow.Heats.AddAsync(heat);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
var heatRegs = registrations.Skip(h * lanesPerHeat).Take(lanesPerHeat).ToList();
|
||||
var heatRegs = registrationIds.Skip(h * lanesPerHeat).Take(lanesPerHeat).ToList();
|
||||
var laneOrder = PreferredLaneOrder(lanesPerHeat);
|
||||
for (int l = 0; l < heatRegs.Count; l++)
|
||||
{
|
||||
var lane = new HeatLane
|
||||
{
|
||||
HeatId = heat.HeatId,
|
||||
EventRegistrationId = heatRegs[l].EventRegistrationId,
|
||||
LaneNumber = l + 1
|
||||
EventRegistrationId = heatRegs[l],
|
||||
LaneNumber = laneOrder[l]
|
||||
};
|
||||
await _uow.HeatLanes.AddAsync(lane);
|
||||
}
|
||||
@@ -93,10 +117,13 @@ public class HeatManagementService : IHeatManagementService
|
||||
|
||||
public async Task SaveHeatTimesAsync(int heatId, List<HeatLaneUpdateDto> lanes, string recordedBy)
|
||||
{
|
||||
// Only lanes belonging to this heat may be updated; posted IDs from other
|
||||
// heats (or other events entirely) are ignored.
|
||||
var heatLanes = (await _uow.HeatLanes.GetByHeatAsync(heatId)).ToDictionary(l => l.HeatLaneId);
|
||||
|
||||
foreach (var laneDto in lanes)
|
||||
{
|
||||
var lane = await _uow.HeatLanes.GetByIdAsync(laneDto.HeatLaneId);
|
||||
if (lane == null) continue;
|
||||
if (!heatLanes.TryGetValue(laneDto.HeatLaneId, out var lane)) continue;
|
||||
|
||||
lane.Time = laneDto.Time;
|
||||
lane.IsDNS = laneDto.IsDNS;
|
||||
@@ -114,6 +141,19 @@ public class HeatManagementService : IHeatManagementService
|
||||
var round = await _uow.Rounds.GetWithHeatsAsync(roundId)
|
||||
?? throw new KeyNotFoundException("Round not found.");
|
||||
|
||||
// Recalculation must start from a clean slate: clear previous advancement
|
||||
// flags so athletes who no longer qualify (e.g. after a time correction)
|
||||
// do not keep advancing.
|
||||
foreach (var lane in round.Heats.SelectMany(h => h.HeatLanes))
|
||||
{
|
||||
if (lane.IsAdvanced || lane.AdvanceReason != null)
|
||||
{
|
||||
lane.IsAdvanced = false;
|
||||
lane.AdvanceReason = null;
|
||||
_uow.HeatLanes.Update(lane);
|
||||
}
|
||||
}
|
||||
|
||||
var allLanes = round.Heats.SelectMany(h => h.HeatLanes)
|
||||
.Where(l => l.Time.HasValue && !l.IsDNS && !l.IsDNF && !l.IsDQ)
|
||||
.OrderBy(l => l.Time)
|
||||
@@ -170,32 +210,23 @@ public class HeatManagementService : IHeatManagementService
|
||||
var nextRound = nextRounds.FirstOrDefault(r => r.RoundOrder == round.RoundOrder + 1)
|
||||
?? throw new InvalidOperationException("No next round exists.");
|
||||
|
||||
int lanesPerHeat = 8;
|
||||
int heatCount = (int)Math.Ceiling((double)advancedLanes.Count / lanesPerHeat);
|
||||
for (int h = 0; h < heatCount; h++)
|
||||
if (advancedLanes.Count == 0)
|
||||
throw new InvalidOperationException("No athletes are marked as advancing — run Calculate Advancement first.");
|
||||
|
||||
// Rebuild the next round from scratch so running this twice (or after a
|
||||
// correction) replaces the heats instead of duplicating every athlete.
|
||||
foreach (var heat in nextRound.Heats.ToList())
|
||||
{
|
||||
var heat = new Heat
|
||||
{
|
||||
RoundId = nextRound.RoundId,
|
||||
HeatNumber = h + 1,
|
||||
Status = HeatStatus.Pending
|
||||
};
|
||||
await _uow.Heats.AddAsync(heat);
|
||||
var lanes = await _uow.HeatLanes.GetByHeatAsync(heat.HeatId);
|
||||
foreach (var lane in lanes)
|
||||
_uow.HeatLanes.Remove(lane);
|
||||
_uow.Heats.Remove(heat);
|
||||
}
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
var heatLanes = advancedLanes.Skip(h * lanesPerHeat).Take(lanesPerHeat).ToList();
|
||||
for (int l = 0; l < heatLanes.Count; l++)
|
||||
{
|
||||
var lane = new HeatLane
|
||||
{
|
||||
HeatId = heat.HeatId,
|
||||
EventRegistrationId = heatLanes[l].EventRegistrationId,
|
||||
LaneNumber = l + 1
|
||||
};
|
||||
await _uow.HeatLanes.AddAsync(lane);
|
||||
}
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
// advancedLanes is ordered fastest-first, so centre-lane preference in
|
||||
// CreateHeatsAsync gives the fastest qualifiers the middle lanes.
|
||||
await CreateHeatsAsync(nextRound.RoundId, advancedLanes.Select(l => l.EventRegistrationId).ToList(), lanesPerHeat: 8);
|
||||
}
|
||||
|
||||
public async Task CompleteHeatAsync(int heatId)
|
||||
|
||||
@@ -26,7 +26,12 @@ public class HighJumpService : IHighJumpService
|
||||
|
||||
public async Task<HighJumpHeightDto> AddHeightAsync(HighJumpHeightCreateDto dto)
|
||||
{
|
||||
if (dto.Height <= 0)
|
||||
throw new InvalidOperationException("Height must be greater than zero.");
|
||||
|
||||
var existingHeights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(dto.TournamentEventLevelId);
|
||||
if (existingHeights.Any(h => h.Height == dto.Height))
|
||||
throw new InvalidOperationException($"A bar height of {dto.Height:0.00}m already exists for this event.");
|
||||
var maxOrder = existingHeights.Any() ? existingHeights.Max(h => h.SortOrder) : 0;
|
||||
|
||||
var height = new HighJumpHeight
|
||||
@@ -54,6 +59,13 @@ public class HighJumpService : IHighJumpService
|
||||
var height = await _uow.HighJumpHeights.GetWithAttemptsAsync(dto.HighJumpHeightId)
|
||||
?? throw new KeyNotFoundException("Height not found.");
|
||||
|
||||
// The registration must belong to the same event level as the bar — otherwise
|
||||
// a crafted POST could write attempts into an unrelated competition.
|
||||
var registration = await _uow.EventRegistrations.GetByIdAsync(dto.EventRegistrationId)
|
||||
?? throw new KeyNotFoundException("Registration not found.");
|
||||
if (registration.TournamentEventLevelId != height.TournamentEventLevelId)
|
||||
throw new InvalidOperationException("The registration does not belong to this event level.");
|
||||
|
||||
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == dto.EventRegistrationId);
|
||||
if (attempt == null)
|
||||
{
|
||||
@@ -78,21 +90,17 @@ public class HighJumpService : IHighJumpService
|
||||
|
||||
public async Task<bool> IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId)
|
||||
{
|
||||
// Attempts are eager-loaded by the repository; bars are judged in height order.
|
||||
var heights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId);
|
||||
int consecutiveFails = 0;
|
||||
|
||||
foreach (var height in heights.OrderBy(h => h.SortOrder))
|
||||
foreach (var height in heights.OrderBy(h => h.Height))
|
||||
{
|
||||
var fullHeight = await _uow.HighJumpHeights.GetWithAttemptsAsync(height.HighJumpHeightId);
|
||||
if (fullHeight == null) continue;
|
||||
|
||||
var attempt = fullHeight.Attempts.FirstOrDefault(a => a.EventRegistrationId == eventRegistrationId);
|
||||
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == eventRegistrationId);
|
||||
if (attempt == null) continue;
|
||||
|
||||
if (attempt.HasCleared)
|
||||
consecutiveFails = 0;
|
||||
else if (attempt.IsEliminated)
|
||||
consecutiveFails += 3;
|
||||
else
|
||||
consecutiveFails += attempt.FailCount;
|
||||
|
||||
@@ -105,8 +113,10 @@ public class HighJumpService : IHighJumpService
|
||||
|
||||
public async Task<string?> CalculateResultsAsync(int tournamentEventLevelId, string recordedBy)
|
||||
{
|
||||
// "Highest bar" is the bar with the greatest height, regardless of the order
|
||||
// the bars were entered; attempts are already eager-loaded by the repository.
|
||||
var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId))
|
||||
.OrderBy(h => h.SortOrder).ToList();
|
||||
.OrderBy(h => h.Height).ToList();
|
||||
|
||||
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
|
||||
?? throw new KeyNotFoundException("Tournament event level not found.");
|
||||
@@ -121,10 +131,7 @@ public class HighJumpService : IHighJumpService
|
||||
|
||||
foreach (var height in heights)
|
||||
{
|
||||
var fullHeight = await _uow.HighJumpHeights.GetWithAttemptsAsync(height.HighJumpHeightId);
|
||||
if (fullHeight == null) continue;
|
||||
|
||||
var attempt = fullHeight.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
|
||||
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
|
||||
if (attempt == null) continue;
|
||||
|
||||
totalFails += attempt.FailCount;
|
||||
@@ -153,6 +160,14 @@ public class HighJumpService : IHighJumpService
|
||||
.ToDictionary(p => p.Placement, p => p.Points);
|
||||
var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId);
|
||||
|
||||
// Athletes who cleared no bar get no result: remove any leftover Score rows
|
||||
// (from a run before a correction, or a manual entry) so stale placements
|
||||
// don't keep feeding the standings and reports.
|
||||
var rankedRegIds = sorted.Select(r => r.RegId).ToHashSet();
|
||||
var existingScores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList();
|
||||
foreach (var stale in existingScores.Where(s => !rankedRegIds.Contains(s.EventRegistrationId)))
|
||||
_uow.Scores.Remove(stale);
|
||||
|
||||
// Assign placements with proper tie handling. Athletes identical on all three
|
||||
// countback keys are a genuine tie: they share one placement (standard competition
|
||||
// ranking, e.g. 1, 2, 3, 3, 5) and share placement points — the points for the
|
||||
|
||||
@@ -17,6 +17,12 @@ public class RegistrationService : IRegistrationService
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<EventRegistrationDto?> GetByIdAsync(int eventRegistrationId)
|
||||
{
|
||||
var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId);
|
||||
return reg == null ? null : _mapper.Map<EventRegistrationDto>(reg);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId)
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tournamentEventLevelId);
|
||||
@@ -31,7 +37,17 @@ public class RegistrationService : IRegistrationService
|
||||
|
||||
public async Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy)
|
||||
{
|
||||
var (isEligible, reason) = await CheckEligibilityAsync(dto.TournamentEventLevelId, dto.StudentId!.Value);
|
||||
if (!dto.StudentId.HasValue)
|
||||
{
|
||||
// Relay teams are modelled in the domain but have no management UI or
|
||||
// eligibility rules yet, so relay-only registrations are rejected rather
|
||||
// than crashing on the missing student.
|
||||
throw new InvalidOperationException(dto.RelayTeamId.HasValue
|
||||
? "Relay team registration is not supported yet — register individual athletes."
|
||||
: "Select a student to register.");
|
||||
}
|
||||
|
||||
var (isEligible, reason) = await CheckEligibilityAsync(dto.TournamentEventLevelId, dto.StudentId.Value);
|
||||
if (!isEligible)
|
||||
throw new InvalidOperationException($"Student is not eligible: {reason}");
|
||||
|
||||
@@ -53,6 +69,17 @@ public class RegistrationService : IRegistrationService
|
||||
{
|
||||
var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId)
|
||||
?? throw new KeyNotFoundException("Registration not found.");
|
||||
|
||||
// Heat lanes and high jump attempts reference registrations with
|
||||
// DeleteBehavior.Restrict — check first so the user gets a clear message
|
||||
// instead of a database error page.
|
||||
if (await _uow.HeatLanes.AnyAsync(l => l.EventRegistrationId == eventRegistrationId))
|
||||
throw new InvalidOperationException(
|
||||
"This registration is assigned to one or more heats. Re-seed the affected round without this athlete before unregistering.");
|
||||
if (await _uow.HighJumpHeights.HasAttemptsForRegistrationAsync(eventRegistrationId))
|
||||
throw new InvalidOperationException(
|
||||
"This registration has recorded high jump attempts and cannot be removed.");
|
||||
|
||||
_uow.EventRegistrations.Remove(reg);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
@@ -65,6 +92,15 @@ public class RegistrationService : IRegistrationService
|
||||
var student = await _uow.Students.GetByIdAsync(studentId);
|
||||
if (student == null) return (false, "Student not found.");
|
||||
|
||||
// Registrations must not alter finished or archived tournaments.
|
||||
if (tel.Tournament.IsArchived)
|
||||
return (false, "This tournament is archived and can no longer accept registrations.");
|
||||
if (tel.Tournament.Status == Domain.Enums.TournamentStatus.Completed)
|
||||
return (false, "This tournament is completed and can no longer accept registrations.");
|
||||
|
||||
if (!student.IsActive)
|
||||
return (false, "Student is deactivated.");
|
||||
|
||||
// Check sex match
|
||||
if (student.Sex != tel.EventLevel.Sex)
|
||||
return (false, $"Student sex ({student.Sex}) does not match event level ({tel.EventLevel.Sex}).");
|
||||
@@ -72,6 +108,8 @@ public class RegistrationService : IRegistrationService
|
||||
// Check school level compatibility
|
||||
var school = await _uow.Schools.GetByIdAsync(student.SchoolId);
|
||||
if (school == null) return (false, "Student's school not found.");
|
||||
if (!school.IsActive)
|
||||
return (false, "Student's school is deactivated.");
|
||||
|
||||
bool schoolLevelMatch = tel.EventLevel.SchoolLevel == school.SchoolLevel;
|
||||
// Allow "compete up" — primary students can enter secondary events, but not the reverse
|
||||
|
||||
@@ -13,43 +13,43 @@ public class ReportService : IReportService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
// Every report loads the tournament's registrations (with student, school, zone,
|
||||
// event, level and score) in a single query and aggregates in memory, instead of
|
||||
// issuing one query per event level.
|
||||
|
||||
public async Task<IEnumerable<PopularEventsReportDto>> GetPopularEventsAsync(int tournamentId)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var report = new Dictionary<string, PopularEventsReportDto>();
|
||||
|
||||
foreach (var tel in tels)
|
||||
foreach (var reg in regs)
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
var eventName = tel.Event?.Name ?? "Unknown";
|
||||
var category = tel.Event?.Category.ToString() ?? "Unknown";
|
||||
var eventName = reg.TournamentEventLevel.Event?.Name ?? "Unknown";
|
||||
var category = reg.TournamentEventLevel.Event?.Category.ToString() ?? "Unknown";
|
||||
|
||||
if (!report.ContainsKey(eventName))
|
||||
report[eventName] = new PopularEventsReportDto { EventName = eventName, Category = category };
|
||||
|
||||
var entry = report[eventName];
|
||||
foreach (var reg in regs)
|
||||
{
|
||||
entry.RegistrationCount++;
|
||||
if (reg.Student?.Sex == Domain.Enums.Sex.Male) entry.MaleCount++;
|
||||
else if (reg.Student?.Sex == Domain.Enums.Sex.Female) entry.FemaleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return report.Values.OrderByDescending(r => r.RegistrationCount);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RegistrationByGenderReportDto>> GetRegistrationByGenderAsync(int tournamentId, int? zoneId = null)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var report = new List<RegistrationByGenderReportDto>();
|
||||
|
||||
foreach (var tel in tels)
|
||||
foreach (var group in regs.GroupBy(r => r.TournamentEventLevelId))
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
var tel = group.First().TournamentEventLevel;
|
||||
var filteredRegs = zoneId.HasValue
|
||||
? regs.Where(r => r.Student?.School?.ZoneId == zoneId.Value)
|
||||
: regs;
|
||||
? group.Where(r => r.Student?.School?.ZoneId == zoneId.Value)
|
||||
: group.AsEnumerable();
|
||||
|
||||
var dto = new RegistrationByGenderReportDto
|
||||
{
|
||||
@@ -67,16 +67,19 @@ public class ReportService : IReportService
|
||||
|
||||
public async Task<IEnumerable<EventSchoolReportDto>> GetEventSchoolReportAsync(int tournamentId, int? eventId = null, int? eventLevelId = null)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
var filtered = tels.AsEnumerable();
|
||||
if (eventId.HasValue) filtered = filtered.Where(t => t.EventId == eventId.Value);
|
||||
if (eventLevelId.HasValue) filtered = filtered.Where(t => t.EventLevelId == eventLevelId.Value);
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var filtered = regs.AsEnumerable();
|
||||
if (eventId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventId == eventId.Value);
|
||||
if (eventLevelId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventLevelId == eventLevelId.Value);
|
||||
|
||||
var report = new List<EventSchoolReportDto>();
|
||||
foreach (var tel in filtered)
|
||||
foreach (var telGroup in filtered
|
||||
.GroupBy(r => r.TournamentEventLevelId)
|
||||
.OrderBy(g => g.First().TournamentEventLevel.EventLevel?.SortOrder ?? 0)
|
||||
.ThenBy(g => g.First().TournamentEventLevel.Event?.Name))
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
var schoolGroups = regs
|
||||
var tel = telGroup.First().TournamentEventLevel;
|
||||
var schoolGroups = telGroup
|
||||
.Where(r => r.Student?.School != null)
|
||||
.GroupBy(r => r.Student!.School!.Name);
|
||||
|
||||
@@ -108,12 +111,9 @@ public class ReportService : IReportService
|
||||
|
||||
public async Task<IEnumerable<StudentsBySchoolReportDto>> GetStudentsBySchoolAsync(int tournamentId, int? schoolId = null, int? zoneId = null)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var schoolStudents = new Dictionary<string, StudentsBySchoolReportDto>();
|
||||
|
||||
foreach (var tel in tels)
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
foreach (var reg in regs)
|
||||
{
|
||||
if (reg.Student?.School == null) continue;
|
||||
@@ -141,40 +141,40 @@ public class ReportService : IReportService
|
||||
};
|
||||
schoolStudents[schoolName].Students.Add(existingStudent);
|
||||
}
|
||||
var eventDesc = $"{tel.Event?.Name} ({tel.EventLevel?.Name})";
|
||||
var eventDesc = $"{reg.TournamentEventLevel.Event?.Name} ({reg.TournamentEventLevel.EventLevel?.Name})";
|
||||
existingStudent.Events.Add(eventDesc);
|
||||
}
|
||||
}
|
||||
|
||||
return schoolStudents.Values.OrderBy(s => s.SchoolName);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ScoresByEventReportDto>> GetScoresByEventAsync(int tournamentId, int? eventId = null, int? eventLevelId = null)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
var filtered = tels.AsEnumerable();
|
||||
if (eventId.HasValue) filtered = filtered.Where(t => t.EventId == eventId.Value);
|
||||
if (eventLevelId.HasValue) filtered = filtered.Where(t => t.EventLevelId == eventLevelId.Value);
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var filtered = regs.Where(r => r.Score != null);
|
||||
if (eventId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventId == eventId.Value);
|
||||
if (eventLevelId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventLevelId == eventLevelId.Value);
|
||||
|
||||
var report = new List<ScoresByEventReportDto>();
|
||||
foreach (var tel in filtered)
|
||||
foreach (var telGroup in filtered
|
||||
.GroupBy(r => r.TournamentEventLevelId)
|
||||
.OrderBy(g => g.First().TournamentEventLevel.EventLevel?.SortOrder ?? 0)
|
||||
.ThenBy(g => g.First().TournamentEventLevel.Event?.Name))
|
||||
{
|
||||
var scores = await _uow.Scores.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
if (!scores.Any()) continue;
|
||||
|
||||
var tel = telGroup.First().TournamentEventLevel;
|
||||
var dto = new ScoresByEventReportDto
|
||||
{
|
||||
EventName = tel.Event?.Name ?? "Unknown",
|
||||
EventLevelName = tel.EventLevel?.Name ?? "Unknown",
|
||||
Category = tel.Event?.Category.ToString() ?? "Unknown",
|
||||
Scores = scores.OrderBy(s => s.Placement ?? int.MaxValue).Select(s => new ScoreEntryDto
|
||||
Scores = telGroup.OrderBy(r => r.Score!.Placement ?? int.MaxValue).Select(r => new ScoreEntryDto
|
||||
{
|
||||
Placement = s.Placement,
|
||||
StudentName = s.EventRegistration?.Student?.FullName ?? "Unknown",
|
||||
SchoolName = s.EventRegistration?.Student?.School?.Name ?? "Unknown",
|
||||
RawPerformance = s.RawPerformance,
|
||||
CalculatedPoints = s.CalculatedPoints,
|
||||
PlacementPoints = s.PlacementPoints
|
||||
Placement = r.Score!.Placement,
|
||||
StudentName = r.Student?.FullName ?? "Unknown",
|
||||
SchoolName = r.Student?.School?.Name ?? "Unknown",
|
||||
RawPerformance = r.Score.RawPerformance,
|
||||
CalculatedPoints = r.Score.CalculatedPoints,
|
||||
PlacementPoints = r.Score.PlacementPoints
|
||||
}).ToList()
|
||||
};
|
||||
report.Add(dto);
|
||||
@@ -184,12 +184,9 @@ public class ReportService : IReportService
|
||||
|
||||
public async Task<IEnumerable<StudentPointsReportDto>> GetStudentPointsAsync(int tournamentId, int? schoolId = null, int? zoneId = null)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var studentPoints = new Dictionary<int, StudentPointsReportDto>();
|
||||
|
||||
foreach (var tel in tels)
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
foreach (var reg in regs)
|
||||
{
|
||||
if (reg.Student == null || reg.Score == null) continue;
|
||||
@@ -212,13 +209,12 @@ public class ReportService : IReportService
|
||||
entry.EventCount++;
|
||||
entry.EventScores.Add(new StudentEventScoreDto
|
||||
{
|
||||
EventName = tel.Event?.Name ?? "Unknown",
|
||||
EventLevelName = tel.EventLevel?.Name ?? "Unknown",
|
||||
EventName = reg.TournamentEventLevel.Event?.Name ?? "Unknown",
|
||||
EventLevelName = reg.TournamentEventLevel.EventLevel?.Name ?? "Unknown",
|
||||
Placement = reg.Score.Placement,
|
||||
PlacementPoints = reg.Score.PlacementPoints
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return studentPoints.Values.OrderByDescending(s => s.TotalPlacementPoints);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ public class SchoolService : ISchoolService
|
||||
{
|
||||
var school = await _uow.Schools.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("School not found.");
|
||||
|
||||
// Students reference schools with DeleteBehavior.Restrict — check first so
|
||||
// the user gets a clear message instead of a database error page.
|
||||
if (await _uow.Students.AnyAsync(s => s.SchoolId == id))
|
||||
throw new InvalidOperationException(
|
||||
"This school still has students. Move or delete its students first, or deactivate the school instead.");
|
||||
|
||||
_uow.Schools.Remove(school);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -37,27 +37,34 @@ public class ScoringService : IScoringService
|
||||
var reg = await _uow.EventRegistrations.GetByIdAsync(dto.EventRegistrationId)
|
||||
?? throw new KeyNotFoundException("Registration not found.");
|
||||
|
||||
var existingScore = await _uow.Scores.GetByRegistrationAsync(dto.EventRegistrationId);
|
||||
if (existingScore != null)
|
||||
{
|
||||
existingScore.RawPerformance = dto.RawPerformance;
|
||||
existingScore.RecordedBy = recordedBy;
|
||||
existingScore.RecordedAt = DateTime.UtcNow;
|
||||
_uow.Scores.Update(existingScore);
|
||||
await _uow.SaveChangesAsync();
|
||||
return _mapper.Map<ScoreDto>(existingScore);
|
||||
}
|
||||
// Keep the WA points in step with the mark: a corrected performance must not
|
||||
// leave the previously calculated points behind.
|
||||
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(reg.TournamentEventLevelId);
|
||||
var constant = tel != null ? await _uow.ScoringConstants.GetByEventAsync(tel.EventId) : null;
|
||||
bool isTrack = tel?.Event.Category == Domain.Enums.EventCategory.Track;
|
||||
int calculated = constant != null
|
||||
? CalculatePoints(dto.RawPerformance, constant.A, constant.B, constant.C, isTrack)
|
||||
: 0;
|
||||
|
||||
var score = new Score
|
||||
{
|
||||
EventRegistrationId = dto.EventRegistrationId,
|
||||
RawPerformance = dto.RawPerformance,
|
||||
RecordedBy = recordedBy,
|
||||
RecordedAt = DateTime.UtcNow
|
||||
};
|
||||
var score = await _uow.Scores.GetByRegistrationAsync(dto.EventRegistrationId);
|
||||
var isNew = score == null;
|
||||
score ??= new Score { EventRegistrationId = dto.EventRegistrationId };
|
||||
|
||||
await _uow.Scores.AddAsync(score);
|
||||
score.RawPerformance = dto.RawPerformance;
|
||||
score.CalculatedPoints = calculated;
|
||||
score.RecordedBy = recordedBy;
|
||||
score.RecordedAt = DateTime.UtcNow;
|
||||
|
||||
if (isNew) await _uow.Scores.AddAsync(score);
|
||||
else _uow.Scores.Update(score);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
// If placements were already calculated for this event, refresh them so a
|
||||
// corrected mark doesn't leave the standings stale.
|
||||
var siblingScores = await _uow.Scores.GetByTournamentEventLevelAsync(reg.TournamentEventLevelId);
|
||||
if (siblingScores.Any(s => s.Placement != null))
|
||||
await CalculatePlacementsAsync(reg.TournamentEventLevelId);
|
||||
|
||||
return _mapper.Map<ScoreDto>(score);
|
||||
}
|
||||
|
||||
@@ -66,15 +73,16 @@ public class ScoringService : IScoringService
|
||||
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
|
||||
?? throw new KeyNotFoundException("Tournament event level not found.");
|
||||
|
||||
var scoringConstant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId);
|
||||
if (scoringConstant == null) return;
|
||||
var scoringConstant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No scoring constant is configured for {tel.Event.Name}. Add one in Scoring Configuration before calculating points.");
|
||||
|
||||
bool isTrack = tel.Event.Category == Domain.Enums.EventCategory.Track;
|
||||
|
||||
foreach (var reg in tel.Registrations)
|
||||
{
|
||||
var score = await _uow.Scores.GetByRegistrationAsync(reg.EventRegistrationId);
|
||||
if (score == null || score.RawPerformance == 0) continue;
|
||||
if (score == null) continue;
|
||||
|
||||
score.CalculatedPoints = CalculatePoints(
|
||||
score.RawPerformance,
|
||||
@@ -147,31 +155,68 @@ public class ScoringService : IScoringService
|
||||
|
||||
public async Task CalculatePlacementsAsync(int tournamentEventLevelId)
|
||||
{
|
||||
var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId))
|
||||
.Where(s => s.RawPerformance > 0)
|
||||
.OrderByDescending(s => s.CalculatedPoints)
|
||||
.ToList();
|
||||
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
|
||||
?? throw new KeyNotFoundException("Tournament event level not found.");
|
||||
|
||||
// Rank on the recorded performance itself (not on WA points, which are all
|
||||
// zero when no scoring constant exists). Track ranks ascending (faster is
|
||||
// better); field ranks descending (further is better).
|
||||
bool lowerIsBetter = tel.Event.Category == Domain.Enums.EventCategory.Track;
|
||||
|
||||
var allScores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList();
|
||||
var ranked = allScores.Where(s => s.RawPerformance > 0).ToList();
|
||||
ranked = lowerIsBetter
|
||||
? ranked.OrderBy(s => s.RawPerformance).ToList()
|
||||
: ranked.OrderByDescending(s => s.RawPerformance).ToList();
|
||||
|
||||
// Scores without a valid mark (e.g. a foul recorded as 0) get no placement;
|
||||
// clear anything left over from an earlier calculation.
|
||||
foreach (var unranked in allScores.Where(s => s.RawPerformance <= 0))
|
||||
{
|
||||
if (unranked.Placement != null || unranked.PlacementPoints != 0)
|
||||
{
|
||||
unranked.Placement = null;
|
||||
unranked.PlacementPoints = 0;
|
||||
_uow.Scores.Update(unranked);
|
||||
}
|
||||
}
|
||||
|
||||
var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync())
|
||||
.ToDictionary(p => p.Placement, p => p.Points);
|
||||
|
||||
for (int i = 0; i < scores.Count; i++)
|
||||
// Identical performances share a placement (standard competition ranking:
|
||||
// 1, 2, 2, 4) and split the pooled placement points for the positions the
|
||||
// tie-group occupies — the same convention as the high jump path.
|
||||
int i = 0;
|
||||
while (i < ranked.Count)
|
||||
{
|
||||
scores[i].Placement = i + 1;
|
||||
scores[i].PlacementPoints = placementPoints.TryGetValue(i + 1, out var pts) ? pts : 0;
|
||||
_uow.Scores.Update(scores[i]);
|
||||
int start = i;
|
||||
int place = start + 1;
|
||||
while (i + 1 < ranked.Count && ranked[i + 1].RawPerformance == ranked[start].RawPerformance) i++;
|
||||
int size = i - start + 1;
|
||||
|
||||
int pooled = 0;
|
||||
for (int p = place; p < place + size; p++)
|
||||
pooled += placementPoints.TryGetValue(p, out var pp) ? pp : 0;
|
||||
int shared = (int)Math.Round((double)pooled / size, MidpointRounding.AwayFromZero);
|
||||
|
||||
for (int k = start; k <= i; k++)
|
||||
{
|
||||
ranked[k].Placement = place;
|
||||
ranked[k].PlacementPoints = shared;
|
||||
_uow.Scores.Update(ranked[k]);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
|
||||
// One query for the whole tournament — this runs on every dashboard load.
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
|
||||
var schoolPoints = new Dictionary<int, SchoolPointsSummaryDto>();
|
||||
|
||||
foreach (var tel in tels)
|
||||
{
|
||||
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
|
||||
foreach (var reg in regs)
|
||||
{
|
||||
if (reg.Student == null || reg.Score == null) continue;
|
||||
@@ -193,7 +238,6 @@ public class ScoringService : IScoringService
|
||||
else if (reg.Score.Placement == 2) summary.SecondPlaceCount++;
|
||||
else if (reg.Score.Placement == 3) summary.ThirdPlaceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return schoolPoints.Values
|
||||
.OrderByDescending(s => s.TotalPoints)
|
||||
@@ -208,6 +252,32 @@ public class ScoringService : IScoringService
|
||||
return _mapper.Map<IEnumerable<ScoringConstantDto>>(constants);
|
||||
}
|
||||
|
||||
public async Task CreateScoringConstantAsync(ScoringConstantCreateDto dto)
|
||||
{
|
||||
var evt = await _uow.Events.GetByIdAsync(dto.EventId)
|
||||
?? throw new KeyNotFoundException("Event not found.");
|
||||
if (await _uow.ScoringConstants.GetByEventAsync(dto.EventId) != null)
|
||||
throw new InvalidOperationException($"{evt.Name} already has a scoring constant — edit the existing one instead.");
|
||||
|
||||
await _uow.ScoringConstants.AddAsync(new ScoringConstant
|
||||
{
|
||||
EventId = dto.EventId,
|
||||
A = dto.A,
|
||||
B = dto.B,
|
||||
C = dto.C,
|
||||
Unit = dto.Unit
|
||||
});
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteScoringConstantAsync(int scoringConstantId)
|
||||
{
|
||||
var constant = await _uow.ScoringConstants.GetByIdAsync(scoringConstantId)
|
||||
?? throw new KeyNotFoundException("Scoring constant not found.");
|
||||
_uow.ScoringConstants.Remove(constant);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto)
|
||||
{
|
||||
var constant = await _uow.ScoringConstants.GetByIdAsync(dto.ScoringConstantId)
|
||||
@@ -225,6 +295,29 @@ public class ScoringService : IScoringService
|
||||
return _mapper.Map<IEnumerable<PlacementPointConfigDto>>(configs);
|
||||
}
|
||||
|
||||
public async Task CreatePlacementPointConfigAsync(PlacementPointConfigCreateDto dto)
|
||||
{
|
||||
if (dto.Placement < 1)
|
||||
throw new InvalidOperationException("Placement must be 1 or higher.");
|
||||
if (await _uow.PlacementPointConfigs.AnyAsync(p => p.Placement == dto.Placement))
|
||||
throw new InvalidOperationException($"Placement {dto.Placement} already has points configured — edit the existing entry instead.");
|
||||
|
||||
await _uow.PlacementPointConfigs.AddAsync(new PlacementPointConfig
|
||||
{
|
||||
Placement = dto.Placement,
|
||||
Points = dto.Points
|
||||
});
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeletePlacementPointConfigAsync(int placementPointConfigId)
|
||||
{
|
||||
var config = await _uow.PlacementPointConfigs.GetByIdAsync(placementPointConfigId)
|
||||
?? throw new KeyNotFoundException("Placement point config not found.");
|
||||
_uow.PlacementPointConfigs.Remove(config);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto)
|
||||
{
|
||||
var config = await _uow.PlacementPointConfigs.GetByIdAsync(dto.PlacementPointConfigId)
|
||||
|
||||
@@ -41,14 +41,15 @@ public class StudentService : IStudentService
|
||||
return _mapper.Map<IEnumerable<StudentDto>>(students);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<StudentDto>> SearchAsync(string searchTerm)
|
||||
public async Task<(IReadOnlyList<StudentDto> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize)
|
||||
{
|
||||
var students = await _uow.Students.SearchAsync(searchTerm);
|
||||
return _mapper.Map<IEnumerable<StudentDto>>(students);
|
||||
var (students, total) = await _uow.Students.GetPagedAsync(schoolId, searchTerm, page, pageSize);
|
||||
return (_mapper.Map<IReadOnlyList<StudentDto>>(students), total);
|
||||
}
|
||||
|
||||
public async Task<StudentDto> CreateAsync(StudentCreateDto dto)
|
||||
{
|
||||
await NormalizeAndCheckExistingIdAsync(dto, studentId: null);
|
||||
var student = _mapper.Map<Student>(dto);
|
||||
await _uow.Students.AddAsync(student);
|
||||
await _uow.SaveChangesAsync();
|
||||
@@ -59,15 +60,39 @@ public class StudentService : IStudentService
|
||||
{
|
||||
var student = await _uow.Students.GetByIdAsync(dto.StudentId)
|
||||
?? throw new KeyNotFoundException("Student not found.");
|
||||
await NormalizeAndCheckExistingIdAsync(dto, dto.StudentId);
|
||||
_mapper.Map(dto, student);
|
||||
_uow.Students.Update(student);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// The external student ID is optional and unique-when-present (filtered index).
|
||||
// Normalise blank to null and pre-check duplicates so the user sees a message
|
||||
// instead of a database error page.
|
||||
private async Task NormalizeAndCheckExistingIdAsync(StudentCreateDto dto, int? studentId)
|
||||
{
|
||||
dto.ExistingStudentId = string.IsNullOrWhiteSpace(dto.ExistingStudentId)
|
||||
? null
|
||||
: dto.ExistingStudentId.Trim();
|
||||
|
||||
if (dto.ExistingStudentId != null &&
|
||||
await _uow.Students.AnyAsync(s => s.ExistingStudentId == dto.ExistingStudentId && s.StudentId != studentId))
|
||||
{
|
||||
throw new InvalidOperationException($"A student with ID \"{dto.ExistingStudentId}\" already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var student = await _uow.Students.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Student not found.");
|
||||
|
||||
// Registrations reference students with DeleteBehavior.Restrict — check first
|
||||
// so the user gets a clear message instead of a database error page.
|
||||
if (await _uow.EventRegistrations.AnyAsync(r => r.StudentId == id))
|
||||
throw new InvalidOperationException(
|
||||
"This student has event registrations. Remove the registrations first, or deactivate the student instead.");
|
||||
|
||||
_uow.Students.Remove(student);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -106,19 +106,8 @@ public class TournamentService : ITournamentService
|
||||
|
||||
public async Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsByCategoryAsync(EventCategory category)
|
||||
{
|
||||
var tournaments = await _uow.Tournaments.GetActiveAsync();
|
||||
var result = new List<TournamentEventLevelDto>();
|
||||
foreach (var t in tournaments)
|
||||
{
|
||||
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(t.TournamentId);
|
||||
foreach (var tel in tels.Where(x => x.Event != null && x.Event.Category == category))
|
||||
{
|
||||
var dto = _mapper.Map<TournamentEventLevelDto>(tel);
|
||||
dto.TournamentName = t.Name;
|
||||
result.Add(dto);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
var tels = await _uow.TournamentEventLevels.GetByCategoryAsync(category);
|
||||
return _mapper.Map<IEnumerable<TournamentEventLevelDto>>(tels);
|
||||
}
|
||||
|
||||
public async Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto)
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="13.*" />
|
||||
<!-- 14.0.x fixes GHSA-rvv3-g6hj-g44x; it is the last major version under the
|
||||
original licence (the commercial licence change lands in 15.x). -->
|
||||
<PackageReference Include="AutoMapper" Version="14.0.*" />
|
||||
<PackageReference Include="FluentValidation" Version="11.*" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace SportsDivision.Domain.Entities;
|
||||
public class Student
|
||||
{
|
||||
public int StudentId { get; set; }
|
||||
public string ExistingStudentId { get; set; } = string.Empty;
|
||||
// Optional external identifier (e.g. a ministry student number); unique when present.
|
||||
public string? ExistingStudentId { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace SportsDivision.Domain.Enums;
|
||||
|
||||
// ByPerformance and Manual seeding were declared here but never implemented —
|
||||
// selecting them silently produced unseeded heats. They can be re-added when a
|
||||
// seed-mark source and a manual lane editor actually exist.
|
||||
public enum SeedingMethod
|
||||
{
|
||||
Random,
|
||||
ByPerformance,
|
||||
Manual
|
||||
Random
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace SportsDivision.Domain.Interfaces;
|
||||
public interface IEventRegistrationRepository : IRepository<EventRegistration>
|
||||
{
|
||||
Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId);
|
||||
/// <summary>All registrations for a tournament in one query (with student, school,
|
||||
/// zone, event, level and score) — for reports and standings, instead of one
|
||||
/// query per event level.</summary>
|
||||
Task<IEnumerable<EventRegistration>> GetByTournamentAsync(int tournamentId);
|
||||
Task<IEnumerable<EventRegistration>> GetByStudentAsync(int studentId);
|
||||
Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId);
|
||||
Task<IEnumerable<EventRegistration>> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace SportsDivision.Domain.Interfaces;
|
||||
|
||||
public interface IHighJumpHeightRepository : IRepository<HighJumpHeight>
|
||||
{
|
||||
Task<bool> HasAttemptsForRegistrationAsync(int eventRegistrationId);
|
||||
Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId);
|
||||
Task<HighJumpHeight?> GetWithAttemptsAsync(int heightId);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,9 @@ public interface IStudentRepository : IRepository<Student>
|
||||
Task<Student?> GetByExistingIdAsync(string existingStudentId);
|
||||
Task<IEnumerable<Student>> GetBySchoolAsync(int schoolId);
|
||||
Task<Student?> GetWithRegistrationsAsync(int studentId);
|
||||
Task<IEnumerable<Student>> SearchAsync(string searchTerm);
|
||||
/// <summary>
|
||||
/// Server-side filtered, ordered and paged student query. A <paramref name="pageSize"/>
|
||||
/// of 0 (or less) returns all matching rows. Returns the page plus the total match count.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<Student> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using SportsDivision.Domain.Entities;
|
||||
using SportsDivision.Domain.Enums;
|
||||
|
||||
namespace SportsDivision.Domain.Interfaces;
|
||||
|
||||
public interface ITournamentEventLevelRepository : IRepository<TournamentEventLevel>
|
||||
{
|
||||
/// <summary>Event levels of the given category across all non-archived tournaments, in one query.</summary>
|
||||
Task<IEnumerable<TournamentEventLevel>> GetByCategoryAsync(EventCategory category);
|
||||
Task<TournamentEventLevel?> GetWithRegistrationsAsync(int id);
|
||||
Task<TournamentEventLevel?> GetWithRoundsAsync(int id);
|
||||
Task<IEnumerable<TournamentEventLevel>> GetByTournamentAsync(int tournamentId);
|
||||
|
||||
@@ -11,5 +11,6 @@ public class HeatConfiguration : IEntityTypeConfiguration<Heat>
|
||||
builder.HasKey(h => h.HeatId);
|
||||
builder.Property(h => h.Status).HasConversion<string>().HasMaxLength(20);
|
||||
builder.HasOne(h => h.Round).WithMany(r => r.Heats).HasForeignKey(h => h.RoundId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasIndex(h => new { h.RoundId, h.HeatNumber }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,8 @@ public class HighJumpHeightConfiguration : IEntityTypeConfiguration<HighJumpHeig
|
||||
builder.HasKey(h => h.HighJumpHeightId);
|
||||
builder.Property(h => h.Height).HasPrecision(5, 2);
|
||||
builder.HasOne(h => h.TournamentEventLevel).WithMany(t => t.HighJumpHeights).HasForeignKey(h => h.TournamentEventLevelId).OnDelete(DeleteBehavior.Cascade);
|
||||
// The same bar can't be added twice; ordering derives from Height, so this
|
||||
// also keeps the countback's "highest bar" deterministic.
|
||||
builder.HasIndex(h => new { h.TournamentEventLevelId, h.Height }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ public class RoundConfiguration : IEntityTypeConfiguration<Round>
|
||||
builder.Property(r => r.RoundType).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20);
|
||||
builder.HasOne(r => r.TournamentEventLevel).WithMany(t => t.Rounds).HasForeignKey(r => r.TournamentEventLevelId).OnDelete(DeleteBehavior.Cascade);
|
||||
// Round order drives next-round lookup and final-round fallback; duplicates
|
||||
// would make both non-deterministic.
|
||||
builder.HasIndex(r => new { r.TournamentEventLevelId, r.RoundOrder }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
|
||||
public void Configure(EntityTypeBuilder<Student> builder)
|
||||
{
|
||||
builder.HasKey(s => s.StudentId);
|
||||
builder.Property(s => s.ExistingStudentId).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(s => s.ExistingStudentId).IsUnique();
|
||||
builder.Property(s => s.ExistingStudentId).HasMaxLength(50);
|
||||
// Optional field: uniqueness only applies when a value is present, so multiple
|
||||
// students without an external ID don't collide on the index.
|
||||
builder.HasIndex(s => s.ExistingStudentId).IsUnique().HasFilter("\"ExistingStudentId\" IS NOT NULL");
|
||||
builder.Property(s => s.FirstName).IsRequired().HasMaxLength(100);
|
||||
builder.Property(s => s.LastName).IsRequired().HasMaxLength(100);
|
||||
builder.Property(s => s.Sex).HasConversion<string>().HasMaxLength(10);
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class DependencyInjection
|
||||
options.User.RequireUniqueEmail = true;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddClaimsPrincipalFactory<AppClaimsPrincipalFactory>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
services.ConfigureApplicationCookie(options =>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace SportsDivision.Infrastructure.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the user's first/last name to the sign-in cookie so the layout can render
|
||||
/// the avatar and display name from claims, without a database query per request.
|
||||
/// </summary>
|
||||
public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
|
||||
{
|
||||
public AppClaimsPrincipalFactory(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole> roleManager,
|
||||
IOptions<IdentityOptions> options)
|
||||
: base(userManager, roleManager, options) { }
|
||||
|
||||
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
|
||||
{
|
||||
var identity = await base.GenerateClaimsAsync(user);
|
||||
if (!string.IsNullOrEmpty(user.FirstName))
|
||||
identity.AddClaim(new Claim(ClaimTypes.GivenName, user.FirstName));
|
||||
if (!string.IsNullOrEmpty(user.LastName))
|
||||
identity.AddClaim(new Claim(ClaimTypes.Surname, user.LastName));
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
1231
src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.Designer.cs
generated
Normal file
1231
src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SportsDivision.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SchemaIntegrityFixes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Students_ExistingStudentId",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Rounds_TournamentEventLevelId",
|
||||
table: "Rounds");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_HighJumpHeights_TournamentEventLevelId",
|
||||
table: "HighJumpHeights");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Heats_RoundId",
|
||||
table: "Heats");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExistingStudentId",
|
||||
table: "Students",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(50)",
|
||||
oldMaxLength: 50);
|
||||
|
||||
// The field used to be required, so students without an external ID were
|
||||
// stored as "" — normalise to NULL so the filtered unique index applies.
|
||||
migrationBuilder.Sql("UPDATE \"Students\" SET \"ExistingStudentId\" = NULL WHERE \"ExistingStudentId\" = '';");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Students_ExistingStudentId",
|
||||
table: "Students",
|
||||
column: "ExistingStudentId",
|
||||
unique: true,
|
||||
filter: "\"ExistingStudentId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Rounds_TournamentEventLevelId_RoundOrder",
|
||||
table: "Rounds",
|
||||
columns: new[] { "TournamentEventLevelId", "RoundOrder" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HighJumpHeights_TournamentEventLevelId_Height",
|
||||
table: "HighJumpHeights",
|
||||
columns: new[] { "TournamentEventLevelId", "Height" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Heats_RoundId_HeatNumber",
|
||||
table: "Heats",
|
||||
columns: new[] { "RoundId", "HeatNumber" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Students_ExistingStudentId",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Rounds_TournamentEventLevelId_RoundOrder",
|
||||
table: "Rounds");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_HighJumpHeights_TournamentEventLevelId_Height",
|
||||
table: "HighJumpHeights");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Heats_RoundId_HeatNumber",
|
||||
table: "Heats");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExistingStudentId",
|
||||
table: "Students",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(50)",
|
||||
oldMaxLength: 50,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Students_ExistingStudentId",
|
||||
table: "Students",
|
||||
column: "ExistingStudentId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Rounds_TournamentEventLevelId",
|
||||
table: "Rounds",
|
||||
column: "TournamentEventLevelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HighJumpHeights_TournamentEventLevelId",
|
||||
table: "HighJumpHeights",
|
||||
column: "TournamentEventLevelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Heats_RoundId",
|
||||
table: "Heats",
|
||||
column: "RoundId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace SportsDivision.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("ProductVersion", "10.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -309,7 +309,8 @@ namespace SportsDivision.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("HeatId");
|
||||
|
||||
b.HasIndex("RoundId");
|
||||
b.HasIndex("RoundId", "HeatNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Heats");
|
||||
});
|
||||
@@ -424,7 +425,8 @@ namespace SportsDivision.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("HighJumpHeightId");
|
||||
|
||||
b.HasIndex("TournamentEventLevelId");
|
||||
b.HasIndex("TournamentEventLevelId", "Height")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("HighJumpHeights");
|
||||
});
|
||||
@@ -533,7 +535,8 @@ namespace SportsDivision.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("RoundId");
|
||||
|
||||
b.HasIndex("TournamentEventLevelId");
|
||||
b.HasIndex("TournamentEventLevelId", "RoundOrder")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rounds");
|
||||
});
|
||||
@@ -662,7 +665,6 @@ namespace SportsDivision.Infrastructure.Migrations
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("ExistingStudentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
@@ -690,7 +692,8 @@ namespace SportsDivision.Infrastructure.Migrations
|
||||
b.HasKey("StudentId");
|
||||
|
||||
b.HasIndex("ExistingStudentId")
|
||||
.IsUnique();
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExistingStudentId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("SchoolId");
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ public class EventRegistrationRepository : Repository<EventRegistration>, IEvent
|
||||
{
|
||||
public EventRegistrationRepository(ApplicationDbContext context) : base(context) { }
|
||||
public async Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
|
||||
public async Task<IEnumerable<EventRegistration>> GetByTournamentAsync(int tournamentId) => await _dbSet.Where(r => r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).ThenInclude(s => s!.School).ThenInclude(sc => sc!.Zone).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
|
||||
public async Task<IEnumerable<EventRegistration>> GetByStudentAsync(int studentId) => await _dbSet.Where(r => r.StudentId == studentId).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Tournament).Include(r => r.Score).ToListAsync();
|
||||
public async Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId);
|
||||
public async Task<IEnumerable<EventRegistration>> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId) => await _dbSet.Where(r => r.Student != null && r.Student.SchoolId == schoolId && r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace SportsDivision.Infrastructure.Repositories;
|
||||
public class HighJumpHeightRepository : Repository<HighJumpHeight>, IHighJumpHeightRepository
|
||||
{
|
||||
public HighJumpHeightRepository(ApplicationDbContext context) : base(context) { }
|
||||
public async Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.SortOrder).ToListAsync();
|
||||
public async Task<bool> HasAttemptsForRegistrationAsync(int eventRegistrationId) => await _context.Set<HighJumpAttempt>().AnyAsync(a => a.EventRegistrationId == eventRegistrationId);
|
||||
// Ordered by the bar height itself: SortOrder is assigned by a read-then-write
|
||||
// that can race under concurrent inserts, while Height is unique per event level.
|
||||
public async Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.Height).ToListAsync();
|
||||
public async Task<HighJumpHeight?> GetWithAttemptsAsync(int heightId) => await _dbSet.Include(h => h.Attempts).ThenInclude(a => a.EventRegistration).ThenInclude(r => r.Student).ThenInclude(s => s!.School).FirstOrDefaultAsync(h => h.HighJumpHeightId == heightId);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,15 @@ public class Repository<T> : IRepository<T> where T : class
|
||||
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => await _dbSet.Where(predicate).ToListAsync();
|
||||
public async Task<T> AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; }
|
||||
public async Task AddRangeAsync(IEnumerable<T> entities) => await _dbSet.AddRangeAsync(entities);
|
||||
public void Update(T entity) => _dbSet.Update(entity);
|
||||
public void Update(T entity)
|
||||
{
|
||||
// Entities the context already tracks have their changes detected at
|
||||
// SaveChanges; calling DbSet.Update on them would mark every property (and
|
||||
// the reachable graph) Modified, producing full-row UPDATEs. Only attach
|
||||
// genuinely detached entities.
|
||||
if (_context.Entry(entity).State == EntityState.Detached)
|
||||
_dbSet.Update(entity);
|
||||
}
|
||||
public void Remove(T entity) => _dbSet.Remove(entity);
|
||||
public async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate) => await _dbSet.AnyAsync(predicate);
|
||||
public async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null) => predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);
|
||||
|
||||
@@ -11,5 +11,22 @@ public class StudentRepository : Repository<Student>, IStudentRepository
|
||||
public async Task<Student?> GetByExistingIdAsync(string existingStudentId) => await _dbSet.Include(s => s.School).FirstOrDefaultAsync(s => s.ExistingStudentId == existingStudentId);
|
||||
public async Task<IEnumerable<Student>> GetBySchoolAsync(int schoolId) => await _dbSet.Where(s => s.SchoolId == schoolId).Include(s => s.School).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToListAsync();
|
||||
public async Task<Student?> GetWithRegistrationsAsync(int studentId) => await _dbSet.Include(s => s.School).Include(s => s.Registrations).ThenInclude(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(s => s.Registrations).ThenInclude(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).FirstOrDefaultAsync(s => s.StudentId == studentId);
|
||||
public async Task<IEnumerable<Student>> SearchAsync(string searchTerm) => await _dbSet.Include(s => s.School).Where(s => s.FirstName.Contains(searchTerm) || s.LastName.Contains(searchTerm) || s.ExistingStudentId.Contains(searchTerm)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).Take(50).ToListAsync();
|
||||
public async Task<(IReadOnlyList<Student> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize)
|
||||
{
|
||||
IQueryable<Student> query = _dbSet.Include(s => s.School);
|
||||
if (schoolId.HasValue)
|
||||
query = query.Where(s => s.SchoolId == schoolId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
query = query.Where(s => s.FirstName.Contains(searchTerm)
|
||||
|| s.LastName.Contains(searchTerm)
|
||||
|| (s.ExistingStudentId != null && s.ExistingStudentId.Contains(searchTerm)));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
query = query.OrderBy(s => s.LastName).ThenBy(s => s.FirstName);
|
||||
if (pageSize > 0)
|
||||
query = query.Skip((page - 1) * pageSize).Take(pageSize);
|
||||
|
||||
return (await query.ToListAsync(), total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories;
|
||||
public class TournamentEventLevelRepository : Repository<TournamentEventLevel>, ITournamentEventLevelRepository
|
||||
{
|
||||
public TournamentEventLevelRepository(ApplicationDbContext context) : base(context) { }
|
||||
public async Task<IEnumerable<TournamentEventLevel>> GetByCategoryAsync(Domain.Enums.EventCategory category) => await _dbSet.Where(t => t.Event.Category == category && !t.Tournament.IsArchived).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).Include(t => t.Registrations).OrderByDescending(t => t.Tournament.StartDate).ThenBy(t => t.EventLevel.SortOrder).ThenBy(t => t.Event.Name).ToListAsync();
|
||||
public async Task<TournamentEventLevel?> GetWithRegistrationsAsync(int id) => await _dbSet.Include(t => t.Registrations).ThenInclude(r => r.Student).ThenInclude(s => s!.School).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).FirstOrDefaultAsync(t => t.TournamentEventLevelId == id);
|
||||
public async Task<TournamentEventLevel?> GetWithRoundsAsync(int id) => await _dbSet.Include(t => t.Rounds).ThenInclude(r => r.Heats).ThenInclude(h => h.HeatLanes).ThenInclude(hl => hl.EventRegistration).ThenInclude(r => r.Student).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).FirstOrDefaultAsync(t => t.TournamentEventLevelId == id);
|
||||
public async Task<IEnumerable<TournamentEventLevel>> GetByTournamentAsync(int tournamentId) => await _dbSet.Where(t => t.TournamentId == tournamentId).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Registrations).Include(t => t.Rounds).OrderBy(t => t.EventLevel.SortOrder).ThenBy(t => t.Event.Name).ToListAsync();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SportsDivision.Domain.Entities;
|
||||
using SportsDivision.Domain.Enums;
|
||||
using SportsDivision.Infrastructure.Data;
|
||||
@@ -12,12 +14,21 @@ public class DatabaseSeeder
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly RoleManager<IdentityRole> _roleManager;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<DatabaseSeeder> _logger;
|
||||
|
||||
public DatabaseSeeder(ApplicationDbContext context, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
|
||||
public DatabaseSeeder(
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole> roleManager,
|
||||
IConfiguration configuration,
|
||||
ILogger<DatabaseSeeder> logger)
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SeedAsync()
|
||||
@@ -43,18 +54,41 @@ public class DatabaseSeeder
|
||||
|
||||
private async Task SeedAdminUserAsync()
|
||||
{
|
||||
if (await _userManager.FindByEmailAsync("admin@sportsdivision.dm") != null) return;
|
||||
// The initial admin's credentials come from configuration
|
||||
// (SeedAdmin:Email / SeedAdmin:Password, e.g. the SeedAdmin__Password
|
||||
// environment variable) so no password lives in the repository.
|
||||
var email = _configuration["SeedAdmin:Email"] ?? "admin@sportsdivision.dm";
|
||||
if (await _userManager.FindByEmailAsync(email) != null) return;
|
||||
|
||||
var password = _configuration["SeedAdmin:Password"];
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No admin account exists and SeedAdmin:Password is not configured — skipping admin seeding. " +
|
||||
"Set the SeedAdmin__Password environment variable and restart to create the initial admin.");
|
||||
return;
|
||||
}
|
||||
|
||||
var admin = new ApplicationUser
|
||||
{
|
||||
UserName = "admin@sportsdivision.dm",
|
||||
Email = "admin@sportsdivision.dm",
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = "System",
|
||||
LastName = "Administrator",
|
||||
EmailConfirmed = true,
|
||||
IsActive = true
|
||||
};
|
||||
var result = await _userManager.CreateAsync(admin, "Admin@123!");
|
||||
if (result.Succeeded) await _userManager.AddToRoleAsync(admin, "Admin");
|
||||
var result = await _userManager.CreateAsync(admin, password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await _userManager.AddToRoleAsync(admin, "Admin");
|
||||
_logger.LogInformation("Seeded initial admin account {Email}. Change its password after first sign-in.", email);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to seed the admin account: {Errors}",
|
||||
string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeedZonesAsync()
|
||||
@@ -175,8 +209,8 @@ public class DatabaseSeeder
|
||||
new() { Name = "Under 20 Girls", Sex = Sex.Female, MaxAge = 19, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 14 },
|
||||
new() { Name = "Under 21 Boys", Sex = Sex.Male, MaxAge = 20, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 15 },
|
||||
new() { Name = "Under 21 Girls", Sex = Sex.Female, MaxAge = 20, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 16 },
|
||||
new() { Name = "Open Boys", Sex = Sex.Male, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 17 },
|
||||
new() { Name = "Open Girls", Sex = Sex.Female, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 18 },
|
||||
new() { Name = "Open Boys", Sex = Sex.Male, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = false, SortOrder = 17 },
|
||||
new() { Name = "Open Girls", Sex = Sex.Female, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = false, SortOrder = 18 },
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -34,17 +34,25 @@ public class AccountController : Controller
|
||||
return View();
|
||||
}
|
||||
|
||||
var result = await _signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: false);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
// Deactivated accounts are rejected before sign-in rather than signed out
|
||||
// afterwards; the generic message avoids confirming whether the account exists.
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user is { IsActive: false })
|
||||
{
|
||||
await _signInManager.SignOutAsync();
|
||||
ModelState.AddModelError("", "This account has been deactivated. Please contact an administrator.");
|
||||
ModelState.AddModelError("", "Invalid login attempt.");
|
||||
return View();
|
||||
}
|
||||
return LocalRedirect(returnUrl ?? "/");
|
||||
|
||||
var result = await _signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
// A crafted absolute returnUrl falls back to home instead of throwing.
|
||||
return Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl!) : RedirectToAction("Index", "Home");
|
||||
}
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
ModelState.AddModelError("", "This account is temporarily locked after repeated failed attempts. Try again in a few minutes.");
|
||||
return View();
|
||||
}
|
||||
ModelState.AddModelError("", "Invalid login attempt.");
|
||||
return View();
|
||||
|
||||
@@ -34,12 +34,14 @@ public class EventController : Controller
|
||||
return View(events);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new EventCreateDto());
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(EventCreateDto dto)
|
||||
@@ -62,6 +64,7 @@ public class EventController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
@@ -93,6 +96,7 @@ public class EventController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(EventUpdateDto dto)
|
||||
@@ -119,6 +123,7 @@ public class EventController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
|
||||
@@ -6,7 +6,8 @@ using SportsDivision.Domain.Enums;
|
||||
|
||||
namespace SportsDivision.Web.Controllers;
|
||||
|
||||
[Authorize]
|
||||
// Recording times, marks and advancement is restricted to meet officials.
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
public class FieldEventController : Controller
|
||||
{
|
||||
private readonly IScoringService _scoringService;
|
||||
@@ -35,33 +36,69 @@ public class FieldEventController : Controller
|
||||
}
|
||||
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
|
||||
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
|
||||
|
||||
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
|
||||
if (tel != null)
|
||||
{
|
||||
ViewBag.EventName = tel.EventName;
|
||||
ViewBag.LevelName = tel.EventLevelName;
|
||||
ViewBag.TournamentName = tel.TournamentName;
|
||||
}
|
||||
|
||||
return View(registrations);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RecordScore(ScoreCreateDto dto, int tournamentEventLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var recordedBy = User.Identity?.Name ?? "Unknown";
|
||||
await _scoringService.RecordScoreAsync(dto, recordedBy);
|
||||
TempData["SuccessMessage"] = "Performance recorded.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CalculateScores(int tournamentEventLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var recordedBy = User.Identity?.Name ?? "Unknown";
|
||||
await _scoringService.CalculateFinalScoresAsync(tournamentEventLevelId, recordedBy);
|
||||
TempData["SuccessMessage"] = "Points calculated.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CalculatePlacements(int tournamentEventLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scoringService.CalculatePlacementsAsync(tournamentEventLevelId);
|
||||
TempData["SuccessMessage"] = "Placements calculated.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ using SportsDivision.Domain.Enums;
|
||||
|
||||
namespace SportsDivision.Web.Controllers;
|
||||
|
||||
[Authorize]
|
||||
// Recording times, marks and advancement is restricted to meet officials.
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
public class HighJumpController : Controller
|
||||
{
|
||||
private readonly IHighJumpService _highJumpService;
|
||||
@@ -40,6 +41,15 @@ public class HighJumpController : Controller
|
||||
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
|
||||
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
|
||||
ViewBag.Registrations = registrations;
|
||||
|
||||
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
|
||||
if (tel != null)
|
||||
{
|
||||
ViewBag.EventName = tel.EventName;
|
||||
ViewBag.LevelName = tel.EventLevelName;
|
||||
ViewBag.TournamentName = tel.TournamentName;
|
||||
}
|
||||
|
||||
return View(heights);
|
||||
}
|
||||
|
||||
@@ -49,26 +59,58 @@ public class HighJumpController : Controller
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["ErrorMessage"] = "The height could not be added — check the value and try again.";
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _highJumpService.AddHeightAsync(dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RemoveHeight(int heightId, int tournamentEventLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _highJumpService.RemoveHeightAsync(heightId);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RecordAttempt(HighJumpAttemptUpdateDto dto, int tournamentEventLevelId)
|
||||
{
|
||||
// A blank result would bind to the enum default and be recorded as a clearance.
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _highJumpService.RecordAttemptAsync(dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SportsDivision.Application.DTOs;
|
||||
using SportsDivision.Application.Interfaces;
|
||||
using SportsDivision.Infrastructure.Identity;
|
||||
|
||||
namespace SportsDivision.Web.Controllers;
|
||||
|
||||
@@ -11,31 +13,56 @@ public class RegistrationController : Controller
|
||||
private readonly IRegistrationService _registrationService;
|
||||
private readonly ITournamentService _tournamentService;
|
||||
private readonly IStudentService _studentService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public RegistrationController(
|
||||
IRegistrationService registrationService,
|
||||
ITournamentService tournamentService,
|
||||
IStudentService studentService)
|
||||
IStudentService studentService,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_registrationService = registrationService;
|
||||
_tournamentService = tournamentService;
|
||||
_studentService = studentService;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coaches and principals act only for their own school; admins and officials
|
||||
/// are unrestricted. Returns an error message, or null when allowed.
|
||||
/// </summary>
|
||||
private async Task<string?> CheckSchoolScopeAsync(int? studentId)
|
||||
{
|
||||
if (User.IsInRole("Admin") || User.IsInRole("Official")) return null;
|
||||
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user?.SchoolId == null)
|
||||
return "Your account is not linked to a school — ask an administrator to set one.";
|
||||
if (!studentId.HasValue) return null; // no student chosen yet; eligibility handles it
|
||||
|
||||
var student = await _studentService.GetByIdAsync(studentId.Value);
|
||||
if (student == null || student.SchoolId != user.SchoolId)
|
||||
return "You can only manage registrations for students of your own school.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index(int tournamentEventLevelId)
|
||||
{
|
||||
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
|
||||
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
|
||||
await PopulateEventLevelViewBag(tournamentEventLevelId);
|
||||
|
||||
return View(registrations);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official,Coach,Principal")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Register(int tournamentEventLevelId, int? studentId)
|
||||
{
|
||||
var students = await _studentService.GetAllAsync();
|
||||
ViewBag.Students = students;
|
||||
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
|
||||
await PopulateEventLevelViewBag(tournamentEventLevelId);
|
||||
|
||||
if (studentId.HasValue)
|
||||
{
|
||||
@@ -54,15 +81,22 @@ public class RegistrationController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official,Coach,Principal")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Register(EventRegistrationCreateDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var students = await _studentService.GetAllAsync();
|
||||
ViewBag.Students = students;
|
||||
ViewBag.TournamentEventLevelId = dto.TournamentEventLevelId;
|
||||
await PopulateRegisterViewBags(dto.TournamentEventLevelId);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
var scopeError = await CheckSchoolScopeAsync(dto.StudentId);
|
||||
if (scopeError != null)
|
||||
{
|
||||
TempData["ErrorMessage"] = scopeError;
|
||||
await PopulateRegisterViewBags(dto.TournamentEventLevelId);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -80,17 +114,43 @@ public class RegistrationController : Controller
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
var students = await _studentService.GetAllAsync();
|
||||
ViewBag.Students = students;
|
||||
ViewBag.TournamentEventLevelId = dto.TournamentEventLevelId;
|
||||
await PopulateRegisterViewBags(dto.TournamentEventLevelId);
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PopulateRegisterViewBags(int tournamentEventLevelId)
|
||||
{
|
||||
ViewBag.Students = await _studentService.GetAllAsync();
|
||||
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
|
||||
await PopulateEventLevelViewBag(tournamentEventLevelId);
|
||||
}
|
||||
|
||||
private async Task PopulateEventLevelViewBag(int tournamentEventLevelId)
|
||||
{
|
||||
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
|
||||
if (tel != null)
|
||||
{
|
||||
ViewBag.EventName = tel.EventName;
|
||||
ViewBag.LevelName = tel.EventLevelName;
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official,Coach,Principal")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Unregister(int eventRegistrationId, int tournamentEventLevelId)
|
||||
{
|
||||
var registration = await _registrationService.GetByIdAsync(eventRegistrationId);
|
||||
if (registration == null) return NotFound();
|
||||
|
||||
var scopeError = await CheckSchoolScopeAsync(registration.StudentId);
|
||||
if (scopeError != null)
|
||||
{
|
||||
TempData["ErrorMessage"] = scopeError;
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _registrationService.UnregisterAsync(eventRegistrationId);
|
||||
|
||||
@@ -26,7 +26,9 @@ public class ReportController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var tournaments = await _tournamentService.GetAllAsync();
|
||||
// Reporting is retrospective: archived tournaments must stay selectable,
|
||||
// otherwise archiving a season silently removes access to its reports.
|
||||
var tournaments = await _tournamentService.GetAllAsync(includeArchived: true);
|
||||
ViewBag.Tournaments = tournaments;
|
||||
return View(tournaments);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ public class SchoolController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
@@ -69,6 +70,7 @@ public class SchoolController : Controller
|
||||
return View(new SchoolCreateDto());
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(SchoolCreateDto dto)
|
||||
@@ -93,6 +95,7 @@ public class SchoolController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
@@ -123,6 +126,7 @@ public class SchoolController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(SchoolUpdateDto dto)
|
||||
@@ -151,6 +155,7 @@ public class SchoolController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
|
||||
@@ -9,32 +9,109 @@ namespace SportsDivision.Web.Controllers;
|
||||
public class ScoringConfigController : Controller
|
||||
{
|
||||
private readonly IScoringService _scoringService;
|
||||
private readonly IEventService _eventService;
|
||||
|
||||
public ScoringConfigController(IScoringService scoringService)
|
||||
public ScoringConfigController(IScoringService scoringService, IEventService eventService)
|
||||
{
|
||||
_scoringService = scoringService;
|
||||
_eventService = eventService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var constants = await _scoringService.GetScoringConstantsAsync();
|
||||
var constants = (await _scoringService.GetScoringConstantsAsync()).ToList();
|
||||
var placementConfigs = await _scoringService.GetPlacementPointConfigsAsync();
|
||||
ViewBag.ScoringConstants = constants;
|
||||
ViewBag.PlacementPointConfigs = placementConfigs;
|
||||
|
||||
// Events that have no scoring constant yet — offered in the "Add" form so
|
||||
// the 12 seeded events without constants can be configured in-app.
|
||||
var eventsWithConstant = constants.Select(c => c.EventId).ToHashSet();
|
||||
ViewBag.EventsWithoutConstant = (await _eventService.GetAllAsync())
|
||||
.Where(e => !eventsWithConstant.Contains(e.EventId))
|
||||
.OrderBy(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
return View(constants);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CreateScoringConstant(ScoringConstantCreateDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid || dto.EventId <= 0)
|
||||
{
|
||||
TempData["ErrorMessage"] = "The scoring constant could not be added — check the values and try again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _scoringService.CreateScoringConstantAsync(dto);
|
||||
TempData["SuccessMessage"] = "Scoring constant added.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateScoringConstant(ScoringConstantUpdateDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["ErrorMessage"] = "The scoring constant could not be saved — check the values and try again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
await _scoringService.UpdateScoringConstantAsync(dto);
|
||||
TempData["SuccessMessage"] = "Scoring constant saved.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteScoringConstant(int scoringConstantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scoringService.DeleteScoringConstantAsync(scoringConstantId);
|
||||
TempData["SuccessMessage"] = "Scoring constant removed.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CreatePlacementPointConfig(PlacementPointConfigCreateDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["ErrorMessage"] = "The placement points could not be added — check the values and try again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _scoringService.CreatePlacementPointConfigAsync(dto);
|
||||
TempData["SuccessMessage"] = "Placement points added.";
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
@@ -44,10 +121,28 @@ public class ScoringConfigController : Controller
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["ErrorMessage"] = "The placement points could not be saved — check the values and try again.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
await _scoringService.UpdatePlacementPointConfigAsync(dto);
|
||||
TempData["SuccessMessage"] = "Placement points saved.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeletePlacementPointConfig(int placementPointConfigId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scoringService.DeletePlacementPointConfigAsync(placementPointConfigId);
|
||||
TempData["SuccessMessage"] = "Placement points removed.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,26 +20,24 @@ public class StudentController : Controller
|
||||
|
||||
public async Task<IActionResult> Index(int? schoolId, string? search, int page = 1, int pageSize = PaginationHelper.PageSize)
|
||||
{
|
||||
IEnumerable<StudentDto> students;
|
||||
pageSize = PaginationHelper.NormalizePageSize(pageSize);
|
||||
if (page < 1) page = 1;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
var (students, total) = await _studentService.GetPagedAsync(schoolId, search, page, pageSize);
|
||||
|
||||
// A stale page number past the end (e.g. after narrowing a filter) is clamped
|
||||
// and re-queried so the user still sees results.
|
||||
var clampedPage = this.SetPagingMetadata(page, total, pageSize);
|
||||
if (clampedPage != page)
|
||||
{
|
||||
students = await _studentService.SearchAsync(search);
|
||||
}
|
||||
else if (schoolId.HasValue)
|
||||
{
|
||||
students = await _studentService.GetBySchoolAsync(schoolId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
students = await _studentService.GetAllAsync();
|
||||
(students, _) = await _studentService.GetPagedAsync(schoolId, search, clampedPage, pageSize);
|
||||
}
|
||||
|
||||
await PopulateSchoolsViewBag();
|
||||
ViewBag.SelectedSchoolId = schoolId;
|
||||
ViewBag.SearchTerm = search;
|
||||
|
||||
return View(this.Page(students, page, pageSize));
|
||||
return View(students);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Details(int id)
|
||||
@@ -60,6 +58,7 @@ public class StudentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
@@ -67,6 +66,7 @@ public class StudentController : Controller
|
||||
return View(new StudentCreateDto());
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(StudentCreateDto dto)
|
||||
@@ -91,6 +91,7 @@ public class StudentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
@@ -123,6 +124,7 @@ public class StudentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(StudentUpdateDto dto)
|
||||
@@ -151,6 +153,7 @@ public class StudentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
|
||||
@@ -61,12 +61,14 @@ public class TournamentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new TournamentCreateDto());
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(TournamentCreateDto dto)
|
||||
@@ -89,6 +91,7 @@ public class TournamentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
@@ -118,6 +121,7 @@ public class TournamentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(TournamentUpdateDto dto)
|
||||
@@ -144,6 +148,7 @@ public class TournamentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
@@ -165,6 +170,7 @@ public class TournamentController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddEventLevel(TournamentEventLevelCreateDto dto)
|
||||
@@ -186,6 +192,7 @@ public class TournamentController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.TournamentId });
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RemoveEventLevel(int tournamentEventLevelId, int id)
|
||||
@@ -207,6 +214,7 @@ public class TournamentController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateStatus(int id, TournamentStatus status)
|
||||
@@ -228,6 +236,7 @@ public class TournamentController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Archive(int id)
|
||||
@@ -249,6 +258,7 @@ public class TournamentController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Unarchive(int id)
|
||||
@@ -270,6 +280,7 @@ public class TournamentController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleAgeWaiver(int tournamentEventLevelId, int id)
|
||||
|
||||
@@ -6,7 +6,8 @@ using SportsDivision.Domain.Enums;
|
||||
|
||||
namespace SportsDivision.Web.Controllers;
|
||||
|
||||
[Authorize]
|
||||
// Recording times, marks and advancement is restricted to meet officials.
|
||||
[Authorize(Roles = "Admin,Official")]
|
||||
public class TrackEventController : Controller
|
||||
{
|
||||
private readonly IHeatManagementService _heatManagementService;
|
||||
@@ -80,18 +81,38 @@ public class TrackEventController : Controller
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["ErrorMessage"] = "The round could not be created — check the values and try again.";
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _heatManagementService.CreateRoundAsync(dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SeedHeats(int roundId, SeedingMethod method, int lanesPerHeat = 8)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _heatManagementService.SeedHeatsAsync(roundId, method, lanesPerHeat);
|
||||
TempData["SuccessMessage"] = "Heats seeded.";
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
TempData["ErrorMessage"] = ex.Message;
|
||||
}
|
||||
return RedirectToAction(nameof(ManageRound), new { roundId });
|
||||
}
|
||||
|
||||
@@ -132,10 +153,10 @@ public class TrackEventController : Controller
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CompleteHeat(int heatId)
|
||||
public async Task<IActionResult> CompleteHeat(int heatId, int roundId)
|
||||
{
|
||||
await _heatManagementService.CompleteHeatAsync(heatId);
|
||||
return RedirectToAction(nameof(ManageRound), new { roundId = heatId });
|
||||
return RedirectToAction(nameof(ManageRound), new { roundId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
||||
@@ -31,10 +31,20 @@ public class UserManagementController : Controller
|
||||
var schools = await _schoolService.GetAllAsync();
|
||||
var schoolLookup = schools.ToDictionary(s => s.SchoolId, s => s.Name);
|
||||
|
||||
// One query per role instead of one per user.
|
||||
var roleNames = await _roleManager.Roles.Select(r => r.Name!).ToListAsync();
|
||||
var roleByUserId = new Dictionary<string, string>();
|
||||
foreach (var roleName in roleNames)
|
||||
{
|
||||
foreach (var member in await _userManager.GetUsersInRoleAsync(roleName))
|
||||
{
|
||||
roleByUserId.TryAdd(member.Id, roleName);
|
||||
}
|
||||
}
|
||||
|
||||
var userDtos = new List<UserDto>();
|
||||
foreach (var user in users)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
var dto = new UserDto
|
||||
{
|
||||
Id = user.Id,
|
||||
@@ -42,7 +52,7 @@ public class UserManagementController : Controller
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
FullName = user.FullName,
|
||||
Role = roles.FirstOrDefault() ?? "None",
|
||||
Role = roleByUserId.GetValueOrDefault(user.Id, "None"),
|
||||
SchoolId = user.SchoolId,
|
||||
SchoolName = user.SchoolId.HasValue && schoolLookup.TryGetValue(user.SchoolId.Value, out var name) ? name : null,
|
||||
IsActive = user.IsActive
|
||||
@@ -152,6 +162,21 @@ public class UserManagementController : Controller
|
||||
var user = await _userManager.FindByIdAsync(dto.Id);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
// Guard against locking the system out of administration: an admin may not
|
||||
// deactivate or demote themselves, and the last active Admin must remain.
|
||||
var currentRoles0 = await _userManager.GetRolesAsync(user);
|
||||
var losesAdmin = currentRoles0.Contains("Admin") && dto.Role != "Admin";
|
||||
if (user.Id == _userManager.GetUserId(User) && (!dto.IsActive || losesAdmin))
|
||||
{
|
||||
TempData["ErrorMessage"] = "You cannot deactivate or demote your own account.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
if ((!dto.IsActive || losesAdmin) && await IsLastActiveAdminAsync(user))
|
||||
{
|
||||
TempData["ErrorMessage"] = "This is the last active Admin account — it cannot be deactivated or demoted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
user.FirstName = dto.FirstName;
|
||||
user.LastName = dto.LastName;
|
||||
user.SchoolId = dto.SchoolId;
|
||||
@@ -195,6 +220,20 @@ public class UserManagementController : Controller
|
||||
var user = await _userManager.FindByIdAsync(id);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
if (user.IsActive)
|
||||
{
|
||||
if (user.Id == _userManager.GetUserId(User))
|
||||
{
|
||||
TempData["ErrorMessage"] = "You cannot deactivate your own account.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
if (await IsLastActiveAdminAsync(user))
|
||||
{
|
||||
TempData["ErrorMessage"] = "This is the last active Admin account — it cannot be deactivated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
user.IsActive = !user.IsActive;
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
@@ -202,6 +241,14 @@ public class UserManagementController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="user"/> is an active Admin and no other active Admin exists.</summary>
|
||||
private async Task<bool> IsLastActiveAdminAsync(ApplicationUser user)
|
||||
{
|
||||
if (!user.IsActive || !await _userManager.IsInRoleAsync(user, "Admin")) return false;
|
||||
var admins = await _userManager.GetUsersInRoleAsync("Admin");
|
||||
return !admins.Any(a => a.IsActive && a.Id != user.Id);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ResetPassword(string id)
|
||||
{
|
||||
|
||||
@@ -34,4 +34,27 @@ public static class PaginationHelper
|
||||
if (showAll) return list.ToList();
|
||||
return list.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records paging metadata for data that was already paged at the database
|
||||
/// (see <see cref="Domain.Interfaces.IStudentRepository.GetPagedAsync"/>), without
|
||||
/// materialising the full result set. Returns the clamped page number.
|
||||
/// </summary>
|
||||
public static int SetPagingMetadata(this Controller controller, int page, int totalItems, int pageSize)
|
||||
{
|
||||
var showAll = pageSize <= 0;
|
||||
var totalPages = showAll ? 1 : (int)Math.Ceiling(totalItems / (double)pageSize);
|
||||
if (page < 1) page = 1;
|
||||
if (totalPages > 0 && page > totalPages) page = totalPages;
|
||||
|
||||
controller.ViewData["Page"] = page;
|
||||
controller.ViewData["TotalPages"] = totalPages;
|
||||
controller.ViewData["TotalItems"] = totalItems;
|
||||
controller.ViewData["PageSize"] = pageSize; // 0 == All
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>Coerces a requested page size to one of the offered choices.</summary>
|
||||
public static int NormalizePageSize(int pageSize) =>
|
||||
PageSizeOptions.Contains(pageSize) ? pageSize : PageSize;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using FluentValidation.AspNetCore;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using SportsDivision.Application;
|
||||
using SportsDivision.Infrastructure;
|
||||
using SportsDivision.Infrastructure.Seeding;
|
||||
@@ -8,9 +10,20 @@ QuestPdfLicenseInitializer.EnsureInitialized();
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// TLS terminates at the reverse proxy (Caddy); trust its X-Forwarded-* headers so
|
||||
// Request.IsHttps is correct and UseHttpsRedirection doesn't loop. The proxy's
|
||||
// address inside the Docker network isn't fixed, hence the cleared known lists.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Seed database
|
||||
@@ -20,6 +33,8 @@ using (var scope = app.Services.CreateScope())
|
||||
await seeder.SeedAsync();
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Home/Error");
|
||||
|
||||
@@ -48,8 +48,10 @@ public static class ReportExcelExporter
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void SetInt(IXLCell cell, int? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; }
|
||||
private static void SetDec(IXLCell cell, decimal? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; }
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace SportsDivision.Web.ViewModels;
|
||||
|
||||
// Placeholder to establish namespace — will be replaced with actual ViewModels
|
||||
@@ -1,20 +1,33 @@
|
||||
@model IEnumerable<EventDto>
|
||||
@{
|
||||
ViewData["Title"] = "Events";
|
||||
var selectedCategory = ViewBag.SelectedCategory as EventCategory?;
|
||||
var categories = selectedCategory.HasValue
|
||||
? new[] { selectedCategory.Value }
|
||||
: Enum.GetValues<EventCategory>();
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Events</h2>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<form method="get" class="d-flex gap-2">
|
||||
<select name="category" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
@foreach (var c in Enum.GetValues<EventCategory>())
|
||||
{
|
||||
<option value="@c" selected="@(selectedCategory == c)">@c</option>
|
||||
}
|
||||
</select>
|
||||
</form>
|
||||
<a asp-action="Create" class="btn btn-primary">Add Event</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="row">
|
||||
@foreach (var category in new[] { "Track", "Field", "HighJump" })
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
var catEvents = Model.Where(e => e.Category.ToString() == category);
|
||||
if (!catEvents.Any()) continue;
|
||||
var catEvents = Model.Where(e => e.Category == category);
|
||||
if (!catEvents.Any()) { continue; }
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-dark text-white"><h6 class="mb-0">@category Events</h6></div>
|
||||
@@ -22,11 +35,19 @@
|
||||
@foreach (var e in catEvents)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
@e.Name
|
||||
<span>
|
||||
@e.Name
|
||||
@if (e.IsRelay) { <span class="badge bg-info">Relay</span> }
|
||||
@if (e.PrimarySchool) { <span class="badge bg-success">P</span> }
|
||||
@if (e.SecondarySchool) { <span class="badge bg-primary">S</span> }
|
||||
@if (!e.IsActive) { <span class="badge bg-secondary">Inactive</span> }
|
||||
</span>
|
||||
<span class="text-nowrap">
|
||||
<a asp-action="Edit" asp-route-id="@e.EventId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<form asp-action="Delete" asp-route-id="@e.EventId" method="post" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete @e.Name? This cannot be undone.')">Delete</button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0 table-responsive">
|
||||
<table class="table table-bordered mb-0">
|
||||
@@ -37,12 +35,13 @@
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>School</th>
|
||||
@foreach (var height in Model.OrderBy(h => h.SortOrder))
|
||||
@foreach (var height in Model.OrderBy(h => h.Height))
|
||||
{
|
||||
<th class="text-center" style="min-width:80px">
|
||||
@height.Height.ToString("0.00")m
|
||||
<form asp-action="RemoveHeight" method="post" class="d-inline">
|
||||
<input type="hidden" name="heightId" value="@height.HighJumpHeightId" />
|
||||
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
|
||||
<button type="submit" class="btn btn-link btn-sm text-danger p-0" onclick="return confirm('Remove?')">x</button>
|
||||
</form>
|
||||
</th>
|
||||
@@ -57,38 +56,30 @@
|
||||
<tr>
|
||||
<td>@reg.StudentName</td>
|
||||
<td>@reg.SchoolName</td>
|
||||
@foreach (var height in Model.OrderBy(h => h.SortOrder))
|
||||
@foreach (var height in Model.OrderBy(h => h.Height))
|
||||
{
|
||||
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
|
||||
<td class="text-center">
|
||||
@if (attempt != null)
|
||||
{
|
||||
@foreach (var a in new[] { attempt.Attempt1, attempt.Attempt2, attempt.Attempt3 })
|
||||
{
|
||||
if (a == HighJumpAttemptResult.Clear) { <span class="text-success fw-bold">O</span> }
|
||||
else if (a == HighJumpAttemptResult.Fail) { <span class="text-danger fw-bold">X</span> }
|
||||
else if (a == HighJumpAttemptResult.Pass) { <span class="text-muted">-</span> }
|
||||
}
|
||||
@if (attempt.IsEliminated) { <br/><span class="badge bg-danger">OUT</span> }
|
||||
}
|
||||
else
|
||||
{
|
||||
@* One control per attempt slot, pre-selected with the recorded
|
||||
result, so attempts 2 and 3 stay editable after attempt 1 is
|
||||
saved (and mistakes can be corrected). *@
|
||||
@for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
var current = i == 1 ? attempt?.Attempt1 : i == 2 ? attempt?.Attempt2 : attempt?.Attempt3;
|
||||
<form asp-action="RecordAttempt" method="post" class="d-inline">
|
||||
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
|
||||
<input type="hidden" name="HighJumpHeightId" value="@height.HighJumpHeightId" />
|
||||
<input type="hidden" name="EventRegistrationId" value="@reg.EventRegistrationId" />
|
||||
<input type="hidden" name="AttemptNumber" value="@i" />
|
||||
<select name="Result" onchange="this.form.submit()" class="form-select form-select-sm d-inline" style="width:50px">
|
||||
<option value="">@i</option>
|
||||
<option value="Clear">O</option>
|
||||
<option value="Fail">X</option>
|
||||
<option value="Pass">-</option>
|
||||
<select name="Result" onchange="this.form.submit()" class="form-select form-select-sm d-inline" style="width:52px">
|
||||
<option value="" selected="@(current == null)">@i</option>
|
||||
<option value="Clear" selected="@(current == HighJumpAttemptResult.Clear)">O</option>
|
||||
<option value="Fail" selected="@(current == HighJumpAttemptResult.Fail)">X</option>
|
||||
<option value="Pass" selected="@(current == HighJumpAttemptResult.Pass)">-</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@if (attempt?.IsEliminated == true) { <br/><span class="badge bg-danger">OUT</span> }
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model IEnumerable<EventRegistrationDto>
|
||||
@{
|
||||
ViewData["Title"] = "Student Registrations";
|
||||
var studentName = ViewBag.StudentName as string;
|
||||
var student = ViewBag.Student as StudentDto;
|
||||
}
|
||||
|
||||
<h2>Registrations for @studentName</h2>
|
||||
<h2>Registrations for @(student?.FullName ?? "student")</h2>
|
||||
<hr />
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
@model EventRegistrationCreateDto
|
||||
@{
|
||||
ViewData["Title"] = "Register Student";
|
||||
var students = ViewBag.Students as IEnumerable<StudentDto>;
|
||||
var telId = ViewBag.TournamentEventLevelId as int?;
|
||||
var eventName = ViewBag.EventName as string;
|
||||
var levelName = ViewBag.LevelName as string;
|
||||
var isEligible = ViewBag.IsEligible as bool?;
|
||||
var eligibilityReason = ViewBag.EligibilityReason as string;
|
||||
var selectedStudentId = Model?.StudentId;
|
||||
}
|
||||
|
||||
<h2>Register Student</h2>
|
||||
<p class="text-muted">@eventName - @levelName</p>
|
||||
@if (!string.IsNullOrEmpty(eventName))
|
||||
{
|
||||
<p class="text-muted">@eventName - @levelName</p>
|
||||
}
|
||||
<hr />
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form asp-action="Register" method="post">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<input type="hidden" name="TournamentEventLevelId" value="@telId" />
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Student</label>
|
||||
<select name="StudentId" class="form-select">
|
||||
@* Re-request the page with the chosen student so eligibility is checked before submitting. *@
|
||||
<select name="StudentId" class="form-select"
|
||||
onchange="window.location = '@Url.Action("Register", new { tournamentEventLevelId = telId })&studentId=' + this.value">
|
||||
<option value="">Select Student</option>
|
||||
@if (students != null) { @foreach (var s in students) { <option value="@s.StudentId">@s.FullName (@s.SchoolName) - @s.Sex</option> } }
|
||||
@if (students != null)
|
||||
{
|
||||
@foreach (var s in students)
|
||||
{
|
||||
<option value="@s.StudentId" selected="@(selectedStudentId == s.StudentId)">@s.FullName (@s.SchoolName) - @s.Sex</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Register</button>
|
||||
@if (isEligible == true)
|
||||
{
|
||||
<div class="alert alert-success py-2">
|
||||
<i class="bi bi-check-circle"></i> This student is eligible for this event level.
|
||||
</div>
|
||||
}
|
||||
else if (isEligible == false)
|
||||
{
|
||||
<div class="alert alert-danger py-2">
|
||||
<i class="bi bi-x-circle"></i> Not eligible: @eligibilityReason
|
||||
</div>
|
||||
}
|
||||
<button type="submit" class="btn btn-primary" disabled="@(isEligible == false)">Register</button>
|
||||
<a asp-action="Index" asp-route-tournamentEventLevelId="@telId" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{
|
||||
<form asp-action="@report.Item1" method="get" class="d-flex gap-2">
|
||||
<select name="tournamentId" class="form-select form-select-sm">
|
||||
@foreach (var t in tournaments) { <option value="@t.TournamentId">@t.Name</option> }
|
||||
@foreach (var t in tournaments) { <option value="@t.TournamentId">@t.Name@(t.IsArchived ? " (archived)" : "")</option> }
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary text-nowrap">View</button>
|
||||
</form>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="card shadow-sm" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
@{
|
||||
ViewData["Title"] = "Schools";
|
||||
var zones = ViewBag.Zones as IEnumerable<ZoneDto>;
|
||||
var selectedZone = ViewBag.SelectedZone as int?;
|
||||
var selectedLevel = ViewBag.SelectedLevel as string;
|
||||
var selectedZone = ViewBag.SelectedZoneId as int?;
|
||||
var selectedLevel = ViewBag.SelectedLevel as SchoolLevel?;
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -21,9 +21,9 @@
|
||||
<div class="col-auto">
|
||||
<select name="level" class="form-select form-select-sm">
|
||||
<option value="">All Levels</option>
|
||||
<option value="Primary" selected="@(selectedLevel == "Primary")">Primary</option>
|
||||
<option value="Secondary" selected="@(selectedLevel == "Secondary")">Secondary</option>
|
||||
<option value="College" selected="@(selectedLevel == "College")">College</option>
|
||||
<option value="Primary" selected="@(selectedLevel == SchoolLevel.Primary)">Primary</option>
|
||||
<option value="Secondary" selected="@(selectedLevel == SchoolLevel.Secondary)">Secondary</option>
|
||||
<option value="College" selected="@(selectedLevel == SchoolLevel.College)">College</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -31,7 +31,6 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
ViewData["Title"] = "Scoring Configuration";
|
||||
var constants = ViewBag.ScoringConstants as IEnumerable<ScoringConstantDto>;
|
||||
var placements = ViewBag.PlacementPointConfigs as IEnumerable<PlacementPointConfigDto>;
|
||||
var eventsWithoutConstant = ViewBag.EventsWithoutConstant as IEnumerable<EventDto>;
|
||||
}
|
||||
|
||||
<h2>Scoring Configuration</h2>
|
||||
<hr />
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
@@ -23,10 +22,13 @@
|
||||
<form id="scf-@c.ScoringConstantId" asp-action="UpdateScoringConstant" method="post" class="d-none">
|
||||
<input type="hidden" name="ScoringConstantId" value="@c.ScoringConstantId" />
|
||||
</form>
|
||||
<form id="scfd-@c.ScoringConstantId" asp-action="DeleteScoringConstant" method="post" class="d-none">
|
||||
<input type="hidden" name="scoringConstantId" value="@c.ScoringConstantId" />
|
||||
</form>
|
||||
}
|
||||
}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Action</th></tr></thead>
|
||||
<thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@if (constants != null)
|
||||
{
|
||||
@@ -38,13 +40,60 @@
|
||||
<td><input type="number" step="0.1" name="B" value="@c.B" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
|
||||
<td><input type="number" step="0.01" name="C" value="@c.C" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
|
||||
<td>@c.Unit</td>
|
||||
<td><button type="submit" form="scf-@c.ScoringConstantId" class="btn btn-sm btn-outline-primary">Save</button></td>
|
||||
<td class="text-nowrap">
|
||||
<button type="submit" form="scf-@c.ScoringConstantId" class="btn btn-sm btn-outline-primary">Save</button>
|
||||
<button type="submit" form="scfd-@c.ScoringConstantId" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Remove the scoring constant for @c.EventName? Its points can no longer be calculated until a new one is added.')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
@if (eventsWithoutConstant != null && eventsWithoutConstant.Any())
|
||||
{
|
||||
<form asp-action="CreateScoringConstant" method="post" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">Event</label>
|
||||
<select name="EventId" class="form-select form-select-sm" required>
|
||||
<option value="">Select event…</option>
|
||||
@foreach (var e in eventsWithoutConstant)
|
||||
{
|
||||
<option value="@e.EventId">@e.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">A</label>
|
||||
<input type="number" step="0.00001" name="A" class="form-control form-control-sm" style="width:100px" required />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">B</label>
|
||||
<input type="number" step="0.1" name="B" class="form-control form-control-sm" style="width:80px" required />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">C</label>
|
||||
<input type="number" step="0.01" name="C" class="form-control form-control-sm" style="width:80px" required />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">Unit</label>
|
||||
<select name="Unit" class="form-select form-select-sm">
|
||||
<option value="seconds">seconds</option>
|
||||
<option value="metres">metres</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-success">Add Constant</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">Every event has a scoring constant.</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -61,10 +110,13 @@
|
||||
<input type="hidden" name="PlacementPointConfigId" value="@p.PlacementPointConfigId" />
|
||||
<input type="hidden" name="Placement" value="@p.Placement" />
|
||||
</form>
|
||||
<form id="ppfd-@p.PlacementPointConfigId" asp-action="DeletePlacementPointConfig" method="post" class="d-none">
|
||||
<input type="hidden" name="placementPointConfigId" value="@p.PlacementPointConfigId" />
|
||||
</form>
|
||||
}
|
||||
}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Place</th><th>Points</th><th>Action</th></tr></thead>
|
||||
<thead><tr><th>Place</th><th>Points</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@if (placements != null)
|
||||
{
|
||||
@@ -73,13 +125,32 @@
|
||||
<tr>
|
||||
<td>#@p.Placement</td>
|
||||
<td><input type="number" name="Points" value="@p.Points" form="ppf-@p.PlacementPointConfigId" class="form-control form-control-sm" style="width:60px" /></td>
|
||||
<td><button type="submit" form="ppf-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-primary">Save</button></td>
|
||||
<td class="text-nowrap">
|
||||
<button type="submit" form="ppf-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-primary">Save</button>
|
||||
<button type="submit" form="ppfd-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Remove points for place #@p.Placement?')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form asp-action="CreatePlacementPointConfig" method="post" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">Place</label>
|
||||
<input type="number" name="Placement" min="1" class="form-control form-control-sm" style="width:70px" required />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-0">Points</label>
|
||||
<input type="number" name="Points" min="0" class="form-control form-control-sm" style="width:70px" required />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-success">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="icon" href="~/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="~/apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--help-primary: #003366;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="icon" href="~/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="~/apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using SportsDivision.Infrastructure.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@using System.Security.Claims
|
||||
|
||||
@if (SignInManager.IsSignedIn(User))
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var currentUser = await UserManager.GetUserAsync(User);
|
||||
var initials = "";
|
||||
var displayName = UserManager.GetUserName(User) ?? "User";
|
||||
if (currentUser != null)
|
||||
{
|
||||
var first = !string.IsNullOrEmpty(currentUser.FirstName) ? currentUser.FirstName[0].ToString() : "";
|
||||
var last = !string.IsNullOrEmpty(currentUser.LastName) ? currentUser.LastName[0].ToString() : "";
|
||||
initials = (first + last).ToUpper();
|
||||
if (!string.IsNullOrEmpty(currentUser.FirstName))
|
||||
{
|
||||
displayName = currentUser.FirstName;
|
||||
}
|
||||
}
|
||||
@* Name and initials come from claims stamped into the cookie at sign-in
|
||||
(AppClaimsPrincipalFactory) — no database query per page render. *@
|
||||
var firstName = User.FindFirstValue(ClaimTypes.GivenName);
|
||||
var lastName = User.FindFirstValue(ClaimTypes.Surname);
|
||||
var displayName = !string.IsNullOrEmpty(firstName) ? firstName : (User.Identity.Name ?? "User");
|
||||
var initials = (
|
||||
(!string.IsNullOrEmpty(firstName) ? firstName[0].ToString() : "") +
|
||||
(!string.IsNullOrEmpty(lastName) ? lastName[0].ToString() : "")).ToUpper();
|
||||
if (string.IsNullOrEmpty(initials))
|
||||
{
|
||||
initials = displayName.Length > 0 ? displayName[0].ToString().ToUpper() : "U";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@if (TempData["SuccessMessage"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert" data-autodismiss="true">
|
||||
@TempData["SuccessMessage"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
@if (TempData["ErrorMessage"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert" data-autodismiss="true">
|
||||
@TempData["ErrorMessage"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
@if (TempData["WarningMessage"] != null)
|
||||
{
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert" data-no-autodismiss="true">
|
||||
@* Warnings stay visible: they carry information the official must act on
|
||||
(e.g. a jump-off is required to decide a tie for first). *@
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
@TempData["WarningMessage"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
@@ -25,8 +27,9 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setTimeout(function () {
|
||||
// Auto-dismiss transient alerts, but keep ones that need the user's attention.
|
||||
var alerts = document.querySelectorAll('.alert:not([data-no-autodismiss])');
|
||||
// Auto-dismiss only transient notifications rendered by this partial —
|
||||
// never warnings, and never informational alerts that are page content.
|
||||
var alerts = document.querySelectorAll('.alert[data-autodismiss]');
|
||||
alerts.forEach(function (alert) {
|
||||
var bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
|
||||
38
src/SportsDivision.Web/Views/Student/Details.cshtml
Normal file
38
src/SportsDivision.Web/Views/Student/Details.cshtml
Normal file
@@ -0,0 +1,38 @@
|
||||
@model StudentDto
|
||||
@{
|
||||
ViewData["Title"] = Model.FullName;
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>@Model.FullName</h2>
|
||||
<div>
|
||||
<a asp-action="Edit" asp-route-id="@Model.StudentId" class="btn btn-primary">Edit</a>
|
||||
<a asp-controller="Registration" asp-action="ByStudent" asp-route-studentId="@Model.StudentId" class="btn btn-outline-info">Registrations</a>
|
||||
<a asp-action="Index" class="btn btn-secondary">Back to Students</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Student ID</dt>
|
||||
<dd class="col-sm-9">@(string.IsNullOrEmpty(Model.ExistingStudentId) ? "—" : Model.ExistingStudentId)</dd>
|
||||
|
||||
<dt class="col-sm-3">Date of Birth</dt>
|
||||
<dd class="col-sm-9">@Model.DateOfBirth.ToString("yyyy-MM-dd")</dd>
|
||||
|
||||
<dt class="col-sm-3">Sex</dt>
|
||||
<dd class="col-sm-9"><span class="badge @(Model.Sex == Sex.Male ? "bg-primary" : "bg-danger")">@Model.Sex</span></dd>
|
||||
|
||||
<dt class="col-sm-3">School</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a asp-controller="School" asp-action="Details" asp-route-id="@Model.SchoolId">@Model.SchoolName</a>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Status</dt>
|
||||
<dd class="col-sm-9">
|
||||
<span class="badge @(Model.IsActive ? "bg-success" : "bg-secondary")">@(Model.IsActive ? "Active" : "Inactive")</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,8 +2,8 @@
|
||||
@{
|
||||
ViewData["Title"] = "Students";
|
||||
var schools = ViewBag.Schools as IEnumerable<SchoolDto>;
|
||||
var selectedSchool = ViewBag.SelectedSchool as int?;
|
||||
var search = ViewBag.Search as string;
|
||||
var selectedSchool = ViewBag.SelectedSchoolId as int?;
|
||||
var search = ViewBag.SearchTerm as string;
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -26,7 +26,6 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
@@ -49,6 +48,7 @@
|
||||
<td><span class="badge @(s.Sex == Sex.Male ? "bg-primary" : "bg-danger")">@s.Sex</span></td>
|
||||
<td>@s.SchoolName</td>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@s.StudentId" class="btn btn-sm btn-outline-secondary">Details</a>
|
||||
<a asp-action="Edit" asp-route-id="@s.StudentId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<a asp-controller="Registration" asp-action="ByStudent" asp-route-studentId="@s.StudentId" class="btn btn-sm btn-outline-info">Registrations</a>
|
||||
</td>
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<a asp-action="Create" class="btn btn-primary">Create Tournament</a>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<form asp-action="Index" method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto">
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
@@ -80,14 +79,21 @@
|
||||
</td>
|
||||
<td>
|
||||
<a asp-action="ManageRound" asp-route-roundId="@r.RoundId" class="btn btn-sm btn-primary">Manage</a>
|
||||
@if (r.Heats.Count == 0)
|
||||
{
|
||||
<form asp-action="SeedHeats" method="post" class="d-inline">
|
||||
<input type="hidden" name="roundId" value="@r.RoundId" />
|
||||
<input type="hidden" name="method" value="Random" />
|
||||
@if (r.Heats.Count == 0)
|
||||
{
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Seed Heats</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning"
|
||||
onclick="return confirm('Re-seed this round? Existing heats, lane assignments and any recorded times for this round will be replaced.')">
|
||||
Re-seed
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Notification" />
|
||||
|
||||
@if (!Model.Heats.Any())
|
||||
{
|
||||
@@ -113,6 +112,7 @@
|
||||
</form>
|
||||
<form id="complete-heat-@heat.HeatId" asp-action="CompleteHeat" method="post" style="display:none">
|
||||
<input type="hidden" name="heatId" value="@heat.HeatId" />
|
||||
<input type="hidden" name="roundId" value="@Model.RoundId" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@using SportsDivision.Web
|
||||
@using SportsDivision.Web.Models
|
||||
@using SportsDivision.Web.ViewModels
|
||||
@using SportsDivision.Application.DTOs
|
||||
@using SportsDivision.Domain.Enums
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@@ -4,5 +4,12 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=sportsdivision;Username=postgres;Password=CHANGE_ME"
|
||||
},
|
||||
"SeedAdmin": {
|
||||
"Email": "admin@sportsdivision.dm",
|
||||
"Password": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,5 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=74.50.64.180;Port=5432;Database=sportsdivision_db;Username=postgres;Password=rG4eX5vU8kD4jY5k"
|
||||
},
|
||||
"EmailSettings": {
|
||||
"GmailEmail": "pcgurudm@gmail.com",
|
||||
"GmailPassword": "bbux tqjo lubq utss",
|
||||
"DisplayName": "Sports Division"
|
||||
}
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=sportsdivision;Username=postgres;Password=aN5eM6zM0nX4nX9j"
|
||||
},
|
||||
"EmailSettings": {
|
||||
"GmailEmail": "pcgurudm@gmail.com",
|
||||
"GmailPassword": "bbux tqjo lubq utss",
|
||||
"DisplayName": "Sports Division"
|
||||
"DefaultConnection": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +39,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-dismiss alerts after 5 seconds
|
||||
setTimeout(function () {
|
||||
var alerts = document.querySelectorAll('.alert');
|
||||
alerts.forEach(function (alert) {
|
||||
var bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
|
||||
if (bsAlert) {
|
||||
bsAlert.close();
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
// Notification auto-dismiss lives in _Notification.cshtml, which spares alerts
|
||||
// marked data-no-autodismiss and leaves informational page content alone.
|
||||
});
|
||||
|
||||
5
src/SportsDivision.Web/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.min.css
vendored
Normal file
5
src/SportsDivision.Web/wwwroot/lib/bootstrap-icons/font/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -2,8 +2,9 @@
|
||||
-- test-data.sql
|
||||
-- Bulk sample data for the Dominica Sports Division app.
|
||||
--
|
||||
-- Adds, for BOTH seeded tournaments (1 = Inter-Zone Championship 2026,
|
||||
-- 2 = National Championship 2026):
|
||||
-- Creates two sample tournaments if they do not already exist
|
||||
-- (Inter-Zone Championship 2026 and National Championship 2026 — the app's
|
||||
-- seeder deliberately seeds no tournaments), then adds for BOTH of them:
|
||||
-- * 60 students (30 male / 30 female) spread across the secondary schools
|
||||
-- * Under-16 Boys & Under-16 Girls event levels for EVERY individual
|
||||
-- (non-relay) event in the catalogue
|
||||
@@ -99,12 +100,28 @@ WHERE NOT EXISTS (
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. TOURNAMENT EVENT LEVELS (every _ev event x U16 Boys/Girls x 2 tournaments)
|
||||
-- 2. TOURNAMENTS (created idempotently; IDs are resolved by name, never
|
||||
-- assumed — the application seeder does not create any tournaments)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO "Tournaments" ("Name", "StartDate", "EndDate", "SchoolLevel", "IsArchived", "Status")
|
||||
SELECT v.name, v.sdate::date, v.edate::date, 'Secondary', false, 'InProgress'
|
||||
FROM (VALUES
|
||||
('Inter-Zone Championship 2026', '2026-03-16', '2026-03-20'),
|
||||
('National Championship 2026', '2026-04-13', '2026-04-17')
|
||||
) AS v(name, sdate, edate)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Tournaments" t WHERE t."Name" = v.name);
|
||||
|
||||
CREATE TEMP TABLE _tt ON COMMIT DROP AS
|
||||
SELECT "TournamentId" AS tid FROM "Tournaments"
|
||||
WHERE "Name" IN ('Inter-Zone Championship 2026', 'National Championship 2026');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. TOURNAMENT EVENT LEVELS (every _ev event x U16 Boys/Girls x 2 tournaments)
|
||||
-- Age restriction waived so the U16 sample athletes are always eligible.
|
||||
-- ---------------------------------------------------------------------------
|
||||
WITH combos AS (
|
||||
SELECT t.tid, e."EventId" AS eid, el."EventLevelId" AS lid
|
||||
FROM (VALUES (1), (2)) AS t(tid)
|
||||
FROM _tt t
|
||||
CROSS JOIN _ev
|
||||
CROSS JOIN (VALUES ('Under 16 Boys'), ('Under 16 Girls')) AS lv(name)
|
||||
JOIN "Events" e ON e."Name" = _ev.ename
|
||||
@@ -120,14 +137,14 @@ WHERE NOT EXISTS (
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. REGISTRATIONS (~16 sample students per event level, sex-matched)
|
||||
-- 4. REGISTRATIONS (~16 sample students per event level, sex-matched)
|
||||
-- ---------------------------------------------------------------------------
|
||||
WITH testtels AS (
|
||||
SELECT tel."TournamentEventLevelId" AS tid, el."Sex" AS sex
|
||||
FROM "TournamentEventLevels" tel
|
||||
JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId"
|
||||
JOIN "Events" e ON e."EventId" = tel."EventId"
|
||||
WHERE tel."TournamentId" IN (1, 2)
|
||||
WHERE tel."TournamentId" IN (SELECT tid FROM _tt)
|
||||
AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls')
|
||||
AND e."Name" IN (SELECT ename FROM _ev)
|
||||
),
|
||||
@@ -158,7 +175,7 @@ CREATE TEMP TABLE _tel ON COMMIT DROP AS
|
||||
FROM "TournamentEventLevels" tel
|
||||
JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId"
|
||||
JOIN "Events" e ON e."EventId" = tel."EventId"
|
||||
WHERE tel."TournamentId" IN (1, 2)
|
||||
WHERE tel."TournamentId" IN (SELECT tid FROM _tt)
|
||||
AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls')
|
||||
AND e."Name" IN (SELECT ename FROM _ev);
|
||||
|
||||
@@ -338,8 +355,10 @@ COMMIT;
|
||||
-- Summary (informational; safe to run repeatedly)
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT 'students (TD-)' AS metric, count(*) AS value FROM "Students" WHERE "ExistingStudentId" LIKE 'TD-%'
|
||||
UNION ALL SELECT 'event levels (t1)', count(*) FROM "TournamentEventLevels" WHERE "TournamentId" = 1
|
||||
UNION ALL SELECT 'event levels (t2)', count(*) FROM "TournamentEventLevels" WHERE "TournamentId" = 2
|
||||
UNION ALL SELECT 'event levels (both tournaments)', count(*)
|
||||
FROM "TournamentEventLevels" tel
|
||||
JOIN "Tournaments" t ON t."TournamentId" = tel."TournamentId"
|
||||
WHERE t."Name" IN ('Inter-Zone Championship 2026', 'National Championship 2026')
|
||||
UNION ALL SELECT 'registrations (total)', count(*) FROM "EventRegistrations"
|
||||
UNION ALL SELECT 'scores (total)', count(*) FROM "Scores"
|
||||
UNION ALL SELECT 'track heat lanes', count(*) FROM "HeatLanes"
|
||||
|
||||
@@ -232,6 +232,102 @@ public class RegistrationEligibilityTests
|
||||
Assert.Contains("already registered", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_InactiveStudent_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
var student = CreateStudent(Sex.Male);
|
||||
student.IsActive = false;
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("deactivated", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_InactiveSchool_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
var student = CreateStudent(Sex.Male);
|
||||
var school = CreateSchool(SchoolLevel.Secondary);
|
||||
school.IsActive = false;
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
SetupSchool(school);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("school is deactivated", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_CompletedTournament_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
tel.Tournament.Status = TournamentStatus.Completed;
|
||||
var student = CreateStudent(Sex.Male);
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("completed", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_ArchivedTournament_ReturnsFalse()
|
||||
{
|
||||
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
|
||||
tel.Tournament.IsArchived = true;
|
||||
var student = CreateStudent(Sex.Male);
|
||||
|
||||
SetupTournamentEventLevel(tel);
|
||||
SetupStudent(student);
|
||||
|
||||
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
|
||||
|
||||
Assert.False(isEligible);
|
||||
Assert.Contains("archived", reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterStudent_RelayOnlyRegistration_ThrowsInsteadOfCrashing()
|
||||
{
|
||||
var dto = new Application.DTOs.EventRegistrationCreateDto
|
||||
{
|
||||
TournamentEventLevelId = 1,
|
||||
StudentId = null,
|
||||
RelayTeamId = 5
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _service.RegisterStudentAsync(dto, "tester"));
|
||||
Assert.Contains("relay", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterStudent_NeitherStudentNorRelay_Throws()
|
||||
{
|
||||
var dto = new Application.DTOs.EventRegistrationCreateDto
|
||||
{
|
||||
TournamentEventLevelId = 1,
|
||||
StudentId = null,
|
||||
RelayTeamId = null
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _service.RegisterStudentAsync(dto, "tester"));
|
||||
Assert.Contains("student", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckEligibility_TelNotFound_ReturnsFalse()
|
||||
{
|
||||
|
||||
145
tests/SportsDivision.Application.Tests/ScoringPlacementTests.cs
Normal file
145
tests/SportsDivision.Application.Tests/ScoringPlacementTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Moq;
|
||||
using SportsDivision.Application.Services;
|
||||
using SportsDivision.Domain.Entities;
|
||||
using SportsDivision.Domain.Enums;
|
||||
using SportsDivision.Domain.Interfaces;
|
||||
|
||||
namespace SportsDivision.Application.Tests;
|
||||
|
||||
public class ScoringPlacementTests
|
||||
{
|
||||
private readonly Mock<IUnitOfWork> _mockUow;
|
||||
private readonly ScoringService _service;
|
||||
|
||||
public ScoringPlacementTests()
|
||||
{
|
||||
_mockUow = new Mock<IUnitOfWork>();
|
||||
_service = new ScoringService(_mockUow.Object, null!);
|
||||
}
|
||||
|
||||
private void Setup(EventCategory category, List<Score> scores)
|
||||
{
|
||||
_mockUow.Setup(u => u.TournamentEventLevels.GetWithRegistrationsAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(new TournamentEventLevel
|
||||
{
|
||||
TournamentEventLevelId = 1,
|
||||
Event = new Event { Category = category }
|
||||
});
|
||||
_mockUow.Setup(u => u.Scores.GetByTournamentEventLevelAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(scores);
|
||||
_mockUow.Setup(u => u.PlacementPointConfigs.GetAllAsync())
|
||||
.ReturnsAsync(new List<PlacementPointConfig>
|
||||
{
|
||||
new() { Placement = 1, Points = 10 },
|
||||
new() { Placement = 2, Points = 8 },
|
||||
new() { Placement = 3, Points = 6 },
|
||||
new() { Placement = 4, Points = 5 },
|
||||
});
|
||||
_mockUow.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
|
||||
}
|
||||
|
||||
private static Score MakeScore(int regId, decimal performance) =>
|
||||
new() { EventRegistrationId = regId, RawPerformance = performance };
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_FieldEvent_RanksByPerformanceDescending()
|
||||
{
|
||||
// Points-based ranking used to assign arbitrary places when no scoring
|
||||
// constant existed (all CalculatedPoints = 0); ranking is now on the mark.
|
||||
var s1 = MakeScore(1, 10.00m);
|
||||
var s2 = MakeScore(2, 12.50m);
|
||||
var s3 = MakeScore(3, 11.00m);
|
||||
Setup(EventCategory.Field, new List<Score> { s1, s2, s3 });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Equal(1, s2.Placement);
|
||||
Assert.Equal(2, s3.Placement);
|
||||
Assert.Equal(3, s1.Placement);
|
||||
Assert.Equal(10, s2.PlacementPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_TrackEvent_RanksAscending()
|
||||
{
|
||||
var s1 = MakeScore(1, 11.20m);
|
||||
var s2 = MakeScore(2, 10.90m);
|
||||
Setup(EventCategory.Track, new List<Score> { s1, s2 });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Equal(1, s2.Placement);
|
||||
Assert.Equal(2, s1.Placement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_Ties_SharePlaceAndSplitPooledPoints()
|
||||
{
|
||||
// Two identical throws share 1st (competition ranking 1, 1, 3) and split
|
||||
// the pooled points for places 1 and 2: (10 + 8) / 2 = 9.
|
||||
var s1 = MakeScore(1, 12.50m);
|
||||
var s2 = MakeScore(2, 12.50m);
|
||||
var s3 = MakeScore(3, 11.00m);
|
||||
Setup(EventCategory.Field, new List<Score> { s1, s2, s3 });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Equal(1, s1.Placement);
|
||||
Assert.Equal(1, s2.Placement);
|
||||
Assert.Equal(9, s1.PlacementPoints);
|
||||
Assert.Equal(9, s2.PlacementPoints);
|
||||
Assert.Equal(3, s3.Placement);
|
||||
Assert.Equal(6, s3.PlacementPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculatePlacements_ZeroMark_GetsNoPlacementAndOldPlacementCleared()
|
||||
{
|
||||
var foul = MakeScore(1, 0m);
|
||||
foul.Placement = 1; // stale from an earlier calculation
|
||||
foul.PlacementPoints = 10;
|
||||
var valid = MakeScore(2, 9.50m);
|
||||
Setup(EventCategory.Field, new List<Score> { foul, valid });
|
||||
|
||||
await _service.CalculatePlacementsAsync(1);
|
||||
|
||||
Assert.Null(foul.Placement);
|
||||
Assert.Equal(0, foul.PlacementPoints);
|
||||
Assert.Equal(1, valid.Placement);
|
||||
}
|
||||
}
|
||||
|
||||
public class HeatAdvancementResetTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CalculateAdvancement_ClearsStaleFlagsBeforeRecalculating()
|
||||
{
|
||||
// An athlete who advanced in a previous calculation but no longer qualifies
|
||||
// after a time correction must lose the flag on recalculation.
|
||||
var stale = new HeatLane { HeatLaneId = 1, Time = 12.5m, IsAdvanced = true, AdvanceReason = AdvanceReason.TopN };
|
||||
var faster = new HeatLane { HeatLaneId = 2, Time = 11.0m };
|
||||
var round = new Round
|
||||
{
|
||||
RoundId = 1,
|
||||
AdvanceTopN = 1,
|
||||
AdvanceFastestLosers = 0,
|
||||
Heats = new List<Heat>
|
||||
{
|
||||
new() { HeatId = 1, HeatLanes = new List<HeatLane> { stale, faster } }
|
||||
}
|
||||
};
|
||||
|
||||
var mockUow = new Mock<IUnitOfWork>();
|
||||
mockUow.Setup(u => u.Rounds.GetWithHeatsAsync(1)).ReturnsAsync(round);
|
||||
mockUow.Setup(u => u.HeatLanes.Update(It.IsAny<HeatLane>()));
|
||||
mockUow.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
|
||||
var service = new HeatManagementService(mockUow.Object, null!);
|
||||
|
||||
await service.CalculateAdvancementAsync(1);
|
||||
|
||||
Assert.False(stale.IsAdvanced);
|
||||
Assert.Null(stale.AdvanceReason);
|
||||
Assert.True(faster.IsAdvanced);
|
||||
Assert.Equal(AdvanceReason.TopN, faster.AdvanceReason);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public class StudentTests
|
||||
DateOfBirth = new DateOnly(2012, 2, 29) // leap year
|
||||
};
|
||||
|
||||
// March 1, 2025 - non-leap year, birthday hasn't technically passed
|
||||
// March 1, 2025 — non-leap year; the Feb 29 birthday is treated as passed
|
||||
var referenceDate = new DateOnly(2025, 3, 1);
|
||||
Assert.Equal(13, student.GetAge(referenceDate));
|
||||
}
|
||||
|
||||
1168
userguide.html
1168
userguide.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user