Compare commits

...

23 Commits

Author SHA1 Message Date
885b937b0b feat: add parsing from files 2025-02-03 03:44:40 +03:00
dc08285ec8 feat: clear old records 2025-02-02 20:31:52 +03:00
b3a0964aac fix: correct filter data 2025-02-02 20:28:04 +03:00
7d6b21c5bb fix: move from body to query 2025-02-02 04:51:09 +03:00
93912caf01 fix: return correct value 2025-02-02 04:50:54 +03:00
c725cfed32 refactor: increase max value 2025-02-02 04:50:35 +03:00
7c7707b1e2 fix: if delay more than int set max of int 2025-02-02 04:50:04 +03:00
1687e9d89b fix: continue if in filter exist value 2025-02-02 04:49:25 +03:00
8d1b709b43 feat: add start term update and cron schedule update 2025-02-02 03:39:30 +03:00
ce6b0f2673 feat: add cron skipping date 2025-02-02 03:30:52 +03:00
16afc0bc69 feat: show enum name instead value 2025-02-02 03:29:19 +03:00
c9bc6a3565 refactor: remove "swagger" in class name 2025-02-02 03:28:24 +03:00
ad8f356fc1 fix: get non negative number 2025-02-02 01:57:08 +03:00
dda0a29300 refactor: subscribe to onChange instead of waiting for the event to be received from the manager 2025-02-01 21:23:51 +03:00
369901db78 fix: set long, because the value may be greater than int 2025-02-01 21:19:56 +03:00
a67b72b7fb refactor: rename cancellation to cancellationToken 2025-02-01 21:18:56 +03:00
2453b2bd51 build: upgrade ref 2025-02-01 20:47:25 +03:00
5870eef552 feat: add a tag schema to combine similar controllers. 2025-02-01 20:47:08 +03:00
52de98969d refactor: remove unused brackets 2025-02-01 20:45:08 +03:00
bc86e077bd refactor: move to SetupConfiguration namespace 2025-02-01 19:39:02 +03:00
03b6560bc4 feat: add lesson type controller 2025-02-01 17:08:00 +03:00
5bcb7bfbc1 feat: allow filter by lesson type 2025-02-01 17:06:02 +03:00
38fba5556f feat: add filter by type of occupation (lesson type) 2025-02-01 16:46:20 +03:00
32 changed files with 994 additions and 132 deletions

@ -0,0 +1,24 @@
using System;
namespace Mirea.Api.Dto.Common;
/// <summary>
/// Represents a date or date range to skip during cron update scheduling.
/// </summary>
public class CronUpdateSkip
{
/// <summary>
/// Gets or sets the start date of the skip range.
/// </summary>
public DateOnly? Start { get; set; }
/// <summary>
/// Gets or sets the end date of the skip range.
/// </summary>
public DateOnly? End { get; set; }
/// <summary>
/// Gets or sets a specific date to skip.
/// </summary>
public DateOnly? Date { get; set; }
}

@ -8,30 +8,30 @@ public class ScheduleRequest
/// <summary>
/// Gets or sets an array of group IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Groups { get; set; } = null;
/// <summary>
/// Gets or sets a value indicating whether to retrieve schedules for even weeks.
/// </summary>
/// <remarks>This property can contain null.</remarks>
public bool? IsEven { get; set; } = null;
/// <summary>
/// Gets or sets an array of discipline IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Disciplines { get; set; } = null;
/// <summary>
/// Gets or sets an array of professor IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? Professors { get; set; } = null;
/// <summary>
/// Gets or sets an array of lecture hall IDs.
/// </summary>
/// <remarks>This array can contain null values.</remarks>
public int[]? LectureHalls { get; set; } = null;
/// <summary>
/// Gets or sets an array of lesson type IDs.
/// </summary>
public int[]? LessonType { get; set; } = null;
}

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses.Configuration;
/// <summary>
/// Represents the response containing the cron update schedule and the next scheduled task dates.
/// </summary>
public class CronUpdateScheduleResponse
{
/// <summary>
/// Gets or sets the cron expression representing the update schedule.
/// </summary>
[Required]
public required string Cron { get; set; }
/// <summary>
/// Gets or sets the list of next scheduled task dates based on the cron expression.
/// </summary>
[Required]
public required List<DateTime> NextStart { get; set; }
}

