using iknow_api.Data;
using iknow_api.DTOs;
using iknow_api.Models;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace iknow_api.Services
{
public class EnrollmentService : IEnrollmentService
{
/// An enrolment is exactly this many subjects.
public const int RequiredSubjects = 5;
private readonly AppDbContext _context;
public EnrollmentService(AppDbContext context)
{
_context = context;
}
public async Task GetOptionsAsync(int studentId)
{
var enrolments = await _context.EnrolledSemesters
.AsNoTracking()
.Include(es => es.Semester)
.Where(es => es.UserId == studentId)
.ToListAsync();
var takenSemesterIds = enrolments.Select(es => es.SemesterId).ToHashSet();
var available = await _context.ActiveSemesters
.AsNoTracking()
.Where(a => !takenSemesterIds.Contains(a.Id))
.ToListAsync();
// Semesters are labelled Year/Year+1, so within a year winter comes first.
var semesters = available
.OrderBy(a => a.Year)
.ThenBy(a => a.Type == sType.summer)
.Select(a => new SemesterOptionDto
{
Id = a.Id,
Year = a.Year,
Type = a.Type.ToString(),
Name = $"{(a.Type == sType.winter ? "Зимски" : "Летен")}({a.Year}/{a.Year + 1})"
})
.ToList();
var passedSubjectIds = await _context.PassedSubjects
.AsNoTracking()
.Where(ps => ps.SemesterSubject!.EnrolledSemester!.UserId == studentId)
.Select(ps => ps.SemesterSubject!.SubjectId)
.ToListAsync();
var passed = passedSubjectIds.ToHashSet();
var majorRows = await _context.MajorSubjects
.AsNoTracking()
.Include(ms => ms.Majors)
.Include(ms => ms.Subjects)
.ToListAsync();
var majors = majorRows
.Where(ms => ms.Majors != null && ms.Subjects != null)
.GroupBy(ms => ms.Majors!.Id)
.Select(g => new MajorOptionDto
{
Id = g.Key,
Name = g.First().Majors!.Name,
Subjects = g
.Select(ms => new SubjectOptionDto
{
Id = ms.Subjects!.Id,
Name = ms.Subjects.Name,
Code = ms.Subjects.Code,
Credits = ms.Subjects.AwardedCredits ?? 0,
MandatorySemester = ms.MandatorySemester,
AlreadyPassed = passed.Contains(ms.Subjects.Id)
})
.OrderBy(x => x.MandatorySemester)
.ThenBy(x => x.Name)
.ToList()
})
.OrderBy(m => m.Name)
.ToList();
var latest = enrolments
.Where(es => es.Semester != null)
.OrderByDescending(es => es.Semester!.Year)
.ThenByDescending(es => es.Semester!.Type == sType.summer)
.FirstOrDefault();
return new EnrollmentOptionsDto
{
RequiredSubjects = RequiredSubjects,
Semesters = semesters,
Majors = majors,
DefaultMajorId = latest?.MajorId
};
}
public async Task EnrollAsync(
int studentId, EnrollSemesterRequestDto request)
{
var subjectIds = request.SubjectIds?.Distinct().ToList() ?? new List();
if (subjectIds.Count != RequiredSubjects)
{
return Fail($"Pick exactly {RequiredSubjects} different subjects; got {subjectIds.Count}.");
}
if (!await _context.ActiveSemesters.AnyAsync(a => a.Id == request.SemesterId))
{
return Fail("That semester does not exist.");
}
// enrolled_semesters has UNIQUE (user_id, semester_id); check first so
// the student gets a sentence rather than a constraint violation.
if (await _context.EnrolledSemesters.AnyAsync(
es => es.UserId == studentId && es.SemesterId == request.SemesterId))
{
return Fail("You are already enrolled in that semester.");
}
if (!await _context.Majors.AnyAsync(m => m.Id == request.MajorId))
{
return Fail("That study programme does not exist.");
}
var offered = await _context.MajorSubjects
.Where(ms => ms.MajorId == request.MajorId)
.Select(ms => ms.SubjectId)
.ToListAsync();
var notOffered = subjectIds.Except(offered).ToList();
if (notOffered.Count > 0)
{
return Fail($"Subject(s) {string.Join(", ", notOffered)} are not offered by that study programme.");
}
// semesters_subjects.professor_id is NOT NULL, so every chosen subject
// needs somebody teaching it that semester.
var teachers = await _context.ProfessorSubjects
.Where(ps => ps.SemesterId == request.SemesterId && subjectIds.Contains(ps.SubjectId))
.ToListAsync();
// A subject can be taught by more than one professor in the same
// semester. Spread students over them instead of always taking the
// lowest id, which would leave the other professor with nobody.
var currentLoad = await _context.SemesterSubjects
.Where(ss => subjectIds.Contains(ss.SubjectId)
&& ss.EnrolledSemester!.SemesterId == request.SemesterId)
.GroupBy(ss => new { ss.SubjectId, ss.ProfessorId })
.Select(g => new { g.Key.SubjectId, g.Key.ProfessorId, Count = g.Count() })
.ToListAsync();
var loadLookup = currentLoad.ToDictionary(
x => (x.SubjectId, x.ProfessorId), x => x.Count);
var professorBySubject = teachers
.GroupBy(ps => ps.SubjectId)
.ToDictionary(
g => g.Key,
// Fewest students first; professor id only breaks ties, so
// the choice stays deterministic and testable.
g => g.OrderBy(ps => loadLookup.TryGetValue((ps.SubjectId, ps.ProfessorId), out var n) ? n : 0)
.ThenBy(ps => ps.ProfessorId)
.First().ProfessorId);
var untaught = subjectIds.Where(id => !professorBySubject.ContainsKey(id)).ToList();
if (untaught.Count > 0)
{
return Fail($"Subject(s) {string.Join(", ", untaught)} are not taught in that semester.");
}
var student = await _context.User.FirstOrDefaultAsync(u => u.Id == studentId);
if (student is null)
{
return Fail("Student not found.");
}
var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var enrolment = new EnrolledSemesters
{
UserId = studentId,
SemesterId = request.SemesterId,
MajorId = request.MajorId,
// enrolled_semesters.quota is NOT NULL; fall back to the
// state quota when the student record has none.
QuotaType = student.Quota ?? Models.Quota.drzavna,
CratedAt = now,
LastChange = now,
Verified = null
};
_context.EnrolledSemesters.Add(enrolment);
await _context.SaveChangesAsync();
foreach (var subjectId in subjectIds)
{
_context.SemesterSubjects.Add(new SemesterSubject
{
EnrolledSemesterId = enrolment.Id,
SubjectId = subjectId,
ProfessorId = professorBySubject[subjectId],
Signature = false
});
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return new EnrollSemesterResultDto
{
Ok = true,
Message = $"Enrolled with {subjectIds.Count} subjects.",
EnrolledSemesterId = enrolment.Id
};
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Two enrolments for the same semester sent at the same time both
// pass the check above, because each transaction reads the state
// from before the other one wrote. UNIQUE (user_id, semester_id)
// is what actually settles it, so the loser gets the same
// sentence as if the check had caught it.
await transaction.RollbackAsync();
return Fail("You are already enrolled in that semester.");
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// 23505 is unique_violation.
private static bool IsUniqueViolation(DbUpdateException ex) =>
ex.InnerException is PostgresException { SqlState: "23505" };
private static EnrollSemesterResultDto Fail(string message) =>
new() { Ok = false, Message = message };
}
}