diff --git a/VueApp/src/CTS/pages/CourseStudents.vue b/VueApp/src/CTS/pages/CourseStudents.vue
index 42ec398bf..33d5dfc89 100644
--- a/VueApp/src/CTS/pages/CourseStudents.vue
+++ b/VueApp/src/CTS/pages/CourseStudents.vue
@@ -1,6 +1,14 @@
Students for VET430 Lab 1 Challenging Communication - Simulated Lab with Actors and Video
@@ -9,12 +17,12 @@ const student = ref("")
@@ -37,73 +45,10 @@ const student = ref("")
-
- |
-
- |
- Montserrat Armero |
- 10/16/24 2:29:01 PM |
-
-
- |
-
-
- |
-
-
- |
-
- |
- Hailey Atwood |
- 10/16/24 2:29:01 PM |
-
-
- |
-
-
- |
-
-
+
|
|
- Xander Avila |
- 10/16/24 2:29:01 PM |
+ {{ row.name }} |
+ {{ row.time }} |
|
diff --git a/test/CMS/CodecsTests.cs b/test/CMS/CodecsTests.cs
new file mode 100644
index 000000000..2883973de
--- /dev/null
+++ b/test/CMS/CodecsTests.cs
@@ -0,0 +1,160 @@
+using System.Text;
+using Viper.Areas.CMS.Data;
+
+namespace Viper.test.CMS;
+
+// Guards the UU encoder/decoder in Viper.Areas.CMS.Data.Codecs, which replicates
+// ColdFusion's default "UU" encrypt()/decrypt() encoding. Round-trips prove encode
+// and decode stay mutually consistent; the canonical-vector tests pin the uuencode
+// wire format itself (the format legacy CF emits).
+// UUEncode is retained for CMS-migration key-storage interop: new files must store
+// their AES data key UU-encoded so the legacy CF system can still decrypt them during
+// the parallel-run period (see PLAN-CMS.md s12.6), so it is exercised here too.
+// Slated to move with Codecs into a dedicated CMS project.
+public class CodecsTests
+{
+ private static byte[] RunCodec(Action codec, byte[] input)
+ {
+ using var inStream = new MemoryStream(input, writable: false);
+ using var outStream = new MemoryStream();
+ codec(inStream, outStream);
+ return outStream.ToArray();
+ }
+
+ // Deterministic, non-trivial byte pattern so failures are reproducible.
+ private static byte[] MakeData(int length)
+ {
+ var data = new byte[length];
+ for (int i = 0; i < length; i++)
+ data[i] = (byte)((i * 31 + 7) & 0xFF);
+ return data;
+ }
+
+ #region Round-trip Tests
+
+ // Lengths bracket the 45-byte line boundary and every encode tail branch
+ // (full 3-byte groups, 2-byte remainder, 1-byte remainder, empty).
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ [InlineData(44)]
+ [InlineData(45)]
+ [InlineData(46)]
+ [InlineData(47)]
+ [InlineData(48)]
+ [InlineData(89)]
+ [InlineData(90)]
+ [InlineData(91)]
+ [InlineData(135)]
+ [InlineData(1000)]
+ public void UUEncode_ThenUUDecode_ReturnsOriginal(int length)
+ {
+ var data = MakeData(length);
+ var roundTripped = RunCodec(Codecs.UUDecode, RunCodec(Codecs.UUEncode, data));
+ Assert.Equal(data, roundTripped);
+ }
+
+ [Fact]
+ public void UUEncode_ThenUUDecode_IsBinarySafeAcrossAllByteValues()
+ {
+ var data = new byte[256];
+ for (int i = 0; i < 256; i++)
+ data[i] = (byte)i;
+
+ var roundTripped = RunCodec(Codecs.UUDecode, RunCodec(Codecs.UUEncode, data));
+ Assert.Equal(data, roundTripped);
+ }
+
+ #endregion
+
+ #region Wire-format Tests
+
+ // "Cat" is the canonical uuencode example: a single data line whose first
+ // byte is the length char '#' (3) followed by "0V%T". Pins the format
+ // independently of the decoder, so a compensating encode+decode change
+ // can't hide behind the round-trip tests.
+ [Fact]
+ public void UUEncode_KnownInput_ProducesCanonicalUuencodedLine()
+ {
+ var encoded = Encoding.ASCII.GetString(RunCodec(Codecs.UUEncode, Encoding.ASCII.GetBytes("Cat")));
+ Assert.Equal("#0V%T", encoded.TrimEnd('\r', '\n'));
+ }
+
+ [Fact]
+ public void UUDecode_CanonicalUuencodedLine_ProducesOriginalBytes()
+ {
+ var decoded = RunCodec(Codecs.UUDecode, Encoding.ASCII.GetBytes("#0V%T\r\n"));
+ Assert.Equal("Cat", Encoding.ASCII.GetString(decoded));
+ }
+
+ #endregion
+
+ #region Empty-input Tests
+
+ [Fact]
+ public void UUEncode_EmptyInput_WritesNothing() => Assert.Empty(RunCodec(Codecs.UUEncode, []));
+
+ [Fact]
+ public void UUDecode_EmptyInput_WritesNothing() => Assert.Empty(RunCodec(Codecs.UUDecode, []));
+
+ #endregion
+
+ #region Null-argument Tests
+
+ [Fact]
+ public void UUEncode_NullInput_ThrowsArgumentNullException()
+ {
+ using var output = new MemoryStream();
+ var ex = Assert.Throws(() => Codecs.UUEncode(null!, output));
+ Assert.Equal("input", ex.ParamName);
+ }
+
+ [Fact]
+ public void UUEncode_NullOutput_ThrowsArgumentNullException()
+ {
+ using var input = new MemoryStream([1, 2, 3]);
+ var ex = Assert.Throws(() => Codecs.UUEncode(input, null!));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void UUDecode_NullInput_ThrowsArgumentNullException()
+ {
+ using var output = new MemoryStream();
+ var ex = Assert.Throws(() => Codecs.UUDecode(null!, output));
+ Assert.Equal("input", ex.ParamName);
+ }
+
+ [Fact]
+ public void UUDecode_NullOutput_ThrowsArgumentNullException()
+ {
+ using var input = new MemoryStream([1, 2, 3]);
+ var ex = Assert.Throws(() => Codecs.UUDecode(input, null!));
+ Assert.Equal("output", ex.ParamName);
+ }
+
+ [Fact]
+ public void UUDecode_OutOfRangeByte_ThrowsFormatException()
+ {
+ // A non-ASCII byte (>= 128) cannot index the 128-entry decode table; malformed
+ // input must surface as a controlled FormatException, not IndexOutOfRangeException.
+ using var input = new MemoryStream([0xFF]);
+ using var output = new MemoryStream();
+ Assert.Throws(() => Codecs.UUDecode(input, output));
+ }
+
+ [Fact]
+ public void UUDecode_TruncatedLine_ThrowsFormatException()
+ {
+ // Line header claims three octets but the encoded quartet is cut short (EOF mid-read).
+ using var input = new MemoryStream(Encoding.ASCII.GetBytes("#0V"));
+ using var output = new MemoryStream();
+ Assert.Throws(() => Codecs.UUDecode(input, output));
+ }
+
+ #endregion
+}
diff --git a/test/Students/StudentGroupServiceQueryTests.cs b/test/Students/StudentGroupServiceQueryTests.cs
new file mode 100644
index 000000000..d72fb7f18
--- /dev/null
+++ b/test/Students/StudentGroupServiceQueryTests.cs
@@ -0,0 +1,246 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Viper.Areas.Curriculum.Services;
+using Viper.Areas.Students.Services;
+using Viper.Classes.SQLContext;
+using Viper.Models.AAUD;
+using Viper.Models.Courses;
+using Viper.Models.SIS;
+using ViperTerm = Viper.Models.VIPER.Term;
+
+namespace Viper.test.Students;
+
+///
+/// Regression tests for the StudentGroupService photo-gallery queries.
+///
+/// These run the AAUD query against the *relational* SQLite provider, NOT the InMemory
+/// provider. That distinction is the whole point: a refactor once projected each row to
+/// a record and then composed OrderBy/Where on top of that projection. It compiled, but
+/// EF threw "The LINQ expression ... could not be translated" at execution, the service
+/// swallowed it into an empty list, and the gallery silently showed no students. The
+/// InMemory provider executes LINQ in memory and never attempts SQL translation, so it
+/// cannot catch this class of bug; SQLite goes through the same relational translator as
+/// SQL Server and does.
+///
+public sealed class StudentGroupServiceQueryTests : IDisposable
+{
+ private const string Term = "202602";
+
+ private readonly SqliteConnection _aaudConnection;
+ private readonly AAUDContext _aaudContext;
+ private readonly SISContext _sisContext;
+ private readonly CoursesContext _coursesContext;
+ private readonly VIPERContext _viperContext;
+ private readonly StudentGroupService _service;
+
+ ///
+ /// A trimmed AAUDContext that maps only the four entities the gallery query touches.
+ /// The full AAUD warehouse model has computed columns and views that SQLite
+ /// EnsureCreated cannot build, so we ignore everything else and create just these.
+ /// Column names differ from production (no HasColumnName mapping), which is irrelevant
+ /// here: EF uses the same model for both schema creation and query translation.
+ ///
+ private sealed class TestAaudContext(DbContextOptions options) : AAUDContext(options)
+ {
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ var keep = new HashSet { typeof(Id), typeof(Person), typeof(Student), typeof(Studentgrp) };
+ foreach (var clrType in modelBuilder.Model.GetEntityTypes()
+ .Select(e => e.ClrType)
+ .Where(t => !keep.Contains(t))
+ .Distinct()
+ .ToList())
+ {
+ modelBuilder.Ignore(clrType);
+ }
+
+ modelBuilder.Entity().HasKey(e => e.IdsPKey);
+ modelBuilder.Entity().HasKey(e => e.PersonPKey);
+ modelBuilder.Entity().HasKey(e => e.StudentsPKey);
+ modelBuilder.Entity().HasKey(e => e.StudentgrpPidm);
+ }
+ }
+
+ public StudentGroupServiceQueryTests()
+ {
+ _aaudConnection = new SqliteConnection("DataSource=:memory:");
+ _aaudConnection.Open();
+ _aaudContext = new TestAaudContext(
+ new DbContextOptionsBuilder().UseSqlite(_aaudConnection).Options);
+ _aaudContext.Database.EnsureCreated();
+
+ _sisContext = new SISContext(InMemory("SIS"));
+ _coursesContext = new CoursesContext(InMemory("Courses"));
+ _viperContext = new VIPERContext(InMemory("VIPER"));
+
+ _viperContext.Terms.Add(new ViperTerm
+ {
+ TermCode = int.Parse(Term),
+ CurrentTerm = true,
+ Description = "Current",
+ TermType = "Q"
+ });
+ _viperContext.SaveChanges();
+
+ var photoService = Substitute.For();
+ photoService.GetDefaultPhotoUrl().Returns("default.jpg");
+ photoService.GetStudentPhotoUrlsBatchAsync(Arg.Any>())
+ .Returns(Task.FromResult(new Dictionary()));
+
+ _service = new StudentGroupService(
+ _aaudContext,
+ _sisContext,
+ _coursesContext,
+ photoService,
+ new TermCodeService(_viperContext),
+ Substitute.For>());
+ }
+
+ private static DbContextOptions InMemory(string prefix) where T : DbContext =>
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(prefix + "_" + Guid.NewGuid())
+ .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
+ .Options;
+
+ private void SeedStudent(string pKey, string pidm, string lastName, string firstName, string classLevel,
+ string? eighths = null, string? twentieths = null, string? team = null, string? v3Specialty = null)
+ {
+ _aaudContext.People.Add(new Person
+ {
+ PersonPKey = pKey,
+ PersonTermCode = Term,
+ PersonClientid = "C",
+ PersonLastName = lastName,
+ PersonFirstName = firstName
+ });
+ _aaudContext.Ids.Add(new Id
+ {
+ IdsPKey = pKey,
+ IdsTermCode = Term,
+ IdsClientid = "C",
+ IdsPidm = pidm,
+ IdsMailid = pKey + "@example.com",
+ IdsIamId = "IAM" + pKey
+ });
+ _aaudContext.Students.Add(new Student
+ {
+ StudentsPKey = pKey,
+ StudentsTermCode = Term,
+ StudentsClientid = "C",
+ StudentsMajorCode1 = "VMD",
+ StudentsDegreeCode1 = "DVM",
+ StudentsCollCode1 = "VM",
+ StudentsLevelCode1 = "4",
+ StudentsClassLevel = classLevel
+ });
+ if (eighths != null || twentieths != null || team != null || v3Specialty != null)
+ {
+ _aaudContext.Studentgrps.Add(new Studentgrp
+ {
+ StudentgrpPidm = pidm,
+ StudentgrpGrp = eighths ?? string.Empty,
+ Studentgrp20 = twentieths,
+ StudentgrpTeamno = team,
+ StudentgrpV3grp = v3Specialty
+ });
+ }
+ _aaudContext.SaveChanges();
+ }
+
+ [Fact]
+ public async Task GetStudentsByClassLevelAsync_TranslatesAndReturnsClassOrdered()
+ {
+ SeedStudent("P2", "PIDM2", "Young", "Amy", "V3");
+ SeedStudent("P1", "PIDM1", "Adams", "Bob", "V3");
+ SeedStudent("P3", "PIDM3", "Brown", "Cara", "V4"); // other class level, must be excluded
+
+ var result = await _service.GetStudentsByClassLevelAsync("V3");
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("Adams", result[0].LastName); // ordered by last name in the database
+ Assert.Equal("Young", result[1].LastName);
+ }
+
+ [Fact]
+ public async Task GetStudentsByGroupAsync_TranslatesAndFiltersByGroup()
+ {
+ SeedStudent("P1", "PIDM1", "Adams", "Bob", "V3", eighths: "1A1");
+ SeedStudent("P2", "PIDM2", "Young", "Amy", "V3", eighths: "2B2");
+
+ var result = await _service.GetStudentsByGroupAsync("eighths", "1A1", "V3");
+
+ Assert.Single(result);
+ Assert.Equal("Adams", result[0].LastName);
+ }
+
+ [Fact]
+ public async Task GetStudentsByCourseAsync_TranslatesAndReturnsEnrolled()
+ {
+ SeedStudent("P1", "PIDM1", "Adams", "Bob", "V3");
+ SeedStudent("P2", "PIDM2", "Young", "Amy", "V3"); // not enrolled in the course
+ _coursesContext.Rosters.Add(new Roster
+ {
+ RosterPkey = "R1",
+ RosterTermCode = Term,
+ RosterCrn = "12345",
+ RosterEnrollStatus = "RE",
+ RosterPidm = "PIDM1"
+ });
+ await _coursesContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var result = await _service.GetStudentsByCourseAsync(Term, "12345");
+
+ Assert.Single(result);
+ Assert.Equal("Adams", result[0].LastName);
+ }
+
+ [Fact]
+ public async Task GetStudentsByCourseAsync_IncludeRoss_ReturnsRossStudentEnrolledInCourse()
+ {
+ // A regular V3 student and a Ross student, both enrolled (RE) in the same course.
+ SeedStudent("P1", "PIDM1", "Adams", "Bob", "V3");
+ SeedStudent("P9", "PIDM9", "Reardon", "Chelsea", "V3");
+ _sisContext.StudentDesignations.Add(new StudentDesignation
+ {
+ DesignationType = "Ross",
+ IamId = "IAMP9", // matches SeedStudent's "IAM" + pKey
+ StartTerm = int.Parse(Term),
+ EndTerm = null
+ });
+ await _sisContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ foreach (var (pkey, pidm) in new[] { ("RST1", "PIDM1"), ("RST9", "PIDM9") })
+ {
+ _coursesContext.Rosters.Add(new Roster
+ {
+ RosterPkey = pkey,
+ RosterTermCode = Term,
+ RosterCrn = "12345",
+ RosterEnrollStatus = "RE",
+ RosterPidm = pidm
+ });
+ }
+ await _coursesContext.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ // The Ross-inclusion branch must not join the Courses and AAUD DbContexts in one
+ // query: EF cannot translate a cross-context join and throws at execution, which the
+ // service surfaces as a failure (previously swallowed into an empty gallery).
+ var result = await _service.GetStudentsByCourseAsync(Term, "12345", includeRossStudents: true);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, s => s.LastName == "Adams" && !s.IsRossStudent);
+ Assert.Contains(result, s => s.LastName == "Reardon" && s.IsRossStudent);
+ }
+
+ public void Dispose()
+ {
+ _aaudContext.Dispose();
+ _sisContext.Dispose();
+ _coursesContext.Dispose();
+ _viperContext.Dispose();
+ _aaudConnection.Dispose();
+ }
+}
diff --git a/web/Areas/CMS/Data/Codecs.cs b/web/Areas/CMS/Data/Codecs.cs
index eab1f4a21..d7aa6f425 100644
--- a/web/Areas/CMS/Data/Codecs.cs
+++ b/web/Areas/CMS/Data/Codecs.cs
@@ -28,187 +28,20 @@ public static class Codecs
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
- static readonly byte[] XXEncMap = "+-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"u8.ToArray();
-
- static readonly byte[] XXDecMap = new byte[]
- {
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
- 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
- 0x0A, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12,
- 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A,
- 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22,
- 0x23, 0x24, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x00, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C,
- 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34,
- 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C,
- 0x3D, 0x3E, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00
- };
-
+ // Replicates ColdFusion's default "UU" encrypt()/decrypt() encoding so VIPER
+ // can read keys/data the legacy CF system wrote. UUDecode is the only live
+ // caller today; UUEncode is retained for CMS migration, where new files must
+ // store their AES key UU-encoded for legacy CF to decrypt during parallel run.
public static void UUDecode(Stream input, Stream output)
- {
- if (input == null)
- throw new ArgumentNullException(nameof(input));
-
- if (output == null)
- throw new ArgumentNullException(nameof(output));
-
- long len = input.Length;
- if (len == 0)
- return;
-
- long didx = 0;
- int nextByte = input.ReadByte();
- while (nextByte >= 0)
- {
- // get line length (in number of encoded octets)
- int line_len = UUDecMap[nextByte];
-
- // ascii printable to 0-63 and 4-byte to 3-byte conversion
- long end = didx + line_len;
- byte A, B, C, D;
- if (end > 2)
- {
- while (didx < end - 2)
- {
- A = UUDecMap[input.ReadByte()];
- B = UUDecMap[input.ReadByte()];
- C = UUDecMap[input.ReadByte()];
- D = UUDecMap[input.ReadByte()];
-
- output.WriteByte((byte)(((A << 2) & 255) | ((B >> 4) & 3)));
- output.WriteByte((byte)(((B << 4) & 255) | ((C >> 2) & 15)));
- output.WriteByte((byte)(((C << 6) & 255) | (D & 63)));
- didx += 3;
- }
- }
-
- if (didx < end)
- {
- A = UUDecMap[input.ReadByte()];
- B = UUDecMap[input.ReadByte()];
- output.WriteByte((byte)(((A << 2) & 255) | ((B >> 4) & 3)));
- didx++;
-
- if (didx < end)
- {
- C = UUDecMap[input.ReadByte()];
- output.WriteByte((byte)(((B << 4) & 255) | ((C >> 2) & 15)));
- didx++;
- }
- }
-
- // skip padding
- do
- {
- nextByte = input.ReadByte();
- }
- while (nextByte >= 0 && nextByte != '\n' && nextByte != '\r');
-
- // skip end of line
- do
- {
- nextByte = input.ReadByte();
- }
- while (nextByte >= 0 && (nextByte == '\n' || nextByte == '\r'));
- }
- }
+ => DecodeWithMap(input, output, UUDecMap);
public static void UUEncode(Stream input, Stream output)
- {
- if (input == null)
- throw new ArgumentNullException(nameof(input));
-
- if (output == null)
- throw new ArgumentNullException(nameof(output));
-
- long len = input.Length;
- if (len == 0)
- return;
-
- int sidx = 0;
- int line_len = 45;
- byte[] nl = Encoding.ASCII.GetBytes(Environment.NewLine);
-
- byte A, B, C;
- // split into lines, adding line-length and line terminator
- while (sidx + line_len < len)
- {
- // line length
- output.WriteByte(UUEncMap[line_len]);
-
- // 3-byte to 4-byte conversion + 0-63 to ascii printable conversion
- for (int end = sidx + line_len; sidx < end; sidx += 3)
- {
- A = (byte)input.ReadByte();
- B = (byte)input.ReadByte();
- C = (byte)input.ReadByte();
+ => EncodeWithMap(input, output, UUEncMap);
- output.WriteByte(UUEncMap[(A >> 2) & 63]);
- output.WriteByte(UUEncMap[(B >> 4) & 15 | (A << 4) & 63]);
- output.WriteByte(UUEncMap[(C >> 6) & 3 | (B << 2) & 63]);
- output.WriteByte(UUEncMap[C & 63]);
- }
-
- // line terminator
- for (int idx = 0; idx < nl.Length; idx++)
- output.WriteByte(nl[idx]);
- }
-
- // line length
- output.WriteByte(UUEncMap[len - sidx]);
-
- // 3-byte to 4-byte conversion + 0-63 to ascii printable conversion
- while (sidx + 2 < len)
- {
- A = (byte)input.ReadByte();
- B = (byte)input.ReadByte();
- C = (byte)input.ReadByte();
-
- output.WriteByte(UUEncMap[(A >> 2) & 63]);
- output.WriteByte(UUEncMap[(B >> 4) & 15 | (A << 4) & 63]);
- output.WriteByte(UUEncMap[(C >> 6) & 3 | (B << 2) & 63]);
- output.WriteByte(UUEncMap[C & 63]);
- sidx += 3;
- }
-
- if (sidx < len - 1)
- {
- A = (byte)input.ReadByte();
- B = (byte)input.ReadByte();
-
- output.WriteByte(UUEncMap[(A >> 2) & 63]);
- output.WriteByte(UUEncMap[(B >> 4) & 15 | (A << 4) & 63]);
- output.WriteByte(UUEncMap[(B << 2) & 63]);
- output.WriteByte(UUEncMap[0]);
- }
- else if (sidx < len)
- {
- A = (byte)input.ReadByte();
-
- output.WriteByte(UUEncMap[(A >> 2) & 63]);
- output.WriteByte(UUEncMap[(A << 4) & 63]);
- output.WriteByte(UUEncMap[0]);
- output.WriteByte(UUEncMap[0]);
- }
-
- // line terminator
- for (int idx = 0; idx < nl.Length; idx++)
- output.WriteByte(nl[idx]);
- }
-
- public static void XXDecode(Stream input, Stream output)
+ private static void DecodeWithMap(Stream input, Stream output, byte[] decMap)
{
- if (input == null)
- throw new ArgumentNullException(nameof(input));
-
- if (output == null)
- throw new ArgumentNullException(nameof(output));
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
long len = input.Length;
if (len == 0)
@@ -219,7 +52,7 @@ public static void XXDecode(Stream input, Stream output)
while (nextByte >= 0)
{
// get line length (in number of encoded octets)
- int line_len = XXDecMap[nextByte];
+ int line_len = MapByte(nextByte, decMap);
// ascii printable to 0-63 and 4-byte to 3-byte conversion
long end = didx + line_len;
@@ -228,10 +61,10 @@ public static void XXDecode(Stream input, Stream output)
{
while (didx < end - 2)
{
- A = XXDecMap[input.ReadByte()];
- B = XXDecMap[input.ReadByte()];
- C = XXDecMap[input.ReadByte()];
- D = XXDecMap[input.ReadByte()];
+ A = MapByte(input.ReadByte(), decMap);
+ B = MapByte(input.ReadByte(), decMap);
+ C = MapByte(input.ReadByte(), decMap);
+ D = MapByte(input.ReadByte(), decMap);
output.WriteByte((byte)(((A << 2) & 255) | ((B >> 4) & 3)));
output.WriteByte((byte)(((B << 4) & 255) | ((C >> 2) & 15)));
@@ -242,14 +75,14 @@ public static void XXDecode(Stream input, Stream output)
if (didx < end)
{
- A = XXDecMap[input.ReadByte()];
- B = XXDecMap[input.ReadByte()];
+ A = MapByte(input.ReadByte(), decMap);
+ B = MapByte(input.ReadByte(), decMap);
output.WriteByte((byte)(((A << 2) & 255) | ((B >> 4) & 3)));
didx++;
if (didx < end)
{
- C = XXDecMap[input.ReadByte()];
+ C = MapByte(input.ReadByte(), decMap);
output.WriteByte((byte)(((B << 4) & 255) | ((C >> 2) & 15)));
didx++;
}
@@ -271,13 +104,20 @@ public static void XXDecode(Stream input, Stream output)
}
}
- public static void XXEncode(Stream input, Stream output)
+ // Maps a raw input byte through the decode table, turning truncated input (EOF, -1)
+ // or out-of-range bytes (>= table length, e.g. corrupted non-ASCII data) into a
+ // controlled FormatException instead of an IndexOutOfRangeException bubbling up.
+ private static byte MapByte(int b, byte[] decMap)
{
- if (input == null)
- throw new ArgumentNullException(nameof(input));
+ if (b < 0 || b >= decMap.Length)
+ throw new FormatException("Malformed UU-encoded input.");
+ return decMap[b];
+ }
- if (output == null)
- throw new ArgumentNullException(nameof(output));
+ private static void EncodeWithMap(Stream input, Stream output, byte[] encMap)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
long len = input.Length;
if (len == 0)
@@ -292,7 +132,7 @@ public static void XXEncode(Stream input, Stream output)
while (sidx + line_len < len)
{
// line length
- output.WriteByte(XXEncMap[line_len]);
+ output.WriteByte(encMap[line_len]);
// 3-byte to 4-byte conversion + 0-63 to ascii printable conversion
for (int end = sidx + line_len; sidx < end; sidx += 3)
@@ -301,10 +141,10 @@ public static void XXEncode(Stream input, Stream output)
B = (byte)input.ReadByte();
C = (byte)input.ReadByte();
- output.WriteByte(XXEncMap[(A >> 2) & 63]);
- output.WriteByte(XXEncMap[(B >> 4) & 15 | (A << 4) & 63]);
- output.WriteByte(XXEncMap[(C >> 6) & 3 | (B << 2) & 63]);
- output.WriteByte(XXEncMap[C & 63]);
+ output.WriteByte(encMap[(A >> 2) & 63]);
+ output.WriteByte(encMap[(B >> 4) & 15 | (A << 4) & 63]);
+ output.WriteByte(encMap[(C >> 6) & 3 | (B << 2) & 63]);
+ output.WriteByte(encMap[C & 63]);
}
// line terminator
@@ -313,7 +153,7 @@ public static void XXEncode(Stream input, Stream output)
}
// line length
- output.WriteByte(XXEncMap[len - sidx]);
+ output.WriteByte(encMap[len - sidx]);
// 3-byte to 4-byte conversion + 0-63 to ascii printable conversion
while (sidx + 2 < len)
@@ -322,10 +162,10 @@ public static void XXEncode(Stream input, Stream output)
B = (byte)input.ReadByte();
C = (byte)input.ReadByte();
- output.WriteByte(XXEncMap[(A >> 2) & 63]);
- output.WriteByte(XXEncMap[(B >> 4) & 15 | (A << 4) & 63]);
- output.WriteByte(XXEncMap[(C >> 6) & 3 | (B << 2) & 63]);
- output.WriteByte(XXEncMap[C & 63]);
+ output.WriteByte(encMap[(A >> 2) & 63]);
+ output.WriteByte(encMap[(B >> 4) & 15 | (A << 4) & 63]);
+ output.WriteByte(encMap[(C >> 6) & 3 | (B << 2) & 63]);
+ output.WriteByte(encMap[C & 63]);
sidx += 3;
}
@@ -334,19 +174,19 @@ public static void XXEncode(Stream input, Stream output)
A = (byte)input.ReadByte();
B = (byte)input.ReadByte();
- output.WriteByte(XXEncMap[(A >> 2) & 63]);
- output.WriteByte(XXEncMap[(B >> 4) & 15 | (A << 4) & 63]);
- output.WriteByte(XXEncMap[(B << 2) & 63]);
- output.WriteByte(XXEncMap[0]);
+ output.WriteByte(encMap[(A >> 2) & 63]);
+ output.WriteByte(encMap[(B >> 4) & 15 | (A << 4) & 63]);
+ output.WriteByte(encMap[(B << 2) & 63]);
+ output.WriteByte(encMap[0]);
}
else if (sidx < len)
{
A = (byte)input.ReadByte();
- output.WriteByte(XXEncMap[(A >> 2) & 63]);
- output.WriteByte(XXEncMap[(A << 4) & 63]);
- output.WriteByte(XXEncMap[0]);
- output.WriteByte(XXEncMap[0]);
+ output.WriteByte(encMap[(A >> 2) & 63]);
+ output.WriteByte(encMap[(A << 4) & 63]);
+ output.WriteByte(encMap[0]);
+ output.WriteByte(encMap[0]);
}
// line terminator
diff --git a/web/Areas/ClinicalScheduler/Services/InstructorScheduleService.cs b/web/Areas/ClinicalScheduler/Services/InstructorScheduleService.cs
index e3bf22b33..4a96f3a49 100644
--- a/web/Areas/ClinicalScheduler/Services/InstructorScheduleService.cs
+++ b/web/Areas/ClinicalScheduler/Services/InstructorScheduleService.cs
@@ -42,43 +42,7 @@ public async Task> GetInstructorScheduleAsync(
.Include(s => s.Service)
.Include(s => s.Rotation)
.AsNoTracking()
- .AsQueryable();
-
- // Apply filters
- if (classYear.HasValue)
- {
- query = query.Where(s => s.Week.WeekGradYears.Any(gy => gy.GradYear == classYear));
- }
-
- if (!string.IsNullOrWhiteSpace(mothraId))
- {
- query = query.Where(s => s.MothraId == mothraId);
- }
-
- if (rotationId.HasValue)
- {
- query = query.Where(s => s.RotationId == rotationId);
- }
-
- if (serviceId.HasValue)
- {
- query = query.Where(s => s.ServiceId == serviceId);
- }
-
- if (weekId.HasValue)
- {
- query = query.Where(s => s.WeekId == weekId);
- }
-
- if (startDate.HasValue)
- {
- query = query.Where(s => s.DateEnd >= startDate);
- }
-
- if (endDate.HasValue)
- {
- query = query.Where(s => s.DateStart <= endDate);
- }
+ .ApplyScheduleFilters(classYear, mothraId, rotationId, serviceId, weekId, startDate, endDate);
if (active.HasValue && active.Value)
{
diff --git a/web/Areas/ClinicalScheduler/Services/ScheduleQueryExtensions.cs b/web/Areas/ClinicalScheduler/Services/ScheduleQueryExtensions.cs
new file mode 100644
index 000000000..c3da702ed
--- /dev/null
+++ b/web/Areas/ClinicalScheduler/Services/ScheduleQueryExtensions.cs
@@ -0,0 +1,59 @@
+using Viper.Models.CTS;
+
+namespace Viper.Areas.ClinicalScheduler.Services;
+
+///
+/// LINQ extensions shared between InstructorScheduleService and StudentScheduleService
+/// for filtering schedule entities by the common rotation / service / week / date filters.
+///
+public static class ScheduleQueryExtensions
+{
+ public static IQueryable ApplyScheduleFilters(
+ this IQueryable query,
+ int? classYear,
+ string? mothraId,
+ int? rotationId,
+ int? serviceId,
+ int? weekId,
+ DateTime? startDate,
+ DateTime? endDate)
+ where T : class, IScheduleEntity
+ {
+ if (classYear.HasValue)
+ {
+ query = query.Where(s => s.Week.WeekGradYears.Any(gy => gy.GradYear == classYear));
+ }
+
+ if (!string.IsNullOrWhiteSpace(mothraId))
+ {
+ query = query.Where(s => s.MothraId == mothraId);
+ }
+
+ if (rotationId.HasValue)
+ {
+ query = query.Where(s => s.RotationId == rotationId);
+ }
+
+ if (serviceId.HasValue)
+ {
+ query = query.Where(s => s.ServiceId == serviceId);
+ }
+
+ if (weekId.HasValue)
+ {
+ query = query.Where(s => s.WeekId == weekId);
+ }
+
+ if (startDate.HasValue)
+ {
+ query = query.Where(s => s.DateEnd >= startDate);
+ }
+
+ if (endDate.HasValue)
+ {
+ query = query.Where(s => s.DateStart <= endDate);
+ }
+
+ return query;
+ }
+}
diff --git a/web/Areas/ClinicalScheduler/Services/StudentScheduleService.cs b/web/Areas/ClinicalScheduler/Services/StudentScheduleService.cs
index 5b2e9b93b..6b7c689ed 100644
--- a/web/Areas/ClinicalScheduler/Services/StudentScheduleService.cs
+++ b/web/Areas/ClinicalScheduler/Services/StudentScheduleService.cs
@@ -41,43 +41,7 @@ public async Task> GetStudentScheduleAsync(
.Include(s => s.Service)
.Include(s => s.Rotation)
.AsNoTracking()
- .AsQueryable();
-
- // Apply filters
- if (classYear.HasValue)
- {
- query = query.Where(s => s.Week.WeekGradYears.Any(gy => gy.GradYear == classYear));
- }
-
- if (!string.IsNullOrWhiteSpace(mothraId))
- {
- query = query.Where(s => s.MothraId == mothraId);
- }
-
- if (rotationId.HasValue)
- {
- query = query.Where(s => s.RotationId == rotationId);
- }
-
- if (serviceId.HasValue)
- {
- query = query.Where(s => s.ServiceId == serviceId);
- }
-
- if (weekId.HasValue)
- {
- query = query.Where(s => s.WeekId == weekId);
- }
-
- if (startDate.HasValue)
- {
- query = query.Where(s => s.DateEnd >= startDate);
- }
-
- if (endDate.HasValue)
- {
- query = query.Where(s => s.DateStart <= endDate);
- }
+ .ApplyScheduleFilters(classYear, mothraId, rotationId, serviceId, weekId, startDate, endDate);
// Apply ordering
query = query.OrderBy(s => s.LastName)
diff --git a/web/Areas/Students/Services/StudentGroupService.cs b/web/Areas/Students/Services/StudentGroupService.cs
index cf75c1a09..1e183ac9d 100644
--- a/web/Areas/Students/Services/StudentGroupService.cs
+++ b/web/Areas/Students/Services/StudentGroupService.cs
@@ -1,8 +1,8 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
-using Viper.Areas.Curriculum.Services;
-using Viper.Areas.Students.Models;
using Viper.Classes.SQLContext;
+using Viper.Areas.Students.Models;
+using Viper.Areas.Curriculum.Services;
using Viper.Classes.Utilities;
namespace Viper.Areas.Students.Services
@@ -21,6 +21,25 @@ public interface IStudentGroupService
public class StudentGroupService : IStudentGroupService
{
+ ///
+ /// Common projection shape used by the AAUD student queries before they are
+ /// turned into StudentPhoto results. Keeps the same column set across the
+ /// class-level / group / course query variants so the photo-building loop is
+ /// shared.
+ ///
+ private sealed record StudentBaseRecord(
+ string? MailId,
+ string? FirstName,
+ string LastName,
+ string? MiddleName,
+ string? IamId,
+ string? BannerId,
+ string? ClassLevel,
+ string? EighthsGroup,
+ string? TwentiethsGroup,
+ string? TeamNumber,
+ string? V3SpecialtyGroup);
+
private readonly AAUDContext _aaudContext;
private readonly SISContext _sisContext;
private readonly CoursesContext _coursesContext;
@@ -90,58 +109,24 @@ join s in _aaudContext.Students on p.PersonPKey equals s.StudentsPKey
join sg in _aaudContext.Studentgrps on i.IdsPidm equals sg.StudentgrpPidm into sgGroup
from sg in sgGroup.DefaultIfEmpty()
where s.StudentsClassLevel == classLevel && i.IdsTermCode == currentTerm
- && (string.IsNullOrEmpty(i.IdsIamId) || !rossIamIds.Contains(i.IdsIamId!))
- select new
- {
- PersonId = p.PersonPKey,
- MailId = i.IdsMailid,
- FirstName = p.PersonDisplayFirstName ?? p.PersonFirstName,
- LastName = p.PersonLastName,
- MiddleName = p.PersonMiddleName,
- IamId = i.IdsIamId,
- BannerId = i.IdsClientid,
- ClassLevel = s.StudentsClassLevel,
- EighthsGroup = sg != null ? sg.StudentgrpGrp : null,
- TwentiethsGroup = sg != null ? sg.Studentgrp20 : null,
- TeamNumber = sg != null ? sg.StudentgrpTeamno : null,
- V3SpecialtyGroup = sg != null ? sg.StudentgrpV3grp : null
- };
-
- var students = await query.OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToListAsync();
-
- var mailIds = students.Where(s => !string.IsNullOrWhiteSpace(s.MailId)).Select(s => s.MailId!).Distinct();
- var photoUrls = await _photoService.GetStudentPhotoUrlsBatchAsync(mailIds);
- var defaultPhotoUrl = _photoService.GetDefaultPhotoUrl();
-
- var photoStudents = new List();
- foreach (var student in students)
- {
- var displayName = FormatStudentDisplayName(student.LastName, student.FirstName, student.MiddleName);
-
- var (photoUrl, hasPhoto) = ResolvePhotoUrl(student.MailId, photoUrls, defaultPhotoUrl);
-
- // Combine Eighths and Twentieths groups in format "2B1 / 1AA"
- var groupAssignment = FormatGroupAssignment(student.EighthsGroup, student.TwentiethsGroup);
-
- var photoStudent = new StudentPhoto
- {
- MailId = student.MailId,
- FirstName = student.FirstName,
- LastName = student.LastName,
- DisplayName = displayName,
- PhotoUrl = photoUrl,
- GroupAssignment = groupAssignment,
- EighthsGroup = student.EighthsGroup?.Trim(),
- TwentiethsGroup = student.TwentiethsGroup?.Trim(),
- TeamNumber = student.ClassLevel == "V3" ? student.TeamNumber?.Trim() : null,
- V3SpecialtyGroup = student.ClassLevel == "V3" ? student.V3SpecialtyGroup?.Trim() : null,
- HasPhoto = hasPhoto,
- IsRossStudent = false,
- ClassLevel = student.ClassLevel
- };
-
- photoStudents.Add(photoStudent);
- }
+ && (string.IsNullOrEmpty(i.IdsIamId) || !EF.Parameter(rossIamIds).Contains(i.IdsIamId!))
+ orderby p.PersonLastName, p.PersonDisplayFirstName ?? p.PersonFirstName
+ select new StudentBaseRecord(
+ i.IdsMailid,
+ p.PersonDisplayFirstName ?? p.PersonFirstName,
+ p.PersonLastName,
+ p.PersonMiddleName,
+ i.IdsIamId,
+ i.IdsClientid,
+ s.StudentsClassLevel,
+ sg != null ? sg.StudentgrpGrp : null,
+ sg != null ? sg.Studentgrp20 : null,
+ sg != null ? sg.StudentgrpTeamno : null,
+ sg != null ? sg.StudentgrpV3grp : null);
+
+ var students = await query.AsNoTracking().ToListAsync();
+
+ var photoStudents = await BuildStudentPhotoListAsync(students);
// Add Ross students if requested
if (includeRossStudents)
@@ -162,13 +147,11 @@ from sg in sgGroup.DefaultIfEmpty()
}
catch (InvalidOperationException ex)
{
- _logger.LogError(ex, "Invalid operation getting students by class level {ClassLevel}", LogSanitizer.SanitizeString(classLevel));
- return new List();
+ throw new InvalidOperationException($"Failed to load students for class level {LogSanitizer.SanitizeString(classLevel)}.", ex);
}
catch (SqlException ex)
{
- _logger.LogError(ex, "Database error getting students by class level {ClassLevel}", LogSanitizer.SanitizeString(classLevel));
- return new List();
+ throw new InvalidOperationException($"Database error loading students for class level {LogSanitizer.SanitizeString(classLevel)}.", ex);
}
}
@@ -226,7 +209,7 @@ private async Task> GetRossStudentsByGradYearAsync(int gradYe
// Get all AAUD records for Ross students (any term <= current term)
var allAaudRecords = await _aaudContext.Ids
- .Where(ids => ids.IdsIamId != null && rossIamIds.Contains(ids.IdsIamId))
+ .Where(ids => ids.IdsIamId != null && EF.Parameter(rossIamIds).Contains(ids.IdsIamId))
.Where(ids => ids.IdsTermCode.CompareTo(currentTermString) <= 0)
.ToListAsync();
@@ -320,35 +303,14 @@ public async Task> GetStudentsByGroupAsync(string groupType,
// Get list of Ross student IamIds to ALWAYS exclude from regular query
// This prevents duplicates - Ross students would need to be added separately if we supported includeRoss for groups
- List rossIamIds = new List();
- try
- {
- rossIamIds = await _sisContext.StudentDesignations
- .Where(sd => sd.DesignationType == "Ross")
- .Where(sd => (sd.EndTerm == null || currentTermInt <= sd.EndTerm) &&
- (sd.StartTerm == null || sd.StartTerm <= currentTermInt))
- .Select(sd => sd.IamId)
- .Where(id => !string.IsNullOrEmpty(id))
- .Distinct()
- .ToListAsync();
- }
- catch (InvalidOperationException ex)
- {
- _logger.LogError(ex, "Invalid operation querying SIS context for Ross students");
- // Continue with empty rossIamIds list - no Ross students will be excluded
- }
- catch (SqlException ex)
- {
- _logger.LogError(ex, "Database error querying SIS context for Ross students");
- // Continue with empty rossIamIds list - no Ross students will be excluded
- }
+ var rossIamIds = await GetActiveRossIamIdsAsync(currentTermInt, "by-group");
var queryBase = from i in _aaudContext.Ids
join p in _aaudContext.People on i.IdsPKey equals p.PersonPKey
join s in _aaudContext.Students on p.PersonPKey equals s.StudentsPKey
join sg in _aaudContext.Studentgrps on i.IdsPidm equals sg.StudentgrpPidm
where i.IdsTermCode == currentTerm
- && (string.IsNullOrEmpty(i.IdsIamId) || !rossIamIds.Contains(i.IdsIamId))
+ && (string.IsNullOrEmpty(i.IdsIamId) || !EF.Parameter(rossIamIds).Contains(i.IdsIamId))
select new { i, p, s, sg };
// Filter by class level first if provided
@@ -375,66 +337,33 @@ join sg in _aaudContext.Studentgrps on i.IdsPidm equals sg.StudentgrpPidm
queryBase = queryBase.Where(x => x.sg.StudentgrpV3grp == groupId);
}
- var query = queryBase.Select(x => new
- {
- PersonId = x.p.PersonPKey,
- MailId = x.i.IdsMailid,
- FirstName = x.p.PersonDisplayFirstName ?? x.p.PersonFirstName,
- LastName = x.p.PersonLastName,
- MiddleName = x.p.PersonMiddleName,
- IamId = x.i.IdsIamId,
- BannerId = x.i.IdsClientid,
- ClassLevel = x.s.StudentsClassLevel,
- EighthsGroup = x.sg.StudentgrpGrp,
- TwentiethsGroup = x.sg.Studentgrp20,
- TeamNumber = x.sg.StudentgrpTeamno,
- V3SpecialtyGroup = x.sg.StudentgrpV3grp
- });
-
- var students = await query.OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToListAsync();
-
- var mailIds = students.Where(s => !string.IsNullOrWhiteSpace(s.MailId)).Select(s => s.MailId!).Distinct();
- var photoUrls = await _photoService.GetStudentPhotoUrlsBatchAsync(mailIds);
- var defaultPhotoUrl = _photoService.GetDefaultPhotoUrl();
-
- var photoStudents = new List();
- foreach (var student in students)
- {
- var displayName = FormatStudentDisplayName(student.LastName, student.FirstName, student.MiddleName);
-
- var (photoUrl, hasPhoto) = ResolvePhotoUrl(student.MailId, photoUrls, defaultPhotoUrl);
-
- // Combine Eighths and Twentieths groups in format "2B1 / 1AA"
- var groupAssignment = FormatGroupAssignment(student.EighthsGroup, student.TwentiethsGroup);
-
- photoStudents.Add(new StudentPhoto
- {
- MailId = student.MailId,
- FirstName = student.FirstName,
- LastName = student.LastName,
- DisplayName = displayName,
- PhotoUrl = photoUrl,
- GroupAssignment = groupAssignment,
- EighthsGroup = student.EighthsGroup?.Trim(),
- TwentiethsGroup = student.TwentiethsGroup?.Trim(),
- TeamNumber = student.ClassLevel == "V3" ? student.TeamNumber?.Trim() : null,
- V3SpecialtyGroup = student.ClassLevel == "V3" ? student.V3SpecialtyGroup?.Trim() : null,
- HasPhoto = hasPhoto,
- IsRossStudent = false
- });
- }
-
- return photoStudents;
+ var query = queryBase
+ .OrderBy(x => x.p.PersonLastName)
+ .ThenBy(x => x.p.PersonDisplayFirstName ?? x.p.PersonFirstName)
+ .Select(x => new StudentBaseRecord(
+ x.i.IdsMailid,
+ x.p.PersonDisplayFirstName ?? x.p.PersonFirstName,
+ x.p.PersonLastName,
+ x.p.PersonMiddleName,
+ x.i.IdsIamId,
+ x.i.IdsClientid,
+ x.s.StudentsClassLevel,
+ x.sg.StudentgrpGrp,
+ x.sg.Studentgrp20,
+ x.sg.StudentgrpTeamno,
+ x.sg.StudentgrpV3grp));
+
+ var students = await query.AsNoTracking().ToListAsync();
+
+ return await BuildStudentPhotoListAsync(students);
}
catch (InvalidOperationException ex)
{
- _logger.LogError(ex, "Invalid operation getting students by group {GroupType}/{GroupId}", LogSanitizer.SanitizeString(groupType), LogSanitizer.SanitizeString(groupId));
- return new List();
+ throw new InvalidOperationException($"Failed to load students for group {LogSanitizer.SanitizeString(groupType)}/{LogSanitizer.SanitizeString(groupId)}.", ex);
}
catch (SqlException ex)
{
- _logger.LogError(ex, "Database error getting students by group {GroupType}/{GroupId}", LogSanitizer.SanitizeString(groupType), LogSanitizer.SanitizeString(groupId));
- return new List();
+ throw new InvalidOperationException($"Database error loading students for group {LogSanitizer.SanitizeString(groupType)}/{LogSanitizer.SanitizeString(groupId)}.", ex);
}
}
@@ -445,26 +374,7 @@ public async Task> GetStudentsByCourseAsync(string termCode,
var currentTermInt = int.Parse(termCode);
// Get list of Ross IAM IDs so we can always exclude them unless explicitly requested
- List rossIamIds = new();
- try
- {
- rossIamIds = await _sisContext.StudentDesignations
- .Where(sd => sd.DesignationType == "Ross")
- .Where(sd => (sd.EndTerm == null || currentTermInt <= sd.EndTerm) &&
- (sd.StartTerm == null || sd.StartTerm <= currentTermInt))
- .Select(sd => sd.IamId)
- .Where(id => !string.IsNullOrEmpty(id))
- .Distinct()
- .ToListAsync();
- }
- catch (InvalidOperationException ex)
- {
- _logger.LogError(ex, "Invalid operation querying SIS context for Ross students");
- }
- catch (SqlException ex)
- {
- _logger.LogError(ex, "Database error querying SIS context for Ross students");
- }
+ var rossIamIds = await GetActiveRossIamIdsAsync(currentTermInt, "by-course");
// Query students enrolled in the course
// Two-step approach to avoid multi-context joins (following legacy implementation pattern)
@@ -486,94 +396,70 @@ public async Task> GetStudentsByCourseAsync(string termCode,
}
// Step 2: Query AAUD database with the enrolled PIDMs
- var query = from i in _aaudContext.Ids
- join p in _aaudContext.People on i.IdsPKey equals p.PersonPKey
- join s in _aaudContext.Students on p.PersonPKey equals s.StudentsPKey
- join sg in _aaudContext.Studentgrps on i.IdsPidm equals sg.StudentgrpPidm into sgGroup
- from sg in sgGroup.DefaultIfEmpty()
- where enrolledPidms.Contains(i.IdsPidm)
- && i.IdsTermCode == termCode
- && (string.IsNullOrEmpty(i.IdsIamId) || !rossIamIds.Contains(i.IdsIamId!))
- select new
- {
- PersonId = p.PersonPKey,
- MailId = i.IdsMailid,
- FirstName = p.PersonDisplayFirstName ?? p.PersonFirstName,
- LastName = p.PersonLastName,
- MiddleName = p.PersonMiddleName,
- IamId = i.IdsIamId,
- BannerId = i.IdsClientid,
- ClassLevel = s.StudentsClassLevel,
- EighthsGroup = sg != null ? sg.StudentgrpGrp : null,
- TwentiethsGroup = sg != null ? sg.Studentgrp20 : null,
- TeamNumber = sg != null ? sg.StudentgrpTeamno : null,
- V3SpecialtyGroup = sg != null ? sg.StudentgrpV3grp : null
- };
+ var queryBase = from i in _aaudContext.Ids
+ join p in _aaudContext.People on i.IdsPKey equals p.PersonPKey
+ join s in _aaudContext.Students on p.PersonPKey equals s.StudentsPKey
+ join sg in _aaudContext.Studentgrps on i.IdsPidm equals sg.StudentgrpPidm into sgGroup
+ from sg in sgGroup.DefaultIfEmpty()
+ where EF.Parameter(enrolledPidms).Contains(i.IdsPidm)
+ && i.IdsTermCode == termCode
+ && (string.IsNullOrEmpty(i.IdsIamId) || !EF.Parameter(rossIamIds).Contains(i.IdsIamId!))
+ select new { i, p, s, sg };
// Apply group filtering if specified
if (!string.IsNullOrEmpty(groupType) && !string.IsNullOrEmpty(groupId))
{
- query = groupType.ToLower() switch
+ queryBase = groupType.ToLower() switch
{
- "eighths" => query.Where(s => s.EighthsGroup == groupId),
- "twentieths" => query.Where(s => s.TwentiethsGroup == groupId),
- "teams" => query.Where(s => s.TeamNumber == groupId),
- "v3specialty" => query.Where(s => s.V3SpecialtyGroup == groupId),
- _ => query
+ "eighths" => queryBase.Where(x => x.sg.StudentgrpGrp == groupId),
+ "twentieths" => queryBase.Where(x => x.sg.Studentgrp20 == groupId),
+ "teams" => queryBase.Where(x => x.sg.StudentgrpTeamno == groupId),
+ "v3specialty" => queryBase.Where(x => x.sg.StudentgrpV3grp == groupId),
+ _ => queryBase
};
}
- var students = await query.OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToListAsync();
-
- var mailIds = students.Where(s => !string.IsNullOrWhiteSpace(s.MailId)).Select(s => s.MailId!).Distinct();
- var photoUrls = await _photoService.GetStudentPhotoUrlsBatchAsync(mailIds);
- var defaultPhotoUrl = _photoService.GetDefaultPhotoUrl();
-
- var photoStudents = new List();
- foreach (var student in students)
- {
- var displayName = FormatStudentDisplayName(student.LastName, student.FirstName, student.MiddleName);
-
- var (photoUrl, hasPhoto) = ResolvePhotoUrl(student.MailId, photoUrls, defaultPhotoUrl);
-
- // Combine Eighths and Twentieths groups
- var groupAssignment = FormatGroupAssignment(student.EighthsGroup, student.TwentiethsGroup);
-
- var photoStudent = new StudentPhoto
- {
- MailId = student.MailId,
- FirstName = student.FirstName,
- LastName = student.LastName,
- DisplayName = displayName,
- PhotoUrl = photoUrl,
- GroupAssignment = groupAssignment,
- EighthsGroup = student.EighthsGroup?.Trim(),
- TwentiethsGroup = student.TwentiethsGroup?.Trim(),
- TeamNumber = student.ClassLevel == "V3" ? student.TeamNumber?.Trim() : null,
- V3SpecialtyGroup = student.ClassLevel == "V3" ? student.V3SpecialtyGroup?.Trim() : null,
- HasPhoto = hasPhoto,
- IsRossStudent = false,
- ClassLevel = student.ClassLevel
- };
-
- photoStudents.Add(photoStudent);
- }
+ var query = queryBase
+ .OrderBy(x => x.p.PersonLastName)
+ .ThenBy(x => x.p.PersonDisplayFirstName ?? x.p.PersonFirstName)
+ .Select(x => new StudentBaseRecord(
+ x.i.IdsMailid,
+ x.p.PersonDisplayFirstName ?? x.p.PersonFirstName,
+ x.p.PersonLastName,
+ x.p.PersonMiddleName,
+ x.i.IdsIamId,
+ x.i.IdsClientid,
+ x.s.StudentsClassLevel,
+ x.sg != null ? x.sg.StudentgrpGrp : null,
+ x.sg != null ? x.sg.Studentgrp20 : null,
+ x.sg != null ? x.sg.StudentgrpTeamno : null,
+ x.sg != null ? x.sg.StudentgrpV3grp : null));
+
+ var students = await query.AsNoTracking().ToListAsync();
+
+ var photoStudents = await BuildStudentPhotoListAsync(students);
// Add Ross students if requested
if (includeRossStudents && rossIamIds.Any())
{
_logger.LogDebug("Including Ross students for course {TermCode}/{Crn}", LogSanitizer.SanitizeString(termCode), LogSanitizer.SanitizeString(crn));
- // Get Ross students who are enrolled in this course
- var rossStudentsInCourse = await (from r in _coursesContext.Rosters
- join i in _aaudContext.Ids on r.RosterPidm equals i.IdsPidm
- where r.RosterTermCode == termCode
- && r.RosterCrn == crn
- && i.IdsIamId != null
- && rossIamIds.Contains(i.IdsIamId)
- select i.IdsIamId)
- .Distinct()
- .ToListAsync();
+ // Get Ross students enrolled in this course. Use a two-step query (like the
+ // enrollment lookup above) to avoid joining the Courses and AAUD DbContexts
+ // in a single query, which EF cannot translate.
+ var courseRosterPidms = await _coursesContext.Rosters
+ .Where(r => r.RosterTermCode == termCode && r.RosterCrn == crn)
+ .Select(r => r.RosterPidm)
+ .Distinct()
+ .ToListAsync();
+
+ var rossStudentsInCourse = await _aaudContext.Ids
+ .Where(i => i.IdsIamId != null
+ && EF.Parameter(courseRosterPidms).Contains(i.IdsPidm)
+ && EF.Parameter(rossIamIds).Contains(i.IdsIamId))
+ .Select(i => i.IdsIamId)
+ .Distinct()
+ .ToListAsync();
if (rossStudentsInCourse.Any())
{
@@ -638,13 +524,11 @@ join s in _aaudContext.Students on p.PersonPKey equals s.StudentsPKey
}
catch (InvalidOperationException ex)
{
- _logger.LogError(ex, "Invalid operation getting students by course {TermCode}/{Crn}", LogSanitizer.SanitizeString(termCode), LogSanitizer.SanitizeString(crn));
- return new List();
+ throw new InvalidOperationException($"Failed to load students for course {LogSanitizer.SanitizeString(termCode)}/{LogSanitizer.SanitizeString(crn)}.", ex);
}
catch (SqlException ex)
{
- _logger.LogError(ex, "Database error getting students by course {TermCode}/{Crn}", LogSanitizer.SanitizeString(termCode), LogSanitizer.SanitizeString(crn));
- return new List();
+ throw new InvalidOperationException($"Database error loading students for course {LogSanitizer.SanitizeString(termCode)}/{LogSanitizer.SanitizeString(crn)}.", ex);
}
}
@@ -767,7 +651,7 @@ private static (string photoUrl, bool hasPhoto) ResolvePhotoUrl(
string? mailId, Dictionary photoUrls, string defaultPhotoUrl)
{
var photoUrl = string.IsNullOrWhiteSpace(mailId) || !photoUrls.TryGetValue(mailId, out var url)
- ? string.Empty
+ ? defaultPhotoUrl
: url;
var hasPhoto = !string.Equals(photoUrl, defaultPhotoUrl, StringComparison.OrdinalIgnoreCase);
return (photoUrl, hasPhoto);
@@ -825,18 +709,87 @@ private static string FormatGroupAssignment(string? eighthsGroup, string? twenti
{
return $"{eighthsGroup} / {twentiethsGroup}";
}
-
- if (!string.IsNullOrEmpty(eighthsGroup))
+ else if (!string.IsNullOrEmpty(eighthsGroup))
{
return eighthsGroup;
}
-
- if (!string.IsNullOrEmpty(twentiethsGroup))
+ else if (!string.IsNullOrEmpty(twentiethsGroup))
{
return twentiethsGroup;
}
return string.Empty;
}
+ ///
+ /// Build StudentPhoto results from already-projected student records: batch-resolve
+ /// photo URLs once, then format display name and group assignment per row.
+ ///
+ private async Task> BuildStudentPhotoListAsync(
+ IReadOnlyList students,
+ bool isRoss = false)
+ {
+ var mailIds = students
+ .Where(s => !string.IsNullOrWhiteSpace(s.MailId))
+ .Select(s => s.MailId!)
+ .Distinct();
+ var photoUrls = await _photoService.GetStudentPhotoUrlsBatchAsync(mailIds);
+ var defaultPhotoUrl = _photoService.GetDefaultPhotoUrl();
+
+ var result = new List(students.Count);
+ foreach (var student in students)
+ {
+ var displayName = FormatStudentDisplayName(student.LastName, student.FirstName ?? string.Empty, student.MiddleName);
+ var (photoUrl, hasPhoto) = ResolvePhotoUrl(student.MailId, photoUrls, defaultPhotoUrl);
+ var groupAssignment = FormatGroupAssignment(student.EighthsGroup, student.TwentiethsGroup);
+
+ result.Add(new StudentPhoto
+ {
+ MailId = student.MailId ?? string.Empty,
+ FirstName = student.FirstName ?? string.Empty,
+ LastName = student.LastName,
+ DisplayName = displayName,
+ PhotoUrl = photoUrl,
+ GroupAssignment = groupAssignment,
+ EighthsGroup = student.EighthsGroup?.Trim(),
+ TwentiethsGroup = student.TwentiethsGroup?.Trim(),
+ TeamNumber = student.ClassLevel == "V3" ? student.TeamNumber?.Trim() : null,
+ V3SpecialtyGroup = student.ClassLevel == "V3" ? student.V3SpecialtyGroup?.Trim() : null,
+ HasPhoto = hasPhoto,
+ IsRossStudent = isRoss,
+ ClassLevel = student.ClassLevel
+ });
+ }
+ return result;
+ }
+
+ ///
+ /// Look up Ross-program IamIds active for the given term so non-Ross queries can
+ /// exclude them. Swallows the recoverable SIS-database errors and returns an
+ /// empty list so the caller's main query still runs.
+ ///
+ private async Task> GetActiveRossIamIdsAsync(int currentTermInt, string contextLabel)
+ {
+ try
+ {
+ return await _sisContext.StudentDesignations.AsNoTracking()
+ .Where(sd => sd.DesignationType == "Ross")
+ .Where(sd => (sd.EndTerm == null || currentTermInt <= sd.EndTerm) &&
+ (sd.StartTerm == null || sd.StartTerm <= currentTermInt))
+ .Select(sd => sd.IamId)
+ .Where(id => !string.IsNullOrEmpty(id))
+ .Distinct()
+ .ToListAsync();
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogError(ex, "Invalid operation querying SIS context for Ross students ({Context})", LogSanitizer.SanitizeString(contextLabel));
+ return new List();
+ }
+ catch (SqlException ex)
+ {
+ _logger.LogError(ex, "Database error querying SIS context for Ross students ({Context})", LogSanitizer.SanitizeString(contextLabel));
+ return new List();
+ }
+ }
}
}
diff --git a/web/Models/CTS/IScheduleEntity.cs b/web/Models/CTS/IScheduleEntity.cs
new file mode 100644
index 000000000..e1fd52909
--- /dev/null
+++ b/web/Models/CTS/IScheduleEntity.cs
@@ -0,0 +1,16 @@
+namespace Viper.Models.CTS;
+
+///
+/// Common filterable fields exposed by clinical-schedule entities (instructor and student).
+/// Used by ScheduleQueryExtensions to apply shared LINQ filter clauses generically.
+///
+public interface IScheduleEntity
+{
+ string MothraId { get; }
+ int RotationId { get; }
+ int ServiceId { get; }
+ int WeekId { get; }
+ DateTime DateStart { get; }
+ DateTime DateEnd { get; }
+ Week Week { get; }
+}
diff --git a/web/Models/CTS/InstructorSchedule.cs b/web/Models/CTS/InstructorSchedule.cs
index 22244c8b0..3f6855d02 100644
--- a/web/Models/CTS/InstructorSchedule.cs
+++ b/web/Models/CTS/InstructorSchedule.cs
@@ -1,6 +1,6 @@
namespace Viper.Models.CTS
{
- public class InstructorSchedule
+ public class InstructorSchedule : IScheduleEntity
{
public int InstructorScheduleId { get; set; }
public string LastName { get; set; } = null!;
diff --git a/web/Models/CTS/StudentSchedule.cs b/web/Models/CTS/StudentSchedule.cs
index e28ecdf0d..ff9725064 100644
--- a/web/Models/CTS/StudentSchedule.cs
+++ b/web/Models/CTS/StudentSchedule.cs
@@ -1,6 +1,6 @@
namespace Viper.Models.CTS
{
- public class StudentSchedule
+ public class StudentSchedule : IScheduleEntity
{
public int StudentScheduleId { get; set; }
public int PersonId { get; set; }