@ -3,7 +3,7 @@
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents basic information about a faculty.
/// Represents information about a faculty.
/// </summary>
public class FacultyResponse
{

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Mirea.Api.Dto.Responses;
/// <summary>
/// Represents information about a lesson type.
/// </summary>
public class LessonTypeResponse
{
/// <summary>
/// Gets or sets the unique identifier of the lesson type.
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the lesson type.
/// </summary>
[Required]
public required string Name { get; set; }
}

@ -0,0 +1,19 @@
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.MapperDto;
public static class CronUpdateSkipConverter
{
public static List<Dto.Common.CronUpdateSkip> ConvertToDto(this IEnumerable<ScheduleSettings.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => new Dto.Common.CronUpdateSkip()
{
Start = x.Start,
End = x.End,
Date = x.Date
}).ToList();
public static List<ScheduleSettings.CronUpdateSkip> ConvertFromDto(this IEnumerable<Dto.Common.CronUpdateSkip> pairPeriod) =>
pairPeriod.Select(x => x.Get()).ToList();
}

@ -0,0 +1,79 @@
using Cronos;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Mirea.Api.Endpoint.Common.Services;
public static class CronUpdateSkipService
{
public static ScheduleSettings.CronUpdateSkip Get(this Dto.Common.CronUpdateSkip date)
{
if (date.Date.HasValue)
return new ScheduleSettings.CronUpdateSkip(date.Date.Value);
if (date is { Start: not null, End: not null })
return new ScheduleSettings.CronUpdateSkip(date.Start.Value, date.End.Value);
throw new ArgumentException("It is impossible to create a structure because it has incorrect values.");
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.OrderBy(x => x.End ?? x.Date)
.Where(x => x.Date == currentDate || (x.Start <= currentDate && x.End >= currentDate))
.ToList();
}
public static List<ScheduleSettings.CronUpdateSkip> FilterDateEntry(this List<ScheduleSettings.CronUpdateSkip> data, DateTime? currentDate = null) =>
data.FilterDateEntry(DateOnly.FromDateTime(currentDate ?? DateTime.Now));
public static List<ScheduleSettings.CronUpdateSkip> Filter(this List<ScheduleSettings.CronUpdateSkip> data, DateOnly? currentDate = null)
{
currentDate ??= DateOnly.FromDateTime(DateTime.Now);
return data.Where(x => x.Date >= currentDate || x.End >= currentDate)
.OrderBy(x => x.End ?? x.Date)
.ToList();
}
public static List<DateTimeOffset> GetNextTask(this List<ScheduleSettings.CronUpdateSkip> data,
CronExpression expression, int depth = 1, DateOnly? currentDate = null)
{
if (depth <= 0)
return [];
currentDate ??= DateOnly.FromDateTime(DateTime.UtcNow);
DateTimeOffset nextRunTime = currentDate.Value.ToDateTime(new TimeOnly(0, 0, 0));
List<DateTimeOffset> result = [];
do
{
var lastSkip = data.FilterDateEntry(nextRunTime.DateTime).LastOrDefault();
if (lastSkip is { Start: not null, End: not null })
nextRunTime = new DateTimeOffset(lastSkip.End.Value.AddDays(1), new TimeOnly(0, 0, 0), TimeSpan.Zero);
else if (lastSkip.Date.HasValue)
nextRunTime = new DateTimeOffset(lastSkip.Date.Value.AddDays(1), new TimeOnly(0, 0, 0), TimeSpan.Zero);
var next = expression.GetNextOccurrence(nextRunTime, TimeZoneInfo.Local);
if (!next.HasValue)
return result;
nextRunTime = next.Value;
if (data.FilterDateEntry(nextRunTime.DateTime).Any())
continue;
result.Add(nextRunTime);
nextRunTime = nextRunTime.AddMinutes(1);
} while (result.Count < depth);
return result;
}
}

@ -4,12 +4,7 @@ namespace Mirea.Api.Endpoint.Common.Services;
public static class ScheduleSyncManager
{
public static event Action? OnUpdateIntervalRequested;
public static event Action? OnForceSyncRequested;
public static void RequestIntervalUpdate() =>
OnUpdateIntervalRequested?.Invoke();
public static void RequestForceSync() =>
OnForceSyncRequested?.Invoke();
}

@ -5,8 +5,11 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -15,23 +18,46 @@ namespace Mirea.Api.Endpoint.Configuration.Core.BackgroundTasks;
public class ScheduleSyncService : IHostedService, IDisposable
{
private Timer? _timer;
private readonly IOptionsMonitor<GeneralConfig> _generalConfigMonitor;
private string _cronUpdate;
private List<ScheduleSettings.CronUpdateSkip> _cronUpdateSkip;
private readonly ILogger<ScheduleSyncService> _logger;
private CancellationTokenSource _cancellationTokenSource = new();
private readonly IServiceProvider _serviceProvider;
private readonly IDisposable? _onChangeUpdateCron;
public ScheduleSyncService(IOptionsMonitor<GeneralConfig> generalConfigMonitor, ILogger<ScheduleSyncService> logger, IServiceProvider serviceProvider)
{
_generalConfigMonitor = generalConfigMonitor;
_logger = logger;
_serviceProvider = serviceProvider;
_cronUpdate = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSchedule;
_cronUpdateSkip = generalConfigMonitor.CurrentValue.ScheduleSettings!.CronUpdateSkipDateList;
ScheduleSyncManager.OnForceSyncRequested += OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested += OnUpdateIntervalRequested;
_onChangeUpdateCron = generalConfigMonitor.OnChange((config) =>
{
var updated = false;
if (config.ScheduleSettings?.CronUpdateSchedule != null && _cronUpdate != config.ScheduleSettings.CronUpdateSchedule)
{
_cronUpdate = config.ScheduleSettings.CronUpdateSchedule;
updated = true;
}
if (config.ScheduleSettings?.CronUpdateSkipDateList != null && !config.ScheduleSettings.CronUpdateSkipDateList.SequenceEqual(_cronUpdateSkip))
{
_cronUpdateSkip = config.ScheduleSettings.CronUpdateSkipDateList
.OrderBy(x => x.End ?? x.Date)
.ToList();
updated = true;
}
if (updated)
OnUpdateIntervalRequested();
});
}
private void OnForceSyncRequested()
{
_logger.LogInformation("It was requested to synchronize the data immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
_cancellationTokenSource = new CancellationTokenSource();
@ -41,6 +67,7 @@ public class ScheduleSyncService : IHostedService, IDisposable
private void OnUpdateIntervalRequested()
{
_logger.LogInformation("It was requested to update the time interval immediately.");
StopAsync(CancellationToken.None).ContinueWith(_ =>
{
StartAsync(CancellationToken.None);
@ -49,31 +76,33 @@ public class ScheduleSyncService : IHostedService, IDisposable
private void ScheduleNextRun()
{
var cronExpression = _generalConfigMonitor.CurrentValue.ScheduleSettings?.CronUpdateSchedule;
if (string.IsNullOrEmpty(cronExpression))
if (string.IsNullOrEmpty(_cronUpdate))
{
_logger.LogWarning("Cron expression is not set. The scheduled task will not run.");
return;
}
var nextRunTime = CronExpression.Parse(cronExpression).GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
var expression = CronExpression.Parse(_cronUpdate);
if (!nextRunTime.HasValue)
var nextRunTime = _cronUpdateSkip.GetNextTask(expression).FirstOrDefault();
if (nextRunTime == default)
{
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}", TimeZoneInfo.Local.DisplayName);
_logger.LogWarning("No next run time found. The task will not be scheduled. Timezone: {TimeZone}",
TimeZoneInfo.Local.DisplayName);
return;
}
_logger.LogInformation("Next task run in {Time}", nextRunTime.Value.ToString("G"));
_logger.LogInformation("Next task run in {Time}", nextRunTime.ToString("G"));
var delay = (nextRunTime.Value - DateTimeOffset.Now).TotalMilliseconds;
var delay = (nextRunTime - DateTimeOffset.Now).TotalMilliseconds;
// The chance is small, but it's better to check
if (delay <= 0)
delay = 1;
_cancellationTokenSource = new CancellationTokenSource();
_timer = new Timer(ExecuteTask, null, (int)delay, Timeout.Infinite);
_timer = new Timer(ExecuteTask, null, delay > int.MaxValue ? int.MaxValue : (int)delay, Timeout.Infinite);
}
private async void ExecuteTask(object? state)
@ -112,7 +141,7 @@ public class ScheduleSyncService : IHostedService, IDisposable
StopAsync(CancellationToken.None).GetAwaiter().GetResult();
_timer?.Dispose();
ScheduleSyncManager.OnForceSyncRequested -= OnForceSyncRequested;
ScheduleSyncManager.OnUpdateIntervalRequested -= OnUpdateIntervalRequested;
_onChangeUpdateCron?.Dispose();
_cancellationTokenSource.Dispose();
GC.SuppressFinalize(this);

@ -17,9 +17,11 @@ public static class SwaggerConfiguration
{
services.AddSwaggerGen(options =>
{
options.SchemaFilter<SwaggerExampleFilter>();
options.OperationFilter<SwaggerDefaultValues>();
options.OperationFilter<TagSchemeFilter>();
options.SchemaFilter<ExampleFilter>();
options.OperationFilter<DefaultValues>();
options.OperationFilter<ActionResultSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
var basePath = AppDomain.CurrentDomain.BaseDirectory;
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme

@ -31,9 +31,33 @@ public class ScheduleSettings : IIsConfigured
public PairPeriodTime(Dto.Common.PairPeriodTime time) : this(time.Start, time.End) { }
}
public record struct CronUpdateSkip
{
public DateOnly? Start { get; set; }
public DateOnly? End { get; set; }
public DateOnly? Date { get; set; }
public CronUpdateSkip(DateOnly d1, DateOnly d2)
{
if (d1 > d2)
{
Start = d2;
End = d1;
}
else
{
Start = d1;
End = d2;
}
}
public CronUpdateSkip(DateOnly d1) => Date = d1;
}
public required string CronUpdateSchedule { get; set; }
public DateOnly StartTerm { get; set; }
public required IDictionary<int, PairPeriodTime> PairPeriod { get; set; }
public List<CronUpdateSkip> CronUpdateSkipDateList { get; set; } = [];
public bool IsConfigured()
{

@ -12,9 +12,7 @@ public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) :
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
}
}
private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)

@ -8,7 +8,7 @@ using System.Text.Json;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerDefaultValues : IOperationFilter
public class DefaultValues : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
@ -23,16 +23,12 @@ public class SwaggerDefaultValues : IOperationFilter
foreach (var contentType in response.Content.Keys)
{
if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType))
{
response.Content.Remove(contentType);
}
}
}
if (operation.Parameters == null)
{
return;
}
foreach (var parameter in operation.Parameters)
{

@ -0,0 +1,28 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System;
using System.Linq;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class EnumSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (!context.Type.IsEnum)
return;
schema.Enum.Clear();
var enumValues = Enum.GetNames(context.Type)
.Select(name => new OpenApiString(name))
.ToList();
foreach (var value in enumValues)
schema.Enum.Add(value);
schema.Type = "string";
schema.Format = null;
}
}

@ -5,7 +5,7 @@ using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class SwaggerExampleFilter : ISchemaFilter
public class ExampleFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Linq;
using System.Reflection;
namespace Mirea.Api.Endpoint.Configuration.SwaggerOptions;
public class TagSchemeFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.ApiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
return;
var controllerType = controllerActionDescriptor.ControllerTypeInfo;
var tagsAttribute = controllerType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute == null)
{
var baseType = controllerType.BaseType;
while (baseType != null)
{
tagsAttribute = baseType.GetCustomAttributes<TagsAttribute>(inherit: true).FirstOrDefault();
if (tagsAttribute != null)
break;
baseType = baseType.BaseType;
}
}
if (tagsAttribute == null)
return;
operation.Tags ??= [];
operation.Tags.Add(new OpenApiTag { Name = tagsAttribute.Tags[0] });
}
}

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Mirea.Api.Endpoint.Controllers;
[Route("api/v{version:apiVersion}/Configuration/[controller]")]
[Authorize]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Tags("Configuration")]
public class ConfigurationBaseController : BaseController;

