From ba97f633388d06419d1a96260a83a1667c6fcd47 Mon Sep 17 00:00:00 2001 From: Darryl Koehn Date: Wed, 7 Sep 2022 12:46:24 -0600 Subject: [PATCH] =?UTF-8?q?Make=20sure=20Job=20date=20times=20are=20stored?= =?UTF-8?q?=20in=20the=20database=20as=20UTC.=20This=20is=20required=20if?= =?UTF-8?q?=20using=20Postgres=20or=20you=20will=20get=20an=20exception=20?= =?UTF-8?q?with=20a=20message=20of=20=E2=80=9CCannot=20write=20DateTime=20?= =?UTF-8?q?with=20Kind=3DUnspecified=20to=20PostgreSQL=20type=20'timestamp?= =?UTF-8?q?=20with=20time=20zone',=20only=20UTC=20is=20supported.=E2=80=9D?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Oqtane.Client/Modules/Admin/Jobs/Edit.razor | 85 ++++++------------- Oqtane.Client/Modules/Admin/Jobs/Index.razor | 2 +- Oqtane.Server/Infrastructure/Jobs/PurgeJob.cs | 2 +- Oqtane.Shared/Shared/Utilities.cs | 45 ++++++++++ .../Oqtane.Shared.Tests/UtilitiesTests.cs | 58 +++++++++++++ 5 files changed, 130 insertions(+), 62 deletions(-) diff --git a/Oqtane.Client/Modules/Admin/Jobs/Edit.razor b/Oqtane.Client/Modules/Admin/Jobs/Edit.razor index 120ede22..34780aa0 100644 --- a/Oqtane.Client/Modules/Admin/Jobs/Edit.razor +++ b/Oqtane.Client/Modules/Admin/Jobs/Edit.razor @@ -132,22 +132,10 @@ _isEnabled = job.IsEnabled.ToString(); _interval = job.Interval.ToString(); _frequency = job.Frequency; - _startDate = job.StartDate; - if (job.StartDate != null && job.StartDate.Value.TimeOfDay.TotalSeconds != 0) - { - _startTime = job.StartDate.Value.ToString("HH:mm"); - } - _endDate = job.EndDate; - if (job.EndDate != null && job.EndDate.Value.TimeOfDay.TotalSeconds != 0) - { - _endTime = job.EndDate.Value.ToString("HH:mm"); - } + (_startDate, _startTime) = Utilities.UtcAsLocalDateAndTime(job.StartDate); + (_endDate, _endTime) = Utilities.UtcAsLocalDateAndTime(job.EndDate); _retentionHistory = job.RetentionHistory.ToString(); - _nextDate = job.NextExecution; - if (job.NextExecution != null && job.NextExecution.Value.TimeOfDay.TotalSeconds != 0) - { - _nextTime = job.NextExecution.Value.ToString("HH:mm"); - } + (_nextDate, _nextTime) = Utilities.UtcAsLocalDateAndTime(job.NextExecution); createdby = job.CreatedBy; createdon = job.CreatedOn; modifiedby = job.ModifiedBy; @@ -180,50 +168,27 @@ { job.Interval = int.Parse(_interval); } - job.StartDate = _startDate; - if (job.StartDate != null) - { - job.StartDate = job.StartDate.Value.Date; - if (!string.IsNullOrEmpty(_startTime)) - { - job.StartDate = DateTime.Parse(job.StartDate.Value.ToShortDateString() + " " + _startTime); - } - } - job.EndDate = _endDate; - if (job.EndDate != null) - { - job.EndDate = job.EndDate.Value.Date; - if (!string.IsNullOrEmpty(_endTime)) - { - job.EndDate = DateTime.Parse(job.EndDate.Value.ToShortDateString() + " " + _endTime); - } - } - job.RetentionHistory = int.Parse(_retentionHistory); - job.NextExecution = _nextDate; - if (job.NextExecution != null) - { - job.NextExecution = job.NextExecution.Value.Date; - if (!string.IsNullOrEmpty(_nextTime)) - { - job.NextExecution = DateTime.Parse(job.NextExecution.Value.ToShortDateString() + " " + _nextTime); - } - } + job.StartDate = Utilities.LocalDateAndTimeAsUtc(_startDate, _startTime); + job.EndDate = Utilities.LocalDateAndTimeAsUtc(_endDate, _endTime); + job.RetentionHistory = int.Parse(_retentionHistory); + job.NextExecution = Utilities.LocalDateAndTimeAsUtc(_nextDate, _nextTime); + + try + { + job = await JobService.UpdateJobAsync(job); + await logger.LogInformation("Job Updated {Job}", job); + NavigationManager.NavigateTo(NavigateUrl()); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Udate Job {Job} {Error}", job, ex.Message); + AddModuleMessage(Localizer["Error.Job.Update"], MessageType.Error); + } + } + else + { + AddModuleMessage(Localizer["Message.Required.JobInfo"], MessageType.Warning); + } + } - try - { - job = await JobService.UpdateJobAsync(job); - await logger.LogInformation("Job Updated {Job}", job); - NavigationManager.NavigateTo(NavigateUrl()); - } - catch (Exception ex) - { - await logger.LogError(ex, "Error Udate Job {Job} {Error}", job, ex.Message); - AddModuleMessage(Localizer["Error.Job.Update"], MessageType.Error); - } - } - else - { - AddModuleMessage(Localizer["Message.Required.JobInfo"], MessageType.Warning); - } - } } diff --git a/Oqtane.Client/Modules/Admin/Jobs/Index.razor b/Oqtane.Client/Modules/Admin/Jobs/Index.razor index 3553d973..2ae6e15c 100644 --- a/Oqtane.Client/Modules/Admin/Jobs/Index.razor +++ b/Oqtane.Client/Modules/Admin/Jobs/Index.razor @@ -33,7 +33,7 @@ else @context.Name @DisplayStatus(context.IsEnabled, context.IsExecuting) @DisplayFrequency(context.Interval, context.Frequency) - @context.NextExecution + @context.NextExecution?.ToLocalTime() @if (context.IsStarted) { diff --git a/Oqtane.Server/Infrastructure/Jobs/PurgeJob.cs b/Oqtane.Server/Infrastructure/Jobs/PurgeJob.cs index 8eb22a97..ff4a88f8 100644 --- a/Oqtane.Server/Infrastructure/Jobs/PurgeJob.cs +++ b/Oqtane.Server/Infrastructure/Jobs/PurgeJob.cs @@ -17,7 +17,7 @@ namespace Oqtane.Infrastructure Name = "Purge Job"; Frequency = "d"; // daily Interval = 1; - StartDate = DateTime.ParseExact("03:00", "H:mm", null, System.Globalization.DateTimeStyles.None); // 3 AM + StartDate = DateTime.ParseExact("03:00", "H:mm", null, System.Globalization.DateTimeStyles.AssumeLocal).ToUniversalTime(); // 3 AM IsEnabled = true; } diff --git a/Oqtane.Shared/Shared/Utilities.cs b/Oqtane.Shared/Shared/Utilities.cs index 8717c6b2..03a6cabc 100644 --- a/Oqtane.Shared/Shared/Utilities.cs +++ b/Oqtane.Shared/Shared/Utilities.cs @@ -446,5 +446,50 @@ namespace Oqtane.Shared { return $"[{@class.GetType()}] {message}"; } + + public static DateTime? LocalDateAndTimeAsUtc(DateTime? date, string time, TimeZoneInfo localTimeZone = null) + { + localTimeZone ??= TimeZoneInfo.Local; + if (date != null) + { + if (!string.IsNullOrEmpty(time)) + { + return TimeZoneInfo.ConvertTime(DateTime.Parse(date.Value.Date.ToShortDateString() + " " + time), localTimeZone, TimeZoneInfo.Utc); + } + return TimeZoneInfo.ConvertTime(date.Value.Date, localTimeZone, TimeZoneInfo.Utc); + } + return null; + } + + public static (DateTime? date, string time) UtcAsLocalDateAndTime(DateTime? dateTime, TimeZoneInfo timeZone = null) + { + timeZone ??= TimeZoneInfo.Local; + DateTime? localDateTime = null; + string localTime = string.Empty; + + if (dateTime.HasValue && dateTime?.Kind != DateTimeKind.Local) + { + if (dateTime?.Kind == DateTimeKind.Unspecified) + { + // Treat Unspecified as Utc not Local. This is due to EF Core, on some databases, after retrieval will have DateTimeKind as Unspecified. + // All values in database should be UTC. + // Normal .net conversion treats Unspecified as local. + // https://docs.microsoft.com/en-us/dotnet/api/system.timezoneinfo.converttime?view=net-6.0 + localDateTime = TimeZoneInfo.ConvertTime(new DateTime(dateTime.Value.Ticks, DateTimeKind.Utc), timeZone); + } + else + { + localDateTime = TimeZoneInfo.ConvertTime(dateTime.Value, timeZone); + } + } + + if (localDateTime != null && localDateTime.Value.TimeOfDay.TotalSeconds != 0) + { + localTime = localDateTime.Value.ToString("HH:mm"); + } + + return (localDateTime?.Date, localTime); + } + } } diff --git a/Oqtane.Test/Oqtane.Shared.Tests/UtilitiesTests.cs b/Oqtane.Test/Oqtane.Shared.Tests/UtilitiesTests.cs index 29adc5d4..0a2dc47c 100644 --- a/Oqtane.Test/Oqtane.Shared.Tests/UtilitiesTests.cs +++ b/Oqtane.Test/Oqtane.Shared.Tests/UtilitiesTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Globalization; using Oqtane.Shared; using Xunit; @@ -27,5 +29,61 @@ namespace Oqtane.Test.Oqtane.Shared.Tests // Assert Assert.Equal(expectedUrl, navigatedUrl); } + + [Theory] + [InlineData(2022, 02, 01, "21:00", "Eastern Standard Time", 2022, 2, 2, 2)] + [InlineData(2022, 02, 02, "15:00", "Eastern Standard Time", 2022, 2, 2, 20)] + [InlineData(2022, 02, 02, "", "Eastern Standard Time", 2022, 2, 2, 5)] + [InlineData(0, 0, 0, "", "Eastern Standard Time", 0, 0, 0, 0)] + public void LocalDateAndTimeAsUtcTest(int yr, int mo, int day, string timeString, string zone, int yrUtc, int moUtc, int dayUtc, int hrUtc) + { + // Arrange + DateTime? srcDate = null; + if (yr > 0) + { + srcDate = new DateTime(yr, mo, day); + } + + // Act + var dateTime = Utilities.LocalDateAndTimeAsUtc(srcDate, timeString, TimeZoneInfo.FindSystemTimeZoneById(zone)); + + // Assert + DateTime? expected = null; + if (yrUtc > 0) + { + expected = new DateTime(yrUtc, moUtc, dayUtc, hrUtc, 0, 0, DateTimeKind.Utc); + } + Assert.Equal(expected, dateTime); + } + + [Theory] + // Standard Time + [InlineData(2022, 2, 2, 2, DateTimeKind.Unspecified, "Eastern Standard Time", "2022/02/01", "21:00")] + [InlineData(2022, 2, 2, 2, DateTimeKind.Utc, "Eastern Standard Time", "2022/02/01", "21:00")] + [InlineData(2022, 2, 2, 20, DateTimeKind.Unspecified, "Eastern Standard Time", "2022/02/02", "15:00")] + [InlineData(2022, 2, 2, 20, DateTimeKind.Utc, "Eastern Standard Time", "2022/02/02", "15:00")] + [InlineData(2022, 2, 2, 5, DateTimeKind.Unspecified, "Eastern Standard Time", "2022/02/02", "")] + [InlineData(2022, 2, 2, 5, DateTimeKind.Utc, "Eastern Standard Time", "2022/02/02", "")] + // Daylight Savings Time + [InlineData(2022, 7, 2, 20, DateTimeKind.Unspecified, "Eastern Standard Time", "2022/07/02", "16:00")] + [InlineData(2022, 7, 2, 20, DateTimeKind.Utc, "Eastern Standard Time", "2022/07/02", "16:00")] + [InlineData(2022, 7, 2, 4, DateTimeKind.Unspecified, "Eastern Standard Time", "2022/07/02", "")] + [InlineData(2022, 7, 2, 4, DateTimeKind.Utc, "Eastern Standard Time", "2022/07/02", "")] + public void UtcAsLocalDateAndTimeTest(int yr, int mo, int day, int hr, DateTimeKind dateTimeKind, string zone, string expectedDate, string expectedTime) + { + // Arrange + DateTime? srcDate = null; + if (yr > 0) + { + srcDate = new DateTime(yr, mo, day, hr, 0, 0, dateTimeKind); + } + + // Act + var dateAndTime = Utilities.UtcAsLocalDateAndTime(srcDate, TimeZoneInfo.FindSystemTimeZoneById(zone)); + + // Assert + Assert.Equal(expectedDate, dateAndTime.date.Value.ToString("yyyy/MM/dd")); + Assert.Equal(expectedTime, dateAndTime.time); + } } }