diff --git a/api/AppJsonSerializerContext.cs b/api/AppJsonSerializerContext.cs index ee4e4f6..e929fd6 100644 --- a/api/AppJsonSerializerContext.cs +++ b/api/AppJsonSerializerContext.cs @@ -3,6 +3,9 @@ using BonchCalendar.Health; namespace BonchCalendar; +/// +/// Custom JSON serializer context for static serialization/deserialization of requests and responses +/// [JsonSerializable(typeof(string))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(int))] diff --git a/api/BonchCalendar.http b/api/BonchCalendar.http index 439d27a..3257777 100644 --- a/api/BonchCalendar.http +++ b/api/BonchCalendar.http @@ -24,13 +24,11 @@ Accept: application/json @groupId = 56606 @facultyId = 50029 -# See remark for id -@id = download GET {{Host}}/timetable/{{facultyId}}/{{groupId}} - ?id={{id}} + ?id=download Accept: text/calendar # id parameter changes behavior for the calendar: +# - If set to "download", nothing changes # - If not present, an additional event will be appended to the calendar (see Program.cs) -# - If set to "download", nothing will happen # - If set to any other value, this request will be counted in active users stats (only once per ID until the next restart) diff --git a/api/Dockerfile b/api/Dockerfile index 4f0d7e3..d9970e7 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,7 +5,7 @@ ADD --link . . RUN --mount=type=cache,target=/root/.nuget \ --mount=type=cache,target=/source/bin \ --mount=type=cache,target=/source/obj \ - dotnet publish --output /out /p:PublishAot=true BonchCalendar.csproj \ + dotnet publish --output /out BonchCalendar.csproj \ && rm /out/*.dbg /out/*.Development.json FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod diff --git a/api/Health/ApiHealthCheck.cs b/api/Health/ApiHealthCheck.cs index 1f9bdb9..1803fda 100644 --- a/api/Health/ApiHealthCheck.cs +++ b/api/Health/ApiHealthCheck.cs @@ -3,6 +3,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; namespace BonchCalendar.Health; +/// +/// Healthcheck service for sut.ru API. +/// public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck { public async Task CheckHealthAsync( @@ -11,8 +14,12 @@ public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck { Dictionary report = trackingService.GetReport(); + // We deem service "unhealthy" if any of the last requests to the API were unsuccessful. if (report.Count > 0) - return HealthCheckResult.Unhealthy(description: "We're having issues with fetching data from the timetable website.", data: report); + return HealthCheckResult.Unhealthy( + description: "We're having issues with fetching data from the timetable website.", + data: report + ); return HealthCheckResult.Healthy(); } diff --git a/api/Health/HealthCheckWriter.cs b/api/Health/HealthCheckWriter.cs index 2953caa..8080d6a 100644 --- a/api/Health/HealthCheckWriter.cs +++ b/api/Health/HealthCheckWriter.cs @@ -7,18 +7,18 @@ namespace BonchCalendar.Health; public static class HealthCheckWriter { private static readonly byte[] _emptyResponse = [ (byte)'{', (byte)'}' ]; - private static JsonSerializerContext? _jsonContext = null; - - public static JsonSerializerContext JsonContext => _jsonContext ??= CreateSerializerContext(); + private static readonly JsonSerializerContext _jsonContext = CreateSerializerContext(); public static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report) { if (report is null) { + // Just in case, but this should not ever happen. await context.Response.BodyWriter.WriteAsync(_emptyResponse).ConfigureAwait(false); return; } + // Create DTO from the report. HealthResponse response = new( Status: report.Status, TotalDuration: report.TotalDuration, @@ -33,9 +33,9 @@ public static class HealthCheckWriter ) ); + // Write DTO to the response body. context.Response.ContentType = "application/json; charset=utf-8"; - - await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), JsonContext).ConfigureAwait(false); + await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), _jsonContext).ConfigureAwait(false); } private static AppJsonSerializerContext CreateSerializerContext() @@ -53,12 +53,25 @@ public static class HealthCheckWriter } } +/// +/// Response body for /health endpoint. +/// +/// Overall status of the application. +/// Total time it took to complete the health check. +/// List of subcomponent healthcheck reports. public record HealthResponse( HealthStatus Status, TimeSpan TotalDuration, IReadOnlyDictionary Entries ); +/// +/// Healthcheck report for a subcomponent. +/// +/// Status of the subcomponent. +/// Report remarks. +/// Time it took to complete health check for this subcomponent. +/// Addtional report data. public record HealthResponseEntry( HealthStatus Status, string? Description, diff --git a/api/Program.cs b/api/Program.cs index 6480ef1..ead0452 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -10,14 +10,17 @@ using Microsoft.AspNetCore.Mvc; WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); +// Adding static JSON serializer since we're running in Native AOT builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default) ); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); -builder.Services.AddValidation(); +builder.Services.AddOpenApi(); // OpenAPI specification generator +builder.Services.AddValidation(); // Request validation + +// Customizing non-200 responses to include trace identifier and a no-as-a-service reason builder.Services.AddProblemDetails(configure => { configure.CustomizeProblemDetails = context => @@ -28,57 +31,76 @@ builder.Services.AddProblemDetails(configure => }); builder.Services - .AddSingleton() - .AddScoped() - .AddScoped() - .AddScoped(); + .AddSingleton() // Service for tracking latest API request results + .AddScoped() // Service for generating a timetable + .AddScoped() // Service for making API calls to sut.ru API + .AddScoped(); // Service for parsing timetable from sut.ru builder.Services.AddHealthChecks() - .AddCheck("timetable_website"); + .AddCheck("timetable_website"); // Healthcheck service +// Get ORIGIN_DOMAIN environmental variable +string? corsDomain = Environment.GetEnvironmentVariable("ORIGIN_DOMAIN"); + +// Configure defautl CORS policy builder.Services.AddCors(options => options.AddDefaultPolicy(policy => + { + // Allow only GET requests with any headers policy .WithMethods(["GET"]) - .AllowAnyOrigin() - .AllowAnyHeader() - ) + .AllowAnyHeader(); + + // If ORIGIN_DOMAIN environmental variable is set, allow request only from this domain, + // otherwise allow request from any domains. + if (string.IsNullOrWhiteSpace(corsDomain)) + policy.AllowAnyOrigin(); + else + policy.WithOrigins(corsDomain); + }) ); WebApplication app = builder.Build(); -// Configure the HTTP request pipeline. -app.UseCors(); -app.UseStatusCodePages(); -app.MapOpenApi(); +// Configure the HTTP request pipeline +app.UseCors(); // Enable CORS +app.UseStatusCodePages(); // Enable default JSON response body for non-200 responses. +app.MapOpenApi(); // Map OpenAPI sepcification. Available at /openapi/v1.json +// Map healthcheck endpoint with custom response writer +// Remark: /health and /openapi/v1.json endpoints are not present in OpenAPI specification. app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse }); +// Request singleton services which will be used in subsequent endpoints ILogger logger = app.Services.GetRequiredService>(); IssueTrackingService tracker = app.Services.GetRequiredService(); + +// List of identifiers for tracking unique visits to /timetable endpoint (essentially, for unique user counting). List ids = []; +// Statistics endpoint. Shows number of active users. app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count))) .WithName("GetStats") .WithDescription("Get basic usage statistics.") .Produces(StatusCodes.Status200OK); +// Retrieve list of faculties and their IDs from sut.ru API app.MapGet("/faculties", async ([FromServices] ApiService apiService) => { try { Dictionary faculties = await apiService.GetFacultiesListAsync(); logger.LogInformation("Fetched {Count} faculties.", faculties.Count); - tracker.TrackFacultyFetch(true); + tracker.TrackFacultyFetch(true); // Record that last attempt to retrieve faculties was successful return Results.Ok(faculties); } catch (Exception ex) { logger.LogError(ex, "Failed to fetch faculties list."); - tracker.TrackFacultyFetch(false); + tracker.TrackFacultyFetch(false); // Record that last attempt to retrieve faculties was unsuccessful return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError); } }) @@ -87,19 +109,24 @@ app.MapGet("/faculties", async ([FromServices] ApiService apiService) => .ProducesProblem(StatusCodes.Status500InternalServerError) .Produces>(StatusCodes.Status200OK); -app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(0, 5)] int year) => +// Retrieve list of groups for a chosen faculty and year. +// Year can be in range from 1 to 5. +// If year is not specified, all groups for chosen faculty will be retrieved. +app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int? year) => { + year ??= 0; // Setting year to 0 (show all groups) if not specified in the request. + try { - Dictionary groups = await apiService.GetGroupsListAsync(facultyId, year); + Dictionary groups = await apiService.GetGroupsListAsync(facultyId, year.Value); logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year); - tracker.TrackGroupFetch(facultyId, year, true); + tracker.TrackGroupFetch(facultyId, year.Value, true); // Track whether retrieving groups for this specific faculty and year was successul or not. return Results.Ok(groups); } catch (Exception ex) { logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year); - tracker.TrackGroupFetch(facultyId, year, false); + tracker.TrackGroupFetch(facultyId, year.Value, false); // Track whether retrieving groups for this specific faculty and year was successul or not. return Results.Problem( "Failed to fetch groups list.", statusCode: StatusCodes.Status500InternalServerError, @@ -117,18 +144,22 @@ app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId .ProducesProblem(StatusCodes.Status500InternalServerError) .ProducesValidationProblem(); +// Retrieve timetable for specified group in form of iCal. app.MapGet("/timetable/{facultyId}/{groupId}", async ( int facultyId, int groupId, string? id, [FromServices] TimetableService timetableService ) => { - string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics"); - string? content = await timetableService.TryServingFromCacheAsync(groupId); + string? content = await timetableService.TryGetTimetableFromCacheAsync(groupId); bool hasId = !string.IsNullOrEmpty(id); + // If this is the first request with given id, we record it. + // "download" is a special "ID" that is used solely for downloading timetable as file. if (hasId && id is not "download" && !ids.Contains(id!)) ids.Add(id!); + // If we have a valid cache, we serve it, instead of retrieving new timetable from sut.ru API + // We don't serve cache for requests with no ID, since they have a special behavior. if (content is not null && hasId) { logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId); @@ -143,6 +174,9 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async ( content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true); else { + // For requests with no ID we append an event at 7pm that asks users to update their calendar URL, + // since now all /timetable requests must have one. + // This is a temporary behavior that will be changed to just sending 4xx response instead later. content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar => calendar.Events.Add(new() { @@ -172,19 +206,13 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async ( } logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId); - tracker.TrackTimetableFetch(facultyId, groupId, true); + tracker.TrackTimetableFetch(facultyId, groupId, true); // Track whether retrieving timetable for this specific group was successul or not. return Results.Text(content, contentType: "text/calendar"); } catch (Exception ex) { logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId); - tracker.TrackTimetableFetch(facultyId, groupId, false); - - if (content is not null) - { - logger.LogWarning("[Fallback] Serving timetable from cache ({CacheFile}).", cacheFile); - return Results.Text(content, contentType: "text/calendar"); - } + tracker.TrackTimetableFetch(facultyId, groupId, false); // Track whether retrieving timetable for this specific group was successul or not. return Results.Problem( "Failed to fetch timetable", @@ -203,4 +231,5 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async ( .ProducesProblem(StatusCodes.Status500InternalServerError) .ProducesValidationProblem(); +// Start the application. app.Run(); diff --git a/api/Services/ApiService.cs b/api/Services/ApiService.cs index e402983..80bd85d 100644 --- a/api/Services/ApiService.cs +++ b/api/Services/ApiService.cs @@ -4,8 +4,19 @@ using BonchCalendar.Utils; namespace BonchCalendar.Services; +// For better understanding of what is happening here, +// I recommend visiting https://cabinet.sut.ru/raspisanie_all_new.php +// and trying to send requests yourself. + +/// +/// Service for calling sut.ru API. +/// public class ApiService { + /// + /// Retrieve list of faculties and their IDs. + /// + /// A dictionary, where the key is faculty's ID and the value is faculty's name. public async Task> GetFacultiesListAsync() => ParseListResponse(await SendRequestAsync(new() { @@ -13,15 +24,30 @@ public class ApiService ["schet"] = GetCurrentSemesterId() })); - public async Task> GetGroupsListAsync(int facultyId, int course) => + /// + /// Retrieve list of groups for specified faculty and year. + /// + /// ID of selected faculty. + /// An academic year. Should be from 0 to 5. + /// A dictionary, where the key is group's ID and the value is group's name. + /// + /// If is set to 0, all groups for the specified faculty will be retrieved instead. + /// + public async Task> GetGroupsListAsync(int facultyId, int year) => ParseListResponse(await SendRequestAsync(new() { ["choice"] = "1", ["schet"] = GetCurrentSemesterId(), ["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups - ["kurs"] = course.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty. + ["kurs"] = year.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty. })); + /// + /// Retrieve timetable document for the specified group. + /// + /// ID of selected group. + /// Type of a timetable to retrieve. + /// A string, represeting raw HTML document, that contains the timetable. public async Task GetScheduleDocumentAsync(int groupId, TimetableType timetableType) => await SendRequestAsync(new() { @@ -30,14 +56,34 @@ public class ApiService ["group"] = groupId.ToString() }); + /// + /// Retrieve current semester start date. + /// + /// ID of a group. + /// A object, representing the first day of current semester. + /// + /// can be any valid group ID. We only need it for retrieving a correct HTML document. + /// public async Task GetSemesterStartDateAsync(int groupId) { using HttpClient client = new(); + // We go to this URL, since it has to contain current week number, + // which we can use to calculate the first day of the semester. + // If we don't specify group, we'll get a page listing all available groups, + // which doesn't contain current week number, thus, rendering it useless for us. string content = await client.GetStringAsync($"https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={groupId}"); using IHtmlDocument doc = new HtmlParser().ParseDocument(content); + + // 1. Get tag with id "rasp-prev" + // 2. Get it's neighbor
tag that is the second child of their parent tag + // 3. Get tag inside the
string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent; + + // Content of the tag is supposed to be something like "Нечетная неделя (15)" + // So, we can use regular expressions to get the "15" part and parse it to an integer. int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value); + DateTime currentDate = DateTime.Today; currentDate = currentDate .AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday @@ -46,6 +92,8 @@ public class ApiService return currentDate; } + // Utility method that converts faculty or group list response into a dictionary. + // It expected the reponse to be in format: "1,Group 1;2,Group2;..." private static Dictionary ParseListResponse(string responseContent) => responseContent .Split(';', StringSplitOptions.RemoveEmptyEntries) @@ -55,7 +103,8 @@ public class ApiService parts => parts[1] ); - public async Task SendRequestAsync(Dictionary formData) + // Utility method for sending request to sut.ru API. + private static async Task SendRequestAsync(Dictionary formData) { HttpRequestMessage request = new(HttpMethod.Post, "https://cabinet.sut.ru/raspisanie_all_new.php") { @@ -64,7 +113,8 @@ public class ApiService using HttpClient client = new(new HttpClientHandler { - // Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly + // Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly, + // so we just assume that we're in the right place. ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true }); @@ -81,11 +131,12 @@ public class ApiService ? 1 // August through January - first semester : 2; // Everything else - second - int termStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025) + int academicYearStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025) + // P.S. I am not a fun of this variable name either. if (now.Month < 8) // Before August means we are in the second semester of the previous academic year - termStartYear--; + academicYearStartYear--; - return $"205.{termStartYear}{termStartYear + 1}/{currentSemester}"; + return $"205.{academicYearStartYear}{academicYearStartYear + 1}/{currentSemester}"; } } diff --git a/api/Services/IssueTrackingService.cs b/api/Services/IssueTrackingService.cs index 60d80e4..b1e5e64 100644 --- a/api/Services/IssueTrackingService.cs +++ b/api/Services/IssueTrackingService.cs @@ -1,5 +1,8 @@ namespace BonchCalendar.Services; +/// +/// Service that tracks results of the most recent requests. +/// public class IssueTrackingService { private bool _isLastFacultyFetchSuccessful = true; @@ -8,12 +11,22 @@ public class IssueTrackingService private readonly List _unsuccessfulTimetableFetches = []; + /// + /// Record whether the last attempt to retrieve faculty list was successful. + /// + /// Set true if the attempt was successful. Otherwise, set false public void TrackFacultyFetch(bool isSuccessful) => _isLastFacultyFetchSuccessful = isSuccessful; - public void TrackGroupFetch(int facultyId, int course, bool isSuccessful) + /// + /// Record whether the last attempt to retrieve groups for provided faculty and term year was successful. + /// + /// ID of a faculty which was used to retrieve the group list. + /// Term year which was used to retrieve the group list. + /// Set true if the attempt was successful. Otherwise, set false + public void TrackGroupFetch(int facultyId, int termYear, bool isSuccessful) { - string key = $"{facultyId}/{course}"; + string key = $"{facultyId}/{termYear}"; if (!isSuccessful) { @@ -24,6 +37,12 @@ public class IssueTrackingService _unsuccessfulGroupFetches.Remove(key); } + /// + /// Record whether the last attempt to retrieve timetable for provided group was successful. + /// + /// ID of a faculty the group belongs to. + /// ID of a group the timetable was retrieved for. + /// Set true if the attempt was successful. Otherwise, set false public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful) { string key = $"{facultyId}/{groupId}"; @@ -37,6 +56,13 @@ public class IssueTrackingService _unsuccessfulTimetableFetches.Remove(key); } + /// + /// Get report on the success of latest retrieval attempts. + /// + /// A dictionary for each of the tracked groups. + /// + /// If the dictionary is empty, that means that there're no known issues. + /// public Dictionary GetReport() { Dictionary report = []; @@ -50,6 +76,26 @@ public class IssueTrackingService if (_unsuccessfulTimetableFetches.Count > 0) report.Add("/timetable", _unsuccessfulTimetableFetches.ToArray()); + // No issues example: + /* + * { } + */ + + // Report example with issues: + /* + * { + * "/faculties": false, + * "/groups": [ + * "123/1", + * "321/3" + * ], + * "/timetable": [ + * "123/321", + * "456/654" + * ], + * } + */ + return report; } } diff --git a/api/Services/ParsingService.cs b/api/Services/ParsingService.cs index d72cd05..e658deb 100644 --- a/api/Services/ParsingService.cs +++ b/api/Services/ParsingService.cs @@ -11,6 +11,13 @@ namespace BonchCalendar.Services; public partial class ParsingService { + /// + /// Parse general timetable document. + /// + /// HTML document retrieved from the API. + /// that represents the first day of current semester. + /// Name of a group this timetable is for. + /// An array of s public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName) { using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml); @@ -27,7 +34,7 @@ public partial class ParsingService Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText); string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText; (TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ? - ParserUtils.GetTimesFromLabel(timeLabelText) : + ParserUtils.GetTimesFromLabel(timeLabelText) : // If the label for some reason doesn't contain start and end time, we can infer it from class' number ( TimeSpan.Parse(timeMatch.Groups["start"].Value), TimeSpan.Parse(timeMatch.Groups["end"].Value) @@ -44,16 +51,26 @@ public partial class ParsingService .AddDays((week - 1) * 7) // Move to the correct week .AddDays(weekday - 1); // Move to the correct weekday - classes.Add(GetEvent( - $"{number}. {className} ({classType})", auditorium, - GetDescription(groupName, professors, auditorium, weeks), - classDate, startTime, endTime)); + classes.Add(CreateEvent( + title: $"{number}. {className} ({classType})", + location: auditorium, + description: CreateDescription(groupName, professors, auditorium, weeks), + date: classDate, + startTime, + endTime + )); } } return [.. classes]; } + /// + /// Parse exam timetable document. + /// + /// HTML document, retrieved from the API. + /// Name of a group this timetable is for. + /// An array of s public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName) { using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml); @@ -77,26 +94,32 @@ public partial class ParsingService TimeSpan startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':')); TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':')); - classes.Add(GetEvent( - $"{number}{className} ({classType})", auditorium, - GetDescription(groupName, professors, auditorium), - classDate, startTime, endTime)); + classes.Add(CreateEvent( + title: $"{number}{className} ({classType})", + location: auditorium, + description: CreateDescription(groupName, professors, auditorium), + date: classDate, + startTime, + endTime + )); } return [.. classes]; } - private static CalendarEvent GetEvent(string title, string auditorium, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) => + // Create a calendar event + private static CalendarEvent CreateEvent(string title, string location, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) => new() { Summary = title, Description = description, Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()), End = new CalDateTime(date.Add(endTime - TimeSpan.FromHours(3)).ToUniversalTime()), - Location = auditorium + Location = location }; - private static string GetDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null) + // Create event description + private static string CreateDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null) { string str = $""" Группа: {groupName} @@ -107,22 +130,26 @@ public partial class ParsingService if (weeks is not null && weeks.Length > 0) str += $"\nНедели: {string.Join(", ", weeks)}"; + // Attempt to recognize wing and room number Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium); if (!auditoriumMatch.Success) auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium); + // If successful, we can add a nav.sut.ru map link if (auditoriumMatch.Success) str += "\n\n" + $""" ГУТ.Навигатор: https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value} """; + // Some shameless self-promotion str += "\n\n" + "Создано при помощи сервиса Бонч.Календарь: https://bonch.xfox111.net"; return str; } + // Parse basic info for a class private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement) { string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty; diff --git a/api/Services/TimetableService.cs b/api/Services/TimetableService.cs index c589dfa..95866b4 100644 --- a/api/Services/TimetableService.cs +++ b/api/Services/TimetableService.cs @@ -4,6 +4,9 @@ using Ical.Net.Serialization; namespace BonchCalendar.Services; +/// +/// Service for retrieving timetable. +/// public class TimetableService( ApiService apiService, ParsingService parsingService, @@ -11,7 +14,12 @@ public class TimetableService( IHostEnvironment environment ) { - public async Task TryServingFromCacheAsync(int groupId) + /// + /// Try to retrieve timetable from application's cache. + /// + /// ID of a group to retrieve timetable for. + /// null if cache for this timetable is not present, or is older than 6 hours. Otherwise, timetable content in iCal format. + public async Task TryGetTimetableFromCacheAsync(int groupId) { string cacheFile = GetCachePath(groupId); @@ -24,17 +32,28 @@ public class TimetableService( return null; } + logger.LogInformation("Calendar for group {GroupId} is present in cache ({CacheFile}).", groupId, cacheFile); + return await File.ReadAllTextAsync(cacheFile); } + /// + /// Retrieve timetable for specified group from sut.ru API. + /// + /// If set to true, result timetable will be wirtten to cache for that group. + /// Action delegate that can be used to manipulate the result object, before converting it to iCal. + /// A string that contains timetable in iCal format. public async Task GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action? transform = null) { + // We need semester start date, since the regular timetable is represented on sut.ru semester week numbers. DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId); string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId]; + // Retrieve and parse regular timetable first, since it's the only timetable that has different structure. string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes); List timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)]; + // Retrieve and parse other timetables using a loop, since they all can be parsed using the same parser. TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural]; foreach (TimetableType type in types) { @@ -42,13 +61,17 @@ public class TimetableService( timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName)); } + // Create and configure the calendar. Calendar calendar = new(); calendar.AddTimeZone(new VTimeZone("Europe/Moscow")); calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName)); calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow")); - calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H")); + calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H")); // Specifies how often calendar client should poll for new timetable. calendar.Events.AddRange(timetable); - transform?.Invoke(calendar); + + transform?.Invoke(calendar); // If transform delegate is not null, invoke it. + + // Serialize calendar to iCal format. string content = new CalendarSerializer().SerializeToString(calendar)!; if (saveToCache) diff --git a/api/StatsResponse.cs b/api/StatsResponse.cs index bf2d580..f3eb6dd 100644 --- a/api/StatsResponse.cs +++ b/api/StatsResponse.cs @@ -1,3 +1,7 @@ namespace BonchCalendar; +/// +/// Response body object for /stats endpoint. +/// +/// Number of active users. public record StatsResponse(int ActiveUsers); diff --git a/api/TimetableType.cs b/api/TimetableType.cs index dc0d68e..61b3260 100644 --- a/api/TimetableType.cs +++ b/api/TimetableType.cs @@ -1,9 +1,27 @@ namespace BonchCalendar; +/// +/// Types of timetable documents retrieved from sut.ru API. +/// public enum TimetableType { + /// + /// Regular timetable document (Занятия). + /// Classes = 1, + + /// + /// Exams timetable document (Экзаменационная сессия). + /// Exams = 2, + + /// + /// Exams timetable for extramural students document (Сессия для заочников). + /// ExamsForExtramural = 4, + + /// + /// Attestations timetable document (Зачеты). + /// Attestations = 14 } diff --git a/api/Utils/ParserUtils.cs b/api/Utils/ParserUtils.cs index 1a4c8e9..deef33b 100644 --- a/api/Utils/ParserUtils.cs +++ b/api/Utils/ParserUtils.cs @@ -2,8 +2,20 @@ using System.Text.RegularExpressions; namespace BonchCalendar.Utils; +/// +/// Utility methods for timetable parser. +/// public static partial class ParserUtils { + /// + /// Get class start and end times from class' number label. + /// + /// Class' number label. + /// A tuple value of start and end times. + /// + /// This method is only supposed to be used as a fallback method of determening class' time + /// + /// Unknown label encountered. public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label) { (string startTime, string endTime) = label switch diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/api/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/api/appsettings.json b/api/appsettings.json index 74084f2..9c3a5de 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -3,9 +3,6 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" - }, - "Console": { - "IncludeScopes": true } }, "AllowedHosts": "*" diff --git a/api/sut.ru.http b/api/sut.ru.http new file mode 100644 index 0000000..2959db3 --- /dev/null +++ b/api/sut.ru.http @@ -0,0 +1,69 @@ +### This file contains exampels of HTTP requests to sut.ru API. +# You can use them for reference and better understanding of what I have to deal with. +# You can use a vscode extension (like REST Client) to make this file interactive. + +# Current semester "ID" (must be updated before sending requests) +@schet=205.2526/2 + +# Breakdown: +# "205." part is static +# "2526" represents current academic year (2025-2026 in this case) +# "/2" represents current semester. Can be either "/1" (first semester) or "/2" (second semester) +# When making requests this ID must always point to current semester, otherwise you may get a broken response. + +# Tip: +# From August through January is considered to be the first semester +# Other months (February-July) are considered to be the second semester + +### + +# Get list of faculties +POST https://cabinet.sut.ru/raspisanie_all_new.php +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 + +choice=1&schet={{schet}} + +### + +# Get list of groups for faculty + +# Year filters out groups by the term year they are at. +# Year should be an integer from 0 to 5, inclusive. +# If year is set to 0, all groups for the chosen faculty will be received instead. + +@facultyId=50029 +@year=0 +POST https://cabinet.sut.ru/raspisanie_all_new.php +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 + +choice=1&schet={{schet}}&faculty={{facultyId}}&kurs={{year}} + +### + +# Get timetable for selected group + +# Type here can be on of the following: +# 1 - for regular timetable (Занятия) +# 2 - for exams timetable (Экзаменационная сессия) +# 4 - for exams timetable for extramural students (Сессия для заочников) +# 14 - for attestations timetable (Зачеты) + +@type=1 +@groupId=55512 +POST https://cabinet.sut.ru/raspisanie_all_new.php +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 + +schet={{schet}}&type_z={{type}}&group={{groupId}} + +### + +# Get page that contains current week number + +# We use this page because it's the only known page that contains publicly accessible semester week number. +# Since regular timetable doesn't show normal dates for classes, and instead uses week numbers, +# we need to know a date of the first day of current semester to calculate dates for them +# (e.g. 3 week tuesday is = first day + 3 * 7 + 1) + +# Since we know current date and weekday, by knowing week number we can calculate date for the first day. + +GET https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={{groupId}} diff --git a/app/.env b/app/.env index 9fb1467..a75029b 100644 --- a/app/.env +++ b/app/.env @@ -1,2 +1,2 @@ -# VITE_BACKEND_HOST=https://api.bonch.xfox111.net +VITE_BACKEND_HOST=https://api.bonch.xfox111.net VITE_BACKEND_HOST=http://localhost:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 806a58d..b8bccdd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ services: image: xfox111/bonch-calendar-api:latest build: context: ./api + environment: + - ORIGIN_DOMAIN=localhost:8000 ports: - 8080:8080