@ -39,7 +39,7 @@ using CookieOptions = Microsoft.AspNetCore.Http.CookieOptions;
using OAuthProvider = Mirea.Api.Security.Common.Domain.OAuthProvider;
using PasswordPolicy = Mirea.Api.Dto.Common.PasswordPolicy;
namespace Mirea.Api.Endpoint.Controllers.Configuration;
namespace Mirea.Api.Endpoint.Controllers.SetupConfiguration;
[ApiVersion("1.0")]
[MaintenanceModeIgnore]

@ -0,0 +1,206 @@
using Asp.Versioning;
using Cronos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Dto.Common;
using Mirea.Api.Dto.Responses.Configuration;
using Mirea.Api.Endpoint.Common.Exceptions;
using Mirea.Api.Endpoint.Common.MapperDto;
using Mirea.Api.Endpoint.Common.Services;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Configuration.Model.GeneralSettings;
using Mirea.Api.Endpoint.Sync;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1.Configuration;
[ApiVersion("1.0")]
public class ScheduleController(ILogger<ScheduleController> logger, IOptionsSnapshot<GeneralConfig> config, UberDbContext dbContext, IServiceProvider provider) : ConfigurationBaseController
{
/// <summary>
/// Retrieves the cron update schedule and calculates the next scheduled tasks based on the provided depth.
/// </summary>
/// <param name="depth">The depth of the next tasks to retrieve.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
[HttpGet("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery][Range(0, 10)] int depth = 5)
{
var cronExpression = CronExpression.Parse(config.Value.ScheduleSettings!.CronUpdateSchedule);
var nextTasks = config.Value.ScheduleSettings!.CronUpdateSkipDateList.GetNextTask(cronExpression, depth);
return new CronUpdateScheduleResponse()
{
Cron = config.Value.ScheduleSettings!.CronUpdateSchedule,
NextStart = nextTasks.Select(x => DateTime.SpecifyKind(x.DateTime, DateTimeKind.Local)).ToList()
};
}
/// <summary>
/// Updates the cron update schedule with the provided cron expression.
/// </summary>
/// <param name="cron">The cron expression to set as the new schedule.</param>
/// <returns>Cron expression and the list of next scheduled task dates.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided cron expression is invalid.</exception>
[HttpPost("CronUpdateSchedule")]
public ActionResult<CronUpdateScheduleResponse> CronUpdateSchedule([FromQuery] string cron)
{
cron = cron.Trim();
if (!CronExpression.TryParse(cron, CronFormat.Standard, out _))
throw new ControllerArgumentException("Incorrect cron value.");
if (config.Value.ScheduleSettings!.CronUpdateSchedule == cron)
return CronUpdateSchedule();
config.Value.ScheduleSettings!.CronUpdateSchedule = cron;
config.Value.SaveSetting();
return CronUpdateSchedule();
}
/// <summary>
/// Retrieves the start term date from the configuration.
/// </summary>
/// <returns>Start term date.</returns>
[HttpGet("StartTerm")]
public ActionResult<DateOnly> StartTerm() =>
config.Value.ScheduleSettings!.StartTerm;
/// <summary>
/// Updates the start term date in the configuration.
/// </summary>
/// <param name="startTerm">The new start term date to set.</param>
/// <param name="force">If true, forces an update by deleting all existing lessons.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the start term date is more than 6 months in the past or future.</exception>
[HttpPost("StartTerm")]
public ActionResult StartTerm([FromQuery] DateOnly startTerm, [FromQuery] bool force = false)
{
var differentByTime = DateTime.Now - startTerm.ToDateTime(new TimeOnly(0, 0, 0));
if (differentByTime > TimeSpan.FromDays(190) || differentByTime.Multiply(-1) > TimeSpan.FromDays(190))
throw new ControllerArgumentException("The semester can't start more than 6 months from now, and it can't have started more than 6 months ago either.");
config.Value.ScheduleSettings!.StartTerm = startTerm;
config.Value.SaveSetting();
if (!force)
return Ok();
logger.LogWarning("A force update is being performed at the beginning of the semester (all classes will be deleted).");
dbContext.Lessons.RemoveRange(dbContext.Lessons.ToList());
dbContext.SaveChanges();
return Ok();
}
/// <summary>
/// Retrieves the list of cron update skip dates filtered by the current date.
/// </summary>
/// <returns>Cron update skip dates.</returns>
[HttpGet("CronUpdateSkip")]
public ActionResult<List<CronUpdateSkip>> CronUpdateSkip()
{
var generalConfig = config.Value;
generalConfig.ScheduleSettings!.CronUpdateSkipDateList =
generalConfig.ScheduleSettings.CronUpdateSkipDateList.Filter();
generalConfig.SaveSetting();
return generalConfig.ScheduleSettings!.CronUpdateSkipDateList
.ConvertToDto();
}
/// <summary>
/// Updates the list of cron update skip dates in the configuration.
/// </summary>
/// <param name="cronUpdateDate">The list of cron update skip dates to set.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">Thrown if the provided list of cron update skip dates is invalid.</exception>
[HttpPost("CronUpdateSkip")]
public ActionResult CronUpdateSkip([FromBody] List<CronUpdateSkip> cronUpdateDate)
{
List<ScheduleSettings.CronUpdateSkip> result;
try
{
result = cronUpdateDate.ConvertFromDto();
}
catch (ArgumentException ex)
{
throw new ControllerArgumentException(ex.Message);
}
config.Value.ScheduleSettings!.CronUpdateSkipDateList = result.Filter();
config.Value.SaveSetting();
return Ok();
}
/// <summary>
/// Uploads schedule files and initiates synchronization.
/// </summary>
/// <param name="files">The list of schedule files to upload.</param>
/// <param name="defaultCampus">The default campus for each uploaded file. Must match the number of files.</param>
/// <param name="force">If true, removes all existing lessons before synchronization. Default is false.</param>
/// <returns>Success or failure.</returns>
/// <exception cref="ControllerArgumentException">
/// Thrown if:
/// - No files are provided.
/// - The number of default campuses does not match the number of files.
/// - Any default campus is null or empty.
/// </exception>
[HttpPost("Upload")]
public async Task<ActionResult> UploadScheduleFiles(List<IFormFile>? files, [FromQuery] string[]? defaultCampus, [FromQuery] bool force = false)
{
if (files == null || files.Count == 0)
throw new ControllerArgumentException("No files were found.");
if (defaultCampus == null || files.Count != defaultCampus.Length)
throw new ControllerArgumentException("No default campuses are specified for the file.");
if (defaultCampus.Any(string.IsNullOrEmpty))
throw new ControllerArgumentException("Each file should have a default campus.");
var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
if (!Directory.Exists(tempDirectory))
Directory.CreateDirectory(tempDirectory);
List<(string, string)> filePaths = [];
for (var i = 0; i < files.Count; i++)
{
if (files[i].Length <= 0)
continue;
var filePath = Path.Combine(tempDirectory, files[i].FileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await files[i].CopyToAsync(stream);
filePaths.Add((filePath, defaultCampus[i]));
}
var sync = (ScheduleSynchronizer)ActivatorUtilities.GetServiceOrCreateInstance(provider, typeof(ScheduleSynchronizer));
if (force)
{
dbContext.Lessons.RemoveRange(dbContext.Lessons.ToList());
await dbContext.SaveChangesAsync();
}
_ = sync.StartSync(filePaths, CancellationToken.None);
return Ok();
}
}

@ -48,7 +48,8 @@ public class ImportController(IMediator mediator, IOptionsSnapshot<GeneralConfig
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;

@ -0,0 +1,43 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
using Mirea.Api.Dto.Responses;
using Mirea.Api.Endpoint.Common.Attributes;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace Mirea.Api.Endpoint.Controllers.V1;
[ApiVersion("1.0")]
[CacheMaxAge(true)]
public class LessonTypeController(IMediator mediator) : BaseController
{
/// <summary>
/// Gets a paginated list of type of occupation.
/// </summary>
/// <param name="page">Page number. Start from 0.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of type of occupation.</returns>
[HttpGet]
[BadRequestResponse]
public async Task<ActionResult<List<LessonTypeResponse>>> Get([FromQuery][Range(0, int.MaxValue)] int? page,
[FromQuery][Range(1, int.MaxValue)] int? pageSize)
{
var result = await mediator.Send(new GetTypeOfOccupationListQuery()
{
Page = page,
PageSize = pageSize
});
return Ok(result.TypeOfOccupations
.Select(f => new LessonTypeResponse()
{
Id = f.Id,
Name = f.Name
})
);
}
}

@ -51,7 +51,8 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
if ((request.Groups == null || request.Groups.Length == 0) &&
(request.Disciplines == null || request.Disciplines.Length == 0) &&
(request.Professors == null || request.Professors.Length == 0) &&
(request.LectureHalls == null || request.LectureHalls.Length == 0))
(request.LectureHalls == null || request.LectureHalls.Length == 0) &&
(request.LessonType == null || request.LessonType.Length == 0))
{
throw new ControllerArgumentException("At least one of the arguments must be selected."
+ (request.IsEven.HasValue
@ -65,7 +66,8 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
DisciplineIds = request.Disciplines,
GroupIds = request.Groups,
LectureHallIds = request.LectureHalls,
ProfessorIds = request.Professors
ProfessorIds = request.Professors,
LessonTypeIds = request.LessonType
})).Schedules.ToList();
if (result.Count == 0)
@ -101,6 +103,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified group.</returns>
[HttpGet("GetByGroup/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -110,14 +113,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = [id],
Professors = professors,
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
/// <summary>
@ -128,6 +133,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified professor.</returns>
[HttpGet("GetByProfessor/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -137,14 +143,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = [id],
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
/// <summary>
@ -155,6 +163,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="disciplines">An array of discipline IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="groups">An array of group IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified lecture hall.</returns>
[HttpGet("GetByLectureHall/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -164,14 +173,16 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? disciplines = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null) =>
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = disciplines,
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = [id]
LectureHalls = [id],
LessonType = lessonType
});
/// <summary>
@ -182,6 +193,7 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
/// <param name="groups">An array of group IDs.</param>
/// <param name="professors">An array of professor IDs.</param>
/// <param name="lectureHalls">An array of lecture hall IDs.</param>
/// <param name="lessonType">An array of type of occupation IDs.</param>
/// <returns>A response containing schedules for the specified discipline.</returns>
[HttpGet("GetByDiscipline/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -191,13 +203,15 @@ public class ScheduleController(IMediator mediator, IOptionsSnapshot<GeneralConf
[FromQuery] bool? isEven = null,
[FromQuery] int[]? groups = null,
[FromQuery] int[]? professors = null,
[FromQuery] int[]? lectureHalls = null) =>
[FromQuery] int[]? lectureHalls = null,
[FromQuery] int[]? lessonType = null) =>
await Get(new ScheduleRequest
{
Disciplines = [id],
IsEven = isEven,
Groups = groups,
Professors = professors,
LectureHalls = lectureHalls
LectureHalls = lectureHalls,
LessonType = lessonType
});
}

@ -5,9 +5,9 @@
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<Company>Winsomnia</Company>
<Version>1.0-rc6</Version>
<AssemblyVersion>1.0.2.6</AssemblyVersion>
<FileVersion>1.0.2.6</FileVersion>
<Version>1.0-rc7</Version>
<AssemblyVersion>1.0.2.7</AssemblyVersion>
<FileVersion>1.0.2.7</FileVersion>
<AssemblyName>Mirea.Api.Endpoint</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<OutputType>Exe</OutputType>
@ -28,10 +28,9 @@
<PackageReference Include="AspNetCore.HealthChecks.System" Version="9.0.0" />
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="EPPlus" Version="7.5.3" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.12.0" />
<PackageReference Include="Microsoft.Build.Framework" Version="17.12.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -40,7 +39,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.3.1" />
<PackageReference Include="Mirea.Tools.Schedule.Parser" Version="1.2.5" />
<PackageReference Include="Mirea.Tools.Schedule.WebParser" Version="1.0.6" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
@ -55,10 +56,17 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="System.CodeDom" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="System.Composition" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="System.Composition.TypedParts" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="System.Composition.AttributedModel" Version="9.0.1" />
<PackageReference Include="System.Composition.Convention" Version="9.0.1" />
<PackageReference Include="System.Composition.Hosting" Version="9.0.1" />
<PackageReference Include="System.Composition.Runtime" Version="9.0.1" />
<PackageReference Include="System.Composition.TypedParts" Version="9.0.1" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.1" />
<PackageReference Include="System.Drawing.Common" Version="9.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.1" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.1" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.1" />
<PackageReference Include="System.Threading.Channels" Version="[8.0.0, 9.0.0)" />
<PackageReference Include="Z.EntityFramework.Extensions.EFCore" Version="9.103.7" />
</ItemGroup>

@ -6,9 +6,11 @@ using Mirea.Api.DataAccess.Persistence;
using Mirea.Api.Endpoint.Common.Interfaces;
using Mirea.Api.Endpoint.Configuration.Model;
using Mirea.Api.Endpoint.Sync.Common;
using Mirea.Tools.Schedule.Parser.Domain;
using Mirea.Tools.Schedule.WebParser;
using Mirea.Tools.Schedule.WebParser.Common.Domain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@ -220,35 +222,15 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
await dbContext.LessonAssociations.BulkSynchronizeAsync(_lessonAssociation.GetAll(), bulkOperation => bulkOperation.BatchSize = 1000, cancellationToken);
}
public async Task StartSync(CancellationToken cancellationToken)
private async Task Sync(Func<CancellationToken, Task<List<GroupResult>>> parseDataAsync, CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod;
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.",
nameof(pairPeriods),
nameof(startTerm));
return;
}
Stopwatch watch = new();
watch.Start();
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
logger.LogDebug("Start parsing schedule");
var data = await parser.ParseAsync(cancellationToken);
var data = await parseDataAsync(cancellationToken);
watch.Stop();
var parsingTime = watch.ElapsedMilliseconds;
@ -282,11 +264,255 @@ internal partial class ScheduleSynchronizer(UberDbContext dbContext, IOptionsSna
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
maintenanceMode.DisableMaintenanceMode();
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(CancellationToken cancellationToken)
{
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
var startTerm = config.Value.ScheduleSettings?.StartTerm;
if (pairPeriods == null || startTerm == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} or {Arg2} variable is not initialized.",
nameof(pairPeriods),
nameof(startTerm));
return;
}
var parser = new Parser
{
Pairs = pairPeriods
.ToDictionary(x => x.Key,
x => (x.Value.Start, x.Value.End)),
TermStart = startTerm.Value.ToDateTime(new TimeOnly(0, 0, 0))
};
try
{
await Sync(parser.ParseAsync, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
public async Task StartSync(List<(string File, string Campus)> files, CancellationToken cancellationToken)
{
await Task.Yield();
var pairPeriods = config.Value.ScheduleSettings?.PairPeriod
.ToDictionary(x => x.Key, x => (x.Value.Start, x.Value.End));
if (pairPeriods == null)
{
logger.LogWarning("It is not possible to synchronize the schedule due to the fact that the {Arg1} variable is not initialized.",
nameof(pairPeriods));
return;
}
try
{
Task<List<GroupResult>> ParseTask(CancellationToken ctx)
{
var mappedData = new ConcurrentBag<GroupResult>();
ParallelOptions options = new() { CancellationToken = ctx, MaxDegreeOfParallelism = Environment.ProcessorCount };
Parallel.ForEach(files, options, (file) =>
{
var parser = new Tools.Schedule.Parser.Parser();
var result = ConvertToGroupResults(parser.Parse(file.File, pairPeriods), file.Campus);
foreach (var item in result) mappedData.Add(item);
});
return Task.FromResult(mappedData.ToList());
}
await Sync(ParseTask, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during synchronization.");
throw;
}
finally
{
maintenanceMode.DisableMaintenanceMode();
}
}
private static List<GroupResult> ConvertToGroupResults(IEnumerable<GroupInfo> groups, string campusDefault, CancellationToken cancellationToken = default)
{
var result = new List<GroupResult>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var day in group.Days)
{
foreach (var pair in day.Lessons)
{
foreach (var lesson in pair.Value)
{
if (string.IsNullOrWhiteSpace(lesson.TypeOfOccupation))
continue;
var (weeks, isExclude) = ParseWeeks(lesson.Discipline);
var (lectureHalls, campuses) = ParseLectureHalls(lesson.LectureHall, campusDefault);
var groupResult = new GroupResult
{
Day = day.DayOfWeek,
Pair = pair.Key,
IsEven = lesson.IsEven,
Group = group.GroupName,
Discipline = NormalizeDiscipline(lesson.Discipline),
Professor = ParseProfessors(lesson.Professor),
TypeOfOccupation = lesson.TypeOfOccupation,
LectureHalls = lectureHalls,
Campuses = campuses,
SpecialWeek = weeks,
IsExclude = isExclude
};
result.Add(groupResult);
}
}
}
}
return result;
}
private static string[]? ParseProfessors(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace(",", " "),
@"\s+", " ").Trim();
return ProfessorFullName().Matches(normalized)
.Select(m => $"{m.Groups["surname"].Value} {m.Groups["initials"].Value}".Trim())
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
}
private static (int[]? weeks, bool? isExclude) ParseWeeks(string discipline)
{
var match = ParseSpecificWeeks().Match(discipline);
if (!match.Success) return (null, null);
var numbers = new List<int>();
var ranges = match.Groups[2].Value.Split(',');
foreach (var range in ranges)
{
if (range.Contains('-'))
{
var parts = range.Split('-');
if (int.TryParse(parts[0], out var start) &&
int.TryParse(parts[1], out var end))
{
numbers.AddRange(Enumerable.Range(start, end - start + 1));
}
}
else
if (int.TryParse(range, out var num)) numbers.Add(num);
}
return (
weeks: numbers.Distinct().OrderBy(x => x).ToArray(),
isExclude: match.Groups[1].Success
);
}
private static string NormalizeDiscipline(string input)
{
var normalized = Regex.Replace(input
.Replace("\n", " ")
.Replace("\r", " "),
@"\s{2,}", " ");
normalized = Regex.Replace(normalized,
@"(\S+)\s(\S{3,})",
"$1 $2");
normalized = ParseSpecificWeeks().Replace(normalized, "");
return normalized.Trim();
}
private static (string[]? lectureHalls, string[]? campuses) ParseLectureHalls(string? input, string defaultCampus)
{
if (string.IsNullOrWhiteSpace(input))
return (null, null);
var matches = ParseLectureCampus().Matches(input);
var lectureHalls = new List<string>();
var campuses = new List<string>();
foreach (Match match in matches)
{
if (match.Groups["lectureWithCampus"].Success)
{
var raw = match.Value.Split('(');
var campus = raw.LastOrDefault()?.Trim(')').Trim();
var lecture = raw.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(campus) || string.IsNullOrEmpty(lecture))
continue;
campuses.Add(campus);
lectureHalls.Add(lecture);
}
else if (match.Groups["lecture"].Success)
{
var lecture = match.Value.Trim();
if (string.IsNullOrEmpty(lecture))
continue;
campuses.Add(defaultCampus);
lectureHalls.Add(lecture);
}
}
return (
lectureHalls: lectureHalls.ToArray(),
campuses: campuses.ToArray()
);
}
[GeneratedRegex(@"\w{4}-\d{2}-\d{2}(?=\s?\d?\s?[Пп]/?[Гг]\s?\d?)?")]
private static partial Regex OnlyGroupName();
[GeneratedRegex(@"(?<surname>[А-ЯЁ][а-яё]+(-[А-ЯЁ][а-яё]+)?)\s*(?<initials>[А-ЯЁ]\.[А-ЯЁ]?\.?)?", RegexOptions.IgnorePatternWhitespace)]
private static partial Regex ProfessorFullName();
[GeneratedRegex(@"([Кк]р\.?)?\s*((\d+-\d+|\d+)(,\s*\d+(-\d+)?)*)\s*[Нн]\.?", RegexOptions.IgnoreCase, "ru-RU")]
private static partial Regex ParseSpecificWeeks();
[GeneratedRegex(@"(?<lectureWithCampus>[^,.\n]+\s?\([А-Яа-яA-Za-z]+-?\d+\))|(?<lecture>[^,.\n]+)")]
private static partial Regex ParseLectureCampus();
}

@ -29,14 +29,14 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed";
private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) =>
private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellationToken) =>
cache.SetAsync(
GetAuthCacheKey(data.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(data),
slidingExpiration: Lifetime,
cancellationToken: cancellation);
cancellationToken: cancellationToken);
private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellationToken) =>
cache.SetAsync(
GetFirstAuthCacheKey(requestContext.Fingerprint),
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
@ -46,14 +46,14 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
TwoFactorAuthenticator = data.TwoFactorAuthenticator
}),
slidingExpiration: LifetimeFirstAuth,
cancellationToken: cancellation);
cancellationToken: cancellationToken);
private Task RevokeAccessToken(string token) =>
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellation)
private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellationToken)
{
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellation) ?? 1;
var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellationToken) ?? 1;
var failedLoginCacheExpiration = TimeSpan.FromHours(1);
if (failedLoginAttemptsCount > 5)
@ -74,30 +74,30 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
failedLoginAttemptsCount);
await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1,
slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellation);
slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellationToken);
}
private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellation) =>
cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellation);
private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellationToken) =>
cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellationToken);
private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
CancellationToken cancellation = default)
CancellationToken cancellationToken = default)
{
if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) ||
user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
{
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellationToken);
return;
}
await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellation);
await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellationToken);
throw new SecurityException("Authentication failed. Please check your credentials.");
}
private async Task GenerateAuthTokensAsync(CookieOptions cookieOptions, HttpContext context,
RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
RequestContextInfo requestContext, string userId, CancellationToken cancellationToken = default)
{
var refreshToken = GenerateRefreshToken();
var (token, expireIn) = GenerateAccessToken(userId);
@ -110,7 +110,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
AccessToken = token
};
await StoreAuthTokenInCache(authToken, cancellation);
await StoreAuthTokenInCache(authToken, cancellationToken);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
@ -121,11 +121,11 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
}
public async Task<bool> LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
CancellationToken cancellation = default)
CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation);
var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellationToken);
if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator)
throw new SecurityException("Session expired. Please log in again.");
@ -147,35 +147,35 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
if (!totp.VerifyToken(code))
{
await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellation);
await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellationToken);
throw new SecurityException("Invalid verification code. Please try again.");
}
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellationToken);
}
break;
default:
throw new InvalidOperationException("Unsupported authorization method.");
}
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellationToken);
return true;
}
private async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
HttpContext context,
User user,
CancellationToken cancellation = default)
CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
{
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellationToken);
return TwoFactorAuthenticator.None;
}
await StoreFirstAuthTokenInCache(user, requestContext, cancellation);
await StoreFirstAuthTokenInCache(user, requestContext, cancellationToken);
return user.TwoFactorAuthenticator;
}
@ -201,20 +201,20 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
User user,
string password,
string username,
CancellationToken cancellation = default)
CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
username = username.Trim();
await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);
await VerifyUserOrThrowError(requestContext, user, password, username, cancellationToken);
return await LoginAsync(cookieOptions, context, user, cancellation);
return await LoginAsync(cookieOptions, context, user, cancellationToken);
}
public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default)
public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellationToken = default)
{
const string defaultMessageError = "The session time has expired";
var requestContext = new RequestContextInfo(context, cookieOptions);
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation) ??
var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken) ??
throw new SecurityException(defaultMessageError);
if (authToken.RefreshToken != requestContext.RefreshToken ||
@ -222,7 +222,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.Ip != requestContext.Ip)
{
await RevokeAccessToken(authToken.AccessToken);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
@ -274,24 +274,24 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
authToken.AccessToken = token;
authToken.RefreshToken = newRefreshToken;
await StoreAuthTokenInCache(authToken, cancellation);
await StoreAuthTokenInCache(authToken, cancellationToken);
cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
}
public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default)
public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellationToken = default)
{
var requestContext = new RequestContextInfo(context, cookieOptions);
cookieOptions.DropCookie(context, CookieNames.AccessToken);
cookieOptions.DropCookie(context, CookieNames.RefreshToken);
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellationToken);
if (authTokenStruct == null)
return;
await RevokeAccessToken(authTokenStruct.AccessToken);
await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
await cache.RemoveAsync(requestContext.Fingerprint, cancellationToken);
}
}

