Skip to content

Commit 34b2f11

Browse files
Ensure template can be completed with an open occurrence
Adds some safeguards to ensure a series occurrence is not instantiated while template is in draft: - InitOccurrenceService refuses to instantiate an occurrence if the template is still in draft - InitNextOccurrenceJob adds a condition to check draft templates, preventing jobs from opening occurrences - template_completed can always recover the broken state: if the first occurrence already exists but the template is still draft, it opens the template instead of redirecting into the loop. https://community.openproject.org/projects/MEET/work_packages/MEET-557/activity
1 parent 003491c commit 34b2f11

9 files changed

Lines changed: 140 additions & 2 deletions

File tree

modules/meeting/app/controllers/recurring_meetings_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ def check_template_completable
423423
.not_cancelled
424424
.exists?(recurrence_start_time: @first_occurrence)
425425

426-
if is_scheduled
426+
if is_scheduled && !@recurring_meeting.template.draft?
427427
flash[:info] = I18n.t("recurring_meeting.occurrence.first_already_exists")
428428
redirect_to action: :show, status: :see_other
429429
end

modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def initialize(user:, recurring_meeting:)
4444

4545
def perform
4646
start_time = params.fetch(:start_time)
47+
return draft_template_failure if recurring_meeting.template.draft?
48+
4749
in_context(recurring_meeting, send_notifications: false) do
4850
call = instantiate(start_time)
4951
if call.success?
@@ -54,6 +56,10 @@ def perform
5456
end
5557
end
5658

59+
def draft_template_failure
60+
ServiceResult.failure(message: I18n.t("recurring_meeting.occurrence.error_template_draft"))
61+
end
62+
5763
def instantiate(start_time)
5864
existing = recurring_meeting.meetings.not_templated.find_by(recurrence_start_time: start_time)
5965

modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def self.unique_key(recurring_meeting)
5757

5858
def perform(recurring_meeting, scheduled_time)
5959
self.recurring_meeting = recurring_meeting
60+
return if recurring_meeting.template.draft?
61+
6062
self.scheduled_time = scheduled_time.in_time_zone(recurring_meeting.time_zone)
6163

6264
# Schedule the next job

modules/meeting/app/workers/recurring_meetings/init_next_occurrence_watchdog_job.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ def perform
3535
key = RecurringMeetings::InitNextOccurrenceJob::CONCURRENCY_KEY_BASE
3636

3737
RecurringMeeting
38+
.includes(:template)
3839
.joins("LEFT JOIN good_jobs ON good_jobs.concurrency_key = CONCAT('#{key}', recurring_meetings.id)")
3940
.where(good_jobs: { id: nil })
4041
.find_each do |series|
42+
next if series.template.draft?
43+
4144
next_occurrence = series.next_occurrence
4245

4346
if next_occurrence

modules/meeting/config/locales/en.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,13 @@ en:
452452
message: "This meeting series has come to an end. There are no upcoming meetings. "
453453
action: "You can still view past occurrences or edit the meeting series to extend it."
454454
occurrence:
455-
infoline: "This meeting is part of a recurring meeting series."
456455
error_no_next: "There is no next occurrence for this meeting."
456+
error_template_draft: "The meeting series template is still in draft mode."
457457
first_already_exists: "The first occurrence of this meeting series is already instantiated."
458458
first_created: >
459459
The first meeting has been successfuly created from template.
460460
All future meetings will be created automatically at the time of the previous occurrence.
461+
infoline: "This meeting is part of a recurring meeting series."
461462
template:
462463
button_finalize: "Open first meeting"
463464
blank_title: "Your meeting series template is empty"

modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,33 @@
102102
end
103103
end
104104

