其他类型考试
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/other-exams")]
|
||||
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||||
{
|
||||
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
||||
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
||||
.ToListAsync(ct));
|
||||
|
||||
[HttpPost("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
|
||||
{
|
||||
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
|
||||
var name = Normalize(request.Name);
|
||||
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
|
||||
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
|
||||
if (error is not null) return ValidationProblem(error);
|
||||
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
|
||||
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
|
||||
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
|
||||
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
|
||||
db.OtherExamBatches.Add(batch);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { batch.Id });
|
||||
}
|
||||
|
||||
[HttpGet("students/lookup")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
|
||||
{
|
||||
var number = Normalize(studentNumber);
|
||||
if (number is null) return ValidationProblem("请输入学号。");
|
||||
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
|
||||
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
|
||||
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
|
||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
|
||||
if (batch is null) return NotFound();
|
||||
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
|
||||
return Ok(new { Batch = batch, Results = results });
|
||||
}
|
||||
|
||||
[HttpPut("batches/{id:guid}/results")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}/template")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
var headers = HeadersFor(batch);
|
||||
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
|
||||
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
|
||||
}
|
||||
|
||||
[HttpPost("batches/{id:guid}/import")]
|
||||
[Authorize(Roles = Managers)]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
|
||||
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
||||
var inputs = new List<OtherExamResultRequest>();
|
||||
var errors = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var number = row["学号"].Trim();
|
||||
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
|
||||
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
|
||||
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
|
||||
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
|
||||
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
|
||||
}
|
||||
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||
var result = await ReplaceResultsAsync(batch, inputs, ct);
|
||||
return result ?? Ok(new { updated = inputs.Count });
|
||||
}
|
||||
|
||||
[HttpPost("batches/{id:guid}/publish")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
|
||||
batch.Status = OtherExamBatchStatus.Published;
|
||||
batch.PublicationCount++;
|
||||
batch.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { batch.PublicationCount, batch.PublishedAt });
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> Mine(CancellationToken ct)
|
||||
{
|
||||
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
|
||||
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
|
||||
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
|
||||
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
|
||||
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
|
||||
return Ok(new { Best = best, History = history });
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
|
||||
{
|
||||
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
|
||||
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
|
||||
var students = await db.Students.Where(x => numbers.Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, ct);
|
||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||
foreach (var item in inputs)
|
||||
{
|
||||
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
||||
if (error is not null) return ValidationProblem(error);
|
||||
}
|
||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||
var beforeCount = await db.OtherExamResults.AsNoTracking()
|
||||
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
|
||||
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
|
||||
db.OtherExamResults.RemoveRange(batch.Results);
|
||||
batch.Status = OtherExamBatchStatus.Draft;
|
||||
batch.Results = inputs.Select(x => { var student = students[x.StudentNumber.Trim()]; return new OtherExamResult { OtherExamBatchId = batch.Id, StudentId = student.Id, AttemptNumber = (beforeCount.GetValueOrDefault(student.Id) + 1), Score = x.Score, Level = Normalize(x.Level), IsPassed = x.IsPassed, Notes = Normalize(x.Notes) }; }).ToList();
|
||||
await db.SaveChangesAsync(ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
|
||||
{
|
||||
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
|
||||
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
|
||||
_ => ["学号", "是否合格", "备注"]
|
||||
};
|
||||
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
|
||||
{
|
||||
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
|
||||
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
|
||||
}
|
||||
private static bool? ParsePass(string value, int row, List<string> errors)
|
||||
{
|
||||
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
|
||||
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
|
||||
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
|
||||
}
|
||||
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
|
||||
{
|
||||
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||
_ => null
|
||||
};
|
||||
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
||||
{
|
||||
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||
_ => null
|
||||
};
|
||||
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
||||
{
|
||||
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
|
||||
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
var index = Array.IndexOf(levels, level ?? "");
|
||||
return index >= 0 ? levels.Length - index : -1;
|
||||
}
|
||||
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||
{
|
||||
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
|
||||
}
|
||||
|
||||
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
|
||||
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
|
||||
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
|
||||
@@ -0,0 +1,44 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class OtherExamBatch : EntityBase
|
||||
{
|
||||
public string? ExamCode { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Organizer { get; set; }
|
||||
public DateOnly ExamDate { get; set; }
|
||||
public OtherExamMetricKind MetricKind { get; set; }
|
||||
public decimal? MaxScore { get; set; }
|
||||
public string? LevelOptions { get; set; }
|
||||
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
|
||||
public int PublicationCount { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<OtherExamResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class OtherExamResult : EntityBase
|
||||
{
|
||||
public Guid OtherExamBatchId { get; set; }
|
||||
public OtherExamBatch? OtherExamBatch { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public int AttemptNumber { get; set; } = 1;
|
||||
public decimal? Score { get; set; }
|
||||
public string? Level { get; set; }
|
||||
public bool? IsPassed { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public enum OtherExamMetricKind
|
||||
{
|
||||
PassFail = 1,
|
||||
Level = 2,
|
||||
Score = 3
|
||||
}
|
||||
|
||||
public enum OtherExamBatchStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2
|
||||
}
|
||||
@@ -66,6 +66,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
|
||||
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
|
||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
|
||||
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
|
||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||
@@ -450,7 +452,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<ScheduleEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind)
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture);
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture)
|
||||
.HasSentinel((ScheduleEntryKind)0);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
@@ -802,7 +805,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Name).HasMaxLength(60);
|
||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||
entity.Property(x => x.SourceType)
|
||||
.HasDefaultValue(GradeItemSourceType.Manual);
|
||||
.HasDefaultValue(GradeItemSourceType.Manual)
|
||||
.HasSentinel((GradeItemSourceType)0);
|
||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||
entity.HasOne(x => x.GradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
@@ -1186,6 +1190,27 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.GradeRecord).WithMany()
|
||||
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<OtherExamBatch>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ExamCode).HasMaxLength(60);
|
||||
entity.Property(x => x.Name).HasMaxLength(150);
|
||||
entity.Property(x => x.Organizer).HasMaxLength(150);
|
||||
entity.Property(x => x.LevelOptions).HasMaxLength(500);
|
||||
entity.Property(x => x.MaxScore).HasPrecision(8, 2);
|
||||
entity.HasIndex(x => new { x.Status, x.ExamDate });
|
||||
});
|
||||
builder.Entity<OtherExamResult>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Score).HasPrecision(8, 2);
|
||||
entity.Property(x => x.Level).HasMaxLength(50);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.OtherExamBatchId, x.StudentId, x.AttemptNumber }).IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.OtherExamBatchId });
|
||||
entity.HasOne(x => x.OtherExamBatch).WithMany(x => x.Results)
|
||||
.HasForeignKey(x => x.OtherExamBatchId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<WarningRule>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(100);
|
||||
|
||||
@@ -82,6 +82,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260803_43_refresh_sessions";
|
||||
private const string StudentPersonalProfileMigration =
|
||||
"20260803_44_student_personal_profile";
|
||||
private const string OtherExamResultsMigration =
|
||||
"20260808_45_other_exam_results";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -613,6 +615,30 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
StudentPersonalProfileMigration,
|
||||
studentPersonalProfileExists ? [] : StudentPersonalProfileStatements,
|
||||
cancellationToken);
|
||||
var otherExamCodeExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('OtherExamBatches')
|
||||
WHERE name = 'ExamCode'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
var otherExamBatchExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'OtherExamBatches'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
OtherExamResultsMigration,
|
||||
!otherExamBatchExists
|
||||
? OtherExamResultsStatements.Skip(1)
|
||||
: otherExamCodeExists
|
||||
? OtherExamResultsStatements.Skip(1)
|
||||
: OtherExamResultsStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -2173,6 +2199,17 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""ALTER TABLE "Students" ADD COLUMN "WeChat" TEXT NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] OtherExamResultsStatements =
|
||||
[
|
||||
"""ALTER TABLE "OtherExamBatches" ADD COLUMN "ExamCode" TEXT NULL;""",
|
||||
"""CREATE TABLE IF NOT EXISTS "OtherExamBatches" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamBatches" PRIMARY KEY, "ExamCode" TEXT NULL, "Name" TEXT NOT NULL, "Organizer" TEXT NULL, "ExamDate" TEXT NOT NULL, "MetricKind" INTEGER NOT NULL, "MaxScore" TEXT NULL, "LevelOptions" TEXT NULL, "Status" INTEGER NOT NULL, "PublicationCount" INTEGER NOT NULL, "PublishedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamBatches_Status_ExamDate" ON "OtherExamBatches" ("Status", "ExamDate");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "OtherExamResults" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamResults" PRIMARY KEY, "OtherExamBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "AttemptNumber" INTEGER NOT NULL, "Score" TEXT NULL, "Level" TEXT NULL, "IsPassed" INTEGER NULL, "Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_OtherExamResults_OtherExamBatches" FOREIGN KEY ("OtherExamBatchId") REFERENCES "OtherExamBatches" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_OtherExamResults_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
|
||||
"""DROP INDEX IF EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber";""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId" ON "OtherExamResults" ("OtherExamBatchId", "StudentId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
|
||||
];
|
||||
|
||||
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);""",
|
||||
|
||||
+6241
File diff suppressed because it is too large
Load Diff
+97
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OtherExamResults : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtherExamBatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Name = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false),
|
||||
Organizer = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true),
|
||||
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
MetricKind = table.Column<int>(type: "int", nullable: false),
|
||||
MaxScore = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
LevelOptions = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
PublicationCount = table.Column<int>(type: "int", nullable: false),
|
||||
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OtherExamBatches", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtherExamResults",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
OtherExamBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AttemptNumber = table.Column<int>(type: "int", nullable: false),
|
||||
Score = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
Level = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true),
|
||||
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
|
||||
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, 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_OtherExamResults", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtherExamResults_OtherExamBatches_OtherExamBatchId",
|
||||
column: x => x.OtherExamBatchId,
|
||||
principalTable: "OtherExamBatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtherExamResults_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamBatches_Status_ExamDate",
|
||||
table: "OtherExamBatches",
|
||||
columns: new[] { "Status", "ExamDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber",
|
||||
table: "OtherExamResults",
|
||||
columns: new[] { "OtherExamBatchId", "StudentId", "AttemptNumber" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamResults_StudentId_OtherExamBatchId",
|
||||
table: "OtherExamResults",
|
||||
columns: new[] { "StudentId", "OtherExamBatchId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtherExamResults");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtherExamBatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6245
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OtherExamIdentityAndImport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExamCode",
|
||||
table: "OtherExamBatches",
|
||||
type: "varchar(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExamCode",
|
||||
table: "OtherExamBatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -3278,6 +3278,107 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("OfficialDocumentDownloads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("ExamCode")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("varchar(60)");
|
||||
|
||||
b.Property<DateTime>("ExamDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("LevelOptions")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<decimal?>("MaxScore")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("decimal(8,2)");
|
||||
|
||||
b.Property<int>("MetricKind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("varchar(150)");
|
||||
|
||||
b.Property<string>("Organizer")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("varchar(150)");
|
||||
|
||||
b.Property<int>("PublicationCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("PublishedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "ExamDate");
|
||||
|
||||
b.ToTable("OtherExamBatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("AttemptNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool?>("IsPassed")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("Level")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<Guid>("OtherExamBatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal?>("Score")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("decimal(8,2)");
|
||||
|
||||
b.Property<Guid>("StudentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StudentId", "OtherExamBatchId");
|
||||
|
||||
b.HasIndex("OtherExamBatchId", "StudentId", "AttemptNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OtherExamResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -5598,6 +5699,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("OfficialDocument");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamResult", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.OtherExamBatch", "OtherExamBatch")
|
||||
.WithMany("Results")
|
||||
.HasForeignKey("OtherExamBatchId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||
.WithMany()
|
||||
.HasForeignKey("StudentId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OtherExamBatch");
|
||||
|
||||
b.Navigation("Student");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||
@@ -6095,6 +6215,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Downloads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamBatch", b =>
|
||||
{
|
||||
b.Navigation("Results");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>2.3.0-rc2</Version>
|
||||
<Version>2.3.0-rc3</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
|
||||
|
||||
Reference in New Issue
Block a user