source: iknow-api/Services/Implementations/EnrollmentService.cs@ ea40556

Last change on this file since ea40556 was ea40556, checked in by Stefan-Saveski <stefansaveski19@…>, 8 days ago
  • ready for presentation
  • Property mode set to 100644
File size: 9.2 KB
Line 
1using iknow_api.Data;
2using iknow_api.DTOs;
3using iknow_api.Models;
4using Microsoft.EntityFrameworkCore;
5
6namespace iknow_api.Services
7{
8 public class EnrollmentService : IEnrollmentService
9 {
10 /// <summary>An enrolment is exactly this many subjects.</summary>
11 public const int RequiredSubjects = 5;
12
13 private readonly AppDbContext _context;
14
15 public EnrollmentService(AppDbContext context)
16 {
17 _context = context;
18 }
19
20 public async Task<EnrollmentOptionsDto> GetOptionsAsync(int studentId)
21 {
22 var enrolments = await _context.EnrolledSemesters
23 .AsNoTracking()
24 .Include(es => es.Semester)
25 .Where(es => es.UserId == studentId)
26 .ToListAsync();
27
28 var takenSemesterIds = enrolments.Select(es => es.SemesterId).ToHashSet();
29
30 var available = await _context.ActiveSemesters
31 .AsNoTracking()
32 .Where(a => !takenSemesterIds.Contains(a.Id))
33 .ToListAsync();
34
35 // Semesters are labelled Year/Year+1, so within a year winter comes first.
36 var semesters = available
37 .OrderBy(a => a.Year)
38 .ThenBy(a => a.Type == sType.summer)
39 .Select(a => new SemesterOptionDto
40 {
41 Id = a.Id,
42 Year = a.Year,
43 Type = a.Type.ToString(),
44 Name = $"{(a.Type == sType.winter ? "Зимски" : "Летен")}({a.Year}/{a.Year + 1})"
45 })
46 .ToList();
47
48 var passedSubjectIds = await _context.PassedSubjects
49 .AsNoTracking()
50 .Where(ps => ps.SemesterSubject!.EnrolledSemester!.UserId == studentId)
51 .Select(ps => ps.SemesterSubject!.SubjectId)
52 .ToListAsync();
53 var passed = passedSubjectIds.ToHashSet();
54
55 var majorRows = await _context.MajorSubjects
56 .AsNoTracking()
57 .Include(ms => ms.Majors)
58 .Include(ms => ms.Subjects)
59 .ToListAsync();
60
61 var majors = majorRows
62 .Where(ms => ms.Majors != null && ms.Subjects != null)
63 .GroupBy(ms => ms.Majors!.Id)
64 .Select(g => new MajorOptionDto
65 {
66 Id = g.Key,
67 Name = g.First().Majors!.Name,
68 Subjects = g
69 .Select(ms => new SubjectOptionDto
70 {
71 Id = ms.Subjects!.Id,
72 Name = ms.Subjects.Name,
73 Code = ms.Subjects.Code,
74 Credits = ms.Subjects.AwardedCredits ?? 0,
75 MandatorySemester = ms.MandatorySemester,
76 AlreadyPassed = passed.Contains(ms.Subjects.Id)
77 })
78 .OrderBy(x => x.MandatorySemester)
79 .ThenBy(x => x.Name)
80 .ToList()
81 })
82 .OrderBy(m => m.Name)
83 .ToList();
84
85 var latest = enrolments
86 .Where(es => es.Semester != null)
87 .OrderByDescending(es => es.Semester!.Year)
88 .ThenByDescending(es => es.Semester!.Type == sType.summer)
89 .FirstOrDefault();
90
91 return new EnrollmentOptionsDto
92 {
93 RequiredSubjects = RequiredSubjects,
94 Semesters = semesters,
95 Majors = majors,
96 DefaultMajorId = latest?.MajorId
97 };
98 }
99
100 public async Task<EnrollSemesterResultDto> EnrollAsync(
101 int studentId, EnrollSemesterRequestDto request)
102 {
103 var subjectIds = request.SubjectIds?.Distinct().ToList() ?? new List<int>();
104
105 if (subjectIds.Count != RequiredSubjects)
106 {
107 return Fail($"Pick exactly {RequiredSubjects} different subjects; got {subjectIds.Count}.");
108 }
109
110 if (!await _context.ActiveSemesters.AnyAsync(a => a.Id == request.SemesterId))
111 {
112 return Fail("That semester does not exist.");
113 }
114
115 // enrolled_semesters has UNIQUE (user_id, semester_id); check first so
116 // the student gets a sentence rather than a constraint violation.
117 if (await _context.EnrolledSemesters.AnyAsync(
118 es => es.UserId == studentId && es.SemesterId == request.SemesterId))
119 {
120 return Fail("You are already enrolled in that semester.");
121 }
122
123 if (!await _context.Majors.AnyAsync(m => m.Id == request.MajorId))
124 {
125 return Fail("That study programme does not exist.");
126 }
127
128 var offered = await _context.MajorSubjects
129 .Where(ms => ms.MajorId == request.MajorId)
130 .Select(ms => ms.SubjectId)
131 .ToListAsync();
132
133 var notOffered = subjectIds.Except(offered).ToList();
134 if (notOffered.Count > 0)
135 {
136 return Fail($"Subject(s) {string.Join(", ", notOffered)} are not offered by that study programme.");
137 }
138
139 // semesters_subjects.professor_id is NOT NULL, so every chosen subject
140 // needs somebody teaching it that semester.
141 var teachers = await _context.ProfessorSubjects
142 .Where(ps => ps.SemesterId == request.SemesterId && subjectIds.Contains(ps.SubjectId))
143 .ToListAsync();
144
145 // A subject can be taught by more than one professor in the same
146 // semester. Spread students over them instead of always taking the
147 // lowest id, which would leave the other professor with nobody.
148 var currentLoad = await _context.SemesterSubjects
149 .Where(ss => subjectIds.Contains(ss.SubjectId)
150 && ss.EnrolledSemester!.SemesterId == request.SemesterId)
151 .GroupBy(ss => new { ss.SubjectId, ss.ProfessorId })
152 .Select(g => new { g.Key.SubjectId, g.Key.ProfessorId, Count = g.Count() })
153 .ToListAsync();
154
155 var loadLookup = currentLoad.ToDictionary(
156 x => (x.SubjectId, x.ProfessorId), x => x.Count);
157
158 var professorBySubject = teachers
159 .GroupBy(ps => ps.SubjectId)
160 .ToDictionary(
161 g => g.Key,
162 // Fewest students first; professor id only breaks ties, so
163 // the choice stays deterministic and testable.
164 g => g.OrderBy(ps => loadLookup.TryGetValue((ps.SubjectId, ps.ProfessorId), out var n) ? n : 0)
165 .ThenBy(ps => ps.ProfessorId)
166 .First().ProfessorId);
167
168 var untaught = subjectIds.Where(id => !professorBySubject.ContainsKey(id)).ToList();
169 if (untaught.Count > 0)
170 {
171 return Fail($"Subject(s) {string.Join(", ", untaught)} are not taught in that semester.");
172 }
173
174 var student = await _context.User.FirstOrDefaultAsync(u => u.Id == studentId);
175 if (student is null)
176 {
177 return Fail("Student not found.");
178 }
179
180 var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
181
182 await using var transaction = await _context.Database.BeginTransactionAsync();
183 try
184 {
185 var enrolment = new EnrolledSemesters
186 {
187 UserId = studentId,
188 SemesterId = request.SemesterId,
189 MajorId = request.MajorId,
190 // enrolled_semesters.quota is NOT NULL; fall back to the
191 // state quota when the student record has none.
192 QuotaType = student.Quota ?? Models.Quota.drzavna,
193 CratedAt = now,
194 LastChange = now,
195 Verified = null
196 };
197
198 _context.EnrolledSemesters.Add(enrolment);
199 await _context.SaveChangesAsync();
200
201 foreach (var subjectId in subjectIds)
202 {
203 _context.SemesterSubjects.Add(new SemesterSubject
204 {
205 EnrolledSemesterId = enrolment.Id,
206 SubjectId = subjectId,
207 ProfessorId = professorBySubject[subjectId],
208 Signature = false
209 });
210 }
211
212 await _context.SaveChangesAsync();
213 await transaction.CommitAsync();
214
215 return new EnrollSemesterResultDto
216 {
217 Ok = true,
218 Message = $"Enrolled with {subjectIds.Count} subjects.",
219 EnrolledSemesterId = enrolment.Id
220 };
221 }
222 catch
223 {
224 await transaction.RollbackAsync();
225 throw;
226 }
227 }
228
229 private static EnrollSemesterResultDto Fail(string message) =>
230 new() { Ok = false, Message = message };
231 }
232}
Note: See TracBrowser for help on using the repository browser.