@ -58,7 +58,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
};
private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code,
string clientId, string secret, CancellationToken cancellation)
string clientId, string secret, CancellationToken cancellationToken)
{
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
@ -75,8 +75,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
var response = await httpClient.SendAsync(tokenRequest, cancellation);
var data = await response.Content.ReadAsStringAsync(cancellation);
var response = await httpClient.SendAsync(tokenRequest, cancellationToken);
var data = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data);
@ -85,7 +85,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
}
private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider,
CancellationToken cancellation)
CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
@ -97,8 +97,8 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
var response = await httpClient.SendAsync(request, cancellation);
var data = await response.Content.ReadAsStringAsync(cancellation);
var response = await httpClient.SendAsync(request, cancellationToken);
var data = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(data);
@ -167,12 +167,12 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
}
}
private Task StoreOAuthUserInCache(string key, OAuthUserExtension data, CancellationToken cancellation) =>
private Task StoreOAuthUserInCache(string key, OAuthUserExtension data, CancellationToken cancellationToken) =>
cache.SetAsync(
key,
JsonSerializer.SerializeToUtf8Bytes(data),
absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
cancellationToken: cancellation);
cancellationToken: cancellationToken);
public Uri GetProviderRedirect(CookieOptions cookieOptions, HttpContext context, string redirectUri,
@ -209,7 +209,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
[.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))];
public async Task<LoginOAuth> LoginOAuth(CookieOptions cookieOptions, HttpContext context,
string redirectUrl, string code, string state, CancellationToken cancellation = default)
string redirectUrl, string code, string state, CancellationToken cancellationToken = default)
{
var result = new LoginOAuth()
{
@ -226,7 +226,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
{
Message = result.ErrorMessage,
Provider = null
}, cancellation);
}, cancellationToken);
return result;
}
@ -251,7 +251,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
result.ErrorMessage = "Invalid authorization request. Please try again later.";
cacheData.Message = result.ErrorMessage;
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
@ -271,7 +271,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
checksum
);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
@ -280,7 +280,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
try
{
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId,
providerInfo.Secret, cancellation);
providerInfo.Secret, cancellationToken);
}
catch (Exception ex)
{
@ -288,7 +288,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
payload.Provider,
checksum);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
@ -300,14 +300,14 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
try
{
user = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken,
payload.Provider, cancellation);
payload.Provider, cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}",
payload.Provider);
await StoreOAuthUserInCache(result.Token, cacheData, cancellation);
await StoreOAuthUserInCache(result.Token, cacheData, cancellationToken);
return result;
}
@ -323,24 +323,24 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
IsSuccess = true,
User = user,
Provider = payload.Provider
}, cancellation);
}, cancellationToken);
return result;
}
public async Task<(OAuthUser? User, string? Message, bool IsSuccess, OAuthProvider? Provider)>
GetOAuthUser(CookieOptions cookieOptions, HttpContext context, string token, CancellationToken cancellation = default)
GetOAuthUser(CookieOptions cookieOptions, HttpContext context, string token, CancellationToken cancellationToken = default)
{
var requestInfo = new RequestContextInfo(context, cookieOptions);
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellation);
var result = await cache.GetAsync<OAuthUserExtension>(token, cancellationToken);
var tokenFailedKey = $"{requestInfo.Fingerprint}_oauth_token_failed";
if (result == null)
{
var failedTokenAttemptsCount = await cache.GetAsync<int?>(
tokenFailedKey,
cancellation) ?? 1;
cancellationToken) ?? 1;
var failedTokenCacheExpiration = TimeSpan.FromHours(1);
@ -364,7 +364,7 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
await cache.SetAsync(tokenFailedKey,
failedTokenAttemptsCount + 1,
slidingExpiration: failedTokenCacheExpiration,
cancellationToken: cancellation);
cancellationToken: cancellationToken);
return (null, "Invalid or expired token.", false, null);
}
@ -406,18 +406,18 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
result.Ip,
requestInfo.Ip);
await cache.RemoveAsync(token, cancellation);
await cache.RemoveAsync(token, cancellationToken);
return (null, "Invalid or expired token.", false, null);
}
await cache.RemoveAsync(tokenFailedKey, cancellation);
await cache.RemoveAsync(tokenFailedKey, cancellationToken);
result.Ip = requestInfo.Ip;
result.UserAgent = requestInfo.UserAgent;
result.Fingerprint = requestInfo.Fingerprint;
await StoreOAuthUserInCache(token, result, cancellation);
await StoreOAuthUserInCache(token, result, cancellationToken);
return (result.User, result.Message, result.IsSuccess, result.Provider);
}

