免修、缓考现在包含当前学期课程,以及未归档且课表已发布的实际修读课程。
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 27m29s
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 27m29s
实际开发库验证已返回 CS101 程序设计基础。 课程替代改为“修读中/未通过课程 → 已发布及格课程”的双栏选择。 新增搜索、教学班号、学期、教师、学分、课表状态和完整空状态说明。 新增课程替代申请记录。 提交接口增加服务端资格校验,不能伪造课程 ID 绕过前端。
This commit is contained in:
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var studentId = await GetStudentIdAsync(ct);
|
||||
if (studentId is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
studentId.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
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() };
|
||||
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
sid.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
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() };
|
||||
@@ -248,12 +254,75 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
// ═══════════════ Course Substitution ═══════════════
|
||||
[HttpGet("substitutions/mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new StudentCourseSubstitutionRecord(
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.Reason,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.OriginalCourse!.Code,
|
||||
x.OriginalCourse.Name,
|
||||
x.SubstituteCourse!.Code,
|
||||
x.SubstituteCourse.Name))
|
||||
.ToListAsync(ct));
|
||||
}
|
||||
|
||||
[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 (req.OriginalCourseId == req.SubstituteCourseId)
|
||||
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||||
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||||
.AnyAsync(task =>
|
||||
task.CourseId == req.OriginalCourseId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(record =>
|
||||
record.StudentId == sid.Value &&
|
||||
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
record.GradeSheet.TeachingTask!.CourseId ==
|
||||
req.OriginalCourseId &&
|
||||
record.TotalScore < 60,
|
||||
ct);
|
||||
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||||
if (!canReplaceOriginal)
|
||||
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||||
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(x =>
|
||||
x.StudentId == sid.Value &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||||
x.TotalScore >= 60,
|
||||
ct);
|
||||
if (!hasPassedSubstitute)
|
||||
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||||
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() };
|
||||
@@ -319,16 +388,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Student enrolled courses ═══════════════
|
||||
// ═══════════════ Student course options ═══════════════
|
||||
[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 })
|
||||
var courses = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)))
|
||||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new StudentApprovalCourseOption(
|
||||
x.Id,
|
||||
x.CourseId,
|
||||
x.TaskNumber,
|
||||
x.Course!.Code,
|
||||
x.Course.Name,
|
||||
x.Course.Credits,
|
||||
x.AcademicTerm!.Name,
|
||||
x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.ThenBy(item => item.Teacher!.Name)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == x.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||||
x.SchedulingMode))
|
||||
.ToListAsync(ct);
|
||||
return Ok(courses);
|
||||
}
|
||||
@@ -341,8 +444,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
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)
|
||||
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||
.Select(x => new StudentApprovalGradeOption(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Code,
|
||||
x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
x.ExamStatus,
|
||||
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.TaskNumber,
|
||||
x.GradeSheet.PublishedAt))
|
||||
.ToListAsync(ct);
|
||||
return Ok(grades);
|
||||
}
|
||||
@@ -377,6 +490,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
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 async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||||
Guid studentId,
|
||||
Guid teachingTaskId,
|
||||
CancellationToken ct) =>
|
||||
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||||
task.Id == teachingTaskId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == studentId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == studentId &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
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); }
|
||||
@@ -390,6 +524,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||||
public sealed record StudentApprovalCourseOption(
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
string TaskNumber,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
string AcademicTermName,
|
||||
IEnumerable<string> TeacherNames,
|
||||
bool HasPublishedSchedule,
|
||||
TeachingTaskSchedulingMode SchedulingMode);
|
||||
public sealed record StudentApprovalGradeOption(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
decimal? GradePoint,
|
||||
GradeExamStatus ExamStatus,
|
||||
string AcademicTermName,
|
||||
string TaskNumber,
|
||||
DateTime? PublishedAt);
|
||||
public sealed record StudentCourseSubstitutionRecord(
|
||||
Guid Id,
|
||||
ApprovalStatus Status,
|
||||
string Reason,
|
||||
string? ReviewComment,
|
||||
DateTime SubmittedAt,
|
||||
string OriginalCourseCode,
|
||||
string OriginalCourseName,
|
||||
string SubstituteCourseCode,
|
||||
string SubstituteCourseName);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user