成绩统计图
This commit is contained in:
@@ -3,7 +3,9 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/grades")]
|
||||
public sealed class GradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string SheetUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -37,6 +40,9 @@ public sealed class GradesController(
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
private const string StatisticsUsers =
|
||||
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
@@ -502,6 +508,7 @@ public sealed class GradesController(
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
@@ -661,6 +668,7 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// covers students added through approved roster corrections.
|
||||
@@ -910,6 +918,7 @@ public sealed class GradesController(
|
||||
if (errors.Count > 0)
|
||||
return ImportValidationProblem(errors);
|
||||
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { updated, total = rows.Count });
|
||||
}
|
||||
@@ -948,6 +957,7 @@ public sealed class GradesController(
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
GradeSheetId = x.GradeSheetId,
|
||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTaskId,
|
||||
@@ -964,6 +974,121 @@ public sealed class GradesController(
|
||||
return Ok(new { Student = student, Records = records });
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}/statistics")]
|
||||
[Authorize(Roles = StatisticsUsers)]
|
||||
public async Task<ActionResult> GetCourseStatistics(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
CourseCode = x.TeachingTask.Course.Code,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
Guid? classId = null;
|
||||
Guid? majorId = null;
|
||||
Guid? collegeId = null;
|
||||
if (scope.IsInRole(SystemRoles.Student))
|
||||
{
|
||||
if (sheet.Status != GradeSheetStatus.Published)
|
||||
return NotFound();
|
||||
var student = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Student!.AdministrativeClassId,
|
||||
MajorId = x.Student.AdministrativeClass!.MajorId,
|
||||
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (student is null) return Forbid();
|
||||
classId = student.AdministrativeClassId;
|
||||
majorId = student.MajorId;
|
||||
collegeId = student.CollegeId;
|
||||
}
|
||||
else if (scope.Scope == DataScope.College)
|
||||
{
|
||||
collegeId = scope.RestrictedCollegeId;
|
||||
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
|
||||
await db.Courses.Where(x => x.Id == sheet.CourseId)
|
||||
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
|
||||
return Forbid();
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.CounselorUserId == scope.UserId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedClassIds.Count == 0) return Forbid();
|
||||
// A counselor sees their classes plus the matching major/college
|
||||
// benchmarks, never an unrelated class-level statistic.
|
||||
classId = allowedClassIds.First();
|
||||
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
|
||||
.Select(x => x.MajorId).FirstAsync(cancellationToken);
|
||||
collegeId = await db.Majors.Where(x => x.Id == majorId)
|
||||
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
|
||||
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
|
||||
{
|
||||
var source = db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == sheet.CourseId &&
|
||||
x.AcademicTermId == sheet.AcademicTermId);
|
||||
return await source.Select(x => new
|
||||
{
|
||||
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
|
||||
x.Below60Count, x.From60To69Count, x.From70To79Count,
|
||||
x.From80To89Count, x.From90To100Count,
|
||||
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
|
||||
x.CalculatedAt
|
||||
}).ToListAsync(token);
|
||||
}, AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics], cancellationToken);
|
||||
|
||||
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
|
||||
{
|
||||
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
|
||||
x.ScopeEntityId == entityId);
|
||||
return item is null ? null : new
|
||||
{
|
||||
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
|
||||
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
|
||||
item.CalculatedAt,
|
||||
Distribution = new[]
|
||||
{
|
||||
new { Range = "0–59", Count = item.Below60Count },
|
||||
new { Range = "60–69", Count = item.From60To69Count },
|
||||
new { Range = "70–79", Count = item.From70To79Count },
|
||||
new { Range = "80–89", Count = item.From80To89Count },
|
||||
new { Range = "90–100", Count = item.From90To100Count }
|
||||
}
|
||||
};
|
||||
}
|
||||
return Ok(new
|
||||
{
|
||||
sheet.CourseName, sheet.CourseCode, sheet.TermName,
|
||||
IsRefreshing = !statistics.Any(),
|
||||
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
|
||||
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
|
||||
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
|
||||
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
|
||||
? Find(CourseGradeStatisticScope.University, null) : null
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> AccessibleTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
@@ -1036,6 +1161,7 @@ public sealed class GradesController(
|
||||
{
|
||||
try
|
||||
{
|
||||
QueueCourseStatisticsRefresh(id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
@@ -1045,6 +1171,14 @@ public sealed class GradesController(
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
||||
{
|
||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user