@ -8,5 +8,6 @@ public class GetScheduleListQuery : IRequest<ScheduleListVm>
public int[]? DisciplineIds { get; set; }
public int[]? LectureHallIds { get; set; }
public int[]? ProfessorIds { get; set; }
public int[]? LessonTypeIds { get; set; }
public bool? IsEven { get; set; }
}

@ -16,6 +16,9 @@ public class GetScheduleListQueryHandler(ILessonDbContext dbContext) : IRequestH
if (request.IsEven != null)
query = query.Where(l => l.IsEven == request.IsEven);
if (request.LessonTypeIds != null && request.LessonTypeIds.Length != 0)
query = query.Where(l => l.LessonAssociations!.Any(la => request.LessonTypeIds.Contains(la.TypeOfOccupationId)));
if (request.GroupIds != null && request.GroupIds.Length != 0)
query = query.Where(l => request.GroupIds.Contains(l.GroupId));

@ -0,0 +1,9 @@
using MediatR;
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
public class GetTypeOfOccupationListQuery : IRequest<TypeOfOccupationListVm>
{
public int? Page { get; set; }
public int? PageSize { get; set; }
}

@ -0,0 +1,31 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Mirea.Api.DataAccess.Application.Interfaces.DbContexts.Schedule;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
public class GetTypeOfOccupationListQueryHandler(ITypeOfOccupationDbContext dbContext) : IRequestHandler<GetTypeOfOccupationListQuery, TypeOfOccupationListVm>
{
public async Task<TypeOfOccupationListVm> Handle(GetTypeOfOccupationListQuery request, CancellationToken cancellationToken)
{
var dtos = dbContext.TypeOfOccupations.OrderBy(t => t.Id)
.Select(t => new TypeOfOccupationLookupDto()
{
Id = t.Id,
Name = t.ShortName
});
if (request is { PageSize: not null, Page: not null })
dtos = dtos.Skip(request.Page.Value * request.PageSize.Value).Take(request.PageSize.Value);
var result = await dtos.ToListAsync(cancellationToken);
return new TypeOfOccupationListVm
{
TypeOfOccupations = result
};
}
}

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
/// <summary>
/// Represents a view model containing multiple type of occupations.
/// </summary>
public class TypeOfOccupationListVm
{
/// <summary>
/// The list of type of occupations.
/// </summary>
public IEnumerable<TypeOfOccupationLookupDto> TypeOfOccupations { get; set; } = [];
}

@ -0,0 +1,17 @@
namespace Mirea.Api.DataAccess.Application.Cqrs.TypeOfOccupation.Queries.GetTypeOfOccupationList;
/// <summary>
/// Represents type of occupations.
/// </summary>
public class TypeOfOccupationLookupDto
{
/// <summary>
/// The unique identifier for the occupation.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The name of the occupation.
/// </summary>
public required string Name { get; set; }
}