diff --git a/src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs b/src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs
deleted file mode 100644
index a4686663d7f6..000000000000
--- a/src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (c) Umbraco.
-// See LICENSE for more details.
-
-using Microsoft.Extensions.Options;
-
-namespace Umbraco.Cms.Core.Configuration.Models.Validation;
-
-///
-/// Validator for configuration representated as .
-///
-public class SystemDateMigrationSettingsValidator
- : IValidateOptions
-{
- ///
- public ValidateOptionsResult Validate(string? name, SystemDateMigrationSettings options)
- {
- if (string.IsNullOrWhiteSpace(options.LocalServerTimeZone))
- {
- return ValidateOptionsResult.Success;
- }
-
- if (TimeZoneInfo.TryFindSystemTimeZoneById(options.LocalServerTimeZone, out _) is false)
- {
- return ValidateOptionsResult.Fail(
- $"Configuration entry {Constants.Configuration.ConfigSystemDateMigration} contains an invalid time zone: {options.LocalServerTimeZone}.");
- }
-
- return ValidateOptionsResult.Success;
- }
-}
diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs
index 52d136e75326..db3c8ef2b34c 100644
--- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs
+++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs
@@ -47,7 +47,6 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder)
builder.Services.AddSingleton, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton, UnattendedSettingsValidator>();
builder.Services.AddSingleton, SecuritySettingsValidator>();
- builder.Services.AddSingleton, SystemDateMigrationSettingsValidator>();
// Register configuration sections.
builder
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs
index 3f17c4d6e47a..c4b1f3da4753 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs
@@ -1,8 +1,9 @@
-using NPoco;
-using Umbraco.Cms.Infrastructure.Scoping;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using NPoco;
using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Infrastructure.Scoping;
+using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0;
@@ -11,6 +12,8 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0;
///
public class MigrateSystemDatesToUtc : UnscopedMigrationBase
{
+ private static readonly string[] UtcIdentifiers = ["Coordinated Universal Time", "UTC"];
+
private readonly IScopeProvider _scopeProvider;
private readonly TimeProvider _timeProvider;
private readonly IOptions _migrationSettings;
@@ -49,96 +52,153 @@ protected override void Migrate()
return;
}
- // If the local server timezone is not set, we detect it.
- var timeZoneName = _migrationSettings.Value.LocalServerTimeZone;
- if (string.IsNullOrWhiteSpace(timeZoneName))
+ // Offsets and Windows name are lazy loaded as they are not always needed.
+ // This also allows using timezones that exist in the SQL Database but not on the local server.
+ (string TimeZoneName, Lazy TimeZoneOffset, Lazy WindowsTimeZoneName) timeZone;
+
+ var configuredTimeZoneName = _migrationSettings.Value.LocalServerTimeZone;
+ if (configuredTimeZoneName.IsNullOrWhiteSpace() is false)
{
- timeZoneName = _timeProvider.LocalTimeZone.Id;
- _logger.LogInformation("Migrating system dates to UTC using the detected local server timezone: {TimeZoneName}.", timeZoneName);
+ timeZone = (
+ configuredTimeZoneName,
+ new Lazy(() => TimeZoneInfo.FindSystemTimeZoneById(configuredTimeZoneName).BaseUtcOffset),
+ new Lazy(() => configuredTimeZoneName));
+
+ _logger.LogInformation(
+ "Migrating system dates to UTC using the configured timezone: {TimeZoneName}.",
+ timeZone.TimeZoneName);
}
else
{
- _logger.LogInformation("Migrating system dates to UTC using the configured local server timezone: {TimeZoneName}.", timeZoneName);
+ // If the local server timezone is not configured, we detect it.
+ TimeZoneInfo timeZoneInfo = _timeProvider.LocalTimeZone;
+
+ timeZone = (
+ timeZoneInfo.Id,
+ new Lazy(() => timeZoneInfo.BaseUtcOffset),
+ new Lazy(() => GetWindowsTimeZoneId(timeZoneInfo)));
+
+ _logger.LogInformation(
+ "Migrating system dates to UTC using the detected local server timezone: {TimeZoneName}.",
+ timeZone.TimeZoneName);
}
// If the local server timezone is UTC, skip the migration.
- if (string.Equals(timeZoneName, "Coordinated Universal Time", StringComparison.OrdinalIgnoreCase))
+ if (UtcIdentifiers.Contains(timeZone.TimeZoneName, StringComparer.OrdinalIgnoreCase))
{
- _logger.LogInformation("Skipping migration {MigrationName} as the local server timezone is UTC.", nameof(MigrateSystemDatesToUtc));
+ _logger.LogInformation(
+ "Skipping migration {MigrationName} as the local server timezone is UTC.",
+ nameof(MigrateSystemDatesToUtc));
Context.Complete();
return;
}
- TimeSpan timeZoneOffset = GetTimezoneOffset(timeZoneName);
-
using IScope scope = _scopeProvider.CreateScope();
using IDisposable notificationSuppression = scope.Notifications.Suppress();
- MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "cmsMember", "lastPasswordChangeDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoAccess", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoAccess", "updateDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoAccessRule", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoAccessRule", "updateDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoCreatedPackageSchema", "updateDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoContentVersion", "versionDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoContentVersionCleanupPolicy", "updated", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoContentVersionCultureVariation", "date", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoExternalLogin", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoExternalLoginToken", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoKeyValue", "updated", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoLog", "Datestamp", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoNode", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoRelation", "datetime", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoServer", "registeredDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoServer", "lastNotifiedDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "updateDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "emailConfirmedDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "lastLockoutDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "lastPasswordChangeDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "lastLoginDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUser", "invitedDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUserGroup", "createDate", timeZoneName, timeZoneOffset);
- MigrateDateColumn(scope, "umbracoUserGroup", "updateDate", timeZoneName, timeZoneOffset);
+ MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZone);
+ MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZone);
+ MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZone);
+ MigrateDateColumn(scope, "cmsMember", "lastPasswordChangeDate", timeZone);
+ MigrateDateColumn(scope, "umbracoAccess", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoAccess", "updateDate", timeZone);
+ MigrateDateColumn(scope, "umbracoAccessRule", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoAccessRule", "updateDate", timeZone);
+ MigrateDateColumn(scope, "umbracoCreatedPackageSchema", "updateDate", timeZone);
+ MigrateDateColumn(scope, "umbracoContentVersion", "versionDate", timeZone);
+ MigrateDateColumn(scope, "umbracoContentVersionCleanupPolicy", "updated", timeZone);
+ MigrateDateColumn(scope, "umbracoContentVersionCultureVariation", "date", timeZone);
+ MigrateDateColumn(scope, "umbracoExternalLogin", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoExternalLoginToken", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoKeyValue", "updated", timeZone);
+ MigrateDateColumn(scope, "umbracoLog", "Datestamp", timeZone);
+ MigrateDateColumn(scope, "umbracoNode", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoRelation", "datetime", timeZone);
+ MigrateDateColumn(scope, "umbracoServer", "registeredDate", timeZone);
+ MigrateDateColumn(scope, "umbracoServer", "lastNotifiedDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "updateDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "emailConfirmedDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "lastLockoutDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "lastPasswordChangeDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "lastLoginDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUser", "invitedDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUserGroup", "createDate", timeZone);
+ MigrateDateColumn(scope, "umbracoUserGroup", "updateDate", timeZone);
scope.Complete();
Context.Complete();
}
- private static TimeSpan GetTimezoneOffset(string timeZoneName)
+ private static string GetWindowsTimeZoneId(TimeZoneInfo timeZone)
+ {
+ if (timeZone.HasIanaId is false)
+ {
+ return timeZone.Id;
+ }
- // We know the provided timezone name exists, as it's either detected or configured (and configuration has been validated).
- => TimeZoneInfo.FindSystemTimeZoneById(timeZoneName).BaseUtcOffset;
+ if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZone.Id, out var windowsId) is false)
+ {
+ throw new InvalidOperationException(
+ $"Could not update system dates to UTC as it was not possible to convert the detected local time zone IANA id '{timeZone.Id}' to a Windows Id necessary for updates with SQL Server. Please manually configure the 'Umbraco:CMS:SystemDateMigration:LocalServerTimeZone' app setting with a valid Windows time zone name.");
+ }
- private void MigrateDateColumn(IScope scope, string tableName, string columName, string timezoneName, TimeSpan timeZoneOffset)
- {
- var offsetInMinutes = -timeZoneOffset.TotalMinutes;
- var offSetInMinutesString = offsetInMinutes > 0
- ? $"+{offsetInMinutes}"
- : $"{offsetInMinutes}";
+ return windowsId;
+ }
- Sql sql;
+ private void MigrateDateColumn(
+ IScope scope,
+ string tableName,
+ string columName,
+ (string Name, Lazy BaseOffset, Lazy WindowsName) timeZone)
+ {
if (DatabaseType == DatabaseType.SQLite)
{
- // SQLite does not support AT TIME ZONE, but we can use the offset to update the dates. It won't take account of daylight saving time, but
- // given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable.
- sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offSetInMinutesString} minutes')");
+ MigrateDateColumnSQLite(scope, tableName, columName, timeZone.Name, timeZone.BaseOffset.Value);
}
else
{
- sql = Sql($"UPDATE {tableName} SET {columName} = {columName} AT TIME ZONE '{timezoneName}' AT TIME ZONE 'UTC'");
+ MigrateDateColumnSqlServer(scope, tableName, columName, timeZone.WindowsName.Value);
}
+ }
+
+ private void MigrateDateColumnSQLite(
+ IScope scope,
+ string tableName,
+ string columName,
+ string timezoneName,
+ TimeSpan timezoneOffset)
+ {
+ // SQLite does not support AT TIME ZONE, but we can use the offset to update the dates. It won't take account of daylight saving time, but
+ // given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable.
+
+ var offsetInMinutes = -timezoneOffset.TotalMinutes;
+ var offsetInMinutesString = offsetInMinutes > 0
+ ? $"+{offsetInMinutes}"
+ : $"{offsetInMinutes}";
+
+ Sql sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offsetInMinutesString} minutes')");
scope.Database.Execute(sql);
_logger.LogInformation(
- "Migrated {TableName}.{ColumnName} from local server timezone of {TimeZoneName} ({OffSetInMinutes} minutes) to UTC.",
+ "Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} ({OffsetInMinutes}) to UTC.",
tableName,
columName,
timezoneName,
- offSetInMinutesString);
+ offsetInMinutesString);
+ }
+
+ private void MigrateDateColumnSqlServer(IScope scope, string tableName, string columName, string timeZoneName)
+ {
+ Sql sql = Sql($"UPDATE {tableName} SET {columName} = {columName} AT TIME ZONE '{timeZoneName}' AT TIME ZONE 'UTC'");
+
+ scope.Database.Execute(sql);
+
+ _logger.LogInformation(
+ "Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} to UTC.",
+ tableName,
+ columName,
+ timeZoneName);
}
}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs
deleted file mode 100644
index 23565db4f150..000000000000
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (c) Umbraco.
-// See LICENSE for more details.
-
-using Microsoft.Extensions.Options;
-using NUnit.Framework;
-using Umbraco.Cms.Core.Configuration.Models;
-using Umbraco.Cms.Core.Configuration.Models.Validation;
-
-namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models.Validation
-{
- [TestFixture]
- public class SystemDateMigrationSettingsValidatorTests
- {
- [Test]
- public void Returns_Success_For_Empty_Configuration()
- {
- var validator = new SystemDateMigrationSettingsValidator();
- SystemDateMigrationSettings options = BuildSystemDateMigrationSettings();
- ValidateOptionsResult result = validator.Validate("settings", options);
- Assert.True(result.Succeeded);
- }
-
- [Test]
- public void Returns_Success_For_Valid_Configuration()
- {
- var validator = new SystemDateMigrationSettingsValidator();
- SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(localServerTimeZone: "Central European Standard Time");
- ValidateOptionsResult result = validator.Validate("settings", options);
- Assert.True(result.Succeeded);
- }
-
- [Test]
- public void Returns_Fail_For_Configuration_With_Invalid_LocalServerTimeZone()
- {
- var validator = new SystemDateMigrationSettingsValidator();
- SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(localServerTimeZone: "Invalid Time Zone");
- ValidateOptionsResult result = validator.Validate("settings", options);
- Assert.False(result.Succeeded);
- }
-
- private static SystemDateMigrationSettings BuildSystemDateMigrationSettings(string? localServerTimeZone = null) =>
- new SystemDateMigrationSettings
- {
- LocalServerTimeZone = localServerTimeZone,
- };
- }
-}