Browse Source

SONAR-13426 Enable project badge for private project

tags/9.2.0.49834
Mathieu Suen 2 years ago
parent
commit
4a478729ea
17 changed files with 144 additions and 146 deletions
  1. 27
    0
      server/sonar-web/src/main/js/api/project-badges.ts
  2. 0
    1
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/ProjectInformation.tsx
  3. 13
    0
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/__tests__/ProjectInformation-test.tsx
  4. 20
    1
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/__tests__/__snapshots__/ProjectInformation-test.tsx.snap
  5. 2
    23
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/BadgeParams.tsx
  6. 31
    5
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/ProjectBadges.tsx
  7. 2
    2
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/BadgeButton-test.tsx
  8. 2
    10
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/BadgeParams-test.tsx
  9. 9
    2
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/ProjectBadges-test.tsx
  10. 2
    2
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/__snapshots__/BadgeButton-test.tsx.snap
  11. 0
    63
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/__snapshots__/BadgeParams-test.tsx.snap
  12. 8
    4
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap
  13. 14
    18
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/utils-test.ts
  14. 7
    13
      server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/utils.ts
  15. 2
    1
      server/sonar-web/src/main/js/components/common/CodeSnippet.tsx
  16. 4
    0
      server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
  17. 1
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 27
- 0
server/sonar-web/src/main/js/api/project-badges.ts View File

