考试排考
This commit is contained in:
1 parent
36049af692
commit
4e7da6709e
12 files changed
+1306
-118
No files matched your search
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -14,11 +15,16 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/exams")]
|
[Route("api/exams")]
|
||||||
public sealed class ExamsController(
|
public sealed class ExamsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
ExamArrangementService examArrangementService) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string Managers =
|
private const string Managers =
|
||||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Plans
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
[HttpGet("plans")]
|
[HttpGet("plans")]
|
||||||
public async Task<ActionResult> GetPlans(
|
public async Task<ActionResult> GetPlans(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
@@ -68,16 +74,29 @@ public sealed class ExamsController(
|
|||||||
{
|
{
|
||||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||||
x.Status, x.Notes, x.PublishedAt,
|
x.Status, x.Notes, x.PublishedAt,
|
||||||
Sessions = x.Sessions.OrderBy(item => item.StartsAt).Select(item => new
|
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
|
||||||
|
.ThenBy(item => item.StartPeriod).Select(item => new
|
||||||
{
|
{
|
||||||
item.Id, item.TeachingTaskId, item.TeachingTask!.TaskNumber,
|
item.Id,
|
||||||
|
item.TeachingTaskId,
|
||||||
|
item.TeachingTask!.TaskNumber,
|
||||||
TaskName = item.TeachingTask.Name,
|
TaskName = item.TeachingTask.Name,
|
||||||
CourseCode = item.TeachingTask.Course!.Code,
|
CourseCode = item.TeachingTask.Course!.Code,
|
||||||
CourseName = item.TeachingTask.Course.Name,
|
CourseName = item.TeachingTask.Course.Name,
|
||||||
item.ClassroomId, ClassroomName = item.Classroom!.Name,
|
item.ClassroomId,
|
||||||
BuildingName = item.Classroom.Building!.Name,
|
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
|
||||||
ClassroomCapacity = item.Classroom.Capacity,
|
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
|
||||||
item.StartsAt, item.EndsAt, item.Notes,
|
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
|
||||||
|
item.ExamDate,
|
||||||
|
item.StartPeriod,
|
||||||
|
item.PeriodCount,
|
||||||
|
item.StartsAt,
|
||||||
|
item.EndsAt,
|
||||||
|
item.RequiredBuildingId,
|
||||||
|
RequiredBuildingName = item.RequiredBuilding != null
|
||||||
|
? item.RequiredBuilding.Name : null,
|
||||||
|
item.RequiredInvigilatorCount,
|
||||||
|
item.Notes,
|
||||||
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
|
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
|
||||||
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
|
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
StudentCount = db.CourseEnrollments.Count(e =>
|
StudentCount = db.CourseEnrollments.Count(e =>
|
||||||
@@ -88,34 +107,97 @@ public sealed class ExamsController(
|
|||||||
return plan is null ? NotFound() : Ok(plan);
|
return plan is null ? NotFound() : Ok(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Sessions
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
[HttpPost("plans/{planId:guid}/sessions")]
|
[HttpPost("plans/{planId:guid}/sessions")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> CreateSession(
|
public async Task<ActionResult> CreateSession(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
ExamSessionRequest request,
|
CreateExamSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != ExamPlanStatus.Draft)
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
return ConflictProblem("已发布的考试计划不能调整场次。");
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
||||||
var validation = await ValidateSessionAsync(plan, null, request, cancellationToken);
|
|
||||||
|
// Resolve time from slots
|
||||||
|
var timeResult = ResolveExamTime(plan.AcademicTermId,
|
||||||
|
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||||
|
if (timeResult.Error is not null) return timeResult.Error;
|
||||||
|
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||||
|
|
||||||
|
var validation = await ValidateSessionAsync(plan, null,
|
||||||
|
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
|
||||||
|
startsAt, endsAt, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
var session = new ExamSession
|
var session = new ExamSession
|
||||||
{
|
{
|
||||||
ExamPlanId = planId,
|
ExamPlanId = planId,
|
||||||
TeachingTaskId = request.TeachingTaskId,
|
TeachingTaskId = request.TeachingTaskId,
|
||||||
ClassroomId = request.ClassroomId,
|
ClassroomId = request.ClassroomId,
|
||||||
StartsAt = request.StartsAt.ToUniversalTime(),
|
ExamDate = request.ExamDate,
|
||||||
EndsAt = request.EndsAt.ToUniversalTime(),
|
StartPeriod = request.StartPeriod,
|
||||||
|
PeriodCount = request.PeriodCount,
|
||||||
|
StartsAt = startsAt,
|
||||||
|
EndsAt = endsAt,
|
||||||
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes),
|
Notes = Normalize(request.Notes),
|
||||||
Invigilators = request.InvigilatorIds.Distinct().Select(id =>
|
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||||
new ExamSessionInvigilator { TeacherId = id }).ToList()
|
new ExamSessionInvigilator { TeacherId = id }).ToList()
|
||||||
};
|
};
|
||||||
db.ExamSessions.Add(session);
|
db.ExamSessions.Add(session);
|
||||||
return await SaveAsync(session.Id, true, cancellationToken);
|
return await SaveAsync(session.Id, true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> UpdateSession(
|
||||||
|
Guid planId,
|
||||||
|
Guid id,
|
||||||
|
CreateExamSessionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await db.ExamSessions
|
||||||
|
.Include(x => x.ExamPlan)
|
||||||
|
.Include(x => x.Invigilators)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("已发布的考试计划不能调整场次。");
|
||||||
|
|
||||||
|
var timeResult = ResolveExamTime(session.ExamPlan.AcademicTermId,
|
||||||
|
request.ExamDate, request.StartPeriod, request.PeriodCount);
|
||||||
|
if (timeResult.Error is not null) return timeResult.Error;
|
||||||
|
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||||
|
|
||||||
|
var validation = await ValidateSessionAsync(session.ExamPlan, id,
|
||||||
|
request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
|
||||||
|
startsAt, endsAt, cancellationToken);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
|
session.TeachingTaskId = request.TeachingTaskId;
|
||||||
|
session.ClassroomId = request.ClassroomId;
|
||||||
|
session.ExamDate = request.ExamDate;
|
||||||
|
session.StartPeriod = request.StartPeriod;
|
||||||
|
session.PeriodCount = request.PeriodCount;
|
||||||
|
session.StartsAt = startsAt;
|
||||||
|
session.EndsAt = endsAt;
|
||||||
|
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||||
|
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||||
|
session.Notes = Normalize(request.Notes);
|
||||||
|
|
||||||
|
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||||
|
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
|
||||||
|
new ExamSessionInvigilator { TeacherId = tid }).ToList();
|
||||||
|
|
||||||
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
|
[HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> DeleteSession(
|
public async Task<ActionResult> DeleteSession(
|
||||||
@@ -132,6 +214,141 @@ public sealed class ExamsController(
|
|||||||
return await SaveAsync(id, false, cancellationToken);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Auto-arrange
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpPost("plans/{planId:guid}/auto-arrange")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> AutoArrange(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await examArrangementService.ArrangeAsync(planId, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
return ConflictProblem(result.Message);
|
||||||
|
return Ok(new { message = result.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Query helpers
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet("available-classrooms")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetAvailableClassrooms(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly examDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
Guid? requiredBuildingId,
|
||||||
|
Guid? planId,
|
||||||
|
Guid? excludeSessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var timeResult = ResolveExamTime(
|
||||||
|
academicTermId, examDate, startPeriod, periodCount);
|
||||||
|
if (timeResult.Error is not null) return timeResult.Error;
|
||||||
|
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||||
|
|
||||||
|
var query = db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled);
|
||||||
|
|
||||||
|
if (requiredBuildingId.HasValue)
|
||||||
|
query = query.Where(x => x.BuildingId == requiredBuildingId.Value);
|
||||||
|
|
||||||
|
// Exclude classrooms already occupied
|
||||||
|
var occupiedQuery = db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ClassroomId != null &&
|
||||||
|
x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||||
|
if (planId.HasValue)
|
||||||
|
occupiedQuery = occupiedQuery.Where(x => x.ExamPlanId == planId.Value);
|
||||||
|
if (excludeSessionId.HasValue)
|
||||||
|
occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);
|
||||||
|
|
||||||
|
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (occupiedIds.Count > 0)
|
||||||
|
query = query.Where(x => !occupiedIds.Contains(x.Id));
|
||||||
|
|
||||||
|
return Ok(await query.OrderBy(x => x.Building!.Name)
|
||||||
|
.ThenBy(x => x.Capacity)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Name,
|
||||||
|
x.Capacity,
|
||||||
|
x.RoomType,
|
||||||
|
x.BuildingId,
|
||||||
|
BuildingName = x.Building!.Name
|
||||||
|
}).ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("available-teachers")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetAvailableTeachers(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly examDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
Guid? planId,
|
||||||
|
Guid? excludeSessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var timeResult = ResolveExamTime(
|
||||||
|
academicTermId, examDate, startPeriod, periodCount);
|
||||||
|
if (timeResult.Error is not null) return timeResult.Error;
|
||||||
|
var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);
|
||||||
|
|
||||||
|
var busyQuery = db.ExamSessionInvigilators.AsNoTracking()
|
||||||
|
.Where(x => x.ExamSession!.StartsAt < endsAt &&
|
||||||
|
startsAt < x.ExamSession!.EndsAt);
|
||||||
|
if (planId.HasValue)
|
||||||
|
busyQuery = busyQuery.Where(x => x.ExamSession!.ExamPlanId == planId.Value);
|
||||||
|
if (excludeSessionId.HasValue)
|
||||||
|
busyQuery = busyQuery.Where(x => x.ExamSessionId != excludeSessionId.Value);
|
||||||
|
|
||||||
|
var busyIds = await busyQuery.Select(x => x.TeacherId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.Status == TeacherStatus.Active &&
|
||||||
|
!busyIds.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TeacherNumber,
|
||||||
|
x.Name,
|
||||||
|
x.Title,
|
||||||
|
CollegeName = x.College!.Name
|
||||||
|
}).ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("time-slots-for-term")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetTimeSlotsForTerm(
|
||||||
|
Guid academicTermId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.PeriodNumber,
|
||||||
|
x.Name,
|
||||||
|
StartsAt = x.StartsAt.ToString("HH:mm"),
|
||||||
|
EndsAt = x.EndsAt.ToString("HH:mm"),
|
||||||
|
x.IsEnabled
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Publish
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
[HttpPost("plans/{id:guid}/publish")]
|
[HttpPost("plans/{id:guid}/publish")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||||
@@ -143,11 +360,22 @@ public sealed class ExamsController(
|
|||||||
return ConflictProblem("只有草稿考试计划可以发布。");
|
return ConflictProblem("只有草稿考试计划可以发布。");
|
||||||
if (plan.Sessions.Count == 0)
|
if (plan.Sessions.Count == 0)
|
||||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
|
var unassigned = plan.Sessions.Count(x =>
|
||||||
|
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||||
|
if (unassigned > 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||||
|
|
||||||
plan.Status = ExamPlanStatus.Published;
|
plan.Status = ExamPlanStatus.Published;
|
||||||
plan.PublishedAt = DateTime.UtcNow;
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Roster
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
[HttpGet("sessions/{id:guid}/roster")]
|
[HttpGet("sessions/{id:guid}/roster")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
||||||
@@ -156,22 +384,31 @@ public sealed class ExamsController(
|
|||||||
.Where(x => x.Id == id)
|
.Where(x => x.Id == id)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber,
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
ClassroomName = x.Classroom!.Name, x.StartsAt,
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
|
||||||
|
x.StartsAt,
|
||||||
Students = db.CourseEnrollments
|
Students = db.CourseEnrollments
|
||||||
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
.Where(e => e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
||||||
.OrderBy(e => e.Student!.StudentNumber)
|
.OrderBy(e => e.Student!.StudentNumber)
|
||||||
.Select(e => new
|
.Select(e => new
|
||||||
{
|
{
|
||||||
e.StudentId, e.Student!.StudentNumber, e.Student.Name,
|
e.StudentId,
|
||||||
|
e.Student!.StudentNumber,
|
||||||
|
e.Student.Name,
|
||||||
ClassName = e.Student.AdministrativeClass!.Name
|
ClassName = e.Student.AdministrativeClass!.Name
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).FirstOrDefaultAsync(cancellationToken);
|
}).FirstOrDefaultAsync(cancellationToken);
|
||||||
return session is null ? NotFound() : Ok(session);
|
return session is null ? NotFound() : Ok(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// My schedule
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
[HttpGet("my-schedule")]
|
[HttpGet("my-schedule")]
|
||||||
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
|
public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -180,20 +417,30 @@ public sealed class ExamsController(
|
|||||||
{
|
{
|
||||||
var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
|
var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
|
||||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||||
if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。");
|
if (!studentId.HasValue)
|
||||||
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
return Ok(await db.ExamSessions.AsNoTracking()
|
return Ok(await db.ExamSessions.AsNoTracking()
|
||||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||||
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
db.CourseEnrollments.Any(e => e.StudentId == studentId &&
|
||||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId))
|
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId))
|
||||||
.OrderBy(x => x.StartsAt).Select(x => new
|
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt,
|
x.Id,
|
||||||
x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code,
|
PlanName = x.ExamPlan!.Name,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course.Name,
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
ClassroomName = x.Classroom!.Name,
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
BuildingName = x.Classroom.Building!.Name,
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name)
|
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
|
IsExam = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
@@ -202,57 +449,127 @@ public sealed class ExamsController(
|
|||||||
return Ok(await db.ExamSessions.AsNoTracking()
|
return Ok(await db.ExamSessions.AsNoTracking()
|
||||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||||
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
|
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
|
||||||
.OrderBy(x => x.StartsAt).Select(x => new
|
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt,
|
x.Id,
|
||||||
x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code,
|
PlanName = x.ExamPlan!.Name,
|
||||||
|
x.ExamDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course.Name,
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
ClassroomName = x.Classroom!.Name,
|
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
|
||||||
BuildingName = x.Classroom.Building!.Name,
|
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
|
||||||
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
|
||||||
StudentCount = db.CourseEnrollments.Count(e =>
|
StudentCount = db.CourseEnrollments.Count(e =>
|
||||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)
|
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId),
|
||||||
|
IsExam = true
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
return Ok(Array.Empty<object>());
|
return Ok(Array.Empty<object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Private helpers
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
|
||||||
|
Guid academicTermId,
|
||||||
|
DateOnly examDate,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount)
|
||||||
|
{
|
||||||
|
var slots = db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (slots.Count == 0)
|
||||||
|
return (default, default,
|
||||||
|
ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。"));
|
||||||
|
|
||||||
|
var slotDict = slots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
var startSlot = slotDict.GetValueOrDefault(startPeriod);
|
||||||
|
var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1);
|
||||||
|
|
||||||
|
if (startSlot is null)
|
||||||
|
return (default, default,
|
||||||
|
ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。"));
|
||||||
|
if (endSlot is null)
|
||||||
|
return (default, default,
|
||||||
|
ValidationProblem(
|
||||||
|
$"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc),
|
||||||
|
examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ActionResult?> ValidateSessionAsync(
|
private async Task<ActionResult?> ValidateSessionAsync(
|
||||||
ExamPlan plan,
|
ExamPlan plan,
|
||||||
Guid? currentId,
|
Guid? currentId,
|
||||||
ExamSessionRequest request,
|
Guid teachingTaskId,
|
||||||
|
Guid? classroomId,
|
||||||
|
IReadOnlyCollection<Guid>? invigilatorIds,
|
||||||
|
DateTime startsAt,
|
||||||
|
DateTime endsAt,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var starts = request.StartsAt.ToUniversalTime();
|
if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。");
|
||||||
var ends = request.EndsAt.ToUniversalTime();
|
|
||||||
if (starts >= ends) return ValidationProblem("考试开始时间必须早于结束时间。");
|
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
var task = await db.TeachingTasks.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||||
if (task is null || task.AcademicTermId != plan.AcademicTermId)
|
if (task is null || task.AcademicTermId != plan.AcademicTermId)
|
||||||
return ValidationProblem("教学班与考试计划必须属于同一学期。");
|
return ValidationProblem("教学班与考试计划必须属于同一学期。");
|
||||||
var room = await db.Classrooms.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken);
|
|
||||||
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
|
|
||||||
var studentCount = await db.CourseEnrollments.CountAsync(x =>
|
var studentCount = await db.CourseEnrollments.CountAsync(x =>
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken);
|
x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken);
|
||||||
if (studentCount > room.Capacity)
|
|
||||||
return ConflictProblem($"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
|
if (classroomId.HasValue)
|
||||||
var teacherIds = request.InvigilatorIds.Distinct().ToArray();
|
{
|
||||||
if (teacherIds.Length == 0) return ValidationProblem("至少安排一名监考教师。");
|
var room = await db.Classrooms.AsNoTracking()
|
||||||
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
.FirstOrDefaultAsync(
|
||||||
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
|
||||||
return ValidationProblem("存在无效监考教师。");
|
if (room is null) return ValidationProblem("所选考场不存在或已停用。");
|
||||||
|
if (studentCount > room.Capacity)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
|
||||||
|
if (teacherIds.Length > 0)
|
||||||
|
{
|
||||||
|
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
||||||
|
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
||||||
|
return ValidationProblem("存在无效监考教师。");
|
||||||
|
}
|
||||||
|
|
||||||
var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id &&
|
var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id &&
|
||||||
x.Id != currentId && x.StartsAt < ends && starts < x.EndsAt);
|
x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||||
if (await overlaps.AnyAsync(x => x.ClassroomId == room.Id, cancellationToken))
|
|
||||||
return ConflictProblem("该时段考场已被占用。");
|
if (classroomId.HasValue)
|
||||||
if (await overlaps.AnyAsync(x =>
|
{
|
||||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), cancellationToken))
|
if (await overlaps.AnyAsync(
|
||||||
return ConflictProblem("监考教师在该时段已有考试任务。");
|
x => x.ClassroomId == classroomId.Value, cancellationToken))
|
||||||
|
return ConflictProblem("该时段考场已被占用。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teacherIds.Length > 0)
|
||||||
|
{
|
||||||
|
if (await overlaps.AnyAsync(x =>
|
||||||
|
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||||
|
cancellationToken))
|
||||||
|
return ConflictProblem("监考教师在该时段已有考试任务。");
|
||||||
|
}
|
||||||
|
|
||||||
var studentIds = db.CourseEnrollments.Where(x =>
|
var studentIds = db.CourseEnrollments.Where(x =>
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
|
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
|
||||||
@@ -271,7 +588,8 @@ public sealed class ExamsController(
|
|||||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(Guid id, bool created, CancellationToken token)
|
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -286,22 +604,30 @@ public sealed class ExamsController(
|
|||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
Title = "无法完成考务操作", Detail = detail,
|
Title = "无法完成考务操作",
|
||||||
|
Detail = detail,
|
||||||
Status = StatusCodes.Status409Conflict
|
Status = StatusCodes.Status409Conflict
|
||||||
});
|
});
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// Request/Response models
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
public sealed record ExamPlanRequest(
|
public sealed record ExamPlanRequest(
|
||||||
Guid AcademicTermId,
|
Guid AcademicTermId,
|
||||||
[Required, MaxLength(120)] string Name,
|
[Required, MaxLength(120)] string Name,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
public sealed record ExamSessionRequest(
|
public sealed record CreateExamSessionRequest(
|
||||||
Guid TeachingTaskId,
|
Guid TeachingTaskId,
|
||||||
Guid ClassroomId,
|
Guid? ClassroomId,
|
||||||
DateTime StartsAt,
|
DateOnly ExamDate,
|
||||||
DateTime EndsAt,
|
[Range(1, 30)] int StartPeriod,
|
||||||
IReadOnlyCollection<Guid> InvigilatorIds,
|
[Range(1, 6)] int PeriodCount,
|
||||||
|
Guid? RequiredBuildingId,
|
||||||
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
@@ -176,7 +176,8 @@ public sealed class GradesController(
|
|||||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||||
CourseCode = x.TeachingTask.Course!.Code,
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course.Name,
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
CollegeName = x.TeachingTask.Course.College!.Name,
|
CourseCollegeId = x.TeachingTask.Course.CollegeId,
|
||||||
|
CourseCollegeName = x.TeachingTask.Course.College!.Name,
|
||||||
x.TeachingTask.Course.Credits,
|
x.TeachingTask.Course.Credits,
|
||||||
TeacherNames = x.TeachingTask.Teachers
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
@@ -231,13 +232,21 @@ public sealed class GradesController(
|
|||||||
.Include(x => x.Teachers)
|
.Include(x => x.Teachers)
|
||||||
.ThenInclude(x => x.Teacher)
|
.ThenInclude(x => x.Teacher)
|
||||||
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
|
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
|
||||||
|
|
||||||
|
// Determine if current user is from the course's college
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var isCourseCollegeReviewer = IsReviewer() &&
|
||||||
|
(scope.Scope == DataScope.All ||
|
||||||
|
scope.RestrictedCollegeId == sheet.CourseCollegeId);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Sheet = sheet,
|
Sheet = sheet,
|
||||||
CanEdit = CanEditScores(task) &&
|
CanEdit = CanEditScores(task) &&
|
||||||
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
||||||
CanReview = IsReviewer() && sheet.Status == GradeSheetStatus.Submitted,
|
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
|
||||||
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved
|
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved,
|
||||||
|
NeedsCollegeReview = sheet.CourseCollegeName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +385,11 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有草稿或已退回成绩单可以提交。");
|
return ConflictProblem("只有草稿或已退回成绩单可以提交。");
|
||||||
if (sheet.Records.Count == 0)
|
if (sheet.Records.Count == 0)
|
||||||
return ConflictProblem("成绩单没有学生记录。");
|
return ConflictProblem("成绩单没有学生记录。");
|
||||||
|
|
||||||
|
// Final recalculation pass before submit
|
||||||
|
foreach (var record in sheet.Records)
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
|
||||||
if (sheet.Records.Any(x =>
|
if (sheet.Records.Any(x =>
|
||||||
x.ExamStatus == GradeExamStatus.Normal &&
|
x.ExamStatus == GradeExamStatus.Normal &&
|
||||||
!x.TotalScore.HasValue))
|
!x.TotalScore.HasValue))
|
||||||
@@ -390,11 +404,22 @@ public sealed class GradesController(
|
|||||||
[Authorize(Roles = Reviewers)]
|
[Authorize(Roles = Reviewers)]
|
||||||
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
var sheet = await db.GradeSheets
|
||||||
x => x.Id == id,
|
.Include(x => x.TeachingTask)
|
||||||
cancellationToken);
|
.ThenInclude(x => x!.Course)
|
||||||
|
.ThenInclude(x => x!.College)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!IsReviewer()) return Forbid();
|
if (!IsReviewer()) return Forbid();
|
||||||
|
|
||||||
|
// College-specific check: CollegeAdmin must belong to the course's college
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.College &&
|
||||||
|
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"该课程属于{sheet.TeachingTask.Course.College!.Name}," +
|
||||||
|
"您只能审核本学院课程的成绩单。");
|
||||||
|
|
||||||
if (sheet.Status != GradeSheetStatus.Submitted)
|
if (sheet.Status != GradeSheetStatus.Submitted)
|
||||||
return ConflictProblem("只有待审核成绩单可以通过审核。");
|
return ConflictProblem("只有待审核成绩单可以通过审核。");
|
||||||
sheet.Status = GradeSheetStatus.Approved;
|
sheet.Status = GradeSheetStatus.Approved;
|
||||||
@@ -410,11 +435,22 @@ public sealed class GradesController(
|
|||||||
GradeReviewRequest request,
|
GradeReviewRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
var sheet = await db.GradeSheets
|
||||||
x => x.Id == id,
|
.Include(x => x.TeachingTask)
|
||||||
cancellationToken);
|
.ThenInclude(x => x!.Course)
|
||||||
|
.ThenInclude(x => x!.College)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!IsReviewer()) return Forbid();
|
if (!IsReviewer()) return Forbid();
|
||||||
|
|
||||||
|
// College-specific check
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.College &&
|
||||||
|
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"该课程属于{sheet.TeachingTask.Course.College!.Name}," +
|
||||||
|
"您只能审核本学院课程的成绩单。");
|
||||||
|
|
||||||
if (sheet.Status != GradeSheetStatus.Submitted)
|
if (sheet.Status != GradeSheetStatus.Submitted)
|
||||||
return ConflictProblem("只有待审核成绩单可以退回。");
|
return ConflictProblem("只有待审核成绩单可以退回。");
|
||||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ public sealed class ExamSession : EntityBase
|
|||||||
public ExamPlan? ExamPlan { get; set; }
|
public ExamPlan? ExamPlan { get; set; }
|
||||||
public Guid TeachingTaskId { get; set; }
|
public Guid TeachingTaskId { get; set; }
|
||||||
public TeachingTask? TeachingTask { get; set; }
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
public Guid ClassroomId { get; set; }
|
public Guid? ClassroomId { get; set; }
|
||||||
public Classroom? Classroom { get; set; }
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly ExamDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; } = 2;
|
||||||
public DateTime StartsAt { get; set; }
|
public DateTime StartsAt { get; set; }
|
||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user