Skip to content

Commit b9016ef

Browse files
committed
Add plugin scaffold: sidecar storage, date mirroring, form hook, JSON API
- issue_datetimes table (starts_at/ends_at, unique per issue) - Issue extension: mirror invariant, journalized time changes, safe_attributes-gated start_time/due_time inputs - View hooks for the issue form (native time inputs) and issue view - JSON API: per-issue GET/PUT/DELETE and per-project bulk endpoint - Plugin settings: enabled trackers, time step, reference zone - EN/JA locales - Test suite (16 tests) passing against Redmine with the GTT plugin set
1 parent 18c6c6d commit b9016ef

17 files changed

Lines changed: 622 additions & 2 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ Gantt, exports, and the REST API keep working exactly as before.
1212

1313
## Status
1414

15-
Design phase. See [docs/design.md](docs/design.md) for the full design
16-
document. No installable release yet.
15+
Early development, no release yet. The core pieces (sidecar storage,
16+
date mirroring, issue form integration, JSON API) are implemented and
17+
covered by tests. See [docs/design.md](docs/design.md) for the full
18+
design document.
1719

1820
## Planned highlights
1921

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
class IssueDatetimesController < ApplicationController
2+
before_action :find_issue, only: [:show, :update, :destroy]
3+
before_action :require_edit_permission, only: [:update, :destroy]
4+
before_action :find_project, only: [:index]
5+
accept_api_auth :index, :show, :update, :destroy
6+
7+
def show
8+
render json: issue_payload(@issue)
9+
end
10+
11+
# Accepts {"starts_at": <iso8601|null>, "ends_at": <iso8601|null>}.
12+
# A timestamp sets both the core date and the time of day; null clears
13+
# the time of day and keeps the date. Omitted keys are left untouched.
14+
def update
15+
zone = RedmineIssueDatetime.reference_zone
16+
@issue.init_journal(User.current)
17+
18+
[[:starts_at, :start_date, :start_time=], [:ends_at, :due_date, :due_time=]].each do |param, date_attr, time_writer|
19+
next unless params.key?(param)
20+
21+
value = params[param]
22+
if value.present?
23+
timestamp = parse_timestamp(value)
24+
return render_parse_error(param) if timestamp.nil?
25+
26+
local = timestamp.in_time_zone(zone)
27+
@issue.send(:"#{date_attr}=", local.to_date)
28+
@issue.send(time_writer, local.strftime('%H:%M'))
29+
else
30+
@issue.send(time_writer, '')
31+
end
32+
end
33+
34+
if @issue.save
35+
render json: issue_payload(@issue.reload)
36+
else
37+
render json: {errors: @issue.errors.full_messages}, status: :unprocessable_entity
38+
end
39+
end
40+
41+
# Clears the times (back to all-day); the core dates stay.
42+
def destroy
43+
@issue.init_journal(User.current)
44+
@issue.start_time = ''
45+
@issue.due_time = ''
46+
if @issue.save
47+
head :no_content
48+
else
49+
render json: {errors: @issue.errors.full_messages}, status: :unprocessable_entity
50+
end
51+
end
52+
53+
# Bulk read for external consumers (schedulers, sync clients).
54+
def index
55+
unless User.current.allowed_to?(:view_issues, @project)
56+
return render_403
57+
end
58+
59+
scope = IssueDatetime
60+
.joins(issue: :project)
61+
.where(issues: {project_id: @project.id})
62+
.where(Issue.visible_condition(User.current))
63+
if params[:updated_since].present?
64+
since = parse_timestamp(params[:updated_since])
65+
return render_parse_error(:updated_since) if since.nil?
66+
67+
scope = scope.where('issue_datetimes.updated_at >= ?', since)
68+
end
69+
70+
render json: {
71+
issue_datetimes: scope.order(:issue_id).map { |record| record_payload(record) }
72+
}
73+
end
74+
75+
private
76+
77+
def find_issue
78+
@issue = Issue.find(params[:issue_id])
79+
render_403 unless @issue.visible?
80+
rescue ActiveRecord::RecordNotFound
81+
render_404
82+
end
83+
84+
def require_edit_permission
85+
render_403 unless @issue.editable?
86+
end
87+
88+
def find_project
89+
@project = Project.find(params[:project_id])
90+
rescue ActiveRecord::RecordNotFound
91+
render_404
92+
end
93+
94+
def parse_timestamp(value)
95+
Time.iso8601(value.to_s)
96+
rescue ArgumentError
97+
nil
98+
end
99+
100+
def render_parse_error(param)
101+
render json: {errors: ["#{param} must be an ISO 8601 timestamp"]}, status: :unprocessable_entity
102+
end
103+
104+
def issue_payload(issue)
105+
record = issue.issue_datetime
106+
{
107+
issue_id: issue.id,
108+
start_date: issue.start_date,
109+
due_date: issue.due_date,
110+
starts_at: record&.starts_at&.iso8601,
111+
ends_at: record&.ends_at&.iso8601,
112+
updated_at: record&.updated_at&.iso8601
113+
}
114+
end
115+
116+
def record_payload(record)
117+
{
118+
issue_id: record.issue_id,
119+
starts_at: record.starts_at&.iso8601,
120+
ends_at: record.ends_at&.iso8601,
121+
updated_at: record.updated_at.iso8601
122+
}
123+
end
124+
end

