diff --git a/app/components/settings/time_zone_setting_component.rb b/app/components/settings/time_zone_setting_component.rb index 674ad6a53ce1..772c8e35b012 100644 --- a/app/components/settings/time_zone_setting_component.rb +++ b/app/components/settings/time_zone_setting_component.rb @@ -30,7 +30,7 @@ module Settings ## - # A text field to enter numeric values. + # A select field to select a time zone from. class TimeZoneSettingComponent < ::ApplicationComponent options :form, :title options container_class: "-wide" diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb index 27e4514d6563..b55057d0e928 100644 --- a/app/models/anonymous_user.rb +++ b/app/models/anonymous_user.rb @@ -27,31 +27,25 @@ #++ class AnonymousUser < User - validate :validate_unique_anonymous_user, on: :create - - # There should be only one AnonymousUser in the database - def validate_unique_anonymous_user - errors.add :base, "An anonymous user already exists." if AnonymousUser.any? - end - - def available_custom_fields - [] - end - - # Overrides a few properties - def logged?; false end - - def builtin?; true end - - def admin; false end + include Users::FunctionUser def name(*_args); I18n.t(:label_user_anonymous) end - def mail; nil end + def self.first + anonymous_user = super - def time_zone; nil end + if anonymous_user.nil? + (anonymous_user = new.tap do |u| + u.lastname = "Anonymous" + u.login = "" + u.firstname = "" + u.mail = "" + u.status = User.statuses[:active] + end).save - def rss_key; nil end + raise "Unable to create the anonymous user." if anonymous_user.new_record? + end - def destroy; false end + anonymous_user + end end diff --git a/app/models/deleted_user.rb b/app/models/deleted_user.rb index ae65cfb4af70..ad89f12e71e0 100644 --- a/app/models/deleted_user.rb +++ b/app/models/deleted_user.rb @@ -1,23 +1,9 @@ class DeletedUser < User - validate :validate_unique_deleted_user, on: :create - - # There should be only one DeletedUser in the database - def validate_unique_deleted_user - errors.add :base, "A DeletedUser already exists." if DeletedUser.any? - end - def self.first super || create(type: to_s, status: statuses[:locked]) end - # Overrides a few properties - def available_custom_fields = [] - def logged? = false - def builtin? = true - def admin = false + include Users::FunctionUser + def name(*_args) = I18n.t("user.deleted") - def mail = nil - def time_zone = nil - def rss_key = nil - def destroy = false end diff --git a/app/models/exports/concerns/csv.rb b/app/models/exports/concerns/csv.rb index 859e6e0ccaa6..5c04f1b64d9c 100644 --- a/app/models/exports/concerns/csv.rb +++ b/app/models/exports/concerns/csv.rb @@ -84,7 +84,7 @@ def format_csv(record, attribute) def csv_export_filename sane_filename( "#{Setting.app_title} #{title} \ - #{format_time_as_date(Time.zone.now, '%Y-%m-%d')}.csv" + #{format_time_as_date(Time.zone.now, format: '%Y-%m-%d')}.csv" ) end end diff --git a/app/models/system_user.rb b/app/models/system_user.rb index 1a3195dacc4b..a840ef477bbd 100644 --- a/app/models/system_user.rb +++ b/app/models/system_user.rb @@ -31,31 +31,35 @@ # class SystemUser < User - validate :validate_unique_system_user, on: :create - - # There should be only one SystemUser in the database - def validate_unique_system_user - errors.add :base, "A SystemUser already exists." if SystemUser.any? - end - - # Overrides a few properties - def logged?; false end - - def builtin?; true end + include Users::FunctionUser def name(*_args); "System" end - def mail; nil end + def run_given + User.execute_as(self) do + yield self + end + end - def time_zone; nil end + def self.first + system_user = super - def rss_key; nil end + if system_user.nil? + system_user = new( + firstname: "", + lastname: "System", + login: "", + mail: "", + admin: true, + status: User.statuses[:active], + first_login: false + ) - def destroy; false end + system_user.save - def run_given - User.execute_as(self) do - yield self + raise "Unable to create the system user." unless system_user.persisted? end + + system_user end end diff --git a/app/models/user.rb b/app/models/user.rb index 577ee7606183..11749506f856 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -399,7 +399,7 @@ def log_failed_login end def log_successful_login - update_attribute(:last_login_on, Time.now) + update_attribute(:last_login_on, Time.current) end def pref @@ -407,7 +407,13 @@ def pref end def time_zone - @time_zone ||= (pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[pref.time_zone]) + @time_zone ||= ActiveSupport::TimeZone[pref.time_zone] || ActiveSupport::TimeZone["Etc/UTC"] + end + + def reload(*) + @time_zone = nil + + super end def wants_comments_in_reverse_order? @@ -538,46 +544,12 @@ def missing_authentication_method? # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only # one anonymous user per database. - def self.anonymous # rubocop:disable Metrics/AbcSize - RequestStore[:anonymous_user] ||= - begin - anonymous_user = AnonymousUser.first - - if anonymous_user.nil? - (anonymous_user = AnonymousUser.new.tap do |u| - u.lastname = "Anonymous" - u.login = "" - u.firstname = "" - u.mail = "" - u.status = User.statuses[:active] - end).save - - raise "Unable to create the anonymous user." if anonymous_user.new_record? - end - anonymous_user - end + def self.anonymous + RequestStore[:anonymous_user] ||= AnonymousUser.first end def self.system - system_user = SystemUser.first - - if system_user.nil? - system_user = SystemUser.new( - firstname: "", - lastname: "System", - login: "", - mail: "", - admin: true, - status: User.statuses[:active], - first_login: false - ) - - system_user.save(validate: false) - - raise "Unable to create the automatic migration user." unless system_user.persisted? - end - - system_user + SystemUser.first end protected @@ -689,6 +661,6 @@ def log_failed_login_timestamp end def self.default_admin_account_changed? - !User.active.find_by_login("admin").try(:current_password).try(:matches_plaintext?, "admin") # rubocop:disable Rails/DynamicFindBy + !User.active.find_by_login("admin").try(:current_password).try(:matches_plaintext?, "admin") end end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index a2f49ede552a..220768c6a2df 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -132,7 +132,11 @@ def high_contrast_theme? end def time_zone - super.presence || Setting.user_default_timezone.presence + super.presence || Setting.user_default_timezone.presence || "Etc/UTC" + end + + def time_zone? + settings["time_zone"].present? end def daily_reminders diff --git a/app/models/users/function_user.rb b/app/models/users/function_user.rb new file mode 100644 index 000000000000..fe8309243f53 --- /dev/null +++ b/app/models/users/function_user.rb @@ -0,0 +1,56 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Users::FunctionUser + extend ActiveSupport::Concern + + included do + validate :validate_unique_function_user, on: :create + + # There should be only one such user in the database + def validate_unique_function_user + errors.add :base, "A #{self.class.name} already exists." if self.class.any? + end + + def available_custom_fields = [] + + def logged? = false + + def builtin? = true + + def name(*_args); raise NotImplementedError end + + def mail = nil + + def time_zone; ActiveSupport::TimeZone[Setting.user_default_timezone.presence || "Etc/UTC"] end + + def rss_key = nil + + def destroy = false + end +end diff --git a/app/models/work_package/pdf_export/page.rb b/app/models/work_package/pdf_export/page.rb index ff5f1eea866b..571efcfaf76f 100644 --- a/app/models/work_package/pdf_export/page.rb +++ b/app/models/work_package/pdf_export/page.rb @@ -127,7 +127,7 @@ def footer_page_nr end def footer_date - format_time(Time.zone.now, true) + format_time(Time.zone.now) end def total_page_nr_text diff --git a/app/views/admin/settings/users_settings/show.html.erb b/app/views/admin/settings/users_settings/show.html.erb index 0f67f8b37049..b4171880d12e 100644 --- a/app/views/admin/settings/users_settings/show.html.erb +++ b/app/views/admin/settings/users_settings/show.html.erb @@ -48,7 +48,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render Settings::TimeZoneSettingComponent.new( "user_default_timezone", - container_class: "-slim", title: I18n.t("tooltip_user_default_timezone") ) %> diff --git a/app/views/users/_preferences.html.erb b/app/views/users/_preferences.html.erb index 44a513b0527e..37d8fe87686e 100644 --- a/app/views/users/_preferences.html.erb +++ b/app/views/users/_preferences.html.erb @@ -30,6 +30,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render Settings::TimeZoneSettingComponent.new( "time_zone", form: pref_fields, + include_blank: false, container_class: (defined? input_size) ? "-#{input_size}" : "-wide" ) %> diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index 01179d7dd3b1..243027c58fbd 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -115,18 +115,18 @@ def link_regex /(\[(.+?)\]\((.+?)\))/ end - # Format the time to a date in the user time zone if one is set. - # If none is set and the time is in utc time zone (meaning it came from active record), format the date in the system timezone - # otherwise just use the date in the time zone attached to the time. - def format_time_as_date(time, format = nil) + # Formats the given time as a date string according to the user's time zone and + # optional specified format. + # + # @param time [Time] The time to format. + # @param format [String, nil] The strftime format to use for the date. If nil, the default + # date format from `Setting.date_format` is used. + # @return [String, nil] The formatted date string, or nil if the time is not provided. + def format_time_as_date(time, format: nil) return nil unless time zone = User.current.time_zone - local_date = (if zone - time.in_time_zone(zone) - else - time.utc? ? time.localtime : time - end).to_date + local_date = time.in_time_zone(zone).to_date if format local_date.strftime(format) @@ -135,18 +135,34 @@ def format_time_as_date(time, format = nil) end end - def format_time(time, include_date = true) + # Formats the given time as a time string according to the user's time zone + # and optional specified format. + # + # @param time [Time] The time to format. + # @param include_date [Boolean] Whether to include the date in the formatted + # output. Defaults to true. + # @param format [String] The strftime format to use for the time. Defaults + # to the format in `Setting.time_format`. + # @return [String, nil] The formatted time string, or nil if the time is not + # provided. + def format_time(time, include_date: true, format: Setting.time_format) return nil unless time - time = time.to_time if time.is_a?(String) zone = User.current.time_zone - local = if zone - time.in_time_zone(zone) - else - (time.utc? ? time.to_time.localtime : time) - end + local = time.in_time_zone(zone) + (include_date ? "#{format_date(local)} " : "") + - (Setting.time_format.blank? ? ::I18n.l(local, format: :time) : local.strftime(Setting.time_format)) + (format.blank? ? ::I18n.l(local, format: :time) : local.strftime(format)) + end + + # Returns the offset to UTC (with utc prepended) currently active + # in the current users time zone. DST is factored in so the offset can + # shift over the course of the year + def formatted_time_zone_offset + # Doing User.current.time_zone and format that will not take heed of DST as it has no notion + # of a current time. + # https://github.com/rails/rails/issues/7297 + "UTC#{User.current.time_zone.now.formatted_offset}" end def day_name(day) diff --git a/modules/bim/lib/open_project/bim/bcf_xml/exporter.rb b/modules/bim/lib/open_project/bim/bcf_xml/exporter.rb index b3dbb206a22d..dcb27d4b8d64 100644 --- a/modules/bim/lib/open_project/bim/bcf_xml/exporter.rb +++ b/modules/bim/lib/open_project/bim/bcf_xml/exporter.rb @@ -43,7 +43,7 @@ def bcf_filename sane_filename( "#{Setting.app_title} #{I18n.t(:label_work_package_plural)} \ - #{format_time_as_date(Time.now, '%Y-%m-%d')}.bcf" + #{format_time_as_date(Time.current, format: '%Y-%m-%d')}.bcf" ) end diff --git a/modules/boards/app/components/boards/row_component.rb b/modules/boards/app/components/boards/row_component.rb index e774c1fa3f38..7323c5140470 100644 --- a/modules/boards/app/components/boards/row_component.rb +++ b/modules/boards/app/components/boards/row_component.rb @@ -39,7 +39,7 @@ def name end def created_at - safe_join([helpers.format_date(model.created_at), helpers.format_time(model.created_at, false)], " ") + safe_join([helpers.format_date(model.created_at), helpers.format_time(model.created_at, include_date: false)], " ") end def type diff --git a/modules/meeting/app/components/meetings/row_component.rb b/modules/meeting/app/components/meetings/row_component.rb index 89b578bed95e..b945f6ddf157 100644 --- a/modules/meeting/app/components/meetings/row_component.rb +++ b/modules/meeting/app/components/meetings/row_component.rb @@ -47,7 +47,7 @@ def type end def start_time - safe_join([helpers.format_date(model.start_time), helpers.format_time(model.start_time, false)], " ") + safe_join([helpers.format_date(model.start_time), helpers.format_time(model.start_time, include_date: false)], " ") end def duration diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb index a49740fe66e1..1c55629c1ce8 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb @@ -30,13 +30,13 @@ flex_layout(align_items: :center) do |time| time.with_column do render(Primer::Beta::Text.new) do - "#{format_time(@meeting.start_time, false)} - #{format_time(@meeting.end_time, false)}" + "#{format_time(@meeting.start_time, include_date: false)} - #{format_time(@meeting.end_time, include_date:false)}" end end time.with_column(ml: 2) do render(Primer::Beta::Text.new(color: :subtle, font_size: :small)) do - (User.current.time_zone || Time.zone).to_s[/\((.*?)\)/m, 1] + formatted_time_zone_offset end end end diff --git a/modules/meeting/app/components/meetings/side_panel/details_form_component.rb b/modules/meeting/app/components/meetings/side_panel/details_form_component.rb index e290cdbe3e72..fb0bdde00cdb 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_form_component.rb +++ b/modules/meeting/app/components/meetings/side_panel/details_form_component.rb @@ -45,11 +45,11 @@ def render? private def start_date_initial_value - @meeting.start_time&.strftime("%Y-%m-%d") + format_time_as_date(@meeting.start_time, format: "%Y-%m-%d") end def start_time_initial_value - @meeting.start_time&.strftime("%H:%M") + format_time(@meeting.start_time, include_date: false, format: "%H:%M") end end end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index c83418b239c5..5fb9334eb5c6 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -27,7 +27,6 @@ #++ class MeetingsController < ApplicationController - around_action :set_time_zone before_action :load_and_authorize_in_optional_project, only: %i[index new show create history] before_action :verify_activities_module_activated, only: %i[history] before_action :determine_date_range, only: %i[history] @@ -96,8 +95,8 @@ def create # rubocop:disable Metrics/AbcSize if call.success? text = I18n.t(:notice_successful_create) - if User.current.time_zone.nil? - link = I18n.t(:notice_timezone_missing, zone: Time.zone) + unless User.current.pref.time_zone? + link = I18n.t(:notice_timezone_missing, zone: formatted_time_zone_offset) text += " #{view_context.link_to(link, { controller: '/my', action: :settings, anchor: 'pref_time_zone' }, class: 'link_to_profile')}" end @@ -288,17 +287,6 @@ def load_meetings(query) .paginate(page: page_param, per_page: per_page_param) end - def set_time_zone(&) - zone = User.current.time_zone - if zone.nil? - localzone = Time.current.utc_offset - localzone -= 3600 if Time.current.dst? - zone = ::ActiveSupport::TimeZone[localzone] - end - - Time.use_zone(zone, &) - end - def build_meeting @meeting = Meeting.new @meeting.project = @project diff --git a/modules/meeting/app/forms/meeting/start_time.rb b/modules/meeting/app/forms/meeting/start_time.rb index d077e40230f0..36b43092839e 100644 --- a/modules/meeting/app/forms/meeting/start_time.rb +++ b/modules/meeting/app/forms/meeting/start_time.rb @@ -27,6 +27,8 @@ #++ class Meeting::StartTime < ApplicationForm + include Redmine::I18n + form do |meeting_form| meeting_form.text_field( name: :start_time_hour, @@ -36,7 +38,7 @@ class Meeting::StartTime < ApplicationForm label: Meeting.human_attribute_name(:start_time), leading_visual: { icon: :clock }, required: true, - caption: Time.zone.to_s[/\((.*?)\)/m, 1] + caption: formatted_time_zone_offset ) end diff --git a/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb b/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb index 976f4ca5580c..cd44d1fedb88 100644 --- a/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb +++ b/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb @@ -52,7 +52,7 @@ class MeetingAgendaItem::MeetingForm < ApplicationForm label: "#{meeting.project.name}: " \ "#{meeting.title} " \ "#{format_date(meeting.start_time)} " \ - "#{format_time(meeting.start_time, false)}", + "#{format_time(meeting.start_time, include_date: false)}", value: meeting.id ) end diff --git a/modules/meeting/app/mailers/meeting_mailer.rb b/modules/meeting/app/mailers/meeting_mailer.rb index 817962f41c3d..fa026b3eb582 100644 --- a/modules/meeting/app/mailers/meeting_mailer.rb +++ b/modules/meeting/app/mailers/meeting_mailer.rb @@ -63,8 +63,6 @@ def icalendar_notification(meeting, user, _actor, **) set_headers @meeting with_attached_ics(meeting, user) do - timezone = Time.zone || Time.zone_default - @formatted_timezone = format_timezone_offset timezone, @meeting.start_time subject = "[#{@meeting.project.name}] #{@meeting.title}" mail(to: user, subject:) end @@ -95,9 +93,4 @@ def set_headers(meeting) headers["Content-Type"] = 'text/calendar; charset=utf-8; method="PUBLISH"; name="meeting.ics"' headers["Content-Transfer-Encoding"] = "8bit" end - - def format_timezone_offset(timezone, time) - offset = ::ActiveSupport::TimeZone.seconds_to_utc_offset time.utc_offest_for_timezone(timezone), true - "(GMT#{offset}) #{timezone.name}" - end end diff --git a/modules/meeting/app/models/activities/meeting_activity_provider.rb b/modules/meeting/app/models/activities/meeting_activity_provider.rb index 802eb0739452..01003beee95f 100644 --- a/modules/meeting/app/models/activities/meeting_activity_provider.rb +++ b/modules/meeting/app/models/activities/meeting_activity_provider.rb @@ -109,8 +109,8 @@ def event_title(event) end_time = start_time + event["meeting_duration"].to_f.hours fstart_with = format_date start_time - fstart_without = format_time start_time, false - fend_without = format_time end_time, false + fstart_without = format_time start_time, include_date: false + fend_without = format_time end_time, include_date: false "#{I18n.t(:label_meeting)}: #{event['meeting_title']} (#{fstart_with} #{fstart_without}-#{fend_without})" else diff --git a/modules/meeting/app/models/activities/meeting_event_mapper.rb b/modules/meeting/app/models/activities/meeting_event_mapper.rb index a02cb4c6ad31..281839a5390a 100644 --- a/modules/meeting/app/models/activities/meeting_event_mapper.rb +++ b/modules/meeting/app/models/activities/meeting_event_mapper.rb @@ -204,8 +204,8 @@ def event_title(_journal, data) end_time = start_time + data[:meeting_duration].to_f.hours fstart_with = format_date start_time - fstart_without = format_time start_time, false - fend_without = format_time end_time, false + fstart_without = format_time start_time, include_date: false + fend_without = format_time end_time, include_date: false "#{I18n.t(:label_meeting)}: #{data[:meeting_title]} (#{fstart_with} #{fstart_without}-#{fend_without})" end diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 58b362589eb6..27697a97fc57 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -253,14 +253,15 @@ def allowed_participants def set_initial_values # set defaults - write_attribute(:start_time, Date.tomorrow + 10.hours) if start_time.nil? + # Start date is set to tomorrow at 10 AM (Current users local time) + write_attribute(:start_time, User.current.time_zone.now.at_midnight + 34.hours) if start_time.nil? self.duration ||= 1 update_derived_fields end def update_derived_fields - @start_date = start_time.to_date.iso8601 - @start_time_hour = start_time.strftime("%H:%M") + @start_date = format_time_as_date(start_time, format: "%Y-%m-%d") + @start_time_hour = format_time(start_time, include_date: false, format: "%H:%M") end private diff --git a/modules/meeting/app/models/meeting/journalized.rb b/modules/meeting/app/models/meeting/journalized.rb index cd49c4211119..b26ffc7b5cb0 100644 --- a/modules/meeting/app/models/meeting/journalized.rb +++ b/modules/meeting/app/models/meeting/journalized.rb @@ -35,7 +35,7 @@ module Meeting::Journalized acts_as_event title: Proc.new { |o| "#{I18n.t(:label_meeting)}: #{o.title} \ #{format_date o.start_time} \ - #{format_time o.start_time, false}-#{format_time o.end_time, false})" + #{format_time o.start_time, include_date: false}-#{format_time o.end_time, include_date: false})" }, url: Proc.new { |o| { controller: "/meetings", action: "show", id: o } }, author: Proc.new(&:user), diff --git a/modules/meeting/app/views/meeting_mailer/icalendar_notification.html.erb b/modules/meeting/app/views/meeting_mailer/icalendar_notification.html.erb index 1aea9386abca..4c2129253fb3 100644 --- a/modules/meeting/app/views/meeting_mailer/icalendar_notification.html.erb +++ b/modules/meeting/app/views/meeting_mailer/icalendar_notification.html.erb @@ -4,7 +4,7 @@

<%= t(:text_notificiation_invited) %>