mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-09-21 06:09:39 +08:00
feat: add project with class metadata
This commit is contained in:
@@ -31,6 +31,8 @@ func init() {
|
||||
cmd.List(&identity_options.ProjectListOptions{})
|
||||
cmd.Perform("user-metadata", &options.ResourceMetadataOptions{})
|
||||
cmd.Perform("set-user-metadata", &options.ResourceMetadataOptions{})
|
||||
cmd.Perform("class-metadata", &options.ResourceMetadataOptions{})
|
||||
cmd.Perform("set-class-metadata", &options.ResourceMetadataOptions{})
|
||||
cmd.GetProperty(&identity_options.ProjectGetPropertyTagValuePairOptions{})
|
||||
cmd.GetProperty(&identity_options.ProjectGetPropertyTagValueTreeOptions{})
|
||||
cmd.GetProperty(&identity_options.ProjectGetPropertyDomainTagValuePairOptions{})
|
||||
|
||||
@@ -497,32 +497,6 @@ func init() {
|
||||
return nil
|
||||
})
|
||||
|
||||
R(&options.ResourceMetadataOptions{}, "image-set-class-metadata", "set class metadata for image", func(s *mcclient.ClientSession, opts *options.ResourceMetadataOptions) error {
|
||||
params, err := opts.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
img, err := modules.Images.PerformAction(s, opts.ID, "set-class-metadata", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(img)
|
||||
return nil
|
||||
})
|
||||
|
||||
R(&options.ResourceMetadataOptions{}, "image-class-metadata", "set class metadata for image", func(s *mcclient.ClientSession, opts *options.ResourceMetadataOptions) error {
|
||||
params, err := opts.Params()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
img, err := modules.Images.PerformAction(s, opts.ID, "class-metadata", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(img)
|
||||
return nil
|
||||
})
|
||||
|
||||
type ImageProbeOptions struct {
|
||||
ID string `help:"ID or name of image to probe"`
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ func (model *SStandaloneAnonResourceBase) SetClassMetadataValues(ctx context.Con
|
||||
return nil
|
||||
}
|
||||
|
||||
func (model *SStandaloneAnonResourceBase) SetClassMetadataAll(ctx context.Context, dictstore map[string]interface{}, userCred mcclient.TokenCredential) error {
|
||||
func (model *SStandaloneAnonResourceBase) SetClassMetadataAll(ctx context.Context, dictstore map[string]string, userCred mcclient.TokenCredential) error {
|
||||
afterCheck := make(map[string]interface{}, len(dictstore))
|
||||
for k, v := range dictstore {
|
||||
if !strings.HasPrefix(k, CLASS_TAG_PREFIX) {
|
||||
@@ -308,19 +308,19 @@ func (model *SStandaloneAnonResourceBase) Inherit(ctx context.Context, sonModel
|
||||
return nil
|
||||
}
|
||||
userCred := auth.AdminCredential()
|
||||
dictstore := make(map[string]interface{}, 0)
|
||||
for k, v := range metadata {
|
||||
dictstore[k] = v
|
||||
}
|
||||
return sonModel.SetClassMetadataAll(ctx, dictstore, userCred)
|
||||
return sonModel.SetClassMetadataAll(ctx, metadata, userCred)
|
||||
}
|
||||
|
||||
func (model *SStandaloneAnonResourceBase) IsInSameClass(ctx context.Context, pModel *SStandaloneAnonResourceBase) (bool, error) {
|
||||
pureTags, err := model.GetAllClassMetadata()
|
||||
type IClassMetadataOwner interface {
|
||||
GetAllClassMetadata() (map[string]string, error)
|
||||
}
|
||||
|
||||
func IsInSameClass(ctx context.Context, cmo1, cmo2 IClassMetadataOwner) (bool, error) {
|
||||
pureTags, err := cmo1.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "GetAllPureMetadata")
|
||||
}
|
||||
pureTagsP, err := pModel.GetAllClassMetadata()
|
||||
pureTagsP, err := cmo2.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "GetAllPureMetadata")
|
||||
}
|
||||
@@ -335,6 +335,10 @@ func (model *SStandaloneAnonResourceBase) IsInSameClass(ctx context.Context, pMo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (model *SStandaloneAnonResourceBase) IsInSameClass(ctx context.Context, pModel *SStandaloneAnonResourceBase) (bool, error) {
|
||||
return IsInSameClass(ctx, model, pModel)
|
||||
}
|
||||
|
||||
func (model *SStandaloneAnonResourceBase) SetSysCloudMetadataAll(ctx context.Context, dictstore map[string]interface{}, userCred mcclient.TokenCredential) error {
|
||||
err := Metadata.SetAll(ctx, model, dictstore, userCred, SYS_CLOUD_TAG_PREFIX)
|
||||
if err != nil {
|
||||
@@ -481,7 +485,7 @@ func (model *SStandaloneAnonResourceBase) PerformClassMetadata(ctx context.Conte
|
||||
|
||||
// 全量替换资源的所有 class 标签
|
||||
func (model *SStandaloneAnonResourceBase) PerformSetClassMetadata(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformSetClassMetadataInput) (jsonutils.JSONObject, error) {
|
||||
dictStore := make(map[string]interface{})
|
||||
dictStore := make(map[string]string)
|
||||
for k, v := range input {
|
||||
if len(k) > 64-len(CLASS_TAG_PREFIX) {
|
||||
return nil, httperrors.NewInputParameterError("input key too long > %d", 64-len(CLASS_TAG_PREFIX))
|
||||
|
||||
@@ -282,6 +282,18 @@ func (model *SVirtualResourceBase) CustomizeCreate(ctx context.Context, userCred
|
||||
return model.SStatusStandaloneResourceBase.CustomizeCreate(ctx, userCred, ownerId, query, data)
|
||||
}
|
||||
|
||||
func (model *SVirtualResourceBase) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
||||
project, err := model.GetTenantCache(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("unable to GetTenantCache: %s", err.Error())
|
||||
return
|
||||
}
|
||||
err = project.Inherit(ctx, &model.SStandaloneAnonResourceBase)
|
||||
if err != nil {
|
||||
log.Errorf("unable to inherit class metadata from poject %s: %s", project.GetId(), err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SVirtualResourceBaseManager) FetchCustomizeColumns(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
@@ -416,6 +428,27 @@ func (model *SVirtualResourceBase) PerformChangeOwner(ctx context.Context, userC
|
||||
log.Warningf("tenant_id %s not found", model.ProjectId)
|
||||
formerObj := NewTenant(model.ProjectId, "unknown", model.DomainId, "unknown")
|
||||
former = &formerObj
|
||||
} else {
|
||||
// check fromer's class metadata
|
||||
cm, err := former.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to GetAllClassMetadata")
|
||||
}
|
||||
if len(cm) > 0 {
|
||||
return nil, httperrors.NewForbiddenError("can't change owner for resource in project with class metadata")
|
||||
}
|
||||
}
|
||||
|
||||
toer, err := TenantCacheManager.FetchTenantById(ctx, ownerId.GetProjectId())
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "unable to get project %s", ownerId.GetProjectId())
|
||||
}
|
||||
toCm, err := toer.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to GetAllClassMetadata")
|
||||
}
|
||||
if model.Keyword() != "image" && len(toCm) > 0 {
|
||||
return nil, httperrors.NewForbiddenError("can't change resource's owner as that in project with class metadata")
|
||||
}
|
||||
|
||||
// clean shared projects before update project id
|
||||
@@ -461,6 +494,13 @@ func (model *SVirtualResourceBase) PerformChangeOwner(ctx context.Context, userC
|
||||
NewDomainId: ownerId.GetProjectDomainId(),
|
||||
NewDomain: ownerId.GetProjectDomain(),
|
||||
}
|
||||
|
||||
// set class metadata
|
||||
|
||||
err = model.SetClassMetadataAll(ctx, toCm, userCred)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to SetClassMetadataAll")
|
||||
}
|
||||
logclient.AddActionLogWithContext(ctx, model, logclient.ACT_CHANGE_OWNER, notes, userCred, true)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -216,11 +216,7 @@ func (self *SCachedimage) syncClassMetadata(ctx context.Context, userCred mcclie
|
||||
return err
|
||||
}
|
||||
|
||||
cm := make(map[string]interface{}, len(classMetadata))
|
||||
for k, v := range classMetadata {
|
||||
cm[k] = v
|
||||
}
|
||||
return self.SetClassMetadataAll(ctx, cm, userCred)
|
||||
return self.SetClassMetadataAll(ctx, classMetadata, userCred)
|
||||
}
|
||||
|
||||
func (manager *SCachedimageManager) cacheGlanceImageInfo(ctx context.Context, userCred mcclient.TokenCredential, info jsonutils.JSONObject) (*SCachedimage, error) {
|
||||
@@ -317,6 +313,28 @@ func (image *SCachedimage) GetStorages() ([]SStorage, error) {
|
||||
return storages, nil
|
||||
}
|
||||
|
||||
func (manager *SCachedimageManager) GetCachedimageById(ctx context.Context, imageId string) (*SCachedimage, error) {
|
||||
img, err := manager.FetchById(imageId)
|
||||
if err == nil {
|
||||
return img.(*SCachedimage), nil
|
||||
}
|
||||
if errors.Cause(err) != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
s := auth.GetAdminSession(ctx, options.Options.Region, "")
|
||||
obj, err := image.Images.Get(s, imageId, nil)
|
||||
if err != nil {
|
||||
log.Errorf("GetImageById %s error %s", imageId, err)
|
||||
return nil, errors.Wrap(err, "modules.Images.Get")
|
||||
}
|
||||
userCred := s.GetToken()
|
||||
cachedImage, err := manager.cacheGlanceImageInfo(ctx, userCred, obj)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "manager.cacheGlanceImageInfo")
|
||||
}
|
||||
return cachedImage, nil
|
||||
}
|
||||
|
||||
func (manager *SCachedimageManager) GetImageById(ctx context.Context, userCred mcclient.TokenCredential, imageId string, refresh bool) (*cloudprovider.SImage, error) {
|
||||
imgObj, _ := manager.FetchById(imageId)
|
||||
if imgObj != nil {
|
||||
|
||||
@@ -5271,10 +5271,6 @@ func (self *SGuest) OnScheduleToHost(ctx context.Context, userCred mcclient.Toke
|
||||
db.OpsLog.LogEvent(self, db.ACT_SCHEDULE, notes, userCred)
|
||||
|
||||
host, _ := self.GetHost()
|
||||
err = host.Inherit(ctx, &self.SStandaloneAnonResourceBase)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unable to inherit from host %s to guest %s", host.GetId(), self.GetId())
|
||||
}
|
||||
return host.ClearSchedDescCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ package predicates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"yunion.io/x/pkg/errors"
|
||||
@@ -28,12 +27,23 @@ import (
|
||||
|
||||
type ClassMetadataPredicate struct {
|
||||
BasePredicate
|
||||
cacheImage *models.SCachedimage
|
||||
//TODO GuestImage
|
||||
snapshot *models.SSnapshot
|
||||
instanceSnapshot *models.SInstanceSnapshot
|
||||
backup *models.SDiskBackup
|
||||
instanceBackup *models.SInstanceBackup
|
||||
|
||||
guestSource *ResourceWithClassMetadata
|
||||
tenant *ResourceWithClassMetadata
|
||||
}
|
||||
|
||||
type ResourceWithClassMetadata struct {
|
||||
keyword string
|
||||
name string
|
||||
classMedata map[string]string
|
||||
}
|
||||
|
||||
func (rcm *ResourceWithClassMetadata) GetAllClassMetadata() (map[string]string, error) {
|
||||
return rcm.classMedata, nil
|
||||
}
|
||||
|
||||
func (rcm *ResourceWithClassMetadata) GetDescription() string {
|
||||
return fmt.Sprintf("%s %s", rcm.keyword, rcm.name)
|
||||
}
|
||||
|
||||
func (p *ClassMetadataPredicate) Name() string {
|
||||
@@ -42,95 +52,94 @@ func (p *ClassMetadataPredicate) Name() string {
|
||||
|
||||
func (p *ClassMetadataPredicate) Clone() core.FitPredicate {
|
||||
return &ClassMetadataPredicate{
|
||||
cacheImage: p.cacheImage,
|
||||
snapshot: p.snapshot,
|
||||
instanceSnapshot: p.instanceSnapshot,
|
||||
backup: p.backup,
|
||||
instanceBackup: p.instanceBackup,
|
||||
guestSource: p.guestSource,
|
||||
tenant: p.tenant,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ClassMetadataPredicate) PreExecute(u *core.Unit, cs []core.Candidater) (bool, error) {
|
||||
info := u.SchedData()
|
||||
if len(info.InstanceSnapshotId) > 0 {
|
||||
tenant, err := db.TenantCacheManager.FetchTenantById(context.Background(), info.Project)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to fetch tenant %s", info.Project)
|
||||
}
|
||||
tcm, err := tenant.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to GetAllClassMetadata of project %s", info.Project)
|
||||
}
|
||||
p.tenant = &ResourceWithClassMetadata{
|
||||
classMedata: tcm,
|
||||
keyword: tenant.Keyword(),
|
||||
name: tenant.GetName(),
|
||||
}
|
||||
|
||||
// guest source
|
||||
guestSource := &ResourceWithClassMetadata{}
|
||||
disks := info.Disks
|
||||
var stand *db.SStandaloneAnonResourceBase
|
||||
// TODO GuestImage
|
||||
switch {
|
||||
case len(info.InstanceBackupId) > 0:
|
||||
obj, err := models.InstanceBackupManager.FetchById(info.InstanceBackupId)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to fetch instanceBackup %s", info.InstanceSnapshotId)
|
||||
}
|
||||
stand = &obj.(*models.SInstanceBackup).SStandaloneAnonResourceBase
|
||||
case len(info.InstanceSnapshotId) > 0:
|
||||
obj, err := models.InstanceSnapshotManager.FetchById(info.InstanceSnapshotId)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to fetch instanceSnapshot %s", info.InstanceSnapshotId)
|
||||
}
|
||||
p.instanceSnapshot = obj.(*models.SInstanceSnapshot)
|
||||
return true, nil
|
||||
}
|
||||
if len(info.InstanceBackupId) > 0 {
|
||||
obj, err := models.InstanceBackupManager.FetchById(info.InstanceBackupId)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to fetch instanceBackup %s", info.InstanceBackupId)
|
||||
}
|
||||
p.instanceBackup = obj.(*models.SInstanceBackup)
|
||||
return true, nil
|
||||
}
|
||||
disks := info.Disks
|
||||
if len(disks) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
switch {
|
||||
stand = &obj.(*models.SInstanceSnapshot).SStandaloneAnonResourceBase
|
||||
case len(disks) == 0:
|
||||
case disks[0].ImageId != "":
|
||||
obj, err := models.CachedimageManager.FetchById(disks[0].ImageId)
|
||||
obj, err := models.CachedimageManager.GetCachedimageById(context.Background(), disks[0].ImageId)
|
||||
if err != nil {
|
||||
// 忽略第一次上传到glance镜像后未缓存的记录
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrapf(err, "unable to fetch cachedimage %s", disks[0].ImageId)
|
||||
}
|
||||
p.cacheImage = obj.(*models.SCachedimage)
|
||||
return true, nil
|
||||
stand = &obj.SStandaloneAnonResourceBase
|
||||
guestSource.keyword = "image"
|
||||
case disks[0].SnapshotId != "":
|
||||
obj, err := models.SnapshotManager.FetchById(disks[0].SnapshotId)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to fetch snapshot %s", disks[0].SnapshotId)
|
||||
}
|
||||
p.snapshot = obj.(*models.SSnapshot)
|
||||
return true, nil
|
||||
stand = &obj.(*models.SSnapshot).SStandaloneAnonResourceBase
|
||||
case disks[0].BackupId != "":
|
||||
obj, err := models.DiskBackupManager.FetchById(disks[0].BackupId)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to fetch diskbackup %s", disks[0].BackupId)
|
||||
}
|
||||
p.backup = obj.(*models.SDiskBackup)
|
||||
stand = &obj.(*models.SDiskBackup).SStandaloneAnonResourceBase
|
||||
}
|
||||
if stand == nil {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
cm, err := stand.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "unable to GetAllClassMetadata %s", stand.GetId())
|
||||
}
|
||||
guestSource.classMedata = cm
|
||||
if guestSource.keyword == "" {
|
||||
guestSource.keyword = stand.Keyword()
|
||||
}
|
||||
guestSource.name = stand.GetName()
|
||||
p.guestSource = guestSource
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *ClassMetadataPredicate) Execute(u *core.Unit, c core.Candidater) (bool, []core.PredicateFailureReason, error) {
|
||||
h := NewPredicateHelper(p, u, c)
|
||||
host := c.Getter().Host()
|
||||
ctx := context.Background()
|
||||
resourceDesc := ""
|
||||
var sara *db.SStandaloneAnonResourceBase
|
||||
switch {
|
||||
case p.cacheImage != nil:
|
||||
sara = &p.cacheImage.SStandaloneAnonResourceBase
|
||||
resourceDesc = fmt.Sprintf("image %s", p.cacheImage.GetName())
|
||||
case p.snapshot != nil:
|
||||
sara = &p.snapshot.SStandaloneAnonResourceBase
|
||||
resourceDesc = fmt.Sprintf("snapshot %s", p.snapshot.GetName())
|
||||
case p.backup != nil:
|
||||
sara = &p.backup.SStandaloneAnonResourceBase
|
||||
resourceDesc = fmt.Sprintf("backup %s", p.backup.GetName())
|
||||
case p.instanceBackup != nil:
|
||||
sara = &p.instanceBackup.SStandaloneAnonResourceBase
|
||||
resourceDesc = fmt.Sprintf("instance backup %s", p.instanceBackup.GetName())
|
||||
case p.instanceSnapshot != nil:
|
||||
sara = &p.instanceSnapshot.SStandaloneAnonResourceBase
|
||||
resourceDesc = fmt.Sprintf("instance snapshot %s", p.instanceSnapshot.GetName())
|
||||
}
|
||||
ic, err := host.IsInSameClass(ctx, sara)
|
||||
if err != nil {
|
||||
return false, nil, errors.Wrap(err, "unable to determine whether they are in a class")
|
||||
}
|
||||
if !ic {
|
||||
h.Exclude(fmt.Sprintf("The host doesn't have the same class metadata as the choosen %s.", resourceDesc))
|
||||
for _, resource := range []*ResourceWithClassMetadata{p.tenant, p.guestSource} {
|
||||
ic, err := db.IsInSameClass(ctx, c.Getter(), resource)
|
||||
if err != nil {
|
||||
return false, nil, errors.Wrap(err, "unable to determine whether they are in a class")
|
||||
}
|
||||
if !ic {
|
||||
h.Exclude(fmt.Sprintf("The host doesn't have the same class metadata as the choosen %s.", resource.GetDescription()))
|
||||
break
|
||||
}
|
||||
}
|
||||
return h.GetResult()
|
||||
}
|
||||
|
||||
+19
@@ -56,6 +56,8 @@ type BaseHostDesc struct {
|
||||
|
||||
SharedDomains []string `json:"shared_domains"`
|
||||
PendingUsage map[string]interface{} `json:"pending_usage"`
|
||||
|
||||
ClassMetadata map[string]string `json:"class_metadata"`
|
||||
}
|
||||
|
||||
type baseHostGetter struct {
|
||||
@@ -148,6 +150,10 @@ func (b baseHostGetter) InstanceGroups() map[string]*api.CandidateGroup {
|
||||
return b.h.InstanceGroups
|
||||
}
|
||||
|
||||
func (b baseHostGetter) GetAllClassMetadata() (map[string]string, error) {
|
||||
return b.h.ClassMetadata, nil
|
||||
}
|
||||
|
||||
func (b baseHostGetter) GetFreeGroupCount(groupId string) (int, error) {
|
||||
// Must Be
|
||||
scg, ok := b.h.InstanceGroups[groupId]
|
||||
@@ -333,6 +339,10 @@ func newBaseHostDesc(b *baseBuilder, host *computemodels.SHost) (*BaseHostDesc,
|
||||
return nil, fmt.Errorf("Fill instance group error: %v", err)
|
||||
}
|
||||
|
||||
if err := desc.fillClassMetadata(host); err != nil {
|
||||
return nil, fmt.Errorf("Fill class metadata error: %v", err)
|
||||
}
|
||||
|
||||
if err := desc.fillIpmiInfo(host); err != nil {
|
||||
return nil, fmt.Errorf("Fill ipmi info error: %v", err)
|
||||
}
|
||||
@@ -674,6 +684,15 @@ func (b *BaseHostDesc) fillInstanceGroups(host *computemodels.SHost) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BaseHostDesc) fillClassMetadata(host *computemodels.SHost) error {
|
||||
cm, err := host.GetAllClassMetadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.ClassMetadata = cm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BaseHostDesc) fillIpmiInfo(host *computemodels.SHost) error {
|
||||
info, err := host.GetIpmiInfo()
|
||||
if err != nil {
|
||||
|
||||
@@ -106,6 +106,8 @@ type CandidatePropertyGetter interface {
|
||||
InstanceGroups() map[string]*api.CandidateGroup
|
||||
GetFreeGroupCount(groupId string) (int, error)
|
||||
|
||||
GetAllClassMetadata() (map[string]string, error)
|
||||
|
||||
GetIpmiInfo() types.SIPMIInfo
|
||||
|
||||
GetNics() []*types.SNic
|
||||
|
||||
Reference in New Issue
Block a user