app/models/issue_datetime.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class IssueDatetime < ApplicationRecord
2+
belongs_to :issue
3+
4+
validates :issue_id, uniqueness: true
5+
validate :validate_range
6+
7+
def blank_times?
8+
starts_at.nil? && ends_at.nil?
9+
end
10+
11+
private
12+
13+
def validate_range
14+
if starts_at && ends_at && ends_at < starts_at
15+
errors.add(:ends_at, :invalid)
16+
end
17+
end
18+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<% if issue && form && RedmineIssueDatetime.enabled_for?(issue.tracker_id) %>
2+
<div id="issue_datetime_attributes" class="splitcontent">
3+
<div class="splitcontentleft">
4+
<p><%= form.time_field :start_time, step: RedmineIssueDatetime.time_step_seconds, label: :field_start_time %></p>
5+
</div>
6+
<div class="splitcontentright">
7+
<p><%= form.time_field :due_time, step: RedmineIssueDatetime.time_step_seconds, label: :field_due_time %></p>
8+
</div>
9+
</div>
10+
<% end %>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<% record = issue && issue.issue_datetime %>
2+
<% if record && !record.blank_times? %>
3+
<div class="issue-datetime">
4+
<span class="issue-datetime-start">
5+
<strong><%= l(:field_start_time) %>:</strong>
6+
<%= RedmineIssueDatetime.format_time_of_day(record.starts_at) || '-' %>
7+
</span>
8+
<span class="issue-datetime-due">
9+
<strong><%= l(:field_due_time) %>:</strong>
10+
<%= RedmineIssueDatetime.format_time_of_day(record.ends_at) || '-' %>
11+
</span>
12+
</div>
13+
<% end %>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<p>
2+
<label><%= l(:label_issue_datetime_trackers) %></label>
3+
<%= hidden_field_tag 'settings[tracker_ids][]', '' %>
4+
<% Tracker.sorted.each do |tracker| %>
5+
<label class="block">
6+
<%= check_box_tag 'settings[tracker_ids][]', tracker.id,
7+
RedmineIssueDatetime.enabled_for?(tracker.id) %>
8+
<%= tracker.name %>
9+
</label>
10+
<% end %>
11+
</p>
12+
<p>
13+
<label><%= l(:label_issue_datetime_step) %></label>
14+
<%= select_tag 'settings[time_step]',
15+
options_for_select([5, 10, 15, 30, 60].map { |m| ["#{m} min", m.to_s] },
16+
RedmineIssueDatetime.settings['time_step']) %>
17+
</p>
18+
<p>
19+
<label><%= l(:label_issue_datetime_zone) %></label>
20+
<%= select_tag 'settings[reference_zone]',
21+
options_for_select([[l(:label_issue_datetime_zone_default), '']] +
22+
ActiveSupport::TimeZone.all.map { |z| [z.to_s, z.name] },
23+
RedmineIssueDatetime.settings['reference_zone']) %>
24+
<em class="info"><%= l(:text_issue_datetime_zone_hint) %></em>
25+
</p>

config/locales/en.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
en:
2+
field_start_time: Start time
3+
field_due_time: Due time
4+
label_issue_datetime_trackers: Enabled for trackers
5+
label_issue_datetime_step: Time input step
6+
label_issue_datetime_zone: Reference time zone
7+
label_issue_datetime_zone_default: Application default
8+
text_issue_datetime_zone_hint: Used to derive the date part mirrored into the start and due date fields.

config/locales/ja.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
ja:
2+
field_start_time: 開始時刻
3+
field_due_time: 期日時刻
4+
label_issue_datetime_trackers: 有効にするトラッカー
5+
label_issue_datetime_step: 時刻入力の間隔
6+
label_issue_datetime_zone: 基準タイムゾーン
7+
label_issue_datetime_zone_default: アプリケーションのデフォルト
8+
text_issue_datetime_zone_hint: 開始日・期日に反映する日付の算出に使用します。

config/routes.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
RedmineApp::Application.routes.draw do
2+
get 'issues/:issue_id/datetime', to: 'issue_datetimes#show',
3+
constraints: {issue_id: /\d+/}, as: 'issue_datetime'
4+
put 'issues/:issue_id/datetime', to: 'issue_datetimes#update',
5+
constraints: {issue_id: /\d+/}
6+
delete 'issues/:issue_id/datetime', to: 'issue_datetimes#destroy',
7+
constraints: {issue_id: /\d+/}
8+
get 'projects/:project_id/issue_datetimes', to: 'issue_datetimes#index',
9+
as: 'project_issue_datetimes'
10+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class CreateIssueDatetimes < ActiveRecord::Migration[7.2]
2+
def change
3+
create_table :issue_datetimes do |t|
4+
t.references :issue, null: false, index: {unique: true}, foreign_key: true
5+
t.datetime :starts_at
6+
t.datetime :ends_at
7+
t.timestamps null: false
8+
end
9+
end
10+
end

0 commit comments

Comments
 (0)