diff --git a/AurionCal.Api.csproj b/AurionCal.Api.csproj index 572be2a..2097620 100644 --- a/AurionCal.Api.csproj +++ b/AurionCal.Api.csproj @@ -31,12 +31,25 @@ - + + + + + + + + + + PreserveNewest PreserveNewest - - - + + PreserveNewest PreserveNewest diff --git a/Endpoints/GetCalendarFeedEndpoint.cs b/Endpoints/GetCalendarFeedEndpoint.cs index 251ad24..2c712e8 100644 --- a/Endpoints/GetCalendarFeedEndpoint.cs +++ b/Endpoints/GetCalendarFeedEndpoint.cs @@ -30,6 +30,7 @@ public override async Task HandleAsync(GetCalendarFeedRequest r, CancellationTok .Include(u => u.RefreshStatus) .FirstOrDefaultAsync(u => u.Id == r.UserId, c); + if (user == null || user.CalendarToken != r.Token) { await Send.NotFoundAsync(c); @@ -58,7 +59,7 @@ public override async Task HandleAsync(GetCalendarFeedRequest r, CancellationTok }).ToList() ?? []; }); - var feed = calendarService.GenerateCalendarFeed(planningEvents); + var feed = calendarService.GenerateCalendarFeed(planningEvents, user.ExamAccommodations); HttpContext.Response.Headers.Append("Content-Disposition", "attachment; filename=\"Planning Junia.ics\""); HttpContext.Response.ContentType = "text/calendar"; diff --git a/Endpoints/GetUserProfileEndpoint.cs b/Endpoints/GetUserProfileEndpoint.cs index 152c788..3c2f765 100644 --- a/Endpoints/GetUserProfileEndpoint.cs +++ b/Endpoints/GetUserProfileEndpoint.cs @@ -11,6 +11,7 @@ public class UserProfileResponse public string Email { get; set; } = string.Empty; public string CalendarFeedUrl { get; set; } = string.Empty; public DateTime? LastUpdated { get; set; } + public bool ExamAccommodations { get; set; } } public class GetUserProfileEndpoint(ApplicationDbContext db, IConfiguration config) : EndpointWithoutRequest @@ -49,7 +50,8 @@ public override async Task HandleAsync(CancellationToken ct) UserId = user.Id, Email = user.JuniaEmail, CalendarFeedUrl = calendarUrl, - LastUpdated = user.LastUpdate ?? null + LastUpdated = user.LastUpdate ?? null, + ExamAccommodations = user.ExamAccommodations }; await Send.OkAsync(response, ct); diff --git a/Endpoints/SetExamAccommodationsEndpoint.cs b/Endpoints/SetExamAccommodationsEndpoint.cs new file mode 100644 index 0000000..bd4e975 --- /dev/null +++ b/Endpoints/SetExamAccommodationsEndpoint.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using AurionCal.Api.Contexts; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace AurionCal.Api.Endpoints; + +public class SetExamAccommodationsRequest +{ + public bool Enabled { get; set; } +} + +public class SetExamAccommodationsEndpoint(ApplicationDbContext db) : Endpoint +{ + public override void Configure() + { + Patch("/api/user/exam-accommodations"); + Claims("UserId"); + } + + public override async Task HandleAsync(SetExamAccommodationsRequest r, CancellationToken ct) + { + var userIdValue = User.FindFirstValue("UserId"); + if (string.IsNullOrWhiteSpace(userIdValue) || !Guid.TryParse(userIdValue, out var userId)) + { + await Send.UnauthorizedAsync(ct); + return; + } + + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + if (user is null) + { + await Send.NotFoundAsync(ct); + return; + } + + user.ExamAccommodations = r.Enabled; + await db.SaveChangesAsync(ct); + await Send.OkAsync(ct); + } +} diff --git a/Entities/User.cs b/Entities/User.cs index fab7353..0bc5c19 100644 --- a/Entities/User.cs +++ b/Entities/User.cs @@ -8,5 +8,6 @@ public class User public DateTime? LastUpdate { get; set; } public virtual List Planning { get; set; } public Guid CalendarToken { get; set; } + public bool ExamAccommodations { get; set; } public virtual UserRefreshStatus? RefreshStatus { get; set; } } \ No newline at end of file diff --git a/Enums/CourseTypes.cs b/Enums/CourseTypes.cs index 7780679..0e77840 100644 --- a/Enums/CourseTypes.cs +++ b/Enums/CourseTypes.cs @@ -7,6 +7,7 @@ public static class RawCourseTypes public const string CoursTp = "TP"; public const string Projet = "PROJET"; public const string Epreuve = "est-epreuve"; + public const string EpreuveAlt = "EXAM_SURV"; public const string AutoAppr = "AUTO_APPR"; public const string Reunion = "REUNION"; public const string Conference = "CONF"; diff --git a/Migrations/20260622000000_AddExamAccommodations.Designer.cs b/Migrations/20260622000000_AddExamAccommodations.Designer.cs new file mode 100644 index 0000000..e6f0e0d --- /dev/null +++ b/Migrations/20260622000000_AddExamAccommodations.Designer.cs @@ -0,0 +1,150 @@ +// +using System; +using AurionCal.Api.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AurionCal.Api.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260622000000_AddExamAccommodations")] + partial class AddExamAccommodations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AurionCal.Api.Entities.CalendarEvent", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ClassName") + .IsRequired() + .HasColumnType("text"); + + b.Property("End") + .HasColumnType("timestamp with time zone"); + + b.Property("Start") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("CalendarEvents"); + }); + + modelBuilder.Entity("AurionCal.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CalendarToken") + .HasColumnType("uuid"); + + b.Property("ExamAccommodations") + .HasColumnType("boolean"); + + b.Property("JuniaEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("JuniaPassword") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("AurionCal.Api.Entities.UserRefreshStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ConsecutiveFailureCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("FailureEmailSentUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailureReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastFailureUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NextAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("UserRefreshStatuses", (string)null); + }); + + modelBuilder.Entity("AurionCal.Api.Entities.CalendarEvent", b => + { + b.HasOne("AurionCal.Api.Entities.User", "User") + .WithMany("Planning") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AurionCal.Api.Entities.UserRefreshStatus", b => + { + b.HasOne("AurionCal.Api.Entities.User", "User") + .WithOne("RefreshStatus") + .HasForeignKey("AurionCal.Api.Entities.UserRefreshStatus", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AurionCal.Api.Entities.User", b => + { + b.Navigation("Planning"); + + b.Navigation("RefreshStatus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260622000000_AddExamAccommodations.cs b/Migrations/20260622000000_AddExamAccommodations.cs new file mode 100644 index 0000000..24f6b27 --- /dev/null +++ b/Migrations/20260622000000_AddExamAccommodations.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AurionCal.Api.Migrations +{ + /// + public partial class AddExamAccommodations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExamAccommodations", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExamAccommodations", + table: "Users"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 9d96d22..9757267 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -60,6 +60,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CalendarToken") .HasColumnType("uuid"); + b.Property("ExamAccommodations") + .HasColumnType("boolean"); + b.Property("JuniaEmail") .IsRequired() .HasColumnType("text"); diff --git a/Services/CalendarService.cs b/Services/CalendarService.cs index d5e369b..7c3f9a6 100644 --- a/Services/CalendarService.cs +++ b/Services/CalendarService.cs @@ -175,14 +175,14 @@ private static TimeSpan ComputeBackoff(int consecutiveFailures) private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max]; - public string GenerateCalendarFeed(IEnumerable planningEvents) + public string GenerateCalendarFeed(IEnumerable planningEvents, bool examAccommodations = false) { var calendar = new Calendar(); calendar.AddTimeZone(new VTimeZone("Europe/Paris")); foreach (var evt in planningEvents) { - calendar.Events.Add(CalendarEventFormatter.ToIcalEvent(evt)); + calendar.Events.Add(CalendarEventFormatter.ToIcalEvent(evt, examAccommodations)); } return new CalendarSerializer().SerializeToString(calendar); diff --git a/Services/CourseTypeMappings.cs b/Services/CourseTypeMappings.cs index 3ec2fe4..597f646 100644 --- a/Services/CourseTypeMappings.cs +++ b/Services/CourseTypeMappings.cs @@ -13,6 +13,7 @@ public static class CourseTypeMappings [RawCourseTypes.CoursTp] = CourseType.CoursTp, [RawCourseTypes.Projet] = CourseType.Projet, [RawCourseTypes.Epreuve] = CourseType.Epreuve, + [RawCourseTypes.EpreuveAlt] = CourseType.Epreuve, [RawCourseTypes.AutoAppr] = CourseType.AutoAppr, [RawCourseTypes.Reunion] = CourseType.Reunion, [RawCourseTypes.Conference] = CourseType.Conference, @@ -40,16 +41,13 @@ public static CourseType Parse(string? rawType) var normalized = Normalize(rawType); - if (RawToEnum.TryGetValue(normalized, out var found)) - return found; - - if (RawToEnum.TryGetValue(rawType, out found)) + if (RawToEnum.TryGetValue(normalized, out var found) || RawToEnum.TryGetValue(rawType, out found)) return found; return CourseType.Unknown; } - public static string ToDisplayName(CourseType type) + private static string ToDisplayName(CourseType type) { return EnumToDisplay.TryGetValue(type, out var display) ? display diff --git a/Services/Formatters/CalendarEventFormatter.cs b/Services/Formatters/CalendarEventFormatter.cs index 02aac0a..68f4f6b 100644 --- a/Services/Formatters/CalendarEventFormatter.cs +++ b/Services/Formatters/CalendarEventFormatter.cs @@ -1,5 +1,6 @@ namespace AurionCal.Api.Services.Formatters; +using System.Globalization; using Entities; using Enums; using Ical.Net.DataTypes; @@ -9,28 +10,32 @@ public static class CalendarEventFormatter { private const string TimeZoneId = "Europe/Paris"; - public static IcalEvent ToIcalEvent(CalendarEvent cEvent) + public static IcalEvent ToIcalEvent(CalendarEvent cEvent, bool examAccommodations = false) { var lines = cEvent.Title - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + .Split(Separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (lines.Length == 0) return CreateBaseEvent(cEvent, "Sans titre", string.Empty); var firstLine = lines[0]; var remainingLines = lines.Skip(1).ToArray(); - + var courseType = CourseTypeMappings.Parse(cEvent.ClassName); return courseType == CourseType.Epreuve - ? FormatExam(cEvent, firstLine, remainingLines) + ? FormatExam(cEvent, firstLine, remainingLines, examAccommodations) : FormatCourse(cEvent, courseType, firstLine, remainingLines); } - private static IcalEvent FormatExam(CalendarEvent cEvent, string firstLine, string[] lines) + private static IcalEvent FormatExam(CalendarEvent cEvent, string firstLine, string[] lines, bool examAccommodations) { // Filtrage spécifique aux examens + // Extraire la ligne "Horaire TT" avant tout autre filtrage + var examAccommodationsLine = lines.FirstOrDefault(l => l.StartsWith("Horaire TT", StringComparison.OrdinalIgnoreCase)); + var filteredLines = lines - .Where(l => !l.Contains("EXAM_SURV", StringComparison.OrdinalIgnoreCase)) + .Where(l => !l.Contains("EXAM_SURV", StringComparison.OrdinalIgnoreCase) + && !l.StartsWith("Horaire TT", StringComparison.OrdinalIgnoreCase)) .ToArray(); // Pattern matching sur le contenu du tableau @@ -51,9 +56,48 @@ private static IcalEvent FormatExam(CalendarEvent cEvent, string firstLine, stri }; var summary = BuildSummary(examName, cEvent.ClassName); + + if (examAccommodations && examAccommodationsLine is not null && TryParseExamAccommodationsTimes(examAccommodationsLine, cEvent.Start.DateTime, out var examAccommodationsStart, out var examAccommodationsEnd)) + { + return CreateBaseEvent(cEvent, summary, location, examAccommodationsStart, examAccommodationsEnd); + } + return CreateBaseEvent(cEvent, summary, location); } + private static bool TryParseExamAccommodationsTimes(string examAccommodationsLine, DateTime eventDate, out DateTime start, out DateTime end) + { + start = default; + end = default; + + var colonIdx = examAccommodationsLine.IndexOf(':'); + if (colonIdx < 0) return false; + + var timePart = examAccommodationsLine[(colonIdx + 1)..].Trim(); + var dashIdx = timePart.IndexOf('-'); + if (dashIdx < 0) return false; + + var startStr = timePart[..dashIdx].Trim(); + var endStr = timePart[(dashIdx + 1)..].Trim(); + + if (!TryParseTime(startStr, out var startTime) || !TryParseTime(endStr, out var endTime)) + return false; + + if (endTime <= startTime) + return false; + + var date = eventDate.Date; + start = date.Add(startTime.ToTimeSpan()); + end = date.Add(endTime.ToTimeSpan()); + return true; + } + + private static readonly string[] TimeFormats = ["H'h'mm", "H'h'm", "H'h'"]; + private static readonly char[] Separator = ['\r', '\n']; + + private static bool TryParseTime(string value, out TimeOnly result) + => TimeOnly.TryParseExact(value, TimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out result); + private static IcalEvent FormatCourse(CalendarEvent cEvent, CourseType type, string firstLine, string[] lines) { if (type == CourseType.Conference) @@ -76,15 +120,15 @@ private static IcalEvent FormatCourse(CalendarEvent cEvent, CourseType type, str var (courseName, teacher) = lines switch { - [var c, var t, ..] => (c, t), // Au moins 2 lignes - [var c] => (c, string.Empty), // 1 ligne + [var c, var t, ..] => (c, t), + [var c] => (c, string.Empty), _ => (string.Empty, string.Empty) }; var parts = new List(); if (!string.IsNullOrWhiteSpace(courseName)) parts.Add(courseName); if (!string.IsNullOrWhiteSpace(teacher)) parts.Add($"- {teacher}"); - + var baseSummary = string.Join(" ", parts); var summary = BuildSummary(baseSummary, cEvent.ClassName); @@ -94,23 +138,25 @@ private static IcalEvent FormatCourse(CalendarEvent cEvent, CourseType type, str private static string BuildSummary(string baseName, string className) { var typeDisplay = CourseTypeMappings.ToDisplayNameFromRaw(className); - + if (string.IsNullOrWhiteSpace(typeDisplay)) return baseName; - if (string.IsNullOrWhiteSpace(baseName)) return typeDisplay; - - return $"{baseName} ({typeDisplay})"; + return string.IsNullOrWhiteSpace(baseName) ? typeDisplay : $"{baseName} ({typeDisplay})"; } - private static IcalEvent CreateBaseEvent(CalendarEvent cEvent, string summary, string location) + private static IcalEvent CreateBaseEvent(CalendarEvent cEvent, string summary, string location, + DateTime? overrideStart = null, DateTime? overrideEnd = null) { + var start = overrideStart ?? cEvent.Start.DateTime; + var end = overrideEnd ?? cEvent.End.DateTime; + return new IcalEvent { Summary = summary, - Start = new CalDateTime(cEvent.Start.DateTime).ToTimeZone(TimeZoneId), - End = new CalDateTime(cEvent.End.DateTime).ToTimeZone(TimeZoneId), + Start = new CalDateTime(start).ToTimeZone(TimeZoneId), + End = new CalDateTime(end).ToTimeZone(TimeZoneId), Description = cEvent.Title.Trim(), Location = location ?? string.Empty, Uid = cEvent.Id }; } -} \ No newline at end of file +}