Commit afb19265 authored by Imre Farkas's avatar Imre Farkas Committed by Douwe Maan
Browse files

Export assigned issues in iCalendar feed

parent c6d12cb37754
......@@ -144,6 +144,9 @@ gem 'truncato', '~> 0.7.9'
gem 'bootstrap_form', '~> 2.7.0'
gem 'nokogiri', '~> 1.8.2'
# Calendar rendering
gem 'icalendar'
# Diffs
gem 'diffy', '~> 3.1.0'
......
......@@ -410,6 +410,7 @@ GEM
httpclient (2.8.3)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
icalendar (2.4.1)
ice_nine (0.11.2)
influxdb (0.2.3)
cause
......@@ -1060,6 +1061,7 @@ DEPENDENCIES
html-pipeline (~> 2.7.1)
html2text
httparty (~> 0.13.3)
icalendar
influxdb (~> 0.2)
jira-ruby (~> 1.4)
jquery-atwho-rails (~> 1.3.2)
......
......@@ -17,10 +17,23 @@ def issues
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def issues_calendar
@issues = issuables_collection
.non_archived
.with_due_date
.limit(100)
respond_to do |format|
format.ics { response.headers['Content-Disposition'] = 'inline' }
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
private
def finder_type
(super if defined?(super)) ||
(IssuesFinder if action_name == 'issues')
(IssuesFinder if %w(issues issues_calendar).include?(action_name))
end
end
......@@ -34,12 +34,12 @@ def reset_incoming_email_token
redirect_to profile_personal_access_tokens_path
end
def reset_rss_token
def reset_feed_token
Users::UpdateService.new(current_user, user: @user).execute! do |user|
user.reset_rss_token!
user.reset_feed_token!
end
flash[:notice] = "RSS token was successfully reset"
flash[:notice] = 'Feed token was successfully reset'
redirect_to profile_personal_access_tokens_path
end
......
......@@ -10,8 +10,8 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
before_action :issue, except: [:index, :new, :create, :bulk_update]
before_action :set_issuables_index, only: [:index]
before_action :issue, except: [:index, :calendar, :new, :create, :bulk_update]
before_action :set_issuables_index, only: [:index, :calendar]
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
......@@ -39,6 +39,17 @@ def index
end
end
def calendar
@issues = @issuables
.non_archived
.with_due_date
.limit(100)
respond_to do |format|
format.ics { response.headers['Content-Disposition'] = 'inline' }
end
end
def new
params[:issue] ||= ActionController::Parameters.new(
assignee_ids: ""
......
......@@ -75,6 +75,8 @@ def by_due_date(items)
items = items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
elsif filter_by_due_this_month?
items = items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
elsif filter_by_due_next_month_and_previous_two_weeks?
items = items.due_between(Date.today - 2.weeks, (Date.today + 1.month).end_of_month)
end
end
......@@ -97,6 +99,10 @@ def filter_by_due_this_month?
due_date? && params[:due_date] == Issue::DueThisMonth.name
end
def filter_by_due_next_month_and_previous_two_weeks?
due_date? && params[:due_date] == Issue::DueNextMonthAndPreviousTwoWeeks.name
end
def due_date?
params[:due_date].present?
end
......
module CalendarHelper
def calendar_url_options
{ format: :ics,
feed_token: current_user.try(:feed_token),
due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name,
sort: 'closest_future_date' }
end
end
module RssHelper
def rss_url_options
{ format: :atom, rss_token: current_user.try(:rss_token) }
{ format: :atom, feed_token: current_user.try(:feed_token) }
end
end
......@@ -14,12 +14,13 @@ class Issue < ActiveRecord::Base
ignore_column :assignee_id, :branch_name, :deleted_at
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
AnyDueDate = DueDateStruct.new('Any Due Date', '').freeze
Overdue = DueDateStruct.new('Overdue', 'overdue').freeze
DueThisWeek = DueDateStruct.new('Due This Week', 'week').freeze
DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
AnyDueDate = DueDateStruct.new('Any Due Date', '').freeze
Overdue = DueDateStruct.new('Overdue', 'overdue').freeze
DueThisWeek = DueDateStruct.new('Due This Week', 'week').freeze
DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze
DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
belongs_to :project
belongs_to :moved_to, class_name: 'Issue'
......@@ -46,6 +47,7 @@ class Issue < ActiveRecord::Base
scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
scope :with_due_date, -> { where('due_date IS NOT NULL') }
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
......@@ -53,6 +55,7 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
scope :order_closest_future_date, -> { reorder('CASE WHEN due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - due_date) ASC') }
scope :preload_associations, -> { preload(:labels, project: :namespace) }
......@@ -119,6 +122,7 @@ def self.project_foreign_key
def self.sort_by_attribute(method, excluded_labels: [])
case method.to_s
when 'closest_future_date' then order_closest_future_date
when 'due_date' then order_due_date_asc
when 'due_date_asc' then order_due_date_asc
when 'due_date_desc' then order_due_date_desc
......
......@@ -26,7 +26,7 @@ class User < ActiveRecord::Base
ignore_column :authentication_token
add_authentication_token_field :incoming_email_token
add_authentication_token_field :rss_token
add_authentication_token_field :feed_token
default_value_for :admin, false
default_value_for(:external) { Gitlab::CurrentSettings.user_default_external }
......@@ -1167,11 +1167,11 @@ def update_two_factor_requirement
save
end
# each existing user needs to have an `rss_token`.
# each existing user needs to have an `feed_token`.
# we do this on read since migrating all existing users is not a feasible
# solution.
def rss_token
ensure_rss_token!
def feed_token
ensure_feed_token!
end
def sync_attribute?(attribute)
......
......@@ -7,8 +7,7 @@
.top-area
= render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set
.nav-controls
= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do
= icon('rss')
= render 'shared/issuable/feed_buttons'
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues
= render 'shared/issuable/filter', type: :issues
......
= render 'issues/issues_calendar', issues: @issues
......@@ -8,10 +8,7 @@
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
= link_to safe_params.merge(rss_url_options), class: 'btn' do
= icon('rss')
%span.icon-label
Subscribe
= render 'shared/issuable/feed_buttons'
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues
= render 'shared/issuable/search_bar', type: :issues
......
= render 'issues/issues_calendar', issues: @issues
cal = Icalendar::Calendar.new
cal.prodid = '-//GitLab//NONSGML GitLab//EN'
cal.x_wr_calname = 'GitLab Issues'
@issues.includes(project: :namespace).each do |issue|
cal.event do |event|
event.dtstart = Icalendar::Values::Date.new(issue.due_date)
event.summary = "#{issue.title} (in #{issue.project.full_path})"
event.description = "Find out more at #{issue_url(issue)}"
event.url = issue_url(issue)
event.transp = 'TRANSPARENT'
end
end
cal.to_ical
......@@ -34,18 +34,18 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
RSS token
Feed token
%p
Your RSS token is used to authenticate you when your RSS reader loads a personalized RSS feed, and is included in your personal RSS feed URLs.
Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when when your calendar application loads a personalized calendar, and is included in those feed URLs.
%p
It cannot be used to access any other data.
.col-lg-8.rss-token-reset
= label_tag :rss_token, 'RSS token', class: "label-light"
= text_field_tag :rss_token, current_user.rss_token, class: 'form-control', readonly: true, onclick: 'this.select()'
.col-lg-8.feed-token-reset
= label_tag :feed_token, 'Feed token', class: "label-light"
= text_field_tag :feed_token, current_user.feed_token, class: 'form-control', readonly: true, onclick: 'this.select()'
%p.form-text.text-muted
Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds as if they were you.
Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you.
You should
= link_to 'reset it', [:reset, :rss_token, :profile], method: :put, data: { confirm: 'Are you sure? Any RSS URLs currently in use will stop working.' }
= link_to 'reset it', [:reset, :feed_token, :profile], method: :put, data: { confirm: 'Are you sure? Any RSS or calendar URLs currently in use will stop working.' }
if that ever happens.
- if incoming_email_token_enabled?
......
= link_to safe_params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
= render 'shared/issuable/feed_buttons'
- if @can_bulk_update
= button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
- if show_new_issue_link?(@project)
......
= render 'issues/issues_calendar', issues: @issues
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M15 5v7a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V5a2 2 0 0 1 2-2h1V2a1 1 0 1 1 2 0v1h4V2a1 1 0 1 1 2 0v1h1a2 2 0 0 1 2 2zM3 6v6a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6H3zm2 2h2a1 1 0 1 1 0 2H5a1 1 0 1 1 0-2z" fill="#000" fill-rule="evenodd"/></svg>
\ No newline at end of file
= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to RSS feed' do
= icon('rss')
= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to calendar' do
= custom_icon('icon_calendar')
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment