SharePoint List selector Expansion (#1786)
## Description Initial changes to support SharePoint Lists being chosen for Backup Operations. <!-- Insert PR description--> ## Type of change <!--- Please check the type of change your PR introduces: ---> - [x] 🌻 Feature ## Issue(s) * close #1785<issue> ## Test Plan - [x] ⚡ Unit test
This commit is contained in:
parent
ea445ec471
commit
0d5043aa1f
@ -41,7 +41,7 @@ func (suite *SharePointUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
|
|||||||
Sites: empty,
|
Sites: empty,
|
||||||
WebURLs: empty,
|
WebURLs: empty,
|
||||||
},
|
},
|
||||||
expectIncludeLen: 1,
|
expectIncludeLen: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single inputs",
|
name: "single inputs",
|
||||||
@ -51,7 +51,7 @@ func (suite *SharePointUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
|
|||||||
Sites: single,
|
Sites: single,
|
||||||
WebURLs: single,
|
WebURLs: single,
|
||||||
},
|
},
|
||||||
expectIncludeLen: 2,
|
expectIncludeLen: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi inputs",
|
name: "multi inputs",
|
||||||
@ -61,7 +61,7 @@ func (suite *SharePointUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
|
|||||||
Sites: multi,
|
Sites: multi,
|
||||||
WebURLs: multi,
|
WebURLs: multi,
|
||||||
},
|
},
|
||||||
expectIncludeLen: 2,
|
expectIncludeLen: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "library contains",
|
name: "library contains",
|
||||||
@ -101,7 +101,7 @@ func (suite *SharePointUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
|
|||||||
Sites: empty,
|
Sites: empty,
|
||||||
WebURLs: containsOnly,
|
WebURLs: containsOnly,
|
||||||
},
|
},
|
||||||
expectIncludeLen: 1,
|
expectIncludeLen: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "library suffixes",
|
name: "library suffixes",
|
||||||
@ -111,7 +111,7 @@ func (suite *SharePointUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
|
|||||||
Sites: empty,
|
Sites: empty,
|
||||||
WebURLs: prefixOnly, // prefix pattern matches suffix pattern
|
WebURLs: prefixOnly, // prefix pattern matches suffix pattern
|
||||||
},
|
},
|
||||||
expectIncludeLen: 1,
|
expectIncludeLen: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "library suffixes and contains",
|
name: "library suffixes and contains",
|
||||||
@ -121,15 +121,16 @@ func (suite *SharePointUtilsSuite) TestIncludeSharePointRestoreDataSelectors() {
|
|||||||
Sites: empty,
|
Sites: empty,
|
||||||
WebURLs: containsAndPrefix, // prefix pattern matches suffix pattern
|
WebURLs: containsAndPrefix, // prefix pattern matches suffix pattern
|
||||||
},
|
},
|
||||||
expectIncludeLen: 2,
|
expectIncludeLen: 4,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, test := range table {
|
for _, test := range table {
|
||||||
suite.T().Run(test.name, func(t *testing.T) {
|
suite.T().Run(test.name, func(t *testing.T) {
|
||||||
sel := selectors.NewSharePointRestore()
|
sel := selectors.NewSharePointRestore()
|
||||||
// no return, mutates sel as a side effect
|
// no return, mutates sel as a side effect
|
||||||
|
t.Logf("Options sent: %v\n", test.opts)
|
||||||
utils.IncludeSharePointRestoreDataSelectors(sel, test.opts)
|
utils.IncludeSharePointRestoreDataSelectors(sel, test.opts)
|
||||||
assert.Len(t, sel.Includes, test.expectIncludeLen)
|
assert.Len(t, sel.Includes, test.expectIncludeLen, sel)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -247,7 +247,7 @@ func SendMailToBackStore(
|
|||||||
sentMessage, err := service.Client().UsersById(user).MailFoldersById(destination).Messages().Post(ctx, message, nil)
|
sentMessage, err := service.Client().UsersById(user).MailFoldersById(destination).Messages().Post(ctx, message, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err,
|
return errors.Wrap(err,
|
||||||
user+": failure sendMailAPI: "+support.ConnectorStackErrorTrace(err),
|
user+": failure sendMailAPI: Dest: "+destination+" Details: "+support.ConnectorStackErrorTrace(err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -509,8 +509,8 @@ func (suite *BackupOpIntegrationSuite) TestBackup_Run_sharePoint() {
|
|||||||
siteID = tester.M365SiteID(t)
|
siteID = tester.M365SiteID(t)
|
||||||
sel = selectors.NewSharePointBackup()
|
sel = selectors.NewSharePointBackup()
|
||||||
)
|
)
|
||||||
|
// TODO: dadams39 Issue #1795: Revert to Sites Upon List Integration
|
||||||
sel.Include(sel.Sites([]string{siteID}))
|
sel.Include(sel.Libraries([]string{siteID}, selectors.Any()))
|
||||||
|
|
||||||
bo, _, _, _, closer := prepNewBackupOp(t, ctx, mb, sel.Selector)
|
bo, _, _, _, closer := prepNewBackupOp(t, ctx, mb, sel.Selector)
|
||||||
defer closer()
|
defer closer()
|
||||||
|
|||||||
@ -190,14 +190,23 @@ func (s *sharePoint) DiscreteScopes(siteIDs []string) []SharePointScope {
|
|||||||
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
// If any slice is empty, it defaults to [selectors.None]
|
// If any slice is empty, it defaults to [selectors.None]
|
||||||
func (s *SharePointRestore) WebURL(urlSuffixes []string, opts ...option) []SharePointScope {
|
func (s *SharePointRestore) WebURL(urlSuffixes []string, opts ...option) []SharePointScope {
|
||||||
return []SharePointScope{
|
scopes := []SharePointScope{}
|
||||||
|
|
||||||
|
scopes = append(
|
||||||
|
scopes,
|
||||||
makeFilterScope[SharePointScope](
|
makeFilterScope[SharePointScope](
|
||||||
SharePointLibraryItem,
|
SharePointLibraryItem,
|
||||||
SharePointWebURL,
|
SharePointWebURL,
|
||||||
urlSuffixes,
|
urlSuffixes,
|
||||||
pathFilterFactory(opts...)),
|
pathFilterFactory(opts...)),
|
||||||
// TODO: list scope
|
makeFilterScope[SharePointScope](
|
||||||
}
|
SharePointListItem,
|
||||||
|
SharePointWebURL,
|
||||||
|
urlSuffixes,
|
||||||
|
pathFilterFactory(opts...)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produces one or more SharePoint site scopes.
|
// Produces one or more SharePoint site scopes.
|
||||||
@ -208,7 +217,43 @@ func (s *SharePointRestore) WebURL(urlSuffixes []string, opts ...option) []Share
|
|||||||
func (s *sharePoint) Sites(sites []string) []SharePointScope {
|
func (s *sharePoint) Sites(sites []string) []SharePointScope {
|
||||||
scopes := []SharePointScope{}
|
scopes := []SharePointScope{}
|
||||||
|
|
||||||
scopes = append(scopes, makeScope[SharePointScope](SharePointLibrary, sites, Any()))
|
scopes = append(
|
||||||
|
scopes,
|
||||||
|
makeScope[SharePointScope](SharePointLibrary, sites, Any()),
|
||||||
|
makeScope[SharePointScope](SharePointList, sites, Any()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists produces one or more SharePoint list scopes.
|
||||||
|
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
||||||
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
|
// Any empty slice defaults to [selectors.None]
|
||||||
|
func (s *sharePoint) Lists(sites, lists []string, opts ...option) []SharePointScope {
|
||||||
|
var (
|
||||||
|
scopes = []SharePointScope{}
|
||||||
|
os = append([]option{pathComparator()}, opts...)
|
||||||
|
)
|
||||||
|
|
||||||
|
scopes = append(scopes, makeScope[SharePointScope](SharePointList, sites, lists, os...))
|
||||||
|
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItems produces one or more SharePoint list item scopes.
|
||||||
|
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
||||||
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
|
// If any slice is empty, it defaults to [selectors.None]
|
||||||
|
// options are only applied to the list scopes.
|
||||||
|
func (s *sharePoint) ListItems(sites, lists, items []string, opts ...option) []SharePointScope {
|
||||||
|
scopes := []SharePointScope{}
|
||||||
|
|
||||||
|
scopes = append(
|
||||||
|
scopes,
|
||||||
|
makeScope[SharePointScope](SharePointListItem, sites, items).
|
||||||
|
set(SharePointList, lists, opts...),
|
||||||
|
)
|
||||||
|
|
||||||
return scopes
|
return scopes
|
||||||
}
|
}
|
||||||
@ -268,6 +313,8 @@ const (
|
|||||||
// types of data identified by SharePoint
|
// types of data identified by SharePoint
|
||||||
SharePointWebURL sharePointCategory = "SharePointWebURL"
|
SharePointWebURL sharePointCategory = "SharePointWebURL"
|
||||||
SharePointSite sharePointCategory = "SharePointSite"
|
SharePointSite sharePointCategory = "SharePointSite"
|
||||||
|
SharePointList sharePointCategory = "SharePointList"
|
||||||
|
SharePointListItem sharePointCategory = "SharePointListItem"
|
||||||
SharePointLibrary sharePointCategory = "SharePointLibrary"
|
SharePointLibrary sharePointCategory = "SharePointLibrary"
|
||||||
SharePointLibraryItem sharePointCategory = "SharePointLibraryItem"
|
SharePointLibraryItem sharePointCategory = "SharePointLibraryItem"
|
||||||
|
|
||||||
@ -284,6 +331,10 @@ var sharePointLeafProperties = map[categorizer]leafProperty{
|
|||||||
pathKeys: []categorizer{SharePointSite},
|
pathKeys: []categorizer{SharePointSite},
|
||||||
pathType: path.UnknownCategory,
|
pathType: path.UnknownCategory,
|
||||||
},
|
},
|
||||||
|
SharePointListItem: {
|
||||||
|
pathKeys: []categorizer{SharePointSite, SharePointList, SharePointListItem},
|
||||||
|
pathType: path.ListsCategory,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c sharePointCategory) String() string {
|
func (c sharePointCategory) String() string {
|
||||||
@ -299,6 +350,8 @@ func (c sharePointCategory) leafCat() categorizer {
|
|||||||
switch c {
|
switch c {
|
||||||
case SharePointLibrary, SharePointLibraryItem:
|
case SharePointLibrary, SharePointLibraryItem:
|
||||||
return SharePointLibraryItem
|
return SharePointLibraryItem
|
||||||
|
case SharePointList, SharePointListItem:
|
||||||
|
return SharePointListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
return c
|
return c
|
||||||
@ -331,10 +384,19 @@ func (c sharePointCategory) isLeaf() bool {
|
|||||||
// [tenantID, service, siteID, category, folder, itemID]
|
// [tenantID, service, siteID, category, folder, itemID]
|
||||||
// => {spSite: siteID, spFolder: folder, spItemID: itemID}
|
// => {spSite: siteID, spFolder: folder, spItemID: itemID}
|
||||||
func (c sharePointCategory) pathValues(p path.Path) map[categorizer]string {
|
func (c sharePointCategory) pathValues(p path.Path) map[categorizer]string {
|
||||||
|
var folderCat, itemCat categorizer
|
||||||
|
|
||||||
|
switch c {
|
||||||
|
case SharePointLibrary, SharePointLibraryItem:
|
||||||
|
folderCat, itemCat = SharePointLibrary, SharePointLibraryItem
|
||||||
|
case SharePointList, SharePointListItem:
|
||||||
|
folderCat, itemCat = SharePointList, SharePointListItem
|
||||||
|
}
|
||||||
|
|
||||||
return map[categorizer]string{
|
return map[categorizer]string{
|
||||||
SharePointSite: p.ResourceOwner(),
|
SharePointSite: p.ResourceOwner(),
|
||||||
SharePointLibrary: p.Folder(),
|
folderCat: p.Folder(),
|
||||||
SharePointLibraryItem: p.Item(),
|
itemCat: p.Item(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,7 +468,9 @@ func (s SharePointScope) Get(cat sharePointCategory) []string {
|
|||||||
// sets a value by category to the scope. Only intended for internal use.
|
// sets a value by category to the scope. Only intended for internal use.
|
||||||
func (s SharePointScope) set(cat sharePointCategory, v []string, opts ...option) SharePointScope {
|
func (s SharePointScope) set(cat sharePointCategory, v []string, opts ...option) SharePointScope {
|
||||||
os := []option{}
|
os := []option{}
|
||||||
if cat == SharePointLibrary {
|
|
||||||
|
switch cat {
|
||||||
|
case SharePointLibrary, SharePointList:
|
||||||
os = append(os, pathComparator())
|
os = append(os, pathComparator())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,8 +483,12 @@ func (s SharePointScope) setDefaults() {
|
|||||||
case SharePointSite:
|
case SharePointSite:
|
||||||
s[SharePointLibrary.String()] = passAny
|
s[SharePointLibrary.String()] = passAny
|
||||||
s[SharePointLibraryItem.String()] = passAny
|
s[SharePointLibraryItem.String()] = passAny
|
||||||
|
s[SharePointList.String()] = passAny
|
||||||
|
s[SharePointListItem.String()] = passAny
|
||||||
case SharePointLibrary:
|
case SharePointLibrary:
|
||||||
s[SharePointLibraryItem.String()] = passAny
|
s[SharePointLibraryItem.String()] = passAny
|
||||||
|
case SharePointList:
|
||||||
|
s[SharePointListItem.String()] = passAny
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,7 +511,7 @@ func (s sharePoint) Reduce(ctx context.Context, deets *details.Details) *details
|
|||||||
s.Selector,
|
s.Selector,
|
||||||
map[path.CategoryType]sharePointCategory{
|
map[path.CategoryType]sharePointCategory{
|
||||||
path.LibrariesCategory: SharePointLibraryItem,
|
path.LibrariesCategory: SharePointLibraryItem,
|
||||||
// TODO: list category type
|
path.ListsCategory: SharePointListItem,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,7 +109,7 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Sites() {
|
|||||||
}
|
}
|
||||||
for _, test := range table {
|
for _, test := range table {
|
||||||
suite.T().Run(test.name, func(t *testing.T) {
|
suite.T().Run(test.name, func(t *testing.T) {
|
||||||
require.Len(t, test.scopesToCheck, 1)
|
require.Len(t, test.scopesToCheck, 2)
|
||||||
for _, scope := range test.scopesToCheck {
|
for _, scope := range test.scopesToCheck {
|
||||||
// Scope value is s1,s2
|
// Scope value is s1,s2
|
||||||
assert.Contains(t, join(s1, s2), scope[SharePointSite.String()].Target)
|
assert.Contains(t, join(s1, s2), scope[SharePointSite.String()].Target)
|
||||||
@ -129,7 +129,7 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Include_WebURLs() {
|
|||||||
|
|
||||||
sel.Include(sel.WebURL([]string{s1, s2}))
|
sel.Include(sel.WebURL([]string{s1, s2}))
|
||||||
scopes := sel.Includes
|
scopes := sel.Includes
|
||||||
require.Len(t, scopes, 1)
|
require.Len(t, scopes, 2)
|
||||||
|
|
||||||
for _, sc := range scopes {
|
for _, sc := range scopes {
|
||||||
scopeMustHave(
|
scopeMustHave(
|
||||||
@ -162,7 +162,7 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Include_WebURLs_any
|
|||||||
sel := NewSharePointRestore()
|
sel := NewSharePointRestore()
|
||||||
sel.Include(sel.WebURL(test.in))
|
sel.Include(sel.WebURL(test.in))
|
||||||
scopes := sel.Includes
|
scopes := sel.Includes
|
||||||
require.Len(t, scopes, 1)
|
require.Len(t, scopes, 2)
|
||||||
|
|
||||||
for _, sc := range scopes {
|
for _, sc := range scopes {
|
||||||
scopeMustHave(
|
scopeMustHave(
|
||||||
@ -186,7 +186,7 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Exclude_WebURLs() {
|
|||||||
|
|
||||||
sel.Exclude(sel.WebURL([]string{s1, s2}))
|
sel.Exclude(sel.WebURL([]string{s1, s2}))
|
||||||
scopes := sel.Excludes
|
scopes := sel.Excludes
|
||||||
require.Len(t, scopes, 1)
|
require.Len(t, scopes, 2)
|
||||||
|
|
||||||
for _, sc := range scopes {
|
for _, sc := range scopes {
|
||||||
scopeMustHave(
|
scopeMustHave(
|
||||||
@ -197,6 +197,8 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Exclude_WebURLs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSharePointselector_Include_Sites ensures that the scopes of
|
||||||
|
// SharePoint Libraries & SharePoint Lists are created.
|
||||||
func (suite *SharePointSelectorSuite) TestSharePointSelector_Include_Sites() {
|
func (suite *SharePointSelectorSuite) TestSharePointSelector_Include_Sites() {
|
||||||
t := suite.T()
|
t := suite.T()
|
||||||
sel := NewSharePointBackup()
|
sel := NewSharePointBackup()
|
||||||
@ -208,7 +210,7 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Include_Sites() {
|
|||||||
|
|
||||||
sel.Include(sel.Sites([]string{s1, s2}))
|
sel.Include(sel.Sites([]string{s1, s2}))
|
||||||
scopes := sel.Includes
|
scopes := sel.Includes
|
||||||
require.Len(t, scopes, 1)
|
require.Len(t, scopes, 2)
|
||||||
|
|
||||||
for _, sc := range scopes {
|
for _, sc := range scopes {
|
||||||
scopeMustHave(
|
scopeMustHave(
|
||||||
@ -230,7 +232,7 @@ func (suite *SharePointSelectorSuite) TestSharePointSelector_Exclude_Sites() {
|
|||||||
|
|
||||||
sel.Exclude(sel.Sites([]string{s1, s2}))
|
sel.Exclude(sel.Sites([]string{s1, s2}))
|
||||||
scopes := sel.Excludes
|
scopes := sel.Excludes
|
||||||
require.Len(t, scopes, 1)
|
require.Len(t, scopes, 2)
|
||||||
|
|
||||||
for _, sc := range scopes {
|
for _, sc := range scopes {
|
||||||
scopeMustHave(
|
scopeMustHave(
|
||||||
@ -352,18 +354,46 @@ func (suite *SharePointSelectorSuite) TestSharePointRestore_Reduce() {
|
|||||||
|
|
||||||
func (suite *SharePointSelectorSuite) TestSharePointCategory_PathValues() {
|
func (suite *SharePointSelectorSuite) TestSharePointCategory_PathValues() {
|
||||||
t := suite.T()
|
t := suite.T()
|
||||||
|
|
||||||
pathBuilder := path.Builder{}.Append("dir1", "dir2", "item")
|
pathBuilder := path.Builder{}.Append("dir1", "dir2", "item")
|
||||||
itemPath, err := pathBuilder.ToDataLayerSharePointPath("tenant", "site", path.LibrariesCategory, true)
|
ten := "tenant"
|
||||||
require.NoError(t, err)
|
site := "site"
|
||||||
|
table := []struct {
|
||||||
expected := map[categorizer]string{
|
name string
|
||||||
SharePointSite: "site",
|
sc sharePointCategory
|
||||||
SharePointLibrary: "dir1/dir2",
|
expected map[categorizer]string
|
||||||
SharePointLibraryItem: "item",
|
}{
|
||||||
|
{
|
||||||
|
name: "SharePoint Libraries",
|
||||||
|
sc: SharePointLibraryItem,
|
||||||
|
expected: map[categorizer]string{
|
||||||
|
SharePointSite: site,
|
||||||
|
SharePointLibrary: "dir1/dir2",
|
||||||
|
SharePointLibraryItem: "item",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SharePoint Lists",
|
||||||
|
sc: SharePointListItem,
|
||||||
|
expected: map[categorizer]string{
|
||||||
|
SharePointSite: site,
|
||||||
|
SharePointList: "dir1/dir2",
|
||||||
|
SharePointListItem: "item",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, expected, SharePointLibraryItem.pathValues(itemPath))
|
for _, test := range table {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
itemPath, err := pathBuilder.ToDataLayerSharePointPath(
|
||||||
|
ten,
|
||||||
|
site,
|
||||||
|
test.sc.PathType(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, test.sc.pathValues(itemPath))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SharePointSelectorSuite) TestSharePointScope_MatchesInfo() {
|
func (suite *SharePointSelectorSuite) TestSharePointScope_MatchesInfo() {
|
||||||
@ -418,6 +448,7 @@ func (suite *SharePointSelectorSuite) TestCategory_PathType() {
|
|||||||
{SharePointSite, path.UnknownCategory},
|
{SharePointSite, path.UnknownCategory},
|
||||||
{SharePointLibrary, path.LibrariesCategory},
|
{SharePointLibrary, path.LibrariesCategory},
|
||||||
{SharePointLibraryItem, path.LibrariesCategory},
|
{SharePointLibraryItem, path.LibrariesCategory},
|
||||||
|
{SharePointList, path.ListsCategory},
|
||||||
}
|
}
|
||||||
for _, test := range table {
|
for _, test := range table {
|
||||||
suite.T().Run(test.cat.String(), func(t *testing.T) {
|
suite.T().Run(test.cat.String(), func(t *testing.T) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user