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
+}