mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Path filter for scenes and galleries (#834)
This commit is contained in:
@@ -64,6 +64,8 @@ input SceneMarkerFilterType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input SceneFilterType {
|
input SceneFilterType {
|
||||||
|
"""Filter by path"""
|
||||||
|
path: StringCriterionInput
|
||||||
"""Filter by rating"""
|
"""Filter by rating"""
|
||||||
rating: IntCriterionInput
|
rating: IntCriterionInput
|
||||||
"""Filter by o-counter"""
|
"""Filter by o-counter"""
|
||||||
@@ -101,6 +103,8 @@ input StudioFilterType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input GalleryFilterType {
|
input GalleryFilterType {
|
||||||
|
"""Filter by path"""
|
||||||
|
path: StringCriterionInput
|
||||||
"""Filter to only include galleries missing this property"""
|
"""Filter to only include galleries missing this property"""
|
||||||
is_missing: String
|
is_missing: String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
|
|||||||
query.addArg(thisArgs...)
|
query.addArg(thisArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query.handleStringCriterionInput(galleryFilter.Path, "galleries.path")
|
||||||
|
|
||||||
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||||
switch *isMissingFilter {
|
switch *isMissingFilter {
|
||||||
case "scene":
|
case "scene":
|
||||||
|
|||||||
@@ -125,6 +125,34 @@ func galleryQueryQ(t *testing.T, qb models.GalleryQueryBuilder, q string, expect
|
|||||||
assert.Len(t, galleries, totalGalleries)
|
assert.Len(t, galleries, totalGalleries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGalleryQueryPath(t *testing.T) {
|
||||||
|
const galleryIdx = 1
|
||||||
|
galleryPath := getGalleryStringValue(galleryIdx, "Path")
|
||||||
|
|
||||||
|
pathCriterion := models.StringCriterionInput{
|
||||||
|
Value: galleryPath,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyGalleriesPath(t, pathCriterion)
|
||||||
|
|
||||||
|
pathCriterion.Modifier = models.CriterionModifierNotEquals
|
||||||
|
verifyGalleriesPath(t, pathCriterion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyGalleriesPath(t *testing.T, pathCriterion models.StringCriterionInput) {
|
||||||
|
sqb := models.NewGalleryQueryBuilder()
|
||||||
|
galleryFilter := models.GalleryFilterType{
|
||||||
|
Path: &pathCriterion,
|
||||||
|
}
|
||||||
|
|
||||||
|
galleries, _ := sqb.Query(&galleryFilter, nil)
|
||||||
|
|
||||||
|
for _, gallery := range galleries {
|
||||||
|
verifyString(t, gallery.Path, pathCriterion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGalleryQueryIsMissingScene(t *testing.T) {
|
func TestGalleryQueryIsMissingScene(t *testing.T) {
|
||||||
qb := models.NewGalleryQueryBuilder()
|
qb := models.NewGalleryQueryBuilder()
|
||||||
isMissing := "scene"
|
isMissing := "scene"
|
||||||
|
|||||||
@@ -206,18 +206,18 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleStringCriterion(tableName+".ethnicity", performerFilter.Ethnicity, &query)
|
query.handleStringCriterionInput(performerFilter.Ethnicity, tableName+".ethnicity")
|
||||||
handleStringCriterion(tableName+".country", performerFilter.Country, &query)
|
query.handleStringCriterionInput(performerFilter.Country, tableName+".country")
|
||||||
handleStringCriterion(tableName+".eye_color", performerFilter.EyeColor, &query)
|
query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color")
|
||||||
handleStringCriterion(tableName+".height", performerFilter.Height, &query)
|
query.handleStringCriterionInput(performerFilter.Height, tableName+".height")
|
||||||
handleStringCriterion(tableName+".measurements", performerFilter.Measurements, &query)
|
query.handleStringCriterionInput(performerFilter.Measurements, tableName+".measurements")
|
||||||
handleStringCriterion(tableName+".fake_tits", performerFilter.FakeTits, &query)
|
query.handleStringCriterionInput(performerFilter.FakeTits, tableName+".fake_tits")
|
||||||
handleStringCriterion(tableName+".career_length", performerFilter.CareerLength, &query)
|
query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length")
|
||||||
handleStringCriterion(tableName+".tattoos", performerFilter.Tattoos, &query)
|
query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos")
|
||||||
handleStringCriterion(tableName+".piercings", performerFilter.Piercings, &query)
|
query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings")
|
||||||
|
|
||||||
// TODO - need better handling of aliases
|
// TODO - need better handling of aliases
|
||||||
handleStringCriterion(tableName+".aliases", performerFilter.Aliases, &query)
|
query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases")
|
||||||
|
|
||||||
query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter)
|
query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter)
|
||||||
idsResult, countResult := query.executeFind()
|
idsResult, countResult := query.executeFind()
|
||||||
@@ -231,27 +231,6 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin
|
|||||||
return performers, countResult
|
return performers, countResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStringCriterion(column string, value *StringCriterionInput, query *queryBuilder) {
|
|
||||||
if value != nil {
|
|
||||||
if modifier := value.Modifier.String(); value.Modifier.IsValid() {
|
|
||||||
switch modifier {
|
|
||||||
case "EQUALS":
|
|
||||||
clause, thisArgs := getSearchBinding([]string{column}, value.Value, false)
|
|
||||||
query.addWhere(clause)
|
|
||||||
query.addArg(thisArgs...)
|
|
||||||
case "NOT_EQUALS":
|
|
||||||
clause, thisArgs := getSearchBinding([]string{column}, value.Value, true)
|
|
||||||
query.addWhere(clause)
|
|
||||||
query.addArg(thisArgs...)
|
|
||||||
case "IS_NULL":
|
|
||||||
query.addWhere(column + " IS NULL")
|
|
||||||
case "NOT_NULL":
|
|
||||||
query.addWhere(column + " IS NOT NULL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBirthYearFilterClause(criterionModifier CriterionModifier, value int) ([]string, []interface{}) {
|
func getBirthYearFilterClause(criterionModifier CriterionModifier, value int) ([]string, []interface{}) {
|
||||||
var clauses []string
|
var clauses []string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|||||||
@@ -312,21 +312,9 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
|
|||||||
query.addArg(thisArgs...)
|
query.addArg(thisArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rating := sceneFilter.Rating; rating != nil {
|
query.handleStringCriterionInput(sceneFilter.Path, "scenes.path")
|
||||||
clause, count := getIntCriterionWhereClause("scenes.rating", *sceneFilter.Rating)
|
query.handleIntCriterionInput(sceneFilter.Rating, "scenes.rating")
|
||||||
query.addWhere(clause)
|
query.handleIntCriterionInput(sceneFilter.OCounter, "scenes.o_counter")
|
||||||
if count == 1 {
|
|
||||||
query.addArg(sceneFilter.Rating.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if oCounter := sceneFilter.OCounter; oCounter != nil {
|
|
||||||
clause, count := getIntCriterionWhereClause("scenes.o_counter", *sceneFilter.OCounter)
|
|
||||||
query.addWhere(clause)
|
|
||||||
if count == 1 {
|
|
||||||
query.addArg(sceneFilter.OCounter.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if durationFilter := sceneFilter.Duration; durationFilter != nil {
|
if durationFilter := sceneFilter.Duration; durationFilter != nil {
|
||||||
clause, thisArgs := getDurationWhereClause(*durationFilter)
|
clause, thisArgs := getDurationWhereClause(*durationFilter)
|
||||||
|
|||||||
@@ -135,6 +135,62 @@ func sceneQueryQ(t *testing.T, sqb models.SceneQueryBuilder, q string, expectedS
|
|||||||
assert.Len(t, scenes, totalScenes)
|
assert.Len(t, scenes, totalScenes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSceneQueryPath(t *testing.T) {
|
||||||
|
const sceneIdx = 1
|
||||||
|
scenePath := getSceneStringValue(sceneIdx, "Path")
|
||||||
|
|
||||||
|
pathCriterion := models.StringCriterionInput{
|
||||||
|
Value: scenePath,
|
||||||
|
Modifier: models.CriterionModifierEquals,
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyScenesPath(t, pathCriterion)
|
||||||
|
|
||||||
|
pathCriterion.Modifier = models.CriterionModifierNotEquals
|
||||||
|
verifyScenesPath(t, pathCriterion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) {
|
||||||
|
sqb := models.NewSceneQueryBuilder()
|
||||||
|
sceneFilter := models.SceneFilterType{
|
||||||
|
Path: &pathCriterion,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenes, _ := sqb.Query(&sceneFilter, nil)
|
||||||
|
|
||||||
|
for _, scene := range scenes {
|
||||||
|
verifyString(t, scene.Path, pathCriterion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyNullString(t *testing.T, value sql.NullString, criterion models.StringCriterionInput) {
|
||||||
|
t.Helper()
|
||||||
|
assert := assert.New(t)
|
||||||
|
if criterion.Modifier == models.CriterionModifierIsNull {
|
||||||
|
assert.False(value.Valid, "expect is null values to be null")
|
||||||
|
}
|
||||||
|
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||||
|
assert.True(value.Valid, "expect is null values to be null")
|
||||||
|
}
|
||||||
|
if criterion.Modifier == models.CriterionModifierEquals {
|
||||||
|
assert.Equal(criterion.Value, value.String)
|
||||||
|
}
|
||||||
|
if criterion.Modifier == models.CriterionModifierNotEquals {
|
||||||
|
assert.NotEqual(criterion.Value, value.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyString(t *testing.T, value string, criterion models.StringCriterionInput) {
|
||||||
|
t.Helper()
|
||||||
|
assert := assert.New(t)
|
||||||
|
if criterion.Modifier == models.CriterionModifierEquals {
|
||||||
|
assert.Equal(criterion.Value, value)
|
||||||
|
}
|
||||||
|
if criterion.Modifier == models.CriterionModifierNotEquals {
|
||||||
|
assert.NotEqual(criterion.Value, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSceneQueryRating(t *testing.T) {
|
func TestSceneQueryRating(t *testing.T) {
|
||||||
const rating = 3
|
const rating = 3
|
||||||
ratingCriterion := models.IntCriterionInput{
|
ratingCriterion := models.IntCriterionInput{
|
||||||
|
|||||||
@@ -48,6 +48,37 @@ func (qb *queryBuilder) addArg(args ...interface{}) {
|
|||||||
qb.args = append(qb.args, args...)
|
qb.args = append(qb.args, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (qb *queryBuilder) handleIntCriterionInput(c *IntCriterionInput, column string) {
|
||||||
|
if c != nil {
|
||||||
|
clause, count := getIntCriterionWhereClause(column, *c)
|
||||||
|
qb.addWhere(clause)
|
||||||
|
if count == 1 {
|
||||||
|
qb.addArg(c.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qb *queryBuilder) handleStringCriterionInput(c *StringCriterionInput, column string) {
|
||||||
|
if c != nil {
|
||||||
|
if modifier := c.Modifier.String(); c.Modifier.IsValid() {
|
||||||
|
switch modifier {
|
||||||
|
case "EQUALS":
|
||||||
|
clause, thisArgs := getSearchBinding([]string{column}, c.Value, false)
|
||||||
|
qb.addWhere(clause)
|
||||||
|
qb.addArg(thisArgs...)
|
||||||
|
case "NOT_EQUALS":
|
||||||
|
clause, thisArgs := getSearchBinding([]string{column}, c.Value, true)
|
||||||
|
qb.addWhere(clause)
|
||||||
|
qb.addArg(thisArgs...)
|
||||||
|
case "IS_NULL":
|
||||||
|
qb.addWhere(column + " IS NULL")
|
||||||
|
case "NOT_NULL":
|
||||||
|
qb.addWhere(column + " IS NOT NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var randomSortFloat = rand.Float64()
|
var randomSortFloat = rand.Float64()
|
||||||
|
|
||||||
func selectAll(tableName string) string {
|
func selectAll(tableName string) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Add selective scene export.
|
* Add selective scene export.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Add path filter to scene and gallery query.
|
||||||
* Add button to hide left panel on scene page.
|
* Add button to hide left panel on scene page.
|
||||||
* Add link to parent studio in studio page.
|
* Add link to parent studio in studio page.
|
||||||
* Add missing scenes movie filter.
|
* Add missing scenes movie filter.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ILabeledId, ILabeledValue, IOptionType } from "../types";
|
|||||||
|
|
||||||
export type CriterionType =
|
export type CriterionType =
|
||||||
| "none"
|
| "none"
|
||||||
|
| "path"
|
||||||
| "rating"
|
| "rating"
|
||||||
| "o_counter"
|
| "o_counter"
|
||||||
| "resolution"
|
| "resolution"
|
||||||
@@ -48,6 +49,8 @@ export abstract class Criterion {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "none":
|
case "none":
|
||||||
return "None";
|
return "None";
|
||||||
|
case "path":
|
||||||
|
return "Path";
|
||||||
case "rating":
|
case "rating":
|
||||||
return "Rating";
|
return "Rating";
|
||||||
case "o_counter":
|
case "o_counter":
|
||||||
@@ -237,10 +240,10 @@ export class StringCriterion extends Criterion {
|
|||||||
public parameterName: string;
|
public parameterName: string;
|
||||||
public modifier = CriterionModifier.Equals;
|
public modifier = CriterionModifier.Equals;
|
||||||
public modifierOptions = [
|
public modifierOptions = [
|
||||||
Criterion.getModifierOption(CriterionModifier.Equals),
|
StringCriterion.getModifierOption(CriterionModifier.Equals),
|
||||||
Criterion.getModifierOption(CriterionModifier.NotEquals),
|
StringCriterion.getModifierOption(CriterionModifier.NotEquals),
|
||||||
Criterion.getModifierOption(CriterionModifier.IsNull),
|
StringCriterion.getModifierOption(CriterionModifier.IsNull),
|
||||||
Criterion.getModifierOption(CriterionModifier.NotNull),
|
StringCriterion.getModifierOption(CriterionModifier.NotNull),
|
||||||
];
|
];
|
||||||
public options: string[] | undefined;
|
public options: string[] | undefined;
|
||||||
public value: string = "";
|
public value: string = "";
|
||||||
@@ -262,6 +265,43 @@ export class StringCriterion extends Criterion {
|
|||||||
this.parameterName = type;
|
this.parameterName = type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static getModifierOption(
|
||||||
|
modifier: CriterionModifier = CriterionModifier.Equals
|
||||||
|
): ILabeledValue {
|
||||||
|
switch (modifier) {
|
||||||
|
case CriterionModifier.Equals:
|
||||||
|
return { value: CriterionModifier.Equals, label: "Includes" };
|
||||||
|
case CriterionModifier.NotEquals:
|
||||||
|
return { value: CriterionModifier.NotEquals, label: "Excludes" };
|
||||||
|
default:
|
||||||
|
return super.getModifierOption(modifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLabel(): string {
|
||||||
|
let modifierString: string;
|
||||||
|
switch (this.modifier) {
|
||||||
|
case CriterionModifier.Equals:
|
||||||
|
modifierString = "includes";
|
||||||
|
break;
|
||||||
|
case CriterionModifier.NotEquals:
|
||||||
|
modifierString = "excludes";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return this.getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueString = this.getLabelValue();
|
||||||
|
return `${Criterion.getLabel(this.type)} ${modifierString} ${valueString}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MandatoryStringCriterion extends StringCriterion {
|
||||||
|
public modifierOptions = [
|
||||||
|
StringCriterion.getModifierOption(CriterionModifier.Equals),
|
||||||
|
StringCriterion.getModifierOption(CriterionModifier.NotEquals),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NumberCriterion extends Criterion {
|
export class NumberCriterion extends Criterion {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
StringCriterion,
|
StringCriterion,
|
||||||
NumberCriterion,
|
NumberCriterion,
|
||||||
DurationCriterion,
|
DurationCriterion,
|
||||||
|
MandatoryStringCriterion,
|
||||||
} from "./criterion";
|
} from "./criterion";
|
||||||
import { FavoriteCriterion } from "./favorite";
|
import { FavoriteCriterion } from "./favorite";
|
||||||
import { HasMarkersCriterion } from "./has-markers";
|
import { HasMarkersCriterion } from "./has-markers";
|
||||||
@@ -30,6 +31,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "none":
|
case "none":
|
||||||
return new NoneCriterion();
|
return new NoneCriterion();
|
||||||
|
case "path":
|
||||||
|
return new MandatoryStringCriterion(type, type);
|
||||||
case "rating":
|
case "rating":
|
||||||
return new RatingCriterion();
|
return new RatingCriterion();
|
||||||
case "o_counter":
|
case "o_counter":
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
NumberCriterion,
|
NumberCriterion,
|
||||||
StringCriterion,
|
StringCriterion,
|
||||||
DurationCriterion,
|
DurationCriterion,
|
||||||
|
MandatoryStringCriterion,
|
||||||
} from "./criteria/criterion";
|
} from "./criteria/criterion";
|
||||||
import {
|
import {
|
||||||
FavoriteCriterion,
|
FavoriteCriterion,
|
||||||
@@ -124,6 +125,7 @@ export class ListFilterModel {
|
|||||||
];
|
];
|
||||||
this.criterionOptions = [
|
this.criterionOptions = [
|
||||||
new NoneCriterionOption(),
|
new NoneCriterionOption(),
|
||||||
|
ListFilterModel.createCriterionOption("path"),
|
||||||
new RatingCriterionOption(),
|
new RatingCriterionOption(),
|
||||||
ListFilterModel.createCriterionOption("o_counter"),
|
ListFilterModel.createCriterionOption("o_counter"),
|
||||||
new ResolutionCriterionOption(),
|
new ResolutionCriterionOption(),
|
||||||
@@ -199,6 +201,7 @@ export class ListFilterModel {
|
|||||||
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
|
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
|
||||||
this.criterionOptions = [
|
this.criterionOptions = [
|
||||||
new NoneCriterionOption(),
|
new NoneCriterionOption(),
|
||||||
|
ListFilterModel.createCriterionOption("path"),
|
||||||
new GalleryIsMissingCriterionOption(),
|
new GalleryIsMissingCriterionOption(),
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
@@ -380,6 +383,14 @@ export class ListFilterModel {
|
|||||||
const result: SceneFilterType = {};
|
const result: SceneFilterType = {};
|
||||||
this.criteria.forEach((criterion) => {
|
this.criteria.forEach((criterion) => {
|
||||||
switch (criterion.type) {
|
switch (criterion.type) {
|
||||||
|
case "path": {
|
||||||
|
const pathCrit = criterion as MandatoryStringCriterion;
|
||||||
|
result.path = {
|
||||||
|
value: pathCrit.value,
|
||||||
|
modifier: pathCrit.modifier,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "rating": {
|
case "rating": {
|
||||||
const ratingCrit = criterion as RatingCriterion;
|
const ratingCrit = criterion as RatingCriterion;
|
||||||
result.rating = {
|
result.rating = {
|
||||||
@@ -647,6 +658,14 @@ export class ListFilterModel {
|
|||||||
const result: GalleryFilterType = {};
|
const result: GalleryFilterType = {};
|
||||||
this.criteria.forEach((criterion) => {
|
this.criteria.forEach((criterion) => {
|
||||||
switch (criterion.type) {
|
switch (criterion.type) {
|
||||||
|
case "path": {
|
||||||
|
const pathCrit = criterion as MandatoryStringCriterion;
|
||||||
|
result.path = {
|
||||||
|
value: pathCrit.value,
|
||||||
|
modifier: pathCrit.modifier,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "galleryIsMissing":
|
case "galleryIsMissing":
|
||||||
result.is_missing = (criterion as IsMissingCriterion).value;
|
result.is_missing = (criterion as IsMissingCriterion).value;
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user