各类申请

This commit is contained in:
2026-07-25 16:51:12 +08:00 Unverified
parent d02b0c56a1
commit 5e4c76a9e4
10 changed files with 901 additions and 4 deletions
@@ -0,0 +1,395 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/approvals")]
public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin;
private const string Teachers = Managers + "," + SystemRoles.Teacher;
private const string Students = Managers + "," + SystemRoles.Student;
// ═══════════════ Aggregated pending ═══════════════
[HttpGet("pending")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetPending(CancellationToken ct)
{
var items = new List<ApprovalItem>();
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
.ToListAsync(ct));
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct));
return Ok(items.OrderByDescending(x => x.Time).ToList());
}
// ═══════════════ Course Exemption ═══════════════
[HttpGet("exemptions/mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyExemptions(CancellationToken ct)
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
return Ok(await db.CourseExemptions.AsNoTracking()
.Where(x => x.StudentId == studentId).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name })
.ToListAsync(ct));
}
[HttpPost("exemptions")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplyExemption(ExemptionRequest req, CancellationToken ct)
{
var studentId = await GetStudentIdAsync(ct);
if (studentId is null) return StudentNotFound();
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有免修申请在审核中。");
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
db.CourseExemptions.Add(ex);
await db.SaveChangesAsync(ct);
// Reload to get nav properties for notification
var info = await db.CourseExemptions.Where(x => x.Id == ex.Id)
.Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name })
.FirstAsync(ct);
await NotifyManagers("免修申请", $"{info.StudentName} 申请免修《{info.CourseName}》", ct);
return Created("", new { ex.Id });
}
[HttpPost("exemptions/{id:guid}/approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ApproveExemption(Guid id, CancellationToken ct)
{
var ex = await db.CourseExemptions.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
if (ex is null) return NotFound();
ex.Status = ApprovalStatus.Approved; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewedByUserId = scope.Current.UserId;
// Auto-apply: mark grade record as exempt
var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == ex.TeachingTaskId, ct);
if (sheet is not null)
{
var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == ex.StudentId, ct);
if (record is not null) { record.ExamStatus = GradeExamStatus.Exempt; record.RegularScore = null; record.FinalScore = null; record.TotalScore = null; record.GradePoint = null; }
}
await db.SaveChangesAsync(ct);
await NotifyStudent(ex.StudentId, "免修申请已通过", $"《{ex.TeachingTask!.Course!.Name}》免修已批准。", ct);
return NoContent();
}
[HttpPost("exemptions/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectExemption(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var ex = await db.CourseExemptions.FindAsync([id], ct);
if (ex is null) return NotFound();
ex.Status = ApprovalStatus.Rejected; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewComment = body.Comment?.Trim(); ex.ReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyStudent(ex.StudentId, "免修申请已驳回", ex.ReviewComment ?? "审核未通过。", ct);
return NoContent();
}
// ═══════════════ Deferred Exam ═══════════════
[HttpGet("deferred/mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyDeferred(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
return Ok(await db.DeferredExams.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name }).ToListAsync(ct));
}
[HttpPost("deferred")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplyDeferred(ExemptionRequest req, CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有缓考申请在审核中。");
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
db.DeferredExams.Add(d);
await db.SaveChangesAsync(ct);
var info = await db.DeferredExams.Where(x => x.Id == d.Id)
.Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name })
.FirstAsync(ct);
await NotifyManagers("缓考申请", $"{info.StudentName} 申请《{info.CourseName}》缓考", ct);
return Created("", new { d.Id });
}
[HttpPost("deferred/{id:guid}/approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ApproveDeferred(Guid id, CancellationToken ct)
{
var d = await db.DeferredExams.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
if (d is null) return NotFound();
d.Status = ApprovalStatus.Approved; d.ReviewedAt = DateTime.UtcNow; d.ReviewedByUserId = scope.Current.UserId;
// Auto-apply: set grade record to deferred
var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == d.TeachingTaskId, ct);
if (sheet is not null)
{
var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == d.StudentId, ct);
if (record is not null) record.ExamStatus = GradeExamStatus.Deferred;
}
await db.SaveChangesAsync(ct);
await NotifyStudent(d.StudentId, "缓考申请已通过", $"《{d.TeachingTask!.Course!.Name}》缓考已批准。", ct);
return NoContent();
}
[HttpPost("deferred/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectDeferred(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var d = await db.DeferredExams.FindAsync([id], ct);
if (d is null) return NotFound();
d.Status = ApprovalStatus.Rejected; d.ReviewedAt = DateTime.UtcNow; d.ReviewComment = body.Comment?.Trim(); d.ReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyStudent(d.StudentId, "缓考申请已驳回", d.ReviewComment ?? "审核未通过。", ct);
return NoContent();
}
// ═══════════════ Grade Modification (3-level) ═══════════════
[HttpPost("grade-modifications")]
[Authorize(Roles = Teachers)]
public async Task<ActionResult> ApplyGradeMod(GradeModRequest req, CancellationToken ct)
{
var record = await db.GradeRecords.Include(x => x.GradeSheet).FirstOrDefaultAsync(x => x.Id == req.GradeRecordId, ct);
if (record is null) return NotFound();
if (record.GradeSheet!.Status != GradeSheetStatus.Published)
return ConflictProblem("只能修改已发布的成绩。");
var gm = new GradeModification { GradeRecordId = req.GradeRecordId, CurrentScore = record.TotalScore ?? 0, RequestedScore = req.RequestedScore, Reason = req.Reason.Trim(), ApplicantUserId = scope.Current.UserId };
db.GradeModifications.Add(gm);
await db.SaveChangesAsync(ct);
var gmInfo = await db.GradeModifications.Where(x => x.Id == gm.Id)
.Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore })
.FirstAsync(ct);
await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核",
$"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct);
return Created("", new { gm.Id });
}
[HttpPost("grade-modifications/{id:guid}/college-approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CollegeApproveGradeMod(Guid id, CancellationToken ct)
{
var gm = await db.GradeModifications.Include(x => x.GradeRecord).ThenInclude(x => x!.GradeSheet).ThenInclude(x => x!.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct);
if (gm is null) return NotFound();
if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。");
gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
return NoContent();
}
[HttpPost("grade-modifications/{id:guid}/approve")]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
public async Task<ActionResult> FinalApproveGradeMod(Guid id, CancellationToken ct)
{
var gm = await db.GradeModifications.Include(x => x.GradeRecord).FirstOrDefaultAsync(x => x.Id == id, ct);
if (gm is null) return NotFound();
if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。");
gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId;
// Auto-apply: update grade record
gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
return NoContent();
}
[HttpPost("grade-modifications/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectGradeMod(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var gm = await db.GradeModifications.FindAsync([id], ct);
if (gm is null) return NotFound();
gm.Status = GradeModificationStatus.Rejected; gm.ReviewComment = body.Comment?.Trim();
if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; }
else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; }
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", cancellationToken: ct);
return NoContent();
}
// ═══════════════ Course Substitution ═══════════════
[HttpPost("substitutions")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
return ConflictProblem("该课程已有替代申请在审核中。");
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
db.CourseSubstitutions.Add(cs);
await db.SaveChangesAsync(ct);
var info = await db.CourseSubstitutions.Where(x => x.Id == cs.Id)
.Select(x => new { StudentName = x.Student!.Name, SubName = x.SubstituteCourse!.Name, OrigName = x.OriginalCourse!.Name })
.FirstAsync(ct);
await NotifyManagers("课程替代申请", $"{info.StudentName} 申请用《{info.SubName}》替代《{info.OrigName}》", ct);
return Created("", new { cs.Id });
}
[HttpPost("substitutions/{id:guid}/approve")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> ApproveSubstitution(Guid id, CancellationToken ct)
{
var cs = await db.CourseSubstitutions.Include(x => x.Student).Include(x => x.OriginalCourse).Include(x => x.SubstituteCourse).FirstOrDefaultAsync(x => x.Id == id, ct);
if (cs is null) return NotFound();
cs.Status = ApprovalStatus.Approved; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewedByUserId = scope.Current.UserId;
// Auto-apply: copy passed grade to original course
var currentTerm = await db.AcademicTerms.FirstOrDefaultAsync(x => x.IsCurrent, ct);
if (currentTerm is not null)
{
var passedRecord = await db.GradeRecords
.Where(x => x.StudentId == cs.StudentId && x.GradeSheet!.TeachingTask!.CourseId == cs.SubstituteCourseId && x.TotalScore >= 60)
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
.FirstOrDefaultAsync(ct);
if (passedRecord is not null)
{
// Find or create grade record for original course
var origTask = await db.TeachingTasks.FirstOrDefaultAsync(x => x.CourseId == cs.OriginalCourseId && x.AcademicTermId == currentTerm.Id, ct);
if (origTask is not null)
{
var origSheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == origTask.Id, ct);
if (origSheet is not null)
{
var origRecord = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == origSheet.Id && x.StudentId == cs.StudentId, ct);
if (origRecord is not null)
{
origRecord.TotalScore = passedRecord.TotalScore;
origRecord.GradePoint = passedRecord.GradePoint;
origRecord.Notes = $"课程替代:{cs.SubstituteCourse.Code} {cs.SubstituteCourse.Name}";
}
}
}
}
}
await db.SaveChangesAsync(ct);
await NotifyStudent(cs.StudentId, "课程替代申请已通过", $"《{cs.SubstituteCourse!.Name}》替代《{cs.OriginalCourse!.Name}》已生效。", ct);
return NoContent();
}
[HttpPost("substitutions/{id:guid}/reject")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> RejectSubstitution(Guid id, [FromBody] ReviewBody body, CancellationToken ct)
{
var cs = await db.CourseSubstitutions.FindAsync([id], ct);
if (cs is null) return NotFound();
cs.Status = ApprovalStatus.Rejected; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewComment = body.Comment?.Trim(); cs.ReviewedByUserId = scope.Current.UserId;
await db.SaveChangesAsync(ct);
await NotifyStudent(cs.StudentId, "课程替代申请已驳回", cs.ReviewComment ?? "审核未通过。", ct);
return NoContent();
}
// ═══════════════ Student enrolled courses ═══════════════
[HttpGet("my-courses")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
var courses = await db.CourseEnrollments.AsNoTracking()
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
.ToListAsync(ct);
return Ok(courses);
}
[HttpGet("my-grades")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyGrades(CancellationToken ct)
{
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
.OrderBy(x => x.CourseCode)
.ToListAsync(ct);
return Ok(grades);
}
[HttpGet("grade-records")]
[Authorize(Roles = Teachers)]
public async Task<ActionResult> GetGradeRecordsForMod(CancellationToken ct)
{
var userId = scope.Current.UserId;
var records = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradeSheet.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
.Select(x => new { x.Id, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, CourseName = x.GradeSheet!.TeachingTask!.Course!.Name, x.TotalScore })
.ToListAsync(ct);
return Ok(records);
}
// ═══════════════ Helpers ═══════════════
private IQueryable<T> Scoped<T>(System.Linq.Expressions.Expression<Func<T, bool>> predicate) where T : class
{
var q = db.Set<T>().AsNoTracking().Where(predicate);
if (scope.Current.Scope == DataScope.All) return q;
var cid = scope.Current.RestrictedCollegeId;
if (typeof(T) == typeof(CourseExemption)) return (IQueryable<T>)((IQueryable<CourseExemption>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(DeferredExam)) return (IQueryable<T>)((IQueryable<DeferredExam>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(GradeModification)) return (IQueryable<T>)((IQueryable<GradeModification>)(object)q).Where(x => x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(CourseSubstitution)) return (IQueryable<T>)((IQueryable<CourseSubstitution>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
if (typeof(T) == typeof(StudentStatusChange)) return (IQueryable<T>)((IQueryable<StudentStatusChange>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
if (typeof(T) == typeof(CourseAdjustment)) return (IQueryable<T>)((IQueryable<CourseAdjustment>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(GradeSheet)) return (IQueryable<T>)((IQueryable<GradeSheet>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(AttendanceRecord)) return (IQueryable<T>)((IQueryable<AttendanceRecord>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
return q;
}
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, cancellationToken: ct); }
private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct); }
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" };
private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" };
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "无法完成", Detail = d, Status = 409 });
}
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
public sealed record ReviewBody([MaxLength(500)] string? Comment);
@@ -155,8 +155,32 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
// Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite)
var exemptStudentIds = await db.CourseExemptions
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var deferredStudentIds = await db.DeferredExams
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft;
return Ok(new { Sheet = sheet, CanEdit = canEdit });
return Ok(new
{
Sheet = new
{
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
sheet.Status, sheet.Notes, sheet.SubmittedAt,
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
Records = sheet.Records.Select(r => new
{
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
r.Status, r.Notes,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
},
CanEdit = canEdit
});
}
[HttpPut("sheets/{id:guid}/records")]
@@ -430,8 +454,7 @@ public sealed class AttendanceController(
var source = db.AttendanceRecords.AsNoTracking()
.Where(r =>
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
r.Student!.AdministrativeClassId != null &&
targetClassIds.Contains(r.Student.AdministrativeClassId));
targetClassIds.Contains(r.Student!.AdministrativeClassId));
if (academicTermId.HasValue)
source = source.Where(r =>
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
@@ -0,0 +1,83 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
// ═══════════════ 免修 ═══════════════
public sealed class CourseExemption : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Reason { get; set; }
public ApprovalStatus Status { get; set; } = ApprovalStatus.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
// ═══════════════ 缓考 ═══════════════
public sealed class DeferredExam : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Reason { get; set; }
public ApprovalStatus Status { get; set; } = ApprovalStatus.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
// ═══════════════ 成绩修改(三级审批) ═══════════════
public sealed class GradeModification : EntityBase
{
public Guid GradeRecordId { get; set; }
public GradeRecord? GradeRecord { get; set; }
public decimal CurrentScore { get; set; }
public decimal RequestedScore { get; set; }
public required string Reason { get; set; }
public GradeModificationStatus Status { get; set; } = GradeModificationStatus.TeacherSubmitted;
public string? ReviewComment { get; set; }
public Guid ApplicantUserId { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? CollegeReviewedAt { get; set; }
public Guid? CollegeReviewedByUserId { get; set; }
public DateTime? FinalReviewedAt { get; set; }
public Guid? FinalReviewedByUserId { get; set; }
}
// ═══════════════ 课程替代 ═══════════════
public sealed class CourseSubstitution : EntityBase
{
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid OriginalCourseId { get; set; }
public Course? OriginalCourse { get; set; }
public Guid SubstituteCourseId { get; set; }
public Course? SubstituteCourse { get; set; }
public required string Reason { get; set; }
public ApprovalStatus Status { get; set; } = ApprovalStatus.Submitted;
public string? ReviewComment { get; set; }
public DateTime SubmittedAt { get; set; } = DateTime.UtcNow;
public DateTime? ReviewedAt { get; set; }
public Guid? ReviewedByUserId { get; set; }
}
public enum ApprovalStatus
{
Submitted = 1,
Approved = 2,
Rejected = 3
}
public enum GradeModificationStatus
{
TeacherSubmitted = 1,
CollegeApproved = 2,
Approved = 3,
Rejected = 4
}
@@ -56,6 +56,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>();
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
public DbSet<CourseExemption> CourseExemptions => Set<CourseExemption>();
public DbSet<DeferredExam> DeferredExams => Set<DeferredExam>();
public DbSet<GradeModification> GradeModifications => Set<GradeModification>();
public DbSet<CourseSubstitution> CourseSubstitutions => Set<CourseSubstitution>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
@@ -673,6 +677,52 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseExemption>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.StudentId, x.TeachingTaskId }).IsUnique();
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<DeferredExam>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.StudentId, x.TeachingTaskId }).IsUnique();
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeModification>(entity =>
{
entity.Property(x => x.CurrentScore).HasPrecision(5, 1);
entity.Property(x => x.RequestedScore).HasPrecision(5, 1);
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasOne(x => x.GradeRecord).WithMany()
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseSubstitution>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.StudentId, x.OriginalCourseId }).IsUnique();
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.OriginalCourse).WithMany()
.HasForeignKey(x => x.OriginalCourseId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.SubstituteCourse).WithMany()
.HasForeignKey(x => x.SubstituteCourseId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseAdjustment>(entity =>
{
entity.Property(x => x.Reason).HasMaxLength(500);
@@ -40,6 +40,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_22_course_adjustments";
private const string AttendanceAppealMigration =
"20260725_23_attendance_appeal";
private const string ApprovalTablesMigration =
"20260725_24_approval_tables";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -270,6 +272,14 @@ public sealed class DevelopmentSqliteMigrator(
AttendanceAppealMigration,
attendanceAppealExists ? [] : AttendanceAppealStatements,
cancellationToken);
var approvalTablesExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ApprovalTablesMigration,
approvalTablesExist ? [] : ApprovalTableStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1578,4 +1588,22 @@ public sealed class DevelopmentSqliteMigrator(
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealReviewedAt" TEXT NULL;""",
"""CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");"""
];
private static readonly string[] ApprovalTableStatements =
[
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_CourseExemptions_StudentId_TeachingTaskId" ON "CourseExemptions" ("StudentId", "TeachingTaskId");""",
"""CREATE INDEX "IX_CourseExemptions_Status_CreatedAt" ON "CourseExemptions" ("Status", "CreatedAt");""",
"""CREATE TABLE "DeferredExams" ("Id" TEXT NOT NULL CONSTRAINT "PK_DeferredExams" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_DeferredExams_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_DeferredExams_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_DeferredExams_StudentId_TeachingTaskId" ON "DeferredExams" ("StudentId", "TeachingTaskId");""",
"""CREATE INDEX "IX_DeferredExams_Status_CreatedAt" ON "DeferredExams" ("Status", "CreatedAt");""",
"""CREATE TABLE "GradeModifications" ("Id" TEXT NOT NULL CONSTRAINT "PK_GradeModifications" PRIMARY KEY, "GradeRecordId" TEXT NOT NULL, "CurrentScore" TEXT NOT NULL, "RequestedScore" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "ApplicantUserId" TEXT NOT NULL, "SubmittedAt" TEXT NOT NULL, "CollegeReviewedAt" TEXT NULL, "CollegeReviewedByUserId" TEXT NULL, "FinalReviewedAt" TEXT NULL, "FinalReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_GradeModifications_GradeRecords" FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE RESTRICT);""",
"""CREATE INDEX "IX_GradeModifications_Status_CreatedAt" ON "GradeModifications" ("Status", "CreatedAt");""",
"""CREATE TABLE "CourseSubstitutions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseSubstitutions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "OriginalCourseId" TEXT NOT NULL, "SubstituteCourseId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseSubstitutions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseSubstitutions_OriginalCourse" FOREIGN KEY ("OriginalCourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseSubstitutions_SubstituteCourse" FOREIGN KEY ("SubstituteCourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT);""",
"""CREATE UNIQUE INDEX "IX_CourseSubstitutions_StudentId_OriginalCourseId" ON "CourseSubstitutions" ("StudentId", "OriginalCourseId");""",
"""CREATE INDEX "IX_CourseSubstitutions_Status_CreatedAt" ON "CourseSubstitutions" ("Status", "CreatedAt");"""
];
}
@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
public partial class ApprovalRequests : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// CourseExemption
migrationBuilder.CreateTable("CourseExemptions", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_CourseExemptions", x => x.Id);
table.ForeignKey("FK_CourseExemptions_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseExemptions_TeachingTasks", x => x.TeachingTaskId, "TeachingTasks", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_CourseExemptions_Status_CreatedAt", "CourseExemptions", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_CourseExemptions_StudentId_TeachingTaskId", "CourseExemptions", new[] { "StudentId", "TeachingTaskId" }, unique: true);
// DeferredExam
migrationBuilder.CreateTable("DeferredExams", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_DeferredExams", x => x.Id);
table.ForeignKey("FK_DeferredExams_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_DeferredExams_TeachingTasks", x => x.TeachingTaskId, "TeachingTasks", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_DeferredExams_Status_CreatedAt", "DeferredExams", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_DeferredExams_StudentId_TeachingTaskId", "DeferredExams", new[] { "StudentId", "TeachingTaskId" }, unique: true);
// GradeModification
migrationBuilder.CreateTable("GradeModifications", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
CurrentScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
RequestedScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CollegeReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CollegeReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
FinalReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_GradeModifications", x => x.Id);
table.ForeignKey("FK_GradeModifications_GradeRecords", x => x.GradeRecordId, "GradeRecords", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_GradeModifications_Status_CreatedAt", "GradeModifications", new[] { "Status", "CreatedAt" });
// CourseSubstitution
migrationBuilder.CreateTable("CourseSubstitutions", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
OriginalCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
SubstituteCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", 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_CourseSubstitutions", x => x.Id);
table.ForeignKey("FK_CourseSubstitutions_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseSubstitutions_OriginalCourse", x => x.OriginalCourseId, "Courses", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseSubstitutions_SubstituteCourse", x => x.SubstituteCourseId, "Courses", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_CourseSubstitutions_Status_CreatedAt", "CourseSubstitutions", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_CourseSubstitutions_StudentId_OriginalCourseId", "CourseSubstitutions", new[] { "StudentId", "OriginalCourseId" }, unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable("CourseSubstitutions");
migrationBuilder.DropTable("GradeModifications");
migrationBuilder.DropTable("DeferredExams");
migrationBuilder.DropTable("CourseExemptions");
}
}
}