105+
context "when first occurrence is already created while the template is still a draft" do
106+
let!(:meeting) do
107+
create(:meeting,
108+
recurring_meeting:,
109+
start_time: recurring_meeting.start_time,
110+
recurrence_start_time: recurring_meeting.start_time)
111+
end
112+
113+
before do
114+
recurring_meeting.template.update!(state: :draft)
115+
end
116+
117+
it "opens the template without creating a duplicate occurrence" do
118+
expect { subject }.not_to change(recurring_meeting.meetings.not_templated, :count)
119+
expect(response).to have_http_status(:ok)
120+
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
121+
expect(response.body).to include('action="redirect_to"')
122+
expect(response.body).to include(project_recurring_meeting_path(project, recurring_meeting))
123+
124+
expect(recurring_meeting.template.reload).to be_open
125+
expect(recurring_meeting.meetings.not_templated.first).to eq(meeting)
126+
expect(RecurringMeetings::InitNextOccurrenceJob)
127+
.to have_been_enqueued.with(recurring_meeting, DateTime.parse("2024-12-06T10:00:00Z"))
128+
.at(DateTime.parse("2024-12-05T10:00:00Z"))
129+
end
130+
end
131+
105132
context "when first occurrence is cancelled" do
106133
let!(:cancelled_occurrence) do
107134
create(:meeting,

modules/meeting/spec/services/recurring_meetings/init_occurrence_service_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@
5656
describe "instantiating an occurrence for a slot" do
5757
let(:start_time) { series.start_time + 3.days }
5858

59+
context "when the template is still in draft mode" do
60+
before do
61+
series.template.update!(state: :draft)
62+
end
63+
64+
it "does not create an occurrence" do
65+
expect(service_result).not_to be_success
66+
expect(service_result.message).to eq(I18n.t("recurring_meeting.occurrence.error_template_draft"))
67+
expect(created_meeting).to be_nil
68+
end
69+
70+
it "does not add an occurrence" do
71+
expect { instance.call(**params) }
72+
.not_to change { series.meetings.not_templated.count }
73+
end
74+
end
75+
5976
context "when no occurrence exists yet for the slot" do
6077
it "creates a new occurrence for the series" do
6178
expect(service_result).to be_success

modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@
4646

4747
subject { described_class.perform_now(series, scheduled_time) }
4848

49+
context "when the template is still in draft mode" do
50+
before do
51+
series.template.update!(state: :draft)
52+
end
53+
54+
it "does not schedule or instantiate occurrences" do
55+
result = nil
56+
57+
expect { result = subject }
58+
.not_to change { series.meetings.not_templated.count }
59+
expect(result).to be_nil
60+
expect(described_class).not_to have_been_enqueued
61+
end
62+
end
63+
4964
it "schedules the first occurrence" do
5065
expect { subject }.to change(Meeting, :count).by(1)
5166
expect(subject).to be_success
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
require_module_spec_helper
33+
34+
RSpec.describe RecurringMeetings::InitNextOccurrenceWatchdogJob, type: :model do
35+
shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) }
36+
shared_let(:user) { create(:user) }
37+
38+
let(:series) do
39+
create(:recurring_meeting,
40+
project:,
41+
author: user,
42+
start_time: Time.zone.tomorrow + 10.hours,
43+
frequency: "daily",
44+
interval: 1,
45+
end_after: "specific_date",
46+
end_date: 1.month.from_now)
47+
end
48+
49+
subject(:perform) { described_class.perform_now }
50+
51+
it "re-schedules missing init jobs for open series" do
52+
expect { perform }
53+
.to have_enqueued_job(RecurringMeetings::InitNextOccurrenceJob)
54+
.with(series, series.next_occurrence)
55+
end
56+
57+
context "when the template is still in draft mode" do
58+
before do
59+
series.template.update!(state: :draft)
60+
end
61+
62+
it "does not schedule an init job" do
63+
expect { perform }
64+
.not_to have_enqueued_job(RecurringMeetings::InitNextOccurrenceJob)
65+
end
66+
end
67+
end

0 commit comments

Comments
 (0)