Commit c0f3a40c authored by Julius Kvedaras's avatar Julius Kvedaras

Merge branch 'ce-to-ee-2018-08-01' into 'master'

CE upstream - 2018-08-01 09:23 UTC

Closes #1756

See merge request gitlab-org/gitlab-ee!6738
parents 07cef0e5 b178fee8
...@@ -160,7 +160,10 @@ class Projects::LabelsController < Projects::ApplicationController ...@@ -160,7 +160,10 @@ class Projects::LabelsController < Projects::ApplicationController
def find_labels def find_labels
@available_labels ||= @available_labels ||=
LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: params[:include_ancestor_groups]).execute LabelsFinder.new(current_user,
project_id: @project.id,
include_ancestor_groups: params[:include_ancestor_groups],
search: params[:search]).execute
end end
def authorize_admin_labels! def authorize_admin_labels!
......
...@@ -14,6 +14,7 @@ class LabelsFinder < UnionFinder ...@@ -14,6 +14,7 @@ class LabelsFinder < UnionFinder
@skip_authorization = skip_authorization @skip_authorization = skip_authorization
items = find_union(label_ids, Label) || Label.none items = find_union(label_ids, Label) || Label.none
items = with_title(items) items = with_title(items)
items = by_search(items)
sort(items) sort(items)
end end
...@@ -63,6 +64,12 @@ class LabelsFinder < UnionFinder ...@@ -63,6 +64,12 @@ class LabelsFinder < UnionFinder
items.where(title: title) items.where(title: title)
end end
def by_search(labels)
return labels unless search?
labels.search(params[:search])
end
# Gets redacted array of group ids # Gets redacted array of group ids
# which can include the ancestors and descendants of the requested group. # which can include the ancestors and descendants of the requested group.
def group_ids_for(group) def group_ids_for(group)
...@@ -106,6 +113,10 @@ class LabelsFinder < UnionFinder ...@@ -106,6 +113,10 @@ class LabelsFinder < UnionFinder
params[:only_group_labels] params[:only_group_labels]
end end
def search?
params[:search].present?
end
def title def title
params[:title] || params[:name] params[:title] || params[:name]
end end
......
...@@ -35,16 +35,21 @@ module AtomicInternalId ...@@ -35,16 +35,21 @@ module AtomicInternalId
define_method("ensure_#{scope}_#{column}!") do define_method("ensure_#{scope}_#{column}!") do
scope_value = association(scope).reader scope_value = association(scope).reader
value = read_attribute(column)
if read_attribute(column).blank? && scope_value return value unless scope_value
scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
usage = self.class.table_name.to_sym
new_iid = InternalId.generate_next(self, scope_attrs, usage, init) scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
write_attribute(column, new_iid) usage = self.class.table_name.to_sym
if value.present?
InternalId.track_greatest(self, scope_attrs, usage, value, init)
else
value = InternalId.generate_next(self, scope_attrs, usage, init)
write_attribute(column, value)
end end
read_attribute(column) value
end end
end end
end end
......
# An InternalId is a strictly monotone sequence of integers # An InternalId is a strictly monotone sequence of integers
# generated for a given scope and usage. # generated for a given scope and usage.
# #
# The monotone sequence may be broken if an ID is explicitly provided
# to `.track_greatest_and_save!` or `#track_greatest`.
#
# For example, issues use their project to scope internal ids: # For example, issues use their project to scope internal ids:
# In that sense, scope is "project" and usage is "issues". # In that sense, scope is "project" and usage is "issues".
# Generated internal ids for an issue are unique per project. # Generated internal ids for an issue are unique per project.
...@@ -25,13 +28,34 @@ class InternalId < ActiveRecord::Base ...@@ -25,13 +28,34 @@ class InternalId < ActiveRecord::Base
# The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL). # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
# As such, the increment is atomic and safe to be called concurrently. # As such, the increment is atomic and safe to be called concurrently.
def increment_and_save! def increment_and_save!
update_and_save { self.last_value = (last_value || 0) + 1 }
end
# Increments #last_value with new_value if it is greater than the current,
# and saves the record
#
# The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
# As such, the increment is atomic and safe to be called concurrently.
def track_greatest_and_save!(new_value)
update_and_save { self.last_value = [last_value || 0, new_value].max }
end
private
def update_and_save(&block)
lock! lock!
self.last_value = (last_value || 0) + 1 yield
save! save!
last_value last_value
end end
class << self class << self
def track_greatest(subject, scope, usage, new_value, init)
return new_value unless available?
InternalIdGenerator.new(subject, scope, usage, init).track_greatest(new_value)
end
def generate_next(subject, scope, usage, init) def generate_next(subject, scope, usage, init)
# Shortcut if `internal_ids` table is not available (yet) # Shortcut if `internal_ids` table is not available (yet)
# This can be the case in other (unrelated) migration specs # This can be the case in other (unrelated) migration specs
...@@ -94,6 +118,16 @@ class InternalId < ActiveRecord::Base ...@@ -94,6 +118,16 @@ class InternalId < ActiveRecord::Base
end end
end end
# Create a record in internal_ids if one does not yet exist
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
def track_greatest(new_value)
subject.transaction do
(lookup || create_record).track_greatest_and_save!(new_value)
end
end
private private
# Retrieve InternalId record for (project, usage) combination, if it exists # Retrieve InternalId record for (project, usage) combination, if it exists
......
...@@ -2,6 +2,7 @@ class Label < ActiveRecord::Base ...@@ -2,6 +2,7 @@ class Label < ActiveRecord::Base
include CacheMarkdownField include CacheMarkdownField
include Referable include Referable
include Subscribable include Subscribable
include Gitlab::SQL::Pattern
# Represents a "No Label" state used for filtering Issues and Merge # Represents a "No Label" state used for filtering Issues and Merge
# Requests that have no label assigned. # Requests that have no label assigned.
...@@ -103,6 +104,17 @@ class Label < ActiveRecord::Base ...@@ -103,6 +104,17 @@ class Label < ActiveRecord::Base
nil nil
end end
# Searches for labels with a matching title or description.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String.
#
# Returns an ActiveRecord::Relation.
def self.search(query)
fuzzy_search(query, [:title, :description])
end
def open_issues_count(user = nil) def open_issues_count(user = nil)
issues_count(user, state: 'opened') issues_count(user, state: 'opened')
end end
......
...@@ -2,32 +2,45 @@ ...@@ -2,32 +2,45 @@
- page_title "Labels" - page_title "Labels"
- can_admin_label = can?(current_user, :admin_label, @project) - can_admin_label = can?(current_user, :admin_label, @project)
- hide_class = '' - hide_class = ''
- search = params[:search]
- if can_admin_label - if can_admin_label
- content_for(:header_content) do - content_for(:header_content) do
.nav-controls .nav-controls
= link_to _('New label'), new_project_label_path(@project), class: "btn btn-new" = link_to _('New label'), new_project_label_path(@project), class: "btn btn-new"
- if @labels.exists? || @prioritized_labels.exists? - if @labels.exists? || @prioritized_labels.exists? || search.present?
#promote-label-modal #promote-label-modal
%div{ class: container_class } %div{ class: container_class }
.top-area.adjust .top-area.adjust
.nav-text .nav-text
= _('Labels can be applied to issues and merge requests.') = _('Labels can be applied to issues and merge requests.')
- if can_admin_label
= _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
.labels-container.prepend-top-5 .nav-controls
= form_tag project_labels_path(@project), method: :get do
.input-group
= search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false }
%span.input-group-append
%button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') }
= icon("search")
.labels-container.prepend-top-10
- if can_admin_label - if can_admin_label
- if search.blank?
%p.text-muted
= _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
-# Only show it in the first page -# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
.prioritized-labels{ class: ('hide' if hide) } .prioritized-labels{ class: ('hide' if hide) }
%h5.prepend-top-10= _('Prioritized Labels') %h5.prepend-top-10= _('Prioritized Labels')
.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) } .content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty?}" } #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
= render 'shared/empty_states/priority_labels' = render 'shared/empty_states/priority_labels'
- if @prioritized_labels.present? - if @prioritized_labels.present?
= render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label, locals: { force_priority: true } = render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label, locals: { force_priority: true }
- elsif search.present?
.nothing-here-block
= _('No prioritised labels with such name or description')
- if @labels.present? - if @labels.present?
.other-labels .other-labels
...@@ -36,6 +49,18 @@ ...@@ -36,6 +49,18 @@
.content-list.manage-labels-list.js-other-labels .content-list.manage-labels-list.js-other-labels
= render partial: 'shared/label', subject: @project, collection: @labels, as: :label = render partial: 'shared/label', subject: @project, collection: @labels, as: :label
= paginate @labels, theme: 'gitlab' = paginate @labels, theme: 'gitlab'
- elsif search.present?
.other-labels
- if @available_labels.any?
%h5
= _('Other Labels')
.nothing-here-block
= _('No other labels with such name or description')
- else
.nothing-here-block
= _('No labels with such name or description')
- else - else
= render 'shared/empty_states/labels' = render 'shared/empty_states/labels'
......
---
title: Allow issues API to receive an internal ID (iid) on create
merge_request: 20626
author: Jamie Schembri
type: fixed
---
title: Search for labels by title or description on project labels page
merge_request: 20749
author:
type: added
---
title: Show one digit after dot in commit_per_day value in charts page.
merge_request:
author: msdundar
type: changed
...@@ -467,6 +467,7 @@ POST /projects/:id/issues ...@@ -467,6 +467,7 @@ POST /projects/:id/issues
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
|-------------------------------------------|----------------|----------|--------------| |-------------------------------------------|----------------|----------|--------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `iid` | integer/string | no | The internal ID of the project's issue (requires admin or project owner rights) |
| `title` | string | yes | The title of an issue | | `title` | string | yes | The title of an issue |
| `description` | string | no | The description of an issue | | `description` | string | no | The description of an issue |
| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | | `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. |
......
...@@ -167,6 +167,9 @@ module API ...@@ -167,6 +167,9 @@ module API
desc: 'The IID of a merge request for which to resolve discussions' desc: 'The IID of a merge request for which to resolve discussions'
optional :discussion_to_resolve, type: String, optional :discussion_to_resolve, type: String,
desc: 'The ID of a discussion to resolve, also pass `merge_request_to_resolve_discussions_of`' desc: 'The ID of a discussion to resolve, also pass `merge_request_to_resolve_discussions_of`'
optional :iid, type: Integer,
desc: 'The internal ID of a project issue. Available only for admins and project owners.'
use :issue_params use :issue_params
end end
post ':id/issues' do post ':id/issues' do
...@@ -174,9 +177,10 @@ module API ...@@ -174,9 +177,10 @@ module API
authorize! :create_issue, user_project authorize! :create_issue, user_project
# Setting created_at time only allowed for admins and project owners # Setting created_at time or iid only allowed for admins and project owners
unless current_user.admin? || user_project.owner == current_user unless current_user.admin? || user_project.owner == current_user
params.delete(:created_at) params.delete(:created_at)
params.delete(:iid)
end end
issue_params = declared_params(include_missing: false) issue_params = declared_params(include_missing: false)
......
...@@ -18,7 +18,7 @@ module Gitlab ...@@ -18,7 +18,7 @@ module Gitlab
end end
def commit_per_day def commit_per_day
@commit_per_day ||= @commits.size / (@duration + 1) @commit_per_day ||= (@commits.size.to_f / (@duration + 1)).round(1)
end end
def collect_data def collect_data
......
...@@ -2916,6 +2916,9 @@ msgstr "" ...@@ -2916,6 +2916,9 @@ msgstr ""
msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>" msgid "Fill in the fields below, turn on <strong>%{enable_label}</strong>, and press <strong>%{save_changes}</strong>"
msgstr "" msgstr ""
msgid "Filter"
msgstr ""
msgid "Filter by commit message" msgid "Filter by commit message"
msgstr "" msgstr ""
...@@ -4366,6 +4369,9 @@ msgstr "" ...@@ -4366,6 +4369,9 @@ msgstr ""
msgid "No issues for the selected time period." msgid "No issues for the selected time period."
msgstr "" msgstr ""
msgid "No labels with such name or description"
msgstr ""
msgid "No merge requests for the selected time period." msgid "No merge requests for the selected time period."
msgstr "" msgstr ""
...@@ -4375,6 +4381,12 @@ msgstr "" ...@@ -4375,6 +4381,12 @@ msgstr ""
msgid "No messages were logged" msgid "No messages were logged"
msgstr "" msgstr ""
msgid "No other labels with such name or description"
msgstr ""
msgid "No prioritised labels with such name or description"
msgstr ""
msgid "No public groups" msgid "No public groups"
msgstr "" msgstr ""
...@@ -5987,6 +5999,9 @@ msgstr "" ...@@ -5987,6 +5999,9 @@ msgstr ""
msgid "Submit as spam" msgid "Submit as spam"
msgstr "" msgstr ""
msgid "Submit search"
msgstr ""
msgid "Subscribe" msgid "Subscribe"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Search for labels', :js do
let(:user) { create(:user) }
let(:project) { create(:project) }
let!(:label1) { create(:label, title: 'Foo', description: 'Lorem ipsum', project: project) }
let!(:label2) { create(:label, title: 'Bar', description: 'Fusce consequat', project: project) }
before do
project.add_maintainer(user)
sign_in(user)
visit project_labels_path(project)
end
it 'searches for label by title' do
fill_in 'label-search', with: 'Bar'
find('#label-search').native.send_keys(:enter)
expect(page).to have_content(label2.title)
expect(page).to have_content(label2.description)
expect(page).not_to have_content(label1.title)
expect(page).not_to have_content(label1.description)
end
it 'searches for label by title' do
fill_in 'label-search', with: 'Lorem'
find('#label-search').native.send_keys(:enter)
expect(page).to have_content(label1.title)
expect(page).to have_content(label1.description)
expect(page).not_to have_content(label2.title)
expect(page).not_to have_content(label2.description)
end
it 'shows nothing found message' do
fill_in 'label-search', with: 'nonexistent'
find('#label-search').native.send_keys(:enter)
expect(page).to have_content('No labels with such name or description')
expect(page).not_to have_content(label1.title)
expect(page).not_to have_content(label1.description)
expect(page).not_to have_content(label2.title)
expect(page).not_to have_content(label2.description)
end
context 'priority labels' do
let!(:label_priority) { create(:label_priority, label: label1, project: project) }
it 'searches for priority label' do
fill_in 'label-search', with: 'Foo'
find('#label-search').native.send_keys(:enter)
page.within('.prioritized-labels') do
expect(page).to have_content(label1.title)
expect(page).to have_content(label1.description)
end
page.within('.other-labels') do
expect(page).to have_content('No other labels with such name or description')
end
end
it 'searches for other label' do
fill_in 'label-search', with: 'Bar'
find('#label-search').native.send_keys(:enter)
page.within('.prioritized-labels') do
expect(page).to have_content('No prioritised labels with such name or description')
end
page.within('.other-labels') do
expect(page).to have_content(label2.title)
expect(page).to have_content(label2.description)
end
end
end
end
...@@ -14,7 +14,7 @@ describe LabelsFinder do ...@@ -14,7 +14,7 @@ describe LabelsFinder do
let(:project_4) { create(:project, :public) } let(:project_4) { create(:project, :public) }
let(:project_5) { create(:project, namespace: group_1) } let(:project_5) { create(:project, namespace: group_1) }
let!(:project_label_1) { create(:label, project: project_1, title: 'Label 1') } let!(:project_label_1) { create(:label, project: project_1, title: 'Label 1', description: 'awesome label') }
let!(:project_label_2) { create(:label, project: project_2, title: 'Label 2') } let!(:project_label_2) { create(:label, project: project_2, title: 'Label 2') }
let!(:project_label_4) { create(:label, project: project_4, title: 'Label 4') } let!(:project_label_4) { create(:label, project: project_4, title: 'Label 4') }
let!(:project_label_5) { create(:label, project: project_5, title: 'Label 5') } let!(:project_label_5) { create(:label, project: project_5, title: 'Label 5') }
...@@ -196,5 +196,19 @@ describe LabelsFinder do ...@@ -196,5 +196,19 @@ describe LabelsFinder do
expect(finder.execute).to be_empty expect(finder.execute).to be_empty
end end
end end
context 'search by title and description' do
it 'returns labels with a partially matching title' do
finder = described_class.new(user, search: '(group)')
expect(finder.execute).to eq [group_label_1]
end
it 'returns labels with a partially matching description' do
finder = described_class.new(user, search: 'awesome')
expect(finder.execute).to eq [project_label_1]
end
end
end end
end end
...@@ -29,7 +29,7 @@ describe Gitlab::Graphs::Commits do ...@@ -29,7 +29,7 @@ describe Gitlab::Graphs::Commits do
context 'with commits from yesterday and today' do context 'with commits from yesterday and today' do
subject { described_class.new([commit2, commit1_yesterday]) } subject { described_class.new([commit2, commit1_yesterday]) }
describe '#commit_per_day' do describe '#commit_per_day' do
it { expect(subject.commit_per_day).to eq 1 } it { expect(subject.commit_per_day).to eq 1.0 }
end end
describe '#duration' do describe '#duration' do
......
...@@ -79,6 +79,46 @@ describe InternalId do ...@@ -79,6 +79,46 @@ describe InternalId do
end end
end end
describe '.track_greatest' do
let(:value) { 9001 }
subject { described_class.track_greatest(issue, scope, usage, value, init) }
context 'in the absence of a record' do
it 'creates a record if not yet present' do
expect { subject }.to change { described_class.count }.from(0).to(1)
end
end
it 'stores record attributes' do
subject
described_class.first.tap do |record|
expect(record.project).to eq(project)
expect(record.usage).to eq(usage.to_s)
expect(record.last_value).to eq(value)
end
end
context 'with existing issues' do
before do
create(:issue, project: project)
described_class.delete_all
end
it 'still returns the last value to that of the given value' do
expect(subject).to eq(value)
end
end
context 'when value is less than the current last_value' do
it 'returns the current last_value' do
described_class.create!(**scope, usage: usage, last_value: 10_001)
expect(subject).to eq 10_001
end
end
end
describe '#increment_and_save!' do describe '#increment_and_save!' do
let(:id) { create(:internal_id) } let(:id) { create(:internal_id) }
subject { id.increment_and_save! } subject { id.increment_and_save! }
...@@ -103,4 +143,30 @@ describe InternalId do ...@@ -103,4 +143,30 @@ describe InternalId do
end end
end end
end end
describe '#track_greatest_and_save!' do
let(:id) { create(:internal_id) }
let(:new_last_value) { 9001 }
subject { id.track_greatest_and_save!(new_last_value) }
it 'returns new last value' do
expect(subject).to eq new_last_value
end
it 'saves the record' do
subject
expect(id.changed?).to be_falsey
end
context 'when new last value is lower than the max' do
it 'does not update the last value' do
id.update!(last_value: 10_001)
subject
expect(id.reload.last_value).to eq 10_001
end
end
end
end end
...@@ -139,4 +139,20 @@ describe Label do ...@@ -139,4 +139,20 @@ describe Label do
end end
end end
end end
describe '.search' do
let(:label) { create(:label, title: 'bug', description: 'incorrect behavior') }
it 'returns labels with a partially matching title' do
expect(described_class.search(label.title[0..2])).to eq([label])
end
it 'returns labels with a partially matching description' do
expect(described_class.search(label.description[0..5])).to eq([label])
end
it 'returns nothing' do
expect(described_class.search('feature')).to be_empty
end
end
end end
...@@ -1006,6 +1006,38 @@ describe API::Issues do ...@@ -1006,6 +1006,38 @@ describe API::Issues do
end end
end end
context 'an internal ID is provided' do
context 'by an admin' do
it 'sets the internal ID on the new issue' do
post api("/projects/#{project.id}/issues", admin),
title: 'new issue', iid: 9001
expect(response).to have_gitlab_http_status(201)
expect(json_response['iid']).to eq 9001
end
end
context 'by an owner' do
it 'sets the internal ID on the new issue' do
post api("/projects/#{project.id}/issues", user),
title: 'new issue', iid: 9001
expect(response).to have_gitlab_http_status(201)
expect(json_response['iid']).to eq 9001
end
end
context 'by another user' do
it 'ignores the given internal ID' do
post api("/projects/#{project.id}/issues", user2),
title: 'new issue', iid: 9001
expect(response).to have_gitlab_http_status(201)
expect(json_response['iid']).not_to eq 9001
end
end
end
it 'creates a new project issue' do it 'creates a new project issue' do
post api("/projects/#{project.id}/issues", user), post api("/projects/#{project.id}/issues", user),
title: 'new issue', labels: 'label, label2', weight: 3, title: 'new issue', labels: 'label, label2', weight: 3,
......
shared_examples 'gitlab projects import validations' do
context 'with an invalid path' do
let(:path) { '/invalid-path/' }
it 'returns an invalid project' do
project = subject.execute
expect(project).not_to be_persisted
expect(project).not_to be_valid
end
end
context 'with a valid path' do
it 'creates a project' do
project = subject.execute
expect(project).to be_persisted
expect(project).to be_valid
end
end
context 'override params' do
it 'stores them as import data when passed' do
project = described_class
.new(namespace.owner, import_params, description: 'Hello')
.execute
expect(project.import_data.data['override_params']['description']).to eq('Hello')
end
end
context 'when there is a project with the same path' do
let(:existing_project) { create(:project, namespace: namespace) }
let(:path) { existing_project.path}
it 'does not create the project' do
project = subject.execute
expect(project).to be_invalid
expect(project).not_to be_persisted
end
context 'when overwrite param is set' do
let(:overwrite) { true }
it 'creates a project in a temporary full_path' do
project = subject.execute
expect(project).to be_valid
expect(project).to be_persisted
end
end
end
end
...@@ -60,6 +60,20 @@ shared_examples_for 'AtomicInternalId' do |validate_presence: true| ...@@ -60,6 +60,20 @@ shared_examples_for 'AtomicInternalId' do |validate_presence: true|
expect { subject }.not_to change { instance.public_send(internal_id_attribute) } expect { subject }.not_to change { instance.public_send(internal_id_attribute) }
end end
context 'when the instance has an internal ID set' do
let(:internal_id) { 9001 }
it 'calls InternalId.update_last_value and sets the `last_value` to that of the instance' do
instance.send("#{internal_id_attribute}=", internal_id)
expect(InternalId)
.to receive(:track_greatest)
.with(instance, scope_attrs, usage, internal_id, any_args)
.and_return(internal_id)
subject
end
end
end end
end end
end end
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