@@ -0,0 +1,27 @@
/*
* SonarQube
* Copyright (C) 2009-2021 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import throwGlobalError from '../app/utils/throwGlobalError';
import { getJSON } from '../helpers/request';

export function getProjectBadgesToken(project: string) {
return getJSON('/api/project_badges/token', { project })
.then(({ token }) => token)
.catch(throwGlobalError);
}

+ 0
- 1
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/ProjectInformation.tsx View File

@@ -90,7 +90,6 @@ export class ProjectInformation extends React.PureComponent<Props, State> {
isLoggedIn(currentUser) && component.qualifier === ComponentQualifier.Project;
const canUseBadges =
metrics !== undefined &&
component.visibility !== 'private' &&
(component.qualifier === ComponentQualifier.Application ||
component.qualifier === ComponentQualifier.Project);


+ 13
- 0
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/__tests__/ProjectInformation-test.tsx View File

@@ -22,6 +22,8 @@ import * as React from 'react';
import { mockComponent } from '../../../../../../helpers/mocks/component';
import { mockCurrentUser, mockLoggedInUser, mockMetric } from '../../../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../../../helpers/testUtils';
import { ComponentQualifier } from '../../../../../../types/component';
import ProjectBadges from '../badges/ProjectBadges';
import { ProjectInformation } from '../ProjectInformation';
import { ProjectInformationPages } from '../ProjectInformationPages';

@@ -53,6 +55,17 @@ it('should handle page change', async () => {
expect(wrapper.state().page).toBe(ProjectInformationPages.badges);
});

it('should display badge', () => {
const wrapper = shallowRender({
component: mockComponent({ qualifier: ComponentQualifier.Project })
});

expect(wrapper.find(ProjectBadges).type).toBeDefined();

wrapper.setProps({ component: mockComponent({ qualifier: ComponentQualifier.Application }) });
expect(wrapper.find(ProjectBadges).type).toBeDefined();
});

function shallowRender(props: Partial<ProjectInformation['props']> = {}) {
return shallow<ProjectInformation>(
<ProjectInformation

+ 20
- 1
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/__tests__/__snapshots__/ProjectInformation-test.tsx.snap View File

@@ -203,7 +203,7 @@ exports[`should render correctly: private 1`] = `
<Fragment>
<Memo(ProjectInformationRenderer)
canConfigureNotifications={false}
canUseBadges={false}
canUseBadges={true}
component={
Object {
"breadcrumbs": Array [],
@@ -230,5 +230,24 @@ exports[`should render correctly: private 1`] = `
onComponentChange={[MockFunction]}
onPageChange={[Function]}
/>
<InfoDrawerPage
displayed={false}
onPageChange={[Function]}
>
<ProjectBadges
metrics={
Object {
"coverage": Object {
"id": "coverage",
"key": "coverage",
"name": "Coverage",
"type": "PERCENT",
},
}
}
project="my-project"
qualifier="TRK"
/>
</InfoDrawerPage>
</Fragment>
`;

+ 2
- 23
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/BadgeParams.tsx View File

@@ -22,7 +22,7 @@ import * as React from 'react';
import { fetchWebApi } from '../../../../../../api/web-api';
import Select from '../../../../../../components/controls/Select';
import { getLocalizedMetricName, translate } from '../../../../../../helpers/l10n';
import { BadgeColors, BadgeFormats, BadgeOptions, BadgeType } from './utils';
import { BadgeFormats, BadgeOptions, BadgeType } from './utils';

interface Props {
className?: string;
@@ -90,10 +90,6 @@ export default class BadgeParams extends React.PureComponent<Props> {
});
};

handleColorChange = ({ value }: { value: BadgeColors }) => {
this.props.updateOptions({ color: value });
};

handleFormatChange = ({ value }: { value: BadgeFormats }) => {
this.props.updateOptions({ format: value });
};
@@ -103,24 +99,7 @@ export default class BadgeParams extends React.PureComponent<Props> {
};

renderBadgeType = (type: BadgeType, options: BadgeOptions) => {
if (type === BadgeType.marketing) {
return (
<>
<label className="spacer-right" htmlFor="badge-color">
{translate('color')}:
</label>
<Select
className="input-medium"
clearable={false}
name="badge-color"
onChange={this.handleColorChange}
options={this.getColorOptions()}
searchable={false}
value={options.color}
/>
</>
);
} else if (type === BadgeType.measure) {
if (type === BadgeType.measure) {
return (
<>
<label className="spacer-right" htmlFor="badge-metric">

+ 31
- 5
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/ProjectBadges.tsx View File

@@ -18,7 +18,9 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { getProjectBadgesToken } from '../../../../../../api/project-badges';
import CodeSnippet from '../../../../../../components/common/CodeSnippet';
import { Alert } from '../../../../../../components/ui/Alert';
import { getBranchLikeQuery } from '../../../../../../helpers/branch-like';
import { translate } from '../../../../../../helpers/l10n';
import { BranchLike } from '../../../../../../types/branch-like';
@@ -36,16 +38,36 @@ interface Props {
}

interface State {
token: string;
selectedType: BadgeType;
badgeOptions: BadgeOptions;
}

export default class ProjectBadges extends React.PureComponent<Props, State> {
mounted = false;
state: State = {
token: '',
selectedType: BadgeType.measure,
badgeOptions: { color: 'white', metric: MetricKey.alert_status }
badgeOptions: { metric: MetricKey.alert_status }
};

componentDidMount() {
this.mounted = true;
this.fetchToken();
}

componentWillUnmount() {
this.mounted = false;
}

async fetchToken() {
const { project } = this.props;
const token = await getProjectBadgesToken(project);
if (this.mounted) {
this.setState({ token });
}
}

handleSelectBadge = (selectedType: BadgeType) => {
this.setState({ selectedType });
};
@@ -56,7 +78,7 @@ export default class ProjectBadges extends React.PureComponent<Props, State> {

render() {
const { branchLike, project, qualifier } = this.props;
const { selectedType, badgeOptions } = this.state;
const { selectedType, badgeOptions, token } = this.state;
const fullBadgeOptions = { project, ...badgeOptions, ...getBranchLikeQuery(branchLike) };

return (
@@ -67,7 +89,7 @@ export default class ProjectBadges extends React.PureComponent<Props, State> {
onClick={this.handleSelectBadge}
selected={BadgeType.measure === selectedType}
type={BadgeType.measure}
url={getBadgeUrl(BadgeType.measure, fullBadgeOptions)}
url={getBadgeUrl(BadgeType.measure, fullBadgeOptions, token)}
/>
<p className="huge-spacer-bottom spacer-top">
{translate('overview.badges', BadgeType.measure, 'description', qualifier)}
@@ -76,7 +98,7 @@ export default class ProjectBadges extends React.PureComponent<Props, State> {
onClick={this.handleSelectBadge}
selected={BadgeType.qualityGate === selectedType}
type={BadgeType.qualityGate}
url={getBadgeUrl(BadgeType.qualityGate, fullBadgeOptions)}
url={getBadgeUrl(BadgeType.qualityGate, fullBadgeOptions, token)}
/>
<p className="huge-spacer-bottom spacer-top">
{translate('overview.badges', BadgeType.qualityGate, 'description', qualifier)}
@@ -88,7 +110,11 @@ export default class ProjectBadges extends React.PureComponent<Props, State> {
type={selectedType}
updateOptions={this.handleUpdateOptions}
/>
<CodeSnippet isOneLine={true} snippet={getBadgeSnippet(selectedType, fullBadgeOptions)} />
<Alert variant="warning">{translate('overview.badges.leak_warning')}</Alert>
<CodeSnippet
isOneLine={true}
snippet={getBadgeSnippet(selectedType, fullBadgeOptions, token)}
/>
</div>
);
}

+ 2
- 2
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/BadgeButton-test.tsx View File

@@ -33,7 +33,7 @@ it('should return the badge type on click', () => {
const onClick = jest.fn();
const wrapper = getWrapper({ onClick });
click(wrapper.find('Button'));
expect(onClick).toHaveBeenCalledWith(BadgeType.marketing);
expect(onClick).toHaveBeenCalledWith(BadgeType.qualityGate);
});

function getWrapper(props = {}) {
@@ -41,7 +41,7 @@ function getWrapper(props = {}) {
<BadgeButton
onClick={jest.fn()}
selected={false}
type={BadgeType.marketing}
type={BadgeType.qualityGate}
url="http://foo.bar"
{...props}
/>

+ 2
- 10
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/BadgeParams-test.tsx View File

@@ -42,14 +42,6 @@ const METRICS = {
coverage: { key: 'coverage', name: 'Coverage' } as T.Metric
};

it('should display marketing badge params', () => {
const updateOptions = jest.fn();
const wrapper = getWrapper({ updateOptions });
expect(wrapper).toMatchSnapshot();
(wrapper.instance() as BadgeParams).handleColorChange({ value: 'black' });
expect(updateOptions).toHaveBeenCalledWith({ color: 'black' });
});

it('should display measure badge params', () => {
const updateOptions = jest.fn();
const wrapper = getWrapper({ updateOptions, type: BadgeType.measure });
@@ -70,8 +62,8 @@ function getWrapper(props = {}) {
return shallow(
<BadgeParams
metrics={METRICS}
options={{ color: 'white', metric: 'alert_status' }}
type={BadgeType.marketing}
options={{ metric: 'alert_status' }}
type={BadgeType.measure}
updateOptions={jest.fn()}
{...props}
/>

+ 9
- 2
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/ProjectBadges-test.tsx View File

@@ -21,6 +21,7 @@ import { shallow } from 'enzyme';
import * as React from 'react';
import { mockBranch } from '../../../../../../../helpers/mocks/branch-like';
import { mockMetric } from '../../../../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../../../../helpers/testUtils';
import { Location } from '../../../../../../../helpers/urls';
import { MetricKey } from '../../../../../../../types/metrics';
import ProjectBadges from '../ProjectBadges';
@@ -31,8 +32,14 @@ jest.mock('../../../../../../../helpers/urls', () => ({
getProjectUrl: () => ({ pathname: '/dashboard' } as Location)
}));

it('should display correctly', () => {
expect(shallowRender()).toMatchSnapshot();
jest.mock('../../../../../../../api/project-badges', () => ({
getProjectBadgesToken: jest.fn().mockResolvedValue('foo')
}));

it('should display correctly', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});

function shallowRender(overrides = {}) {

+ 2
- 2
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/__snapshots__/BadgeButton-test.tsx.snap View File

@@ -6,7 +6,7 @@ exports[`should display correctly 1`] = `
onClick={[Function]}
>
<img
alt="overview.badges.marketing.alt"
alt="overview.badges.quality_gate.alt"
src="http://foo.bar"
width="128px"
/>
@@ -19,7 +19,7 @@ exports[`should display correctly 2`] = `
onClick={[Function]}
>
<img
alt="overview.badges.marketing.alt"
alt="overview.badges.quality_gate.alt"
src="http://foo.bar"
width="128px"
/>

+ 0
- 63
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/__snapshots__/BadgeParams-test.tsx.snap View File

@@ -1,68 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should display marketing badge params 1`] = `
<div>
<label
className="spacer-right"
htmlFor="badge-color"
>
color
:
</label>
<Select
className="input-medium"
clearable={false}
name="badge-color"
onChange={[Function]}
options={
Array [
Object {
"label": "overview.badges.options.colors.white",
"value": "white",
},
Object {
"label": "overview.badges.options.colors.black",
"value": "black",
},
Object {
"label": "overview.badges.options.colors.orange",
"value": "orange",
},
]
}
searchable={false}
value="white"
/>
<label
className="spacer-right spacer-top"
htmlFor="badge-format"
>
format
:
</label>
<Select
className="input-medium"
clearable={false}
name="badge-format"
onChange={[Function]}
options={
Array [
Object {
"label": "overview.badges.options.formats.md",
"value": "md",
},
Object {
"label": "overview.badges.options.formats.url",
"value": "url",
},
]
}
searchable={false}
value="md"
/>
</div>
`;

exports[`should display measure badge params 1`] = `
<div>
<label

+ 8
- 4
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap View File

@@ -16,7 +16,7 @@ exports[`should display correctly 1`] = `
onClick={[Function]}
selected={true}
type="measure"
url="host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status"
url="host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status&token=foo"
/>
<p
className="huge-spacer-bottom spacer-top"
@@ -27,7 +27,7 @@ exports[`should display correctly 1`] = `
onClick={[Function]}
selected={false}
type="quality_gate"
url="host/api/project_badges/quality_gate?branch=branch-6.7&project=foo"
url="host/api/project_badges/quality_gate?branch=branch-6.7&project=foo&token=foo"
/>
<p
className="huge-spacer-bottom spacer-top"
@@ -54,16 +54,20 @@ exports[`should display correctly 1`] = `
}
options={
Object {
"color": "white",
"metric": "alert_status",
}
}
type="measure"
updateOptions={[Function]}
/>
<Alert
variant="warning"
>
overview.badges.leak_warning
</Alert>
<CodeSnippet
isOneLine={true}
snippet="[![alert_status](host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status)](/dashboard)"
snippet="[![alert_status](host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status&token=foo)](/dashboard)"
/>
</div>
`;

+ 14
- 18
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/__tests__/utils-test.ts View File

@@ -29,44 +29,40 @@ jest.mock('../../../../../../../helpers/urls', () => ({

const options: BadgeOptions = {
branch: 'master',
color: 'white',
metric: 'alert_status',
project: 'foo'
};

describe('#getBadgeUrl', () => {
it('should generate correct marketing badge links', () => {
expect(getBadgeUrl(BadgeType.marketing, options)).toBe(
'host/images/project_badges/sonarcloud-white.svg'
);
expect(getBadgeUrl(BadgeType.marketing, { ...options, color: 'orange' })).toBe(
'host/images/project_badges/sonarcloud-orange.svg'
);
});

it('should generate correct quality gate badge links', () => {
expect(getBadgeUrl(BadgeType.qualityGate, options)).toBe(
'host/api/project_badges/quality_gate?branch=master&project=foo'
expect(getBadgeUrl(BadgeType.qualityGate, options, 'foo')).toBe(
'host/api/project_badges/quality_gate?branch=master&project=foo&token=foo'
);
});

it('should generate correct measures badge links', () => {
expect(getBadgeUrl(BadgeType.measure, options)).toBe(
'host/api/project_badges/measure?branch=master&project=foo&metric=alert_status'
expect(getBadgeUrl(BadgeType.measure, options, 'foo')).toBe(
'host/api/project_badges/measure?branch=master&project=foo&metric=alert_status&token=foo'
);
});

it('should ignore undefined parameters', () => {
expect(getBadgeUrl(BadgeType.measure, { color: 'white', metric: 'alert_status' })).toBe(
'host/api/project_badges/measure?metric=alert_status'
expect(getBadgeUrl(BadgeType.measure, { metric: 'alert_status' }, 'foo')).toBe(
'host/api/project_badges/measure?metric=alert_status&token=foo'
);
});

it('should force metric parameters', () => {
expect(getBadgeUrl(BadgeType.measure, {}, 'foo')).toBe(
'host/api/project_badges/measure?metric=alert_status&token=foo'
);
});
});

describe('#getBadgeSnippet', () => {
it('should generate a correct markdown image', () => {
expect(getBadgeSnippet(BadgeType.marketing, { ...options, format: 'md' })).toBe(
'[![SonarCloud](host/images/project_badges/sonarcloud-white.svg)](host/dashboard?id=foo&branch=master)'
expect(getBadgeSnippet(BadgeType.measure, { ...options, format: 'md' }, 'foo')).toBe(
'[![alert_status](host/api/project_badges/measure?branch=master&project=foo&metric=alert_status&token=foo)](host/dashboard?id=foo&branch=master)'
);
});
});

+ 7
- 13
server/sonar-web/src/main/js/app/components/nav/component/projectInformation/badges/utils.ts View File

@@ -26,7 +26,6 @@ export type BadgeFormats = 'md' | 'url';

export interface BadgeOptions {
branch?: string;
color?: BadgeColors;
format?: BadgeFormats;
project?: string;
metric?: string;
@@ -35,12 +34,11 @@ export interface BadgeOptions {

export enum BadgeType {
measure = 'measure',
qualityGate = 'quality_gate',
marketing = 'marketing'
qualityGate = 'quality_gate'
}

export function getBadgeSnippet(type: BadgeType, options: BadgeOptions) {
const url = getBadgeUrl(type, options);
export function getBadgeSnippet(type: BadgeType, options: BadgeOptions, token: string) {
const url = getBadgeUrl(type, options, token);
const { branch, format = 'md', metric = 'alert_status', project } = options;

if (format === 'url') {
@@ -50,9 +48,6 @@ export function getBadgeSnippet(type: BadgeType, options: BadgeOptions) {
let projectUrl;

switch (type) {
case BadgeType.marketing:
label = 'SonarCloud';
break;
case BadgeType.measure:
label = getLocalizedMetricName({ key: metric });
break;
@@ -73,19 +68,18 @@ export function getBadgeSnippet(type: BadgeType, options: BadgeOptions) {

export function getBadgeUrl(
type: BadgeType,
{ branch, project, color = 'white', metric = 'alert_status', pullRequest }: BadgeOptions
{ branch, project, metric = 'alert_status', pullRequest }: BadgeOptions,
token: string
) {
switch (type) {
case BadgeType.marketing:
return `${getHostUrl()}/images/project_badges/sonarcloud-${color}.svg`;
case BadgeType.qualityGate:
return `${getHostUrl()}/api/project_badges/quality_gate?${new URLSearchParams(
omitNil({ branch, project, pullRequest })
omitNil({ branch, project, pullRequest, token })
).toString()}`;
case BadgeType.measure:
default:
return `${getHostUrl()}/api/project_badges/measure?${new URLSearchParams(
omitNil({ branch, project, metric, pullRequest })
omitNil({ branch, project, metric, pullRequest, token })
).toString()}`;
}
}

+ 2
- 1
server/sonar-web/src/main/js/components/common/CodeSnippet.tsx View File

@@ -42,7 +42,8 @@ export default function CodeSnippet(props: CodeSnippetProps) {

return (
<div className={classNames('code-snippet spacer-top spacer-bottom display-flex-row', {})}>
<pre className="flex-1" ref={snippetRef}>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<pre className="flex-1" ref={snippetRef} tabIndex={0}>
{finalSnippet}
</pre>
{!noCopy && <ClipboardButton copyValue={finalSnippet} />}

+ 4
- 0
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/CodeSnippet-test.tsx.snap View File

@@ -6,6 +6,7 @@ exports[`renders correctly: array snippet 1`] = `
>
<pre
className="flex-1"
tabIndex={0}
>
foo \\
bar
@@ -23,6 +24,7 @@ exports[`renders correctly: default 1`] = `
>
<pre
className="flex-1"
tabIndex={0}
>
foo
bar
@@ -40,6 +42,7 @@ exports[`renders correctly: no copy 1`] = `
>
<pre
className="flex-1"
tabIndex={0}
>
foo
bar
@@ -53,6 +56,7 @@ exports[`renders correctly: single line with array snippet 1`] = `
>
<pre
className="flex-1"
tabIndex={0}
>
foo bar
</pre>

+ 1
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -3169,7 +3169,7 @@ overview.badges.quality_gate.description=Displays the current quality gate statu
overview.badges.quality_gate.description.APP=Displays the current quality gate status of your application.
overview.badges.quality_gate.description.TRK=Displays the current quality gate status of your project.
overview.badges.quality_gate.description.VW=Displays the current quality gate status of your portfolio.
overview.badges.leak_warning=Project badges can expose your security rating and other measures. Only use project badges in trusted environments.

#------------------------------------------------------------------------------
#

Loading…
Cancel
Save