576 lines
23 KiB
C#
576 lines
23 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using Jiaowu.Api.Contracts;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Auth;
|
|
using Jiaowu.Api.Infrastructure.Graduation;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize(Roles = ReadRoles)]
|
|
[Route("api/curriculum-plans")]
|
|
public sealed class CurriculumPlansController(
|
|
AppDbContext db,
|
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
|
{
|
|
private const string ReadRoles =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin;
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<PagedResult<object>>> GetPlans(
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
string? keyword = null,
|
|
Guid? collegeId = null,
|
|
Guid? majorId = null,
|
|
int? effectiveGrade = null,
|
|
CurriculumPlanStatus? status = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
|
var source = ScopedPlans().AsNoTracking();
|
|
if (collegeId.HasValue)
|
|
source = source.Where(x => x.Major!.CollegeId == collegeId.Value);
|
|
if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId);
|
|
if (effectiveGrade.HasValue)
|
|
source = source.Where(x => x.EffectiveGrade == effectiveGrade);
|
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
|
if (!string.IsNullOrWhiteSpace(keyword))
|
|
{
|
|
keyword = keyword.Trim();
|
|
source = source.Where(x =>
|
|
x.Name.Contains(keyword) ||
|
|
x.Version.Contains(keyword) ||
|
|
x.Major!.Name.Contains(keyword));
|
|
}
|
|
|
|
var total = await source.CountAsync(cancellationToken);
|
|
var items = await source
|
|
.OrderByDescending(x => x.EffectiveGrade)
|
|
.ThenByDescending(x => x.CreatedAt)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.Name,
|
|
x.Version,
|
|
x.MajorId,
|
|
MajorName = x.Major!.Name,
|
|
CollegeId = x.Major.CollegeId,
|
|
CollegeName = x.Major.College!.Name,
|
|
x.EffectiveGrade,
|
|
x.TotalCredits,
|
|
x.Status,
|
|
x.PublishedAt,
|
|
ModuleCount = x.Modules.Count,
|
|
CourseCount = x.Modules.SelectMany(module => module.Courses).Count(),
|
|
x.UpdatedAt
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
|
}
|
|
|
|
[HttpGet("filter-options")]
|
|
public async Task<ActionResult> GetFilterOptions(CancellationToken cancellationToken)
|
|
{
|
|
var collegeId = ScopedCollegeId();
|
|
var majors = db.Majors.AsNoTracking().Where(x => x.IsEnabled);
|
|
if (collegeId.HasValue)
|
|
majors = majors.Where(x => x.CollegeId == collegeId.Value);
|
|
|
|
var majorOptions = await majors
|
|
.OrderBy(x => x.College!.SortOrder)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Code)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.Code,
|
|
x.Name,
|
|
x.CollegeId,
|
|
CollegeName = x.College!.Name,
|
|
x.SchoolingYears
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var accessibleCollegeIds = majorOptions
|
|
.Select(x => x.CollegeId)
|
|
.Distinct()
|
|
.ToArray();
|
|
var colleges = await db.Colleges.AsNoTracking()
|
|
.Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id))
|
|
.OrderBy(x => x.SortOrder)
|
|
.ThenBy(x => x.Code)
|
|
.Select(x => new { x.Id, x.Code, x.Name })
|
|
.ToListAsync(cancellationToken);
|
|
var grades = await ScopedPlans()
|
|
.AsNoTracking()
|
|
.Select(x => x.EffectiveGrade)
|
|
.Distinct()
|
|
.OrderByDescending(x => x)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(new { Colleges = colleges, Majors = majorOptions, Grades = grades });
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var plan = await ScopedPlans()
|
|
.AsNoTracking()
|
|
.Where(x => x.Id == id)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.Name,
|
|
x.Version,
|
|
x.MajorId,
|
|
MajorName = x.Major!.Name,
|
|
SchoolingYears = x.Major.SchoolingYears,
|
|
CollegeId = x.Major.CollegeId,
|
|
CollegeName = x.Major.College!.Name,
|
|
x.EffectiveGrade,
|
|
x.TotalCredits,
|
|
x.Status,
|
|
x.Description,
|
|
x.PublishedAt,
|
|
x.CreatedAt,
|
|
x.UpdatedAt,
|
|
Modules = x.Modules
|
|
.OrderBy(module => module.SortOrder)
|
|
.ThenBy(module => module.Code)
|
|
.Select(module => new
|
|
{
|
|
module.Id,
|
|
module.Code,
|
|
module.Name,
|
|
module.RequiredCredits,
|
|
module.SortOrder,
|
|
AssignedCredits = module.Courses.Sum(course => course.Course!.Credits),
|
|
Courses = module.Courses
|
|
.OrderBy(course => course.RecommendedSemester)
|
|
.ThenBy(course => course.Course!.Code)
|
|
.Select(course => new
|
|
{
|
|
course.Id,
|
|
course.CourseId,
|
|
CourseCode = course.Course!.Code,
|
|
CourseName = course.Course.Name,
|
|
course.Course.Credits,
|
|
course.Course.TotalHours,
|
|
course.Course.Nature,
|
|
CategoryName = course.Course.CourseCategory != null
|
|
? course.Course.CourseCategory.Name
|
|
: null,
|
|
course.RecommendedSemester,
|
|
course.Type,
|
|
course.Notes
|
|
})
|
|
})
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
return plan is null ? NotFound() : Ok(plan);
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult> Create(
|
|
CurriculumPlanRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken);
|
|
if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。");
|
|
|
|
var plan = new CurriculumPlan
|
|
{
|
|
MajorId = request.MajorId,
|
|
Name = request.Name.Trim(),
|
|
Version = request.Version.Trim(),
|
|
EffectiveGrade = request.EffectiveGrade,
|
|
TotalCredits = request.TotalCredits,
|
|
Description = Normalize(request.Description)
|
|
};
|
|
db.CurriculumPlans.Add(plan);
|
|
return await SaveCreatedAsync(plan.Id, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("{id:guid}")]
|
|
public async Task<ActionResult> Update(
|
|
Guid id,
|
|
CurriculumPlanRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var plan = await EditablePlanAsync(id, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (!CurriculumPlanEditingPolicy.CanModifyContent(plan.Status))
|
|
return ConflictProblem("已归档的培养方案不可修改。");
|
|
if (!CurriculumPlanEditingPolicy.CanChangeApplicability(plan.Status) &&
|
|
(plan.MajorId != request.MajorId ||
|
|
plan.EffectiveGrade != request.EffectiveGrade ||
|
|
!string.Equals(plan.Version, request.Version.Trim(), StringComparison.Ordinal)))
|
|
{
|
|
return ConflictProblem(
|
|
"已发布方案可以修改名称、学分说明和课程结构;如需变更适用专业、入学年级或版本号,请复制为新版本。");
|
|
}
|
|
var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken);
|
|
if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。");
|
|
|
|
plan.MajorId = request.MajorId;
|
|
plan.Name = request.Name.Trim();
|
|
plan.Version = request.Version.Trim();
|
|
plan.EffectiveGrade = request.EffectiveGrade;
|
|
plan.TotalCredits = request.TotalCredits;
|
|
plan.Description = Normalize(request.Description);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("{id:guid}")]
|
|
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var plan = await EditablePlanAsync(id, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (plan.Status != CurriculumPlanStatus.Draft)
|
|
return ConflictProblem("仅草稿方案可以删除。");
|
|
db.CurriculumPlans.Remove(plan);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/clone")]
|
|
public async Task<ActionResult> Clone(
|
|
Guid id,
|
|
CloneCurriculumPlanRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var source = await ScopedPlans()
|
|
.AsNoTracking()
|
|
.Include(x => x.Modules)
|
|
.ThenInclude(x => x.Courses)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (source is null) return NotFound();
|
|
|
|
var copy = new CurriculumPlan
|
|
{
|
|
MajorId = source.MajorId,
|
|
Name = request.Name.Trim(),
|
|
Version = request.Version.Trim(),
|
|
EffectiveGrade = request.EffectiveGrade,
|
|
TotalCredits = source.TotalCredits,
|
|
Description = source.Description,
|
|
Modules = source.Modules.Select(module => new CurriculumModule
|
|
{
|
|
Code = module.Code,
|
|
Name = module.Name,
|
|
RequiredCredits = module.RequiredCredits,
|
|
SortOrder = module.SortOrder,
|
|
Courses = module.Courses.Select(course => new CurriculumCourse
|
|
{
|
|
CourseId = course.CourseId,
|
|
RecommendedSemester = course.RecommendedSemester,
|
|
Type = course.Type,
|
|
Notes = course.Notes
|
|
}).ToList()
|
|
}).ToList()
|
|
};
|
|
db.CurriculumPlans.Add(copy);
|
|
return await SaveCreatedAsync(copy.Id, cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{id:guid}/publish")]
|
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var plan = await ScopedPlans()
|
|
.Include(x => x.Major)
|
|
.Include(x => x.Modules)
|
|
.ThenInclude(x => x.Courses)
|
|
.ThenInclude(x => x.Course)
|
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (plan.Status != CurriculumPlanStatus.Draft)
|
|
return ConflictProblem("只有草稿方案可以发布。");
|
|
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0))
|
|
return ConflictProblem("发布前每个课程模块都必须配置课程。");
|
|
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
|
|
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
|
|
if (plan.Modules.Any(module =>
|
|
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits))
|
|
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。");
|
|
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1))
|
|
return ConflictProblem("同一门课程不能重复加入多个模块。");
|
|
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
|
|
x.RecommendedSemester > plan.Major!.SchoolingYears * 2))
|
|
return ConflictProblem("建议学期超出了该专业学制。");
|
|
|
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
|
var previousPlans = await ScopedPlans()
|
|
.Where(x =>
|
|
x.Id != plan.Id &&
|
|
x.MajorId == plan.MajorId &&
|
|
x.EffectiveGrade == plan.EffectiveGrade &&
|
|
x.Status == CurriculumPlanStatus.Published)
|
|
.ToListAsync(cancellationToken);
|
|
foreach (var previous in previousPlans)
|
|
{
|
|
previous.Status = CurriculumPlanStatus.Archived;
|
|
}
|
|
|
|
plan.Status = CurriculumPlanStatus.Published;
|
|
plan.PublishedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("{planId:guid}/modules")]
|
|
public async Task<ActionResult> CreateModule(
|
|
Guid planId,
|
|
CurriculumModuleRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var plan = await ModifiablePlanAsync(planId, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
var module = new CurriculumModule
|
|
{
|
|
CurriculumPlanId = plan.Id,
|
|
Code = request.Code.Trim(),
|
|
Name = request.Name.Trim(),
|
|
RequiredCredits = request.RequiredCredits,
|
|
SortOrder = request.SortOrder
|
|
};
|
|
db.CurriculumModules.Add(module);
|
|
return await SaveCreatedAsync(module.Id, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("{planId:guid}/modules/{moduleId:guid}")]
|
|
public async Task<ActionResult> UpdateModule(
|
|
Guid planId,
|
|
Guid moduleId,
|
|
CurriculumModuleRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await ModifiablePlanAsync(planId, cancellationToken) is null) return NotFound();
|
|
var module = await db.CurriculumModules
|
|
.FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken);
|
|
if (module is null) return NotFound();
|
|
module.Code = request.Code.Trim();
|
|
module.Name = request.Name.Trim();
|
|
module.RequiredCredits = request.RequiredCredits;
|
|
module.SortOrder = request.SortOrder;
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("{planId:guid}/modules/{moduleId:guid}")]
|
|
public async Task<ActionResult> DeleteModule(
|
|
Guid planId,
|
|
Guid moduleId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await ModifiablePlanAsync(planId, cancellationToken) is null) return NotFound();
|
|
var module = await db.CurriculumModules
|
|
.FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken);
|
|
if (module is null) return NotFound();
|
|
db.CurriculumModules.Remove(module);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpPost("{planId:guid}/modules/{moduleId:guid}/courses")]
|
|
public async Task<ActionResult> AddCourse(
|
|
Guid planId,
|
|
Guid moduleId,
|
|
CurriculumCourseRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var plan = await ModifiablePlanAsync(planId, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
if (!await db.CurriculumModules.AnyAsync(
|
|
x => x.Id == moduleId && x.CurriculumPlanId == planId,
|
|
cancellationToken))
|
|
return NotFound();
|
|
if (!await db.Courses.AnyAsync(
|
|
x => x.Id == request.CourseId && x.IsEnabled,
|
|
cancellationToken))
|
|
return ValidationProblem("所选课程不存在或已停用。");
|
|
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
|
|
return ValidationProblem("建议学期超出了该专业学制。");
|
|
if (await db.CurriculumCourses.AnyAsync(
|
|
x => x.CurriculumModule!.CurriculumPlanId == planId &&
|
|
x.CourseId == request.CourseId,
|
|
cancellationToken))
|
|
return ConflictProblem("该课程已存在于本方案中。");
|
|
|
|
var item = new CurriculumCourse
|
|
{
|
|
CurriculumModuleId = moduleId,
|
|
CourseId = request.CourseId,
|
|
RecommendedSemester = request.RecommendedSemester,
|
|
Type = request.Type,
|
|
Notes = Normalize(request.Notes)
|
|
};
|
|
db.CurriculumCourses.Add(item);
|
|
return await SaveCreatedAsync(item.Id, cancellationToken);
|
|
}
|
|
|
|
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
|
|
public async Task<ActionResult> UpdateCourse(
|
|
Guid planId,
|
|
Guid moduleId,
|
|
Guid itemId,
|
|
CurriculumCourseRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var plan = await ModifiablePlanAsync(planId, cancellationToken);
|
|
if (plan is null) return NotFound();
|
|
var item = await db.CurriculumCourses
|
|
.FirstOrDefaultAsync(x =>
|
|
x.Id == itemId &&
|
|
x.CurriculumModuleId == moduleId &&
|
|
x.CurriculumModule!.CurriculumPlanId == planId,
|
|
cancellationToken);
|
|
if (item is null) return NotFound();
|
|
if (!await db.Courses.AnyAsync(
|
|
x => x.Id == request.CourseId && x.IsEnabled,
|
|
cancellationToken))
|
|
return ValidationProblem("所选课程不存在或已停用。");
|
|
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
|
|
return ValidationProblem("建议学期超出了该专业学制。");
|
|
if (await db.CurriculumCourses.AnyAsync(
|
|
x => x.Id != itemId &&
|
|
x.CurriculumModule!.CurriculumPlanId == planId &&
|
|
x.CourseId == request.CourseId,
|
|
cancellationToken))
|
|
return ConflictProblem("该课程已存在于本方案中。");
|
|
|
|
item.CourseId = request.CourseId;
|
|
item.RecommendedSemester = request.RecommendedSemester;
|
|
item.Type = request.Type;
|
|
item.Notes = Normalize(request.Notes);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpDelete("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
|
|
public async Task<ActionResult> DeleteCourse(
|
|
Guid planId,
|
|
Guid moduleId,
|
|
Guid itemId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await ModifiablePlanAsync(planId, cancellationToken) is null) return NotFound();
|
|
var item = await db.CurriculumCourses
|
|
.FirstOrDefaultAsync(x =>
|
|
x.Id == itemId &&
|
|
x.CurriculumModuleId == moduleId &&
|
|
x.CurriculumModule!.CurriculumPlanId == planId,
|
|
cancellationToken);
|
|
if (item is null) return NotFound();
|
|
db.CurriculumCourses.Remove(item);
|
|
return await SaveNoContentAsync(cancellationToken);
|
|
}
|
|
|
|
private IQueryable<CurriculumPlan> ScopedPlans()
|
|
{
|
|
var source = db.CurriculumPlans.AsQueryable();
|
|
var collegeId = ScopedCollegeId();
|
|
return collegeId.HasValue
|
|
? source.Where(x => x.Major!.CollegeId == collegeId.Value)
|
|
: source;
|
|
}
|
|
|
|
private async Task<Major?> FindAccessibleMajorAsync(Guid majorId, CancellationToken cancellationToken)
|
|
{
|
|
var source = db.Majors.AsQueryable();
|
|
var collegeId = ScopedCollegeId();
|
|
if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value);
|
|
return await source.FirstOrDefaultAsync(x => x.Id == majorId, cancellationToken);
|
|
}
|
|
|
|
private async Task<CurriculumPlan?> EditablePlanAsync(
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await ScopedPlans().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
|
|
private async Task<CurriculumPlan?> ModifiablePlanAsync(
|
|
Guid id,
|
|
CancellationToken cancellationToken) =>
|
|
await ScopedPlans()
|
|
.Include(x => x.Major)
|
|
.FirstOrDefaultAsync(
|
|
x => x.Id == id &&
|
|
(x.Status == CurriculumPlanStatus.Draft ||
|
|
x.Status == CurriculumPlanStatus.Published),
|
|
cancellationToken);
|
|
|
|
private Guid? ScopedCollegeId()
|
|
=> currentUserDataScope.Current.RestrictedCollegeId;
|
|
|
|
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return Created(string.Empty, new { id });
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("版本、模块编码或课程配置重复。");
|
|
}
|
|
}
|
|
|
|
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return ConflictProblem("数据重复,或记录已被其他业务引用。");
|
|
}
|
|
}
|
|
|
|
private ActionResult ConflictProblem(string detail) =>
|
|
Conflict(new ProblemDetails
|
|
{
|
|
Title = "无法完成操作",
|
|
Detail = detail,
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
|
|
private static string? Normalize(string? value) =>
|
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
public sealed record CurriculumPlanRequest(
|
|
Guid MajorId,
|
|
[Required, MaxLength(120)] string Name,
|
|
[Required, MaxLength(30)] string Version,
|
|
[Range(2000, 2200)] int EffectiveGrade,
|
|
[Range(typeof(decimal), "0.1", "999")] decimal TotalCredits,
|
|
[MaxLength(1000)] string? Description);
|
|
|
|
public sealed record CloneCurriculumPlanRequest(
|
|
[Required, MaxLength(120)] string Name,
|
|
[Required, MaxLength(30)] string Version,
|
|
[Range(2000, 2200)] int EffectiveGrade);
|
|
|
|
public sealed record CurriculumModuleRequest(
|
|
[Required, MaxLength(30)] string Code,
|
|
[Required, MaxLength(100)] string Name,
|
|
[Range(typeof(decimal), "0", "999")] decimal RequiredCredits,
|
|
[Range(0, 9999)] int SortOrder);
|
|
|
|
public sealed record CurriculumCourseRequest(
|
|
Guid CourseId,
|
|
[Range(1, 20)] int RecommendedSemester,
|
|
CurriculumCourseType Type,
|
|
[MaxLength(500)] string? Notes);
|