Commit d8563bd6 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce

parents e7a8fe07 bf4455d1
......@@ -9,6 +9,8 @@ v 8.9.0 (unreleased)
- Allow enabling wiki page events from Webhook management UI
- Bump rouge to 1.11.0
- Fix issue with arrow keys not working in search autocomplete dropdown
- Fix an issue where note polling stopped working if a window was in the
background during a refresh.
- Make EmailsOnPushWorker use Sidekiq mailers queue
- Fix wiki page events' webhook to point to the wiki repository
- Don't show tags for revert and cherry-pick operations
......@@ -25,6 +27,7 @@ v 8.9.0 (unreleased)
- Redesign navigation for project pages
- Fix groups API to list only user's accessible projects
- Redesign account and email confirmation emails
- Don't fail builds for projects that are deleted
- `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix
- Bump nokogiri to 1.6.8
- Use gitlab-shell v3.0.0
......@@ -57,6 +60,7 @@ v 8.9.0 (unreleased)
- Use Knapsack only in CI environment
- Cache project build count in sidebar nav
- Add milestone expire date to the right sidebar
- Manually mark a issue or merge request as a todo
- Fix markdown_spec to use before instead of before(:all) to properly cleanup database after testing
- Reduce number of queries needed to render issue labels in the sidebar
- Improve error handling importing projects
......@@ -80,6 +84,13 @@ v 8.9.0 (unreleased)
- Measure CPU time for instrumented methods
- Instrument private methods and private instance methods by default instead just public methods
- Only show notes through JSON on confidential issues that the user has access to
- Updated the allocations Gem to version 1.0.5
- The background sampler now ignores classes without names
- Update design for `Close` buttons
- New custom icons for navigation
- Horizontally scrolling navigation on project, group, and profile settings pages
- Hide global side navigation by default
- Remove tanuki logo from side navigation; center on top nav
v 8.8.5 (unreleased)
- Ensure branch cleanup regardless of whether the GitHub import process succeeds
......
......@@ -50,7 +50,7 @@ GEM
after_commit_queue (1.3.0)
activerecord (>= 3.0)
akismet (2.0.0)
allocations (1.0.4)
allocations (1.0.5)
arel (6.0.3)
asana (0.4.0)
faraday (~> 0.9)
......
......@@ -56,13 +56,6 @@ issuable_created = false
Issuable.filterResults $('.filter-form')
$('.js-label-select').trigger('update.label')
toggleLabelFilters: ->
$filteredLabels = $('.filtered-labels')
if $filteredLabels.find('.label-row').length > 0
$filteredLabels.removeClass('hidden')
else
$filteredLabels.addClass('hidden')
filterResults: (form) =>
formData = form.serialize()
......@@ -71,58 +64,16 @@ issuable_created = false
issuesUrl = formAction
issuesUrl += ("#{if formAction.indexOf('?') < 0 then '?' else '&'}")
issuesUrl += formData
$.ajax
type: 'GET'
url: formAction
data: formData
complete: ->
$('.issues-holder, .merge-requests-holder').css('opacity', '1.0')
success: (data) ->
$('.issues-holder, .merge-requests-holder').html(data.html)
# Change url so if user reload a page - search results are saved
history.replaceState {page: issuesUrl}, document.title, issuesUrl
Issuable.reload()
Issuable.updateStateFilters()
$filteredLabels = $('.filtered-labels')
if typeof Issuable.labelRow is 'function'
$filteredLabels.html(Issuable.labelRow(data))
Issuable.toggleLabelFilters()
dataType: "json"
reload: ->
if Issuable.created
Issuable.initChecks()
$('#filter_issue_search').val($('#issue_search').val())
Turbolinks.visit(issuesUrl);
initChecks: ->
$('.check_all_issues').on 'click', ->
$('.check_all_issues').off('click').on('click', ->
$('.selected_issue').prop('checked', @checked)
Issuable.checkChanged()
)
$('.selected_issue').on 'change', Issuable.checkChanged
updateStateFilters: ->
stateFilters = $('.issues-state-filters, .dropdown-menu-sort')
newParams = {}
paramKeys = ['author_id', 'milestone_title', 'assignee_id', 'issue_search', 'issue_search']
for paramKey in paramKeys
newParams[paramKey] = gl.utils.getParameterValues(paramKey)[0] or ''
if stateFilters.length
stateFilters.find('a').each ->
initialUrl = gl.utils.removeParamQueryString($(this).attr('href'), 'label_name[]')
labelNameValues = gl.utils.getParameterValues('label_name[]')
if labelNameValues
labelNameQueryString = ("label_name[]=#{value}" for value in labelNameValues).join('&')
newUrl = "#{gl.utils.mergeUrlParams(newParams, initialUrl)}&#{labelNameQueryString}"
else
newUrl = gl.utils.mergeUrlParams(newParams, initialUrl)
$(this).attr 'href', newUrl
$('.selected_issue').off('change').on('change', Issuable.checkChanged)
checkChanged: ->
checked_issues = $('.selected_issue:checked')
......
......@@ -9,6 +9,9 @@ class @IssuableBulkActions
@bindEvents()
# Fixes bulk-assign not working when navigating through pages
Issuable.initChecks();
getElement: (selector) ->
@container.find selector
......
class @LayoutNav
$ ->
$('.fade-left').addClass('end-scroll')
$('.scrolling-tabs').on 'scroll', (event) ->
$this = $(this)
$el = $(event.target)
currentPosition = $this.scrollLeft()
size = bp.getBreakpointSize()
controlBtnWidth = $('.controls').width()
maxPosition = $this.get(0).scrollWidth - $this.parent().width()
maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length
$el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0)
$el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition)
hideEndFade = ($scrollingTabs) ->
$scrollingTabs.each ->
$this = $(@)
$this
.find('.fade-right')
.toggleClass('end-scroll', $this.width() is $this.prop('scrollWidth'))
$ ->
$('.fade-left').addClass('end-scroll')
hideEndFade($('.scrolling-tabs'))
$(window)
.off 'resize.nav'
.on 'resize.nav', ->
hideEndFade($('.scrolling-tabs'))
$('.scrolling-tabs').on 'scroll', (event) ->
$this = $(this)
currentPosition = $this.scrollLeft()
maxPosition = $this.prop('scrollWidth') - $this.outerWidth()
$this.find('.fade-left').toggleClass('end-scroll', currentPosition is 0)
$this.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition)
......@@ -115,12 +115,14 @@ class @Notes
, @pollingInterval
refresh: =>
return if @refreshing is true
@refreshing = true
if not document.hidden and document.URL.indexOf(@noteable_url) is 0
@getContent()
getContent: ->
return if @refreshing
@refreshing = true
$.ajax
url: @notes_url
data: "last_fetched_at=" + @last_fetched_at
......
......@@ -43,6 +43,55 @@ class @Sidebar
$('.right-sidebar')
.hasClass('right-sidebar-collapsed'), { path: '/' })
$(document)
.off 'click', '.js-issuable-todo'
.on 'click', '.js-issuable-todo', @toggleTodo
toggleTodo: (e) =>
$this = $(e.currentTarget)
$todoLoading = $('.js-issuable-todo-loading')
$btnText = $('.js-issuable-todo-text', $this)
ajaxType = if $this.attr('data-id') then 'PATCH' else 'POST'
ajaxUrlExtra = if $this.attr('data-id') then "/#{$this.attr('data-id')}" else ''
$.ajax(
url: "#{$this.data('url')}#{ajaxUrlExtra}"
type: ajaxType
dataType: 'json'
data:
issuable_id: $this.data('issuable')
issuable_type: $this.data('issuable-type')
beforeSend: =>
@beforeTodoSend($this, $todoLoading)
).done (data) =>
@todoUpdateDone(data, $this, $btnText, $todoLoading)
beforeTodoSend: ($btn, $todoLoading) ->
$btn.disable()
$todoLoading.removeClass 'hidden'
todoUpdateDone: (data, $btn, $btnText, $todoLoading) ->
$todoPendingCount = $('.todos-pending-count')
$todoPendingCount.text data.count
$btn.enable()
$todoLoading.addClass 'hidden'
if data.count is 0
$todoPendingCount.addClass 'hidden'
else
$todoPendingCount.removeClass 'hidden'
if data.todo?
$btn
.attr 'aria-label', $btn.data('mark-text')
.attr 'data-id', data.todo.id
$btnText.text $btn.data('mark-text')
else
$btn
.attr 'aria-label', $btn.data('todo-text')
.removeAttr 'data-id'
$btnText.text $btn.data('todo-text')
sidebarDropdownLoading: (e) ->
$sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon')
......@@ -117,5 +166,3 @@ class @Sidebar
getBlock: (name) ->
@sidebar.find(".block.#{name}")
......@@ -280,11 +280,10 @@
}
.dropdown {
margin-left: 7px;
@media (max-width: $screen-xs-min) {
margin-left: 0;
}
position: absolute;
top: 7px;
right: 15px;
z-index: 2;
li.active {
font-weight: bold;
......
......@@ -83,6 +83,12 @@
margin-top: 10px;
}
.icon-container {
width: 34px;
display: inline-block;
text-align: center;
}
a {
width: $sidebar_width;
padding: 7px 15px 7px 23px;
......
......@@ -39,3 +39,20 @@
}
}
}
.groups-cover-block {
.container-fluid {
position: relative;
}
.access-request-button {
@include btn-gray;
position: absolute;
right: 16px;
bottom: 32px;
padding: 3px 10px;
text-transform: none;
background-color: $background-color;
}
}
......@@ -34,6 +34,10 @@
color: inherit;
}
.issuable-header-text {
margin-top: 7px;
}
.block {
@include clearfix;
padding: $gl-padding 0;
......@@ -60,10 +64,6 @@
margin-top: 0;
}
.issuable-count {
margin-top: 7px;
}
.gutter-toggle {
margin-left: 20px;
padding-left: 10px;
......@@ -250,7 +250,7 @@
}
}
.issuable-pager {
.issuable-header-btn {
background: $gray-normal;
border: 1px solid $border-gray-normal;
&:hover {
......@@ -263,7 +263,7 @@
}
}
a:not(.issuable-pager) {
a {
&:hover {
color: $md-link-color;
text-decoration: none;
......
......@@ -229,13 +229,20 @@
right: 16px;
bottom: 0;
.btn {
padding: 3px 10px;
background-color: $background-color;
@media (max-width: $screen-lg-min) {
top: 0;
}
@media (max-width: 1304px) {
top: 0;
.access-request-button {
position: absolute;
right: 0;
bottom: 61px;
@media (max-width: $screen-lg-min) {
position: relative;
bottom: 0;
margin-right: 10px;
}
}
}
......@@ -286,10 +293,6 @@
color: #555;
}
.project_member_row form {
margin: 0;
}
.transfer-project .select2-container {
min-width: 200px;
}
......
module MembershipActions
extend ActiveSupport::Concern
include MembersHelper
def request_access
membershipable.request_access(current_user)
redirect_to polymorphic_path(membershipable),
notice: 'Your request for access has been queued for review.'
end
def approve_access_request
@member = membershipable.members.request.find(params[:id])
return render_403 unless can?(current_user, action_member_permission(:update, @member), @member)
@member.accept_request
redirect_to polymorphic_url([membershipable, :members])
end
def leave
@member = membershipable.members.find_by(user_id: current_user)
return render_403 unless @member
source_type = @member.real_source_type.humanize(capitalize: false)
if can?(current_user, action_member_permission(:destroy, @member), @member)
notice =
if @member.request?
"Your access request to the #{source_type} has been withdrawn."
else
"You left the \"#{@member.source.human_name}\" #{source_type}."
end
@member.destroy
redirect_to [:dashboard, @member.real_source_type.tableize], notice: notice
else
if cannot_leave?
alert = "You can not leave the \"#{@member.source.human_name}\" #{source_type}."
alert << " Transfer or delete the #{source_type}."
redirect_to polymorphic_url(membershipable), alert: alert
else
render_403
end
end
end
protected
def membershipable
raise NotImplementedError
end
def cannot_leave?
raise NotImplementedError
end
end
class Groups::GroupMembersController < Groups::ApplicationController
include MembershipActions
# Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave]
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access]
def index
@project = @group.projects.find(params[:project_id]) if params[:project_id]
@members = @group.group_members
@members = @members.non_invite unless can?(current_user, :admin_group, @group)
@members = @members.non_pending unless can?(current_user, :admin_group, @group)
if params[:search].present?
users = @group.users.search(params[:search]).to_a
......@@ -58,25 +60,16 @@ class Groups::GroupMembersController < Groups::ApplicationController
end
end
def leave
@group_member = @group.group_members.find_by(user_id: current_user)
if can?(current_user, :destroy_group_member, @group_member)
@group_member.destroy
redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.")
else
if @group.last_owner?(current_user)
redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.")
else
return render_403
end
end
end
protected
def member_params
params.require(:group_member).permit(:access_level, :user_id)
end
# MembershipActions concern
alias_method :membershipable, :group
def cannot_leave?
@group.last_owner?(current_user)
end
end
class Projects::ProjectMembersController < Projects::ApplicationController
include MembershipActions
# Authorize
before_action :authorize_admin_project_member!, except: [:leave, :index]
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index
@project_members = @project.project_members
@project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project)
@project_members = @project_members.non_pending unless can?(current_user, :admin_project, @project)
if params[:search].present?
users = @project.users.search(params[:search]).to_a
......@@ -14,9 +16,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@project_members = @project_members.order('access_level DESC')
@group = @project.group
if @group
@group_members = @group.group_members
@group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group)
@group_members = @group_members.non_pending unless can?(current_user, :admin_group, @group)
if params[:search].present?
users = @group.users.search(params[:search]).to_a
......@@ -73,26 +76,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
end
def leave
@project_member = @project.project_members.find_by(user_id: current_user)
if can?(current_user, :destroy_project_member, @project_member)
@project_member.destroy
respond_to do |format|
format.html { redirect_to dashboard_projects_path, notice: "You left the project." }
format.js { head :ok }
end
else
if current_user == @project.owner
message = 'You can not leave your own project. Transfer or delete the project.'
redirect_back_or_default(default: { action: 'index' }, options: { alert: message })
else
render_403
end
end
end
def apply_import
source_project = Project.find(params[:source_project_id])
......@@ -112,4 +95,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def member_params
params.require(:project_member).permit(:user_id, :access_level)
end
# MembershipActions concern
alias_method :membershipable, :project
def cannot_leave?
current_user == @project.owner
end
end
class Projects::TodosController < Projects::ApplicationController
def create
todos = TodoService.new.mark_todo(issuable, current_user)
render json: {
todo: todos,
count: current_user.todos.pending.count,
}
end
def update
current_user.todos.find_by_id(params[:id]).update(state: :done)
render json: {
count: current_user.todos.pending.count,
}
end
private
def issuable
@issuable ||= begin
case params[:issuable_type]
when "issue"
@project.issues.find(params[:issuable_id])
when "merge_request"
@project.merge_requests.find(params[:issuable_id])
end
end
end
end
......@@ -36,7 +36,7 @@ class TodosFinder
private
def action_id?
action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED].include?(action_id.to_i)
action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED, Todo::MARKED].include?(action_id.to_i)
end
def action_id
......
......@@ -13,10 +13,23 @@
# merge_request_path(merge_request)
#
module GitlabRoutingHelper
# Project
def project_path(project, *args)
namespace_project_path(project.namespace, project, *args)
end
def project_url(project, *args)
namespace_project_url(project.namespace, project, *args)
end
def edit_project_path(project, *args)
edit_namespace_project_path(project.namespace, project, *args)
end
def edit_project_url(project, *args)
edit_namespace_project_url(project.namespace, project, *args)
end
def project_files_path(project, *args)
namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref)
end
......@@ -41,10 +54,6 @@ module GitlabRoutingHelper
activity_namespace_project_path(project.namespace, project, *args)
end
def edit_project_path(project, *args)
edit_namespace_project_path(project.namespace, project, *args)
end
def runners_path(project, *args)
namespace_project_runners_path(project.namespace, project, *args)
end
......@@ -65,14 +74,6 @@ module GitlabRoutingHelper
namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args)
end
def project_url(project, *args)
namespace_project_url(project.namespace, project, *args)
end
def edit_project_url(project, *args)
edit_namespace_project_url(project.namespace, project, *args)
end
def issue_url(entity, *args)
namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args)
end
......@@ -92,4 +93,56 @@ module GitlabRoutingHelper
toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity)
end
end
## Members
def project_members_url(project, *args)
namespace_project_project_members_url(project.namespace, project)
end
def project_member_path(project_member, *args)
namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
def request_access_project_members_path(project, *args)
request_access_namespace_project_project_members_path(project.namespace, project)
end
def leave_project_members_path(project, *args)
leave_namespace_project_project_members_path(project.namespace, project)
end
def approve_access_request_project_member_path(project_member, *args)
approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
def resend_invite_project_member_path(project_member, *args)
resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member)
end
# Groups
## Members
def group_members_url(group, *args)
group_group_members_url(group, *args)
end
def group_member_path(group_member, *args)
group_group_member_path(group_member.source, group_member)
end
def request_access_group_members_path(group, *args)
request_access_group_group_members_path(group)
end
def leave_group_members_path(group, *args)
leave_group_group_members_path(group)
end
def approve_access_request_group_member_path(group_member, *args)
approve_access_request_group_group_member_path(group_member.source, group_member)
end
def resend_invite_group_member_path(group_member, *args)
resend_invite_group_group_member_path(group_member.source, group_member)
end
end
module GroupsHelper
def remove_user_from_group_message(group, member)
if member.user
"Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?"
else
"Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?"
end
end
def leave_group_message(group)
"Are you sure you want to leave \"#{group}\" group?"
end
def should_user_see_group_roles?(user, group)
if user
user.is_admin? || group.members.exists?(user_id: user.id)
else
false
end
end
def can_change_group_visibility_level?(group)
can?(current_user, :change_visibility_level, group)
end
......
......@@ -67,6 +67,12 @@ module IssuablesHelper
end
end
def has_todo(issuable)
unless current_user.nil?
current_user.todos.find_by(target_id: issuable.id, state: :pending)
end
end
private
def sidebar_gutter_collapsed?
......
module MembersHelper
# Returns a `<action>_<source>_member` association, e.g.:
# - admin_project_member, update_project_member, destroy_project_member
# - admin_group_member, update_group_member, destroy_group_member
def action_member_permission(action, member)
"#{action}_#{member.type.underscore}".to_sym
end
def can_see_member_roles?(source:, user: nil)
return false unless user
user.is_admin? || source.members.exists?(user_id: user.id)
end
def remove_member_message(member, user: nil)
user = current_user if defined?(current_user)
text = 'Are you sure you want to '
action =
if member.request?
if member.user == user
'withdraw your access request for'
else
"deny #{member.user.name}'s request to join"
end
elsif member.invite?
"revoke the invitation for #{member.invite_email} to join"
else
"remove #{member.user.name} from"
end
text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?"
end
def remove_member_title(member)
text = " from #{member.real_source_type.humanize(capitalize: false)}"
text.prepend(member.request? ? 'Deny access request' : 'Remove user')
end
def leave_confirmation_message(member_source)
"Are you sure you want to leave the " \
"\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?"
end
end
module ProjectsHelper
def remove_from_project_team_message(project, member)
if member.user
"You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?"
else
"You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?"
end
end
def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
......@@ -115,14 +107,6 @@ module ProjectsHelper
end
end
def user_max_access_in_project(user_id, project)
level = project.team.max_member_access(user_id)
if level
Gitlab::Access.options_with_owner.key(level)
end
end
def license_short_name(project)
return 'LICENSE' if project.repository.license_key.nil?
......@@ -286,10 +270,6 @@ module ProjectsHelper
end
end
def leave_project_message(project)
"Are you sure you want to leave \"#{project.name}\" project?"
end
def new_readme_path
ref = @repository.root_ref if @repository
ref ||= 'master'
......
......@@ -12,6 +12,7 @@ module TodosHelper
when Todo::ASSIGNED then 'assigned you'
when Todo::MENTIONED then 'mentioned you on'
when Todo::BUILD_FAILED then 'The build failed for your'
when Todo::MARKED then 'marked this as a Todo for'
end
end
......
module Emails
module Groups
def group_access_granted_email(group_member_id)
@group_member = GroupMember.find(group_member_id)
@group = @group_member.group
@target_url = group_url(@group)
@current_user = @group_member.user
mail(to: @group_member.user.notification_email,
subject: subject("Access to group was granted"))
end
def group_member_invited_email(group_member_id, token)
@group_member = GroupMember.find group_member_id
@group = @group_member.group
@token = token
@target_url = group_url(@group)
@current_user = @group_member.user
mail(to: @group_member.invite_email,
subject: "Invitation to join group #{@group.name}")
end
def group_invite_accepted_email(group_member_id)
@group_member = GroupMember.find group_member_id
return if @group_member.created_by.nil?
@group = @group_member.group
@target_url = group_url(@group)
@current_user = @group_member.created_by
mail(to: @group_member.created_by.notification_email,
subject: subject("Invitation accepted"))
end
def group_invite_declined_email(group_id, invite_email, access_level, created_by_id)
return if created_by_id.nil?
@group = Group.find(group_id)
@current_user = @created_by = User.find(created_by_id)
@access_level = access_level
@invite_email = invite_email
@target_url = group_url(@group)
mail(to: @created_by.notification_email,
subject: subject("Invitation declined"))
end
end
end
module Emails
module Members
extend ActiveSupport::Concern
include MembersHelper
included do
helper_method :member_source, :member
end
def member_access_requested_email(member_source_type, member_id)
@member_source_type = member_source_type
@member_id = member_id
admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email)
mail(to: admins,
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
end
def member_access_granted_email(member_source_type, member_id)
@member_source_type = member_source_type
@member_id = member_id
mail(to: member.user.notification_email,
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted"))
end
def member_access_denied_email(member_source_type, source_id, user_id)
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
requester = User.find(user_id)
mail(to: requester.notification_email,
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied"))
end
def member_invited_email(member_source_type, member_id, token)
@member_source_type = member_source_type
@member_id = member_id
@token = token
mail(to: member.invite_email,
subject: "Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")
end
def member_invite_accepted_email(member_source_type, member_id)
@member_source_type = member_source_type
@member_id = member_id
return unless member.created_by
mail(to: member.created_by.notification_email,
subject: subject('Invitation accepted'))
end
def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id)
return unless created_by_id
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
@invite_email = invite_email
inviter = User.find(created_by_id)
mail(to: inviter.notification_email,
subject: subject('Invitation declined'))
end
def member
@member ||= Member.find(@member_id)
end
def member_source
@member_source ||= member.source
end
private
def member_source_class
@member_source_type.classify.constantize
end
end
end
module Emails
module Projects
def project_access_granted_email(project_member_id)
@project_member = ProjectMember.find project_member_id
@project = @project_member.project
@target_url = namespace_project_url(@project.namespace, @project)
@current_user = @project_member.user
mail(to: @project_member.user.notification_email,
subject: subject("Access to project was granted"))
end
def project_member_invited_email(project_member_id, token)
@project_member = ProjectMember.find project_member_id
@project = @project_member.project
@token = token
@target_url = namespace_project_url(@project.namespace, @project)
@current_user = @project_member.user
mail(to: @project_member.invite_email,
subject: "Invitation to join project #{@project.name_with_namespace}")
end
def project_invite_accepted_email(project_member_id)
@project_member = ProjectMember.find project_member_id
return if @project_member.created_by.nil?
@project = @project_member.project
@target_url = namespace_project_url(@project.namespace, @project)
@current_user = @project_member.created_by
mail(to: @project_member.created_by.notification_email,
subject: subject("Invitation accepted"))
end
def project_invite_declined_email(project_id, invite_email, access_level, created_by_id)
return if created_by_id.nil?
@project = Project.find(project_id)
@current_user = @created_by = User.find(created_by_id)
@access_level = access_level
@invite_email = invite_email
@target_url = namespace_project_url(@project.namespace, @project)
mail(to: @created_by.notification_email,
subject: subject("Invitation declined"))
end
def project_was_moved_email(project_id, user_id, old_path_with_namespace)
@current_user = @user = User.find user_id
@project = Project.find project_id
......
......@@ -6,13 +6,15 @@ class Notify < BaseMailer
include Emails::Notes
include Emails::Projects
include Emails::Profile
include Emails::Groups
include Emails::Builds
include Emails::Members
add_template_helper MergeRequestsHelper
add_template_helper DiffHelper
add_template_helper BlobHelper
add_template_helper EmailsHelper
add_template_helper MembersHelper
add_template_helper GitlabRoutingHelper
def test_email(recipient_email, subject, body)
mail(to: recipient_email,
......
......@@ -187,6 +187,8 @@ class Ability
project_report_rules
elsif team.guest?(user)
project_guest_rules
else
[]
end
end
......
# == AccessRequestable concern
#
# Contains functionality related to objects that can receive request for access.
#
# Used by Project, and Group.
#
module AccessRequestable
extend ActiveSupport::Concern
def request_access(user)
members.create(
access_level: Gitlab::Access::DEVELOPER,
user: user,
requested_at: Time.now.utc)
end
end
......@@ -3,11 +3,12 @@ require 'carrierwave/orm/activerecord'
class Group < Namespace
include Gitlab::ConfigHelper
include Gitlab::VisibilityLevel
include AccessRequestable
include Referable
has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember'
alias_method :members, :group_members
has_many :users, through: :group_members
has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members
has_many :project_group_links, dependent: :destroy
has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source
......@@ -58,6 +59,10 @@ class Group < Namespace
"#{self.class.reference_prefix}#{name}"
end
def web_url
Gitlab::Routing.url_helpers.group_url(self)
end
def human_name
name
end
......
......@@ -26,20 +26,28 @@ class Member < ActiveRecord::Base
allow_nil: true
}
scope :invite, -> { where(user_id: nil) }
scope :non_invite, -> { where("user_id IS NOT NULL") }
scope :invite, -> { where.not(invite_token: nil) }
scope :non_invite, -> { where(invite_token: nil) }
scope :request, -> { where.not(requested_at: nil) }
scope :non_request, -> { where(requested_at: nil) }
scope :non_pending, -> { non_request.non_invite }
scope :guests, -> { where(access_level: GUEST) }
scope :reporters, -> { where(access_level: REPORTER) }
scope :developers, -> { where(access_level: DEVELOPER) }
scope :masters, -> { where(access_level: MASTER) }
scope :owners, -> { where(access_level: OWNER) }
scope :owners_and_masters, -> { where(access_level: [OWNER, MASTER]) }
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite?
after_create :create_notification_setting, unless: :invite?
after_create :post_create_hook, unless: :invite?
after_update :post_update_hook, unless: :invite?
after_destroy :post_destroy_hook, unless: :invite?
after_create :send_request, if: :request?
after_create :create_notification_setting, unless: :pending?
after_create :post_create_hook, unless: :pending?
after_update :post_update_hook, unless: :pending?
after_destroy :post_destroy_hook, unless: :pending?
after_destroy :post_decline_request, if: :request?
delegate :name, :username, :email, to: :user, prefix: true
......@@ -96,10 +104,31 @@ class Member < ActiveRecord::Base
end
end
def real_source_type
source_type
end
def invite?
self.invite_token.present?
end
def request?
requested_at.present?
end
def pending?
invite? || request?
end
def accept_request
return false unless request?
updated = self.update(requested_at: nil)
after_accept_request if updated
updated
end
def accept_invite!(new_user)
return false unless invite?
......@@ -157,6 +186,10 @@ class Member < ActiveRecord::Base
# override in subclass
end
def send_request
# override in subclass
end
def post_create_hook
system_hook_service.execute_hooks_for(self, :create)
end
......@@ -177,6 +210,14 @@ class Member < ActiveRecord::Base
# override in subclass
end
def after_accept_request
post_create_hook
end
def post_decline_request
# override in subclass
end
def system_hook_service
SystemHooksService.new
end
......
......@@ -8,9 +8,6 @@ class GroupMember < Member
validates_format_of :source_type, with: /\ANamespace\z/
default_scope { where(source_type: SOURCE_TYPE) }
scope :with_group, ->(group) { where(source_id: group.id) }
scope :with_user, ->(user) { where(user_id: user.id) }
def self.access_level_roles
Gitlab::Access.options_with_owner
end
......@@ -23,6 +20,11 @@ class GroupMember < Member
access_level
end
# Because source_type is `Namespace`...
def real_source_type
'Group'
end
private
def send_invite
......@@ -31,6 +33,12 @@ class GroupMember < Member
super
end
def send_request
notification_service.new_group_access_request(self)
super
end
def post_create_hook
notification_service.new_group_member(self)
......@@ -56,4 +64,10 @@ class GroupMember < Member
super
end
def post_decline_request
notification_service.decline_group_access_request(self)
super
end
end
......@@ -11,8 +11,6 @@ class ProjectMember < Member
default_scope { where(source_type: SOURCE_TYPE) }
scope :in_project, ->(project) { where(source_id: project.id) }
scope :in_projects, ->(projects) { where(source_id: projects.pluck(:id)) }
scope :with_user, ->(user) { where(user_id: user.id) }
before_destroy :delete_member_todos
......@@ -84,7 +82,7 @@ class ProjectMember < Member
Gitlab::Access.sym_options
end
def access_roles
def access_level_roles
Gitlab::Access.options
end
end
......@@ -113,6 +111,12 @@ class ProjectMember < Member
super
end
def send_request
notification_service.new_project_access_request(self)
super
end
def post_create_hook
unless owner?
event_service.join_project(self.project, self.user)
......@@ -148,6 +152,12 @@ class ProjectMember < Member
super
end
def post_decline_request
notification_service.decline_project_access_request(self)
super
end
def event_service
EventCreateService.new
end
......
......@@ -5,6 +5,7 @@ class Project < ActiveRecord::Base
include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
include Gitlab::CurrentSettings
include AccessRequestable
include Referable
include Sortable
include AfterCommitQueue
......@@ -102,8 +103,9 @@ class Project < ActiveRecord::Base
has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet'
has_many :hooks, dependent: :destroy, class_name: 'ProjectHook'
has_many :protected_branches, dependent: :destroy
has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember'
has_many :users, through: :project_members
has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember'
alias_method :members, :project_members
has_many :users, -> { where(members: { requested_at: nil }) }, through: :project_members
has_many :deploy_keys_projects, dependent: :destroy
has_many :deploy_keys, through: :deploy_keys_projects
has_many :users_star_projects, dependent: :destroy
......@@ -680,16 +682,6 @@ class Project < ActiveRecord::Base
end
end
def project_member_by_name_or_email(name = nil, email = nil)
user = users.find_by('name like ? or email like ?', name, email)
project_members.where(user: user) if user
end
# Get Team Member record by user id
def project_member_by_id(user_id)
project_members.find_by(user_id: user_id)
end
def name_with_namespace
@name_with_namespace ||= begin
if namespace
......@@ -699,6 +691,7 @@ class Project < ActiveRecord::Base
end
end
end
alias_method :human_name, :name_with_namespace
def path_with_namespace
if namespace
......
......@@ -21,23 +21,13 @@ class ProjectTeam
end
end
def find(user_id)
user = project.users.find_by(id: user_id)
if group
user ||= group.users.find_by(id: user_id)
end
user
end
def find_member(user_id)
member = project.project_members.find_by(user_id: user_id)
member = project.members.non_request.find_by(user_id: user_id)
# If user is not in project members
# we should check for group membership
if group && !member
member = group.group_members.find_by(user_id: user_id)
member = group.members.non_request.find_by(user_id: user_id)
end
member
......@@ -61,13 +51,10 @@ class ProjectTeam
ProjectMember.truncate_team(project)
end
def users
members
end
def members
@members ||= fetch_members
end
alias_method :users, :members
def guests
@guests ||= fetch_members(:guests)
......@@ -150,7 +137,7 @@ class ProjectTeam
def max_member_access(user_id)
access = []
project.project_members.each do |member|
project.members.non_request.each do |member|
if member.user_id == user_id
access << member.access_field if member.access_field
break
......@@ -158,7 +145,7 @@ class ProjectTeam
end
if group
group.group_members.each do |member|
group.members.non_request.each do |member|
if member.user_id == user_id
access << member.access_field if member.access_field
break
......@@ -173,6 +160,7 @@ class ProjectTeam
access.compact.max
end
private
def max_invited_level(user_id)
project.project_group_links.map do |group_link|
......@@ -189,17 +177,15 @@ class ProjectTeam
end.compact.max
end
private
def fetch_members(level = nil)
project_members = project.project_members
group_members = group ? group.group_members : []
project_members = project.members.non_request
group_members = group ? group.members.non_request : []
invited_members = []
if project.invited_groups.any? && project.allowed_to_share_with_group?
project.project_group_links.each do |group_link|
invited_group = group_link.group
im = invited_group.group_members
im = invited_group.members.non_request
if level
int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize]
......
......@@ -2,6 +2,7 @@ class Todo < ActiveRecord::Base
ASSIGNED = 1
MENTIONED = 2
BUILD_FAILED = 3
MARKED = 4
belongs_to :author, class_name: "User"
belongs_to :note
......
......@@ -56,8 +56,7 @@ class User < ActiveRecord::Base
# Groups
has_many :members, dependent: :destroy
has_many :project_members, source: 'ProjectMember'
has_many :group_members, source: 'GroupMember'
has_many :group_members, dependent: :destroy, source: 'GroupMember'
has_many :groups, through: :group_members
has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group
has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group
......@@ -65,13 +64,13 @@ class User < ActiveRecord::Base
# Projects
has_many :groups_projects, through: :groups, source: :projects
has_many :personal_projects, through: :namespace, source: :projects
has_many :project_members, dependent: :destroy, class_name: 'ProjectMember'
has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
has_many :users_star_projects, dependent: :destroy
has_many :starred_projects, through: :users_star_projects, source: :project
has_many :snippets, dependent: :destroy, foreign_key: :author_id, class_name: "Snippet"
has_many :project_members, dependent: :destroy, class_name: 'ProjectMember'
has_many :issues, dependent: :destroy, foreign_key: :author_id
has_many :notes, dependent: :destroy, foreign_key: :author_id
has_many :merge_requests, dependent: :destroy, foreign_key: :author_id
......
......@@ -173,16 +173,26 @@ class NotificationService
end
end
# Project access request
def new_project_access_request(project_member)
mailer.member_access_requested_email(project_member.real_source_type, project_member.id).deliver_later
end
def decline_project_access_request(project_member)
mailer.member_access_denied_email(project_member.real_source_type, project_member.project.id, project_member.user.id).deliver_later
end
def invite_project_member(project_member, token)
mailer.project_member_invited_email(project_member.id, token).deliver_later
mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later
end
def accept_project_invite(project_member)
mailer.project_invite_accepted_email(project_member.id).deliver_later
mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later
end
def decline_project_invite(project_member)
mailer.project_invite_declined_email(
mailer.member_invite_declined_email(
project_member.real_source_type,
project_member.project.id,
project_member.invite_email,
project_member.access_level,
......@@ -191,23 +201,33 @@ class NotificationService
end
def new_project_member(project_member)
mailer.project_access_granted_email(project_member.id).deliver_later
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
def update_project_member(project_member)
mailer.project_access_granted_email(project_member.id).deliver_later
mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
end
# Group access request
def new_group_access_request(group_member)
mailer.member_access_requested_email(group_member.real_source_type, group_member.id).deliver_later
end
def decline_group_access_request(group_member)
mailer.member_access_denied_email(group_member.real_source_type, group_member.group.id, group_member.user.id).deliver_later
end
def invite_group_member(group_member, token)
mailer.group_member_invited_email(group_member.id, token).deliver_later
mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later
end
def accept_group_invite(group_member)
mailer.group_invite_accepted_email(group_member.id).deliver_later
mailer.member_invite_accepted_email(group_member.id).deliver_later
end
def decline_group_invite(group_member)
mailer.group_invite_declined_email(
mailer.member_invite_declined_email(
group_member.real_source_type,
group_member.group.id,
group_member.invite_email,
group_member.access_level,
......@@ -216,11 +236,11 @@ class NotificationService
end
def new_group_member(group_member)
mailer.group_access_granted_email(group_member.id).deliver_later
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def update_group_member(group_member)
mailer.group_access_granted_email(group_member.id).deliver_later
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
def project_was_moved(project, old_path_with_namespace)
......
......@@ -139,10 +139,16 @@ class TodoService
pending_todos(user, attributes).update_all(state: :done)
end
# When user marks an issue as todo
def mark_todo(issuable, current_user)
attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED)
create_todos(current_user, attributes)
end
private
def create_todos(users, attributes)
Array(users).each do |user|
Array(users).map do |user|
next if pending_todos(user, attributes).exists?
Todo.create(attributes.merge(user_id: user.id))
end
......
......@@ -109,7 +109,7 @@
%span.pull-right.light
= member.human_access
- if can?(current_user, :destroy_group_member, member)
= link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do
= link_to group_group_member_path(@group, member), data: { confirm: remove_member_message(member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do
%i.fa.fa-minus.fa-inverse
.panel-footer
= paginate @members, param_name: 'members_page', theme: 'gitlab'
......@@ -142,7 +142,7 @@
%i.fa.fa-pencil-square-o
%ul.well-list
- @group_members.each do |member|
= render 'groups/group_members/group_member', member: member, show_controls: false
= render 'shared/members/member', member: member, show_controls: false
.panel-footer
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
......@@ -172,7 +172,7 @@
%span.light Owner
- else
%span.light= project_member.human_access
= link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do
= link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_member_message(project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do
%i.fa.fa-times
.panel-footer
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
......@@ -13,7 +13,7 @@
.pull-right
%span.light= group_member.human_access
- unless group_member.owner?
= link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do
= link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do
%i.fa.fa-times.fa-inverse
- else
.nothing-here-block This user has no groups.
......@@ -38,6 +38,5 @@
%span.light= member.human_access
- if member.respond_to? :project
= link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_from_project_team_message(project, member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do
= link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do
%i.fa.fa-times
......@@ -6,12 +6,13 @@
.panel-heading
Add new user to group
.panel-body
- if should_user_see_group_roles?(current_user, @group)
%p.light
Members of group have access to all group projects.
%p.light
Members of group have access to all group projects.
.new-group-member-holder
= render "new_group_member"
= render 'shared/members/requests', membership_source: @group, members: @members.request
.panel.panel-default
.panel-heading
%strong #{@group.name}
......@@ -25,9 +26,8 @@
= button_tag class: 'btn', title: 'Search' do
= icon("search")
%ul.content-list
- @members.each do |member|
= render 'groups/group_members/group_member', member: member, show_controls: true
= paginate @members, theme: 'gitlab'
= render partial: 'shared/members/member', collection: @members.non_request, as: :member
= paginate @members.non_request, theme: 'gitlab'
:javascript
$('form.member-search-form').on('submit', function(event) {
......
:plain
$("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member, show_controls: true))}');
$("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member))}');
......@@ -19,6 +19,9 @@
.cover-desc.description
= markdown(@group.description, pipeline: :description)
- if current_user
= render 'shared/members/access_request_buttons', source: @group
%div{ class: container_class }
.top-area
%ul.nav-links
......
......@@ -27,9 +27,8 @@
%li
= link_to dashboard_todos_path, title: 'Todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('bell fw')
- unless todos_pending_count == 0
%span.badge.todos-pending-count
= todos_pending_count
%span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0) }
= todos_pending_count
- if current_user.can_create_project?
%li
= link_to new_project_path, title: 'New project', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
......
%ul.nav.nav-sidebar
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
= navbar_icon('project')
.icon-container
= navbar_icon('project')
%span
Projects
= nav_link(controller: :todos) do
= link_to dashboard_todos_path, title: 'Todos' do
= icon('bell fw')
.icon-container
= icon('bell fw')
%span
Todos
%span.count= number_with_delimiter(todos_pending_count)
= nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
= navbar_icon('activity')
.icon-container
= navbar_icon('activity')
%span
Activity
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
= link_to dashboard_groups_path, title: 'Groups' do
= navbar_icon('group')
.icon-container
= navbar_icon('group')
%span
Groups
= nav_link(controller: 'dashboard/milestones') do
= link_to dashboard_milestones_path, title: 'Milestones' do
= navbar_icon('milestones')
.icon-container
= navbar_icon('milestones')
%span
Milestones
= nav_link(path: 'dashboard#issues') do
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do
= navbar_icon('issues')
.icon-container
= navbar_icon('issues')
%span
Issues
%span.count= number_with_delimiter(current_user.assigned_issues.opened.count)
= nav_link(path: 'dashboard#merge_requests') do
= link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do
= navbar_icon('mr')
.icon-container
= navbar_icon('mr')
%span
Merge Requests
%span.count= number_with_delimiter(current_user.assigned_merge_requests.opened.count)
= nav_link(controller: :snippets) do
= link_to dashboard_snippets_path, title: 'Snippets' do
= icon('clipboard fw')
.icon-container
= icon('clipboard fw')
%span
Snippets
= nav_link(controller: :help) do
= link_to help_path, title: 'Help' do
= icon('question-circle fw')
.icon-container
= icon('question-circle fw')
%span
Help
= nav_link(html_options: {class: profile_tab_class}) do
= link_to profile_path, title: 'Profile Settings', data: {placement: 'bottom'} do
= icon('user fw')
.icon-container
= icon('user fw')
%span
Profile Settings
......@@ -14,7 +14,3 @@
%li
= link_to edit_group_path(@group) do
Edit Group
%li
= link_to leave_group_group_members_path(@group),
data: { confirm: leave_group_message(@group.name) }, method: :delete, title: 'Leave group' do
Leave Group
- if current_user
- access = user_max_access_in_project(current_user.id, @project)
- can_edit = can?(current_user, :admin_project, @project)
.controls
.dropdown.project-settings-dropdown
%a.dropdown-new.btn.btn-default#project-settings-button{href: '#', 'data-toggle' => 'dropdown'}
= icon('cog')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
- access = @project.team.max_member_access(current_user.id)
- can_edit = can?(current_user, :admin_project, @project)
= render 'layouts/nav/project_settings', access: access, can_edit: can_edit
- if can_edit || access
%li.divider
- if can_edit
......@@ -16,8 +18,8 @@
Edit Project
- if access
%li
= link_to leave_namespace_project_project_members_path(@project.namespace, @project),
data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do
= link_to polymorphic_path([:leave, @project, :members]),
data: { confirm: leave_confirmation_message(@project) }, method: :delete, title: 'Leave project' do
Leave Project
%div{ class: nav_control_class }
......
%p
= "You have been granted #{@group_member.human_access} access to group"
= link_to group_url(@group) do
= @group.name
You have been granted <%= @group_member.human_access %> access to group <%= @group.name %>
<%= url_for(group_url(@group)) %>
%p
#{@group_member.invite_email}, now known as
#{link_to @group_member.user.name, user_url(@group_member.user)},
has accepted your invitation to join group
#{link_to @group.name, group_url(@group)}.
<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %>.
<%= group_url(@group) %>
%p
#{@invite_email}
has declined your invitation to join group
#{link_to @group.name, group_url(@group)}.
<%= @invite_email %> has declined your invitation to join group <%= @group.name %>.
<%= group_url(@group) %>
%p
Your request to join the
#{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}
has been denied.
Your request to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> has been denied.
<%= member_source.web_url %>
%p
You have been granted #{member.human_access} access to the
#{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}.
You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= member_source.web_url %>
%p
#{link_to member.user.name, member.user} requested #{member.human_access}
access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members])} #{member_source.model_name.singular}.
<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= polymorphic_url([member_source, :members]) %>
%p
#{member.invite_email}, now known as
#{link_to member.user.name, user_url(member.user)},
has accepted your invitation to join the
#{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}.
<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= member_source.web_url %>
%p
#{@invite_email}
has declined your invitation to join the
#{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}.
<%= @invite_email %> has declined your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= member_source.web_url %>
%p
You have been invited
- if inviter = @group_member.created_by
- if member.created_by
by
= link_to inviter.name, user_url(inviter)
to join group
= link_to @group.name, group_url(@group)
as #{@group_member.human_access}.
= link_to member.created_by.name, user_url(member.created_by)
to join the
= link_to member_source.human_name, member_source.web_url
#{member_source.model_name.singular} as #{member.human_access}.
%p
= link_to 'Accept invitation', invite_url(@token)
or
= link_to 'decline', decline_invite_url(@token)
You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>.
You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
Accept invitation: <%= invite_url(@token) %>
Decline invitation: <%= decline_invite_url(@token) %>
%p
= "You have been granted #{@project_member.human_access} access to project"
%p
= link_to namespace_project_url(@project.namespace, @project) do
= @project.name_with_namespace
You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>
<%= url_for(namespace_project_url(@project.namespace, @project)) %>
%p
#{@project_member.invite_email}, now known as
#{link_to @project_member.user.name, user_url(@project_member.user)},
has accepted your invitation to join project
#{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}.
<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %>.
<%= namespace_project_url(@project.namespace, @project) %>
%p
#{@invite_email}
has declined your invitation to join project
#{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}.
<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %>.
<%= namespace_project_url(@project.namespace, @project) %>
%p
You have been invited
- if inviter = @project_member.created_by
by
= link_to inviter.name, user_url(inviter)
to join project
= link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)
as #{@project_member.human_access}.
%p
= link_to 'Accept invitation', invite_url(@token)
or
= link_to 'decline', decline_invite_url(@token)
You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>.
Accept invitation: <%= invite_url(@token) %>
Decline invitation: <%= decline_invite_url(@token) %>
......@@ -29,10 +29,13 @@
.project-clone-holder
= render "shared/clone_panel"
.project-repo-buttons.btn-group.project-right-buttons
= render "projects/buttons/download"
= render 'projects/buttons/dropdown'
= render 'projects/buttons/notifications'
.project-repo-buttons.project-right-buttons
- if current_user
= render 'shared/members/access_request_buttons', source: @project
.btn-group
= render "projects/buttons/download"
= render 'projects/buttons/dropdown'
= render 'projects/buttons/notifications'
:javascript
new Star();
- if @notification_setting
= form_for @notification_setting, url: namespace_project_notification_setting_path(@project.namespace.becomes(Namespace), @project), method: :patch, remote: true, html: { class: 'inline', id: 'notification-form' } do |f|
= f.hidden_field :level
.dropdown
.dropdown.hidden-sm
%button.btn.btn-default.notifications-btn#notifications-button{ data: { toggle: "dropdown" }, aria: { haspopup: "true", expanded: "false" } }
= icon('bell')
= notification_title(@notification_setting.level)
......
......@@ -6,11 +6,14 @@
(#{members.count})
- if can?(current_user, :admin_group_member, @group)
.controls
= link_to group_group_members_path(@group), class: 'btn' do
Manage group members
= link_to 'Manage group members',
group_group_members_path(@group),
class: 'btn'
%ul.content-list
- members.limit(20).each do |member|
= render 'groups/group_members/group_member', member: member, show_controls: false
- if members.count > 20
= render partial: 'shared/members/member',
collection: members.limit(20),
as: :member,
locals: { show_controls: false }
- if members.size > 20
%li
and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)}
......@@ -9,7 +9,7 @@
.form-group
= f.label :access_level, "Project Access", class: 'control-label'
.col-sm-10
= select_tag :access_level, options_for_select(ProjectMember.access_roles, @project_member.access_level), class: "project-access-select select2"
= select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "project-access-select select2"
.help-block
Read more about role permissions
%strong= link_to "here", help_page_path("permissions", "permissions"), class: "vlink"
......
- user = member.user
- return unless user || member.invite?
%li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)}
%span.list-item-name
- if member.user
= image_tag avatar_icon(user, 24), class: "avatar s24", alt: ''
%strong
= link_to user.name, user_path(user)
%span.cgray= user.username
- if user == current_user
%span.label.label-success It's you
- if user.blocked?
%label.label.label-danger
%strong Blocked
- else
= image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: ''
%strong
= member.invite_email
%span.cgray
invited
- if member.created_by
by
= link_to member.created_by.name, user_path(member.created_by)
= time_ago_with_tooltip(member.created_at)
- if can?(current_user, :admin_project_member, @project)
= link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do
Resend invite
- if can?(current_user, :admin_project_member, @project)
.pull-right
%strong= member.human_access
- if can?(current_user, :update_project_member, member)
= button_tag class: "btn-xs btn-grouped inline btn js-toggle-button",
title: 'Edit access level', type: 'button' do
= icon('pencil')
- if can?(current_user, :destroy_project_member, member)
&nbsp;
- if current_user == user
= link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do
= icon("sign-out")
Leave
- else
= link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do
= icon('trash')
.edit-member.hide.js-toggle-content
%br
= form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member), remote: true do |f|
.prepend-top-10
= f.select :access_level, options_for_select(ProjectMember.access_roles, member.access_level), {}, class: 'form-control'
.prepend-top-10
= f.submit 'Save', class: 'btn btn-save'
......@@ -14,8 +14,10 @@
%i.fa.fa-pencil-square-o
Edit group members
%ul.content-list
- shared_group.group_members.order('access_level DESC').limit(20).each do |member|
= render 'groups/group_members/group_member', member: member, show_controls: false, show_roles: false
= render partial: 'shared/members/member',
collection: shared_group.group_members.order(access_level: :desc).limit(20),
as: :member,
locals: { show_controls: false, show_roles: false }
- if shared_group_users_count > 20
%li
and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)}
......@@ -11,8 +11,7 @@
= button_tag class: 'btn', title: 'Search' do
= icon("search")
%ul.content-list
- members.each do |project_member|
= render 'project_member', member: project_member
= render partial: 'shared/members/member', collection: members, as: :member
:javascript
$('form.member-search-form').on('submit', function (event) {
......
......@@ -13,7 +13,9 @@
Users with access to this project are listed below.
= render "new_project_member"
= render "team", members: @project_members
= render 'shared/members/requests', membership_source: @project, members: @project_members.request
= render 'team', members: @project_members.non_request
- if @group
= render "group_members", members: @group_members
......
......@@ -9,7 +9,7 @@
= link_to edit_group_path(group), class: "btn" do
= icon('cogs')
= link_to leave_group_group_members_path(group), data: { confirm: leave_group_message(group.name) }, method: :delete, class: "btn", title: 'Leave this group' do
= link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do
= icon('sign-out')
.stats
......
- todo = has_todo(issuable)
%aside.right-sidebar{ class: sidebar_gutter_collapsed_class }
.issuable-sidebar
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
%a.gutter-toggle.pull-right.js-sidebar-toggle{href: '#'}
- if current_user
%span.issuable-header-text.hide-collapsed.pull-left
Todo
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } }
= sidebar_gutter_toggle_icon
- if current_user
%button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project) } }
%span.js-issuable-todo-text
- if todo.nil?
Add Todo
- else
Mark Done
= icon('spin spinner', class: 'hidden js-issuable-todo-loading')
= form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f|
.block.assignee
......
- member = source.members.find_by(user_id: current_user.id)
- if member
- if member.request?
= link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: remove_member_message(member) },
class: 'btn access-request-button hidden-xs'
- else
= link_to 'Request Access', polymorphic_path([:request_access, source, :members]),
method: :post,
class: 'btn access-request-button hidden-xs'
- user = member.user
- return unless user || member.invite?
- show_roles = local_assigns.fetch(:show_roles, true)
- show_controls = local_assigns.fetch(:show_controls, true)
- user = member.user
%li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)}
%span{class: ("list-item-name" if show_controls)}
- if member.user
%li.js-toggle-container{ class: dom_class(member), id: dom_id(member) }
%span{ class: ("list-item-name" if show_controls) }
- if user
= image_tag avatar_icon(user, 24), class: "avatar s24", alt: ''
%strong
= link_to user.name, user_path(user)
%span.cgray= user.username
- if user == current_user
%span.label.label-success It's you
- if user.blocked?
%label.label.label-danger
%strong Blocked
- if member.request?
%span.cgray
– Requested
= time_ago_with_tooltip(member.requested_at)
- else
= image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: ''
%strong
= member.invite_email
%strong= member.invite_email
%span.cgray
invited
– Invited
- if member.created_by
by
= link_to member.created_by.name, user_path(member.created_by)
= time_ago_with_tooltip(member.created_at)
- if show_controls && can?(current_user, :admin_group_member, @group)
= link_to resend_invite_group_group_member_path(@group, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do
Resend invite
- if show_controls && can?(current_user, action_member_permission(:admin, member), member.source)
= link_to 'Resend invite', polymorphic_path([:resend_invite, member]),
method: :post,
class: 'btn-xs btn'
- if show_roles && should_user_see_group_roles?(current_user, @group)
- if show_roles && can_see_member_roles?(source: member.source, user: current_user)
%span.pull-right
%strong.member-access-level= member.human_access
%strong= member.human_access
- if show_controls
- if can?(current_user, :update_group_member, member)
= button_tag class: "btn-xs btn btn-grouped inline js-toggle-button",
title: 'Edit access level', type: 'button' do
= icon('pencil')
- if can?(current_user, action_member_permission(:update, member), member)
= button_tag icon('pencil'),
type: 'button',
class: 'btn-xs btn btn-grouped inline js-toggle-button',
title: 'Edit access level'
- if member.request?
&nbsp;
= link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]),
method: :post,
class: 'btn-xs btn btn-success',
title: 'Grant access'
- if can?(current_user, :destroy_group_member, member)
- if can?(current_user, action_member_permission(:destroy, member), member)
&nbsp;
- if current_user == user
= link_to leave_group_group_members_path(@group), data: { confirm: leave_group_message(@group.name)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do
= icon("sign-out")
Leave
= link_to icon('sign-out', text: 'Leave'), polymorphic_path([:leave, member.source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(member.source) },
class: 'btn-xs btn btn-remove'
- else
= link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do
= icon('trash')
= link_to icon('trash'), member,
remote: true,
method: :delete,
data: { confirm: remove_member_message(member) },
class: 'btn-xs btn btn-remove',
title: remove_member_title(member)
.edit-member.hide.js-toggle-content
%br
= form_for [@group, member], remote: true do |f|
= form_for member, remote: true do |f|
.prepend-top-10
= f.select :access_level, options_for_select(GroupMember.access_level_roles, member.access_level), {}, class: 'form-control'
= f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control'
.prepend-top-10
= f.submit 'Save', class: 'btn btn-save btn-sm'
- if members.any?
.panel.panel-default
.panel-heading
%strong= membership_source.name
access requests
%small= "(#{members.size})"
%ul.content-list
= render partial: 'shared/members/member', collection: members, as: :member
......@@ -6,7 +6,7 @@ class StuckCiBuildsWorker
def perform
Rails.logger.info 'Cleaning stuck builds'
builds = Ci::Build.running_or_pending.where('updated_at < ?', BUILD_STUCK_TIMEOUT.ago)
builds = Ci::Build.joins(:project).running_or_pending.where('ci_builds.updated_at < ?', BUILD_STUCK_TIMEOUT.ago)
builds.find_each(batch_size: 50).each do |build|
Rails.logger.debug "Dropping stuck #{build.status} build #{build.id} for runner #{build.runner_id}"
build.drop
......
......@@ -30,6 +30,11 @@ Rails.application.routes.draw do
mount LetterOpenerWeb::Engine, at: '/rails/letter_opener'
end
concern :access_requestable do
post :request_access, on: :collection
post :approve_access_request, on: :member
end
namespace :ci do
# CI API
Ci::API::API.logger Rails.logger
......@@ -409,7 +414,7 @@ Rails.application.routes.draw do
end
scope module: :groups do
resources :group_members, only: [:index, :create, :update, :destroy] do
resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do
post :resend_invite, on: :member
delete :leave, on: :collection
end
......@@ -766,7 +771,7 @@ Rails.application.routes.draw do
end
end
resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do
resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do
collection do
delete :leave
......@@ -790,6 +795,8 @@ Rails.application.routes.draw do
end
end
resources :todos, only: [:create, :update], constraints: { id: /\d+/ }
resources :uploads, only: [:create] do
collection do
get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ }
......
class AddRequestedAtToMembers < ActiveRecord::Migration
def change
add_column :members, :requested_at, :datetime
end
end
......@@ -537,6 +537,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do
t.string "invite_email"
t.string "invite_token"
t.datetime "invite_accepted_at"
t.datetime "requested_at"
end
add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree
......
......@@ -147,7 +147,8 @@ bt
To output a backtrace from all threads at once:
```
apply all thread bt
set pagination off
thread apply all bt
```
Once you're done debugging with `gdb`, be sure to detach from the process and
......
......@@ -62,6 +62,6 @@ class Spinach::Features::DashboardGroup < Spinach::FeatureSteps
end
step 'I should see the "Can not leave message"' do
expect(page).to have_content "You can not leave Owned group because you're the last owner"
expect(page).to have_content "You can not leave the \"Owned\" group."
end
end
......@@ -53,7 +53,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do
page.within '.content-list' do
expect(page).to have_content('sjobs@apple.com')
expect(page).to have_content('invited')
expect(page).to have_content('Invited')
expect(page).to have_content('Reporter')
end
end
......@@ -116,11 +116,9 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
member = mary_jane_member
page.within "#group_member_#{member.id}" do
find(".js-toggle-button").click
page.within "#edit_group_member_#{member.id}" do
select 'Developer', from: 'group_member_access_level'
click_on 'Save'
end
click_button "Edit access level"
select 'Developer', from: 'group_member_access_level'
click_on 'Save'
end
end
......@@ -128,9 +126,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
member = mary_jane_member
page.within "#group_member_#{member.id}" do
page.within '.member-access-level' do
expect(page).to have_content "Developer"
end
expect(page).to have_content "Developer"
end
end
......
......@@ -26,8 +26,11 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
end
step 'I should see "Mike" in team list as "Reporter"' do
page.within ".access-reporter" do
user = User.find_by(name: 'Mike')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('Mike')
expect(page).to have_content('Reporter')
end
end
......@@ -40,16 +43,20 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
end
step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do
page.within ".access-reporter" do
project_member = project.project_members.find_by(invite_email: 'sjobs@apple.com')
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('sjobs@apple.com')
expect(page).to have_content('invited')
expect(page).to have_content('Invited')
expect(page).to have_content('Reporter')
end
end
step 'I should see "Dmitriy" in team list as "Developer"' do
page.within ".access-developer" do
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('Dmitriy')
expect(page).to have_content('Developer')
end
end
......@@ -65,15 +72,14 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
end
step 'I should see "Dmitriy" in team list as "Reporter"' do
page.within ".access-reporter" do
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('Dmitriy')
expect(page).to have_content('Reporter')
end
end
step 'I click link "Remove from team"' do
click_link "Remove from team"
end
step 'I should not see "Dmitriy" in team list' do
user = User.find_by(name: "Dmitriy")
expect(page).not_to have_content(user.name)
......@@ -120,7 +126,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
click_link('Remove user from team')
click_link('Remove user from project')
end
end
......
......@@ -88,10 +88,7 @@ module API
class Group < Grape::Entity
expose :id, :name, :path, :description, :visibility_level
expose :avatar_url
expose :web_url do |group, options|
Gitlab::Routing.url_helpers.group_url(group)
end
expose :web_url
end
class GroupDetail < Group
......
......@@ -46,7 +46,7 @@ module API
required_attributes! [:user_id, :access_level]
# either the user is already a team member or a new one
project_member = user_project.project_member_by_id(params[:user_id])
project_member = user_project.project_member(params[:user_id])
if project_member.nil?
project_member = user_project.project_members.new(
user_id: params[:user_id],
......
......@@ -66,7 +66,11 @@ module Gitlab
def sample_objects
sample = Allocations.to_hash
counts = sample.each_with_object({}) do |(klass, count), hash|
hash[klass.name] = count
name = klass.name
next unless name
hash[name] = count
end
# Symbols aren't allocated so we'll need to add those manually.
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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