成绩+点名

This commit is contained in:
2026-07-25 14:08:50 +08:00 Unverified
parent 7800951aee
commit 36049af692
16 changed files with 1809 additions and 98 deletions
@@ -0,0 +1,359 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AttendanceRoles)]
[Route("api/attendance")]
public sealed class AttendanceController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string AttendanceRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
[HttpGet("my-tasks")]
public async Task<ActionResult> GetMyTasks(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var tasks = AccessibleTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
var result = await tasks
.OrderBy(x => x.Course!.Code)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name,
StudentCount = db.CourseEnrollments.Count(enrollment =>
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
SheetCount = db.AttendanceSheets.Count(sheet =>
sheet.TeachingTaskId == x.Id)
})
.ToListAsync(cancellationToken);
return Ok(result);
}
[HttpGet("sheets")]
public async Task<ActionResult> GetSheets(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var task = await AccessibleTasks().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
var sheets = await db.AttendanceSheets.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId)
.OrderByDescending(x => x.AttendanceDate)
.Select(x => new
{
x.Id,
x.Name,
x.AttendanceDate,
x.Status,
x.Notes,
x.SubmittedAt,
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
LeaveCount = x.Records.Count(r => r.Status == AttendanceStatus.Leave),
ExcusedCount = x.Records.Count(r => r.Status == AttendanceStatus.Excused),
TotalCount = x.Records.Count
})
.ToListAsync(cancellationToken);
return Ok(sheets);
}
[HttpPost("sheets")]
public async Task<ActionResult> CreateSheet(
AttendanceSheetRequest request,
CancellationToken cancellationToken)
{
var task = await AccessibleTasks().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
var studentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (studentIds.Count == 0)
return ConflictProblem("该教学班没有有效选课学生。");
var sheet = new AttendanceSheet
{
TeachingTaskId = request.TeachingTaskId,
Name = request.Name.Trim(),
AttendanceDate = request.AttendanceDate,
Notes = Normalize(request.Notes),
Records = studentIds.Select(studentId => new AttendanceRecord
{
StudentId = studentId
}).ToList()
};
db.AttendanceSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
}
[HttpGet("sheets/{id:guid}")]
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.Name,
x.AttendanceDate,
x.Status,
x.Notes,
x.SubmittedAt,
TaskNumber = x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
Records = x.Records
.OrderBy(r => r.Student!.StudentNumber)
.Select(r => new
{
r.StudentId,
r.Student!.StudentNumber,
r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name,
r.Status,
r.Notes
})
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var canEdit = sheet.Status == AttendanceSheetStatus.Draft;
return Ok(new { Sheet = sheet, CanEdit = canEdit });
}
[HttpPut("sheets/{id:guid}/records")]
public async Task<ActionResult> UpdateRecords(
Guid id,
AttendanceRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交,不能修改。");
var recordMap = sheet.Records.ToDictionary(r => r.StudentId);
foreach (var item in request.Records)
{
if (recordMap.TryGetValue(item.StudentId, out var record))
{
record.Status = item.Status;
record.Notes = Normalize(item.Notes);
}
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/import")]
public async Task<ActionResult> Import(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交,不能导入。");
var rows = await ExcelWorkbookHelper.ReadAsync(
file,
new HashSet<string> { "学号", "考勤状态" },
cancellationToken);
var studentNumberMap = sheet.Records
.ToDictionary(r => r.Student!.StudentNumber, r => r);
var statusMap = new Dictionary<string, AttendanceStatus>(StringComparer.OrdinalIgnoreCase)
{
["出勤"] = AttendanceStatus.Present,
["缺勤"] = AttendanceStatus.Absent,
["迟到"] = AttendanceStatus.Late,
["请假"] = AttendanceStatus.Leave,
["免修"] = AttendanceStatus.Excused
};
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"];
var statusText = row["考勤状态"];
if (string.IsNullOrEmpty(studentNumber)) continue;
if (!studentNumberMap.TryGetValue(studentNumber, out var record)) continue;
if (statusMap.TryGetValue(statusText, out var status))
{
record.Status = status;
updated++;
}
}
await db.SaveChangesAsync(cancellationToken);
return Ok(new { Updated = updated });
}
[HttpGet("sheets/{id:guid}/export.xlsx")]
public async Task<ActionResult> Export(Guid id, CancellationToken cancellationToken)
{
var sheetData = await db.AttendanceSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Name,
x.AttendanceDate,
TaskNumber = x.TeachingTask!.TaskNumber,
CourseName = x.TeachingTask.Course!.Name,
Records = x.Records
.OrderBy(r => r.Student!.StudentNumber)
.Select(r => new
{
r.Student!.StudentNumber,
r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name,
r.Status,
r.Notes
})
.ToList()
})
.FirstOrDefaultAsync(cancellationToken);
if (sheetData is null) return NotFound();
var statusLabels = new Dictionary<AttendanceStatus, string>
{
[AttendanceStatus.Present] = "出勤",
[AttendanceStatus.Absent] = "缺勤",
[AttendanceStatus.Late] = "迟到",
[AttendanceStatus.Leave] = "请假",
[AttendanceStatus.Excused] = "免修"
};
var bytes = ExcelWorkbookHelper.Create(
"考勤表",
["学号", "姓名", "班级", "考勤状态", "备注"],
sheetData.Records.Select(r => new List<object?>
{
r.StudentNumber, r.Name, r.ClassName,
statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes
}).ToList<IReadOnlyList<object?>>());
return File(bytes, ExcelWorkbookHelper.ContentType,
$"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx");
}
[HttpPost("sheets/{id:guid}/submit")]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交。");
sheet.Status = AttendanceSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("sheets/{id:guid}")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
db.AttendanceSheets.Remove(sheet);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private IQueryable<TeachingTask> AccessibleTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private bool CanManageSheet(AttendanceSheet sheet) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
sheet.TeachingTask!.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成考勤操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record AttendanceSheetRequest(
Guid TeachingTaskId,
[MaxLength(120)] string Name,
DateTime AttendanceDate,
[MaxLength(500)] string? Notes);
public sealed record AttendanceRecordsRequest(
IReadOnlyCollection<AttendanceRecordRequest> Records);
public sealed record AttendanceRecordRequest(
Guid StudentId,
AttendanceStatus Status,
[MaxLength(300)] string? Notes);
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.CourseSelection;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -868,6 +869,100 @@ public sealed class CourseSelectionsController(
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("my-offerings")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyOfferings(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var offeringsQuery = db.CourseSelectionOfferings.AsNoTracking()
.Where(x =>
x.TeachingTask!.Teachers.Any(t =>
t.Teacher!.UserId == userId) &&
x.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft);
if (academicTermId.HasValue)
offeringsQuery = offeringsQuery.Where(x =>
x.CourseSelectionRound!.AcademicTermId == academicTermId);
var offerings = await offeringsQuery
.OrderByDescending(x => x.CourseSelectionRound!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
RoundName = x.CourseSelectionRound!.Name,
x.Capacity,
EnrolledCount = x.Enrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled),
RoundStatus = x.CourseSelectionRound.Status
})
.ToListAsync(cancellationToken);
return Ok(offerings);
}
[HttpGet("offerings/{id:guid}/roster/export.xlsx")]
[Authorize(Roles = RosterReaders)]
public async Task<ActionResult> ExportRoster(
Guid id,
CancellationToken cancellationToken)
{
var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
RoundName = x.CourseSelectionRound!.Name,
CollegeId = x.TeachingTask.Course.CollegeId,
TeacherUserIds = x.TeachingTask.Teachers
.Select(item => item.Teacher!.UserId)
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
var scope = currentUserDataScope.Current;
var isAssignedTeacher =
scope.IsInRole(SystemRoles.Teacher) &&
offering.TeacherUserIds.Contains(scope.UserId);
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
return Forbid();
var students = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
x.EnrolledAt
})
.ToListAsync(cancellationToken);
var bytes = ExcelWorkbookHelper.Create(
"选课名单",
["学号", "姓名", "班级", "专业", "选课时间"],
students.Select(s => new List<object?>
{
s.StudentNumber, s.Name, s.ClassName, s.MajorName,
s.EnrolledAt.ToString("yyyy-MM-dd HH:mm")
}).ToList<IReadOnlyList<object?>>());
return File(bytes, ExcelWorkbookHelper.ContentType,
$"选课名单-{offering.TaskNumber}.xlsx");
}
private IQueryable<CourseSelectionOffering> ScopedOfferings()
{
var source = db.CourseSelectionOfferings.AsQueryable();
+129 -35
View File
@@ -71,8 +71,13 @@ public sealed class GradesController(
sheet.Id,
sheet.Status,
sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight,
Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record =>
record.TotalScore != null ||
@@ -99,11 +104,20 @@ public sealed class GradesController(
GradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.MidtermWeight,
request.FinalWeight))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
request.FinalWeight,
items))
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var task = await AccessibleTasks()
.Include(x => x.Teachers)
@@ -131,11 +145,15 @@ public sealed class GradesController(
{
TeachingTaskId = task.Id,
RegularWeight = request.RegularWeight,
MidtermWeight = request.MidtermWeight,
FinalWeight = request.FinalWeight,
Items = items,
Records = studentIds.Select(studentId => new GradeRecord
{
StudentId = studentId
StudentId = studentId,
ItemScores = items.Select(item => new GradeItemScore
{
GradeItemId = item.Id
}).ToList()
}).ToList()
};
db.GradeSheets.Add(sheet);
@@ -166,8 +184,13 @@ public sealed class GradesController(
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.RegularWeight,
x.MidtermWeight,
x.FinalWeight,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
@@ -183,8 +206,15 @@ public sealed class GradesController(
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.MidtermScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
@@ -218,20 +248,58 @@ public sealed class GradesController(
GradeWeightsRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.MidtermWeight,
request.FinalWeight))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
var sheet = await EditableSheetAsync(id, cancellationToken);
request.FinalWeight,
items))
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改成绩构成。");
sheet.RegularWeight = request.RegularWeight;
sheet.MidtermWeight = request.MidtermWeight;
sheet.FinalWeight = request.FinalWeight;
foreach (var record in sheet.Records) Recalculate(sheet, record);
db.GradeItems.RemoveRange(sheet.Items);
var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var record in sheet.Records)
{
var toRemove = record.ItemScores
.Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId))
.ToList();
foreach (var removed in toRemove)
record.ItemScores.Remove(removed);
}
sheet.Items = items;
foreach (var record in sheet.Records)
{
foreach (var item in items)
{
record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id });
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
}
@@ -242,28 +310,49 @@ public sealed class GradesController(
GradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await EditableSheetAsync(id, cancellationToken);
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能继续修改。");
var records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var item in request.Records)
{
if (!ValidScore(item.RegularScore) ||
!ValidScore(item.MidtermScore) ||
!ValidScore(item.FinalScore))
return ValidationProblem("成绩必须在 0—100 分之间。");
var record = records[item.Id];
record.RegularScore = item.RegularScore;
record.MidtermScore = item.MidtermScore;
record.FinalScore = item.FinalScore;
record.ExamStatus = item.ExamStatus;
record.Notes = Normalize(item.Notes);
if (item.ItemScores is not null)
{
var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId);
foreach (var scoreEntry in item.ItemScores)
{
if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
existing.Score = scoreEntry.Score;
}
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
@@ -273,7 +362,14 @@ public sealed class GradesController(
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await EditableSheetAsync(id, cancellationToken);
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
@@ -412,16 +508,6 @@ public sealed class GradesController(
db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
task.Id == x.TeachingTaskId));
private async Task<GradeSheet?> EditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await AccessibleSheets()
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
@@ -449,11 +535,11 @@ public sealed class GradesController(
{
record.TotalScore = GradeCalculator.CalculateTotal(
record.RegularScore,
record.MidtermScore,
record.FinalScore,
record.ItemScores.ToList(),
sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight,
sheet.Items.ToList(),
record.ExamStatus);
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
}
@@ -492,13 +578,17 @@ public sealed class GradesController(
public sealed record GradeSheetRequest(
Guid TeachingTaskId,
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeItemRequest(
[MaxLength(60)] string Name,
[Range(typeof(decimal), "0", "100")] decimal Weight);
public sealed record GradeWeightsRequest(
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeRecordsRequest(
IReadOnlyCollection<GradeRecordRequest> Records);
@@ -506,10 +596,14 @@ public sealed record GradeRecordsRequest(
public sealed record GradeRecordRequest(
Guid Id,
decimal? RegularScore,
decimal? MidtermScore,
decimal? FinalScore,
IReadOnlyCollection<GradeItemScoreRequest>? ItemScores,
GradeExamStatus ExamStatus,
[MaxLength(300)] string? Notes);
public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);
@@ -0,0 +1,40 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class AttendanceSheet : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Name { get; set; }
public DateTime AttendanceDate { get; set; }
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = [];
}
public sealed class AttendanceRecord
{
public Guid AttendanceSheetId { get; set; }
public AttendanceSheet? AttendanceSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
public string? Notes { get; set; }
}
public enum AttendanceSheetStatus
{
Draft = 1,
Submitted = 2
}
public enum AttendanceStatus
{
Present = 1,
Absent = 2,
Late = 3,
Leave = 4,
Excused = 5
}
@@ -7,7 +7,6 @@ public sealed class GradeSheet : EntityBase
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public decimal RegularWeight { get; set; } = 30;
public decimal MidtermWeight { get; set; }
public decimal FinalWeight { get; set; } = 70;
public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft;
public string? ReviewComment { get; set; }
@@ -15,6 +14,17 @@ public sealed class GradeSheet : EntityBase
public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<GradeRecord> Records { get; set; } = [];
public ICollection<GradeItem> Items { get; set; } = [];
}
public sealed class GradeItem : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public required string Name { get; set; }
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public ICollection<GradeItemScore> Scores { get; set; } = [];
}
public sealed class GradeRecord : EntityBase
@@ -24,12 +34,21 @@ public sealed class GradeRecord : EntityBase
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? RegularScore { get; set; }
public decimal? MidtermScore { get; set; }
public decimal? FinalScore { get; set; }
public decimal? TotalScore { get; set; }
public decimal? GradePoint { get; set; }
public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal;
public string? Notes { get; set; }
public ICollection<GradeItemScore> ItemScores { get; set; } = [];
}
public sealed class GradeItemScore
{
public Guid GradeRecordId { get; set; }
public GradeRecord? GradeRecord { get; set; }
public Guid GradeItemId { get; set; }
public GradeItem? GradeItem { get; set; }
public decimal? Score { get; set; }
}
public enum GradeSheetStatus
@@ -6,34 +6,45 @@ public static class GradeCalculator
{
public static bool AreWeightsValid(
decimal regularWeight,
decimal midtermWeight,
decimal finalWeight) =>
decimal finalWeight,
IEnumerable<GradeItem> items) =>
regularWeight is >= 0 and <= 100 &&
midtermWeight is >= 0 and <= 100 &&
finalWeight is >= 0 and <= 100 &&
regularWeight + midtermWeight + finalWeight == 100;
items.All(item => item.Weight is >= 0 and <= 100) &&
regularWeight + finalWeight + items.Sum(item => item.Weight) == 100;
public static decimal? CalculateTotal(
decimal? regularScore,
decimal? midtermScore,
decimal? finalScore,
IReadOnlyCollection<GradeItemScore> itemScores,
decimal regularWeight,
decimal midtermWeight,
decimal finalWeight,
IReadOnlyCollection<GradeItem> items,
GradeExamStatus examStatus)
{
if (examStatus != GradeExamStatus.Normal ||
regularWeight > 0 && !regularScore.HasValue ||
midtermWeight > 0 && !midtermScore.HasValue ||
finalWeight > 0 && !finalScore.HasValue)
{
if (examStatus != GradeExamStatus.Normal)
return null;
if (regularWeight > 0 && !regularScore.HasValue)
return null;
if (finalWeight > 0 && !finalScore.HasValue)
return null;
var itemWeightById = items.ToDictionary(item => item.Id, item => item.Weight);
foreach (var itemScore in itemScores)
{
if (itemWeightById.TryGetValue(itemScore.GradeItemId, out var weight) &&
weight > 0 && !itemScore.Score.HasValue)
return null;
}
var total =
(regularScore ?? 0) * regularWeight / 100 +
(midtermScore ?? 0) * midtermWeight / 100 +
(finalScore ?? 0) * finalWeight / 100;
var total = (regularScore ?? 0) * regularWeight / 100;
foreach (var itemScore in itemScores)
{
if (itemWeightById.TryGetValue(itemScore.GradeItemId, out var weight))
total += (itemScore.Score ?? 0) * weight / 100;
}
total += (finalScore ?? 0) * finalWeight / 100;
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
@@ -47,6 +47,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
@@ -468,7 +472,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<GradeSheet>(entity =>
{
entity.Property(x => x.RegularWeight).HasPrecision(5, 1);
entity.Property(x => x.MidtermWeight).HasPrecision(5, 1);
entity.Property(x => x.FinalWeight).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
@@ -479,10 +482,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeItem>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items)
.HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<GradeRecord>(entity =>
{
entity.Property(x => x.RegularScore).HasPrecision(5, 1);
entity.Property(x => x.MidtermScore).HasPrecision(5, 1);
entity.Property(x => x.FinalScore).HasPrecision(5, 1);
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.GradePoint).HasPrecision(3, 1);
@@ -499,6 +512,45 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeItemScore>(entity =>
{
entity.HasKey(x => new { x.GradeRecordId, x.GradeItemId });
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.HasOne(x => x.GradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.GradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.GradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.GradeItemId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AttendanceRecord>(entity =>
{
entity.HasKey(x => new { x.AttendanceSheetId, x.StudentId });
entity.Property(x => x.Notes).HasMaxLength(300);
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.AttendanceSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -546,7 +546,6 @@ public sealed class DatabaseInitializer(
{
TeachingTaskId = task.Id,
RegularWeight = 30,
MidtermWeight = 0,
FinalWeight = 70,
Status = GradeSheetStatus.Draft,
Records =
@@ -14,6 +14,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams";
private const string AttendanceMigration = "20260724_08b_attendance";
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
@@ -29,6 +30,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_17_teaching_task_scheduling_modes";
private const string SchedulePublishJobsMigration =
"20260725_18_schedule_publish_jobs";
private const string FlexibleGradesMigration =
"20260725_19_flexible_grades";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -177,6 +180,34 @@ public sealed class DevelopmentSqliteMigrator(
SchedulePublishJobsMigration,
schedulePublishJobsExist ? [] : SchedulePublishJobStatements,
cancellationToken);
var midtermWeightExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('GradeSheets')
WHERE name = 'MidtermWeight'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
FlexibleGradesMigration,
midtermWeightExists
? FlexibleGradesUpgradeStatements
: FlexibleGradesNewStatements,
cancellationToken);
var attendanceSheetsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'AttendanceSheets'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceMigration,
attendanceSheetsExist ? [] : AttendanceStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -629,7 +660,6 @@ public sealed class DevelopmentSqliteMigrator(
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"RegularWeight" TEXT NOT NULL,
"MidtermWeight" TEXT NOT NULL,
"FinalWeight" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"ReviewComment" TEXT NULL,
@@ -656,7 +686,6 @@ public sealed class DevelopmentSqliteMigrator(
"GradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"RegularScore" TEXT NULL,
"MidtermScore" TEXT NULL,
"FinalScore" TEXT NULL,
"TotalScore" TEXT NULL,
"GradePoint" TEXT NULL,
@@ -723,6 +752,182 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");"""
];
private static readonly string[] FlexibleGradesNewStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GradeItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeItems" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Weight" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeItems_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeItems_GradeSheetId_SortOrder"
ON "GradeItems" ("GradeSheetId", "SortOrder");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeItemScores" (
"GradeRecordId" TEXT NOT NULL,
"GradeItemId" TEXT NOT NULL,
"Score" TEXT NULL,
CONSTRAINT "PK_GradeItemScores" PRIMARY KEY ("GradeRecordId", "GradeItemId"),
CONSTRAINT "FK_GradeItemScores_GradeRecords_GradeRecordId"
FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeItemScores_GradeItems_GradeItemId"
FOREIGN KEY ("GradeItemId") REFERENCES "GradeItems" ("Id") ON DELETE RESTRICT
);
"""
];
private static readonly string[] FlexibleGradesUpgradeStatements =
[
"""
CREATE TABLE "GradeSheets_v2" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"RegularWeight" TEXT NOT NULL,
"FinalWeight" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"ReviewComment" TEXT NULL,
"SubmittedAt" TEXT NULL,
"ReviewedAt" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeSheets_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
INSERT INTO "GradeSheets_v2" ("Id","TeachingTaskId","RegularWeight","FinalWeight",
"Status","ReviewComment","SubmittedAt","ReviewedAt","PublishedAt","CreatedAt","UpdatedAt")
SELECT "Id","TeachingTaskId","RegularWeight","FinalWeight",
"Status","ReviewComment","SubmittedAt","ReviewedAt","PublishedAt","CreatedAt","UpdatedAt"
FROM "GradeSheets";
""",
"DROP TABLE \"GradeSheets\";",
"ALTER TABLE \"GradeSheets_v2\" RENAME TO \"GradeSheets\";",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeSheets_TeachingTaskId"
ON "GradeSheets" ("TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeSheets_Status"
ON "GradeSheets" ("Status");
""",
"""
CREATE TABLE "GradeRecords_v2" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeRecords" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"RegularScore" TEXT NULL,
"FinalScore" TEXT NULL,
"TotalScore" TEXT NULL,
"GradePoint" TEXT NULL,
"ExamStatus" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeRecords_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
INSERT INTO "GradeRecords_v2" ("Id","GradeSheetId","StudentId","RegularScore","FinalScore",
"TotalScore","GradePoint","ExamStatus","Notes","CreatedAt","UpdatedAt")
SELECT "Id","GradeSheetId","StudentId","RegularScore","FinalScore",
"TotalScore","GradePoint","ExamStatus","Notes","CreatedAt","UpdatedAt"
FROM "GradeRecords";
""",
"DROP TABLE \"GradeRecords\";",
"ALTER TABLE \"GradeRecords_v2\" RENAME TO \"GradeRecords\";",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeRecords_GradeSheetId_StudentId"
ON "GradeRecords" ("GradeSheetId", "StudentId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeRecords_StudentId_TotalScore"
ON "GradeRecords" ("StudentId", "TotalScore");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeItems" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Weight" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeItems_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeItems_GradeSheetId_SortOrder"
ON "GradeItems" ("GradeSheetId", "SortOrder");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeItemScores" (
"GradeRecordId" TEXT NOT NULL,
"GradeItemId" TEXT NOT NULL,
"Score" TEXT NULL,
CONSTRAINT "PK_GradeItemScores" PRIMARY KEY ("GradeRecordId", "GradeItemId"),
CONSTRAINT "FK_GradeItemScores_GradeRecords_GradeRecordId"
FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeItemScores_GradeItems_GradeItemId"
FOREIGN KEY ("GradeItemId") REFERENCES "GradeItems" ("Id") ON DELETE RESTRICT
);
"""
];
private static readonly string[] AttendanceStatements =
[
"""
CREATE TABLE IF NOT EXISTS "AttendanceSheets" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"AttendanceDate" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"SubmittedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_AttendanceSheets_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceSheets_TeachingTaskId_AttendanceDate"
ON "AttendanceSheets" ("TeachingTaskId", "AttendanceDate");
""",
"""
CREATE TABLE IF NOT EXISTS "AttendanceRecords" (
"AttendanceSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
CONSTRAINT "PK_AttendanceRecords" PRIMARY KEY ("AttendanceSheetId", "StudentId"),
CONSTRAINT "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId"
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_AttendanceRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AttendanceSheetId_StudentId"
ON "AttendanceRecords" ("AttendanceSheetId", "StudentId");
"""
];
private static readonly string[] StudentStatusChangesStatements =
[
"""
@@ -0,0 +1,184 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class FlexibleGradesAndAttendance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GradeItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
Weight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeItems", x => x.Id);
table.ForeignKey(
name: "FK_GradeItems_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GradeItemScores",
columns: table => new
{
GradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
GradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeItemScores", x => new { x.GradeRecordId, x.GradeItemId });
table.ForeignKey(
name: "FK_GradeItemScores_GradeItems_GradeItemId",
column: x => x.GradeItemId,
principalTable: "GradeItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GradeItemScores_GradeRecords_GradeRecordId",
column: x => x.GradeRecordId,
principalTable: "GradeRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GradeItems_GradeSheetId_SortOrder",
table: "GradeItems",
columns: new[] { "GradeSheetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_GradeItemScores_GradeRecordId_GradeItemId",
table: "GradeItemScores",
columns: new[] { "GradeRecordId", "GradeItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GradeItemScores_GradeItemId",
table: "GradeItemScores",
column: "GradeItemId");
migrationBuilder.DropColumn(
name: "MidtermWeight",
table: "GradeSheets");
migrationBuilder.DropColumn(
name: "MidtermScore",
table: "GradeRecords");
migrationBuilder.CreateTable(
name: "AttendanceSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
AttendanceDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceSheets", x => x.Id);
table.ForeignKey(
name: "FK_AttendanceSheets_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AttendanceRecords",
columns: table => new
{
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceRecords", x => new { x.AttendanceSheetId, x.StudentId });
table.ForeignKey(
name: "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId",
column: x => x.AttendanceSheetId,
principalTable: "AttendanceSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AttendanceRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AttendanceSheets_TeachingTaskId_AttendanceDate",
table: "AttendanceSheets",
columns: new[] { "TeachingTaskId", "AttendanceDate" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_AttendanceSheetId_StudentId",
table: "AttendanceRecords",
columns: new[] { "AttendanceSheetId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_StudentId",
table: "AttendanceRecords",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AttendanceRecords");
migrationBuilder.DropTable(name: "AttendanceSheets");
migrationBuilder.DropTable(name: "GradeItemScores");
migrationBuilder.DropTable(name: "GradeItems");
migrationBuilder.AddColumn<decimal>(
name: "MidtermWeight",
table: "GradeSheets",
type: "decimal(5,1)",
precision: 5,
scale: 1,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MidtermScore",
table: "GradeRecords",
type: "decimal(5,1)",
precision: 5,
scale: 1,
nullable: true);
}
}
}