Added ef core integration
This commit is contained in:
@@ -4,14 +4,17 @@
|
||||
* The configuration for a single property
|
||||
*/
|
||||
public class PropertyConfig {
|
||||
/** The unique identifier for the property (usually the real property name in the model) */
|
||||
/** [GENERATED] The unique identifier for the property (usually the real property name in the model) */
|
||||
public required string Identifier { get; init; }
|
||||
|
||||
/** The displayed name of the Property */
|
||||
/** [GENERATED] The displayed name of the Property */
|
||||
public required string DisplayName { get; set; }
|
||||
|
||||
/** The type of the property */
|
||||
/** [GENERATED] The real type of the property */
|
||||
public required Type Type { get; set; }
|
||||
|
||||
/** [GENERATED] The type as wich the property should be treated */
|
||||
public required PropertyType PropertyType { get; set; }
|
||||
|
||||
/** Determines if the property will appear in the table */
|
||||
public bool Listable { get; set; } = true;
|
||||
@@ -30,12 +33,58 @@ public class PropertyConfig {
|
||||
|
||||
/** Determines if the property is visible in the creation or edit dialog */
|
||||
public bool Creatable { get; set; } = true;
|
||||
|
||||
/** Determines if the actual value should be displayed (useful for passwords) */
|
||||
public bool DisplayValue { get; set; } = true;
|
||||
|
||||
/** The place (from left to right) that the property will appear in the table and editor */
|
||||
/** [GENERATED] The place (from left to right) that the property will appear in the table and editor */
|
||||
public int OrderIndex { get; set; }
|
||||
|
||||
internal PropertyConfig() {}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to distinguish between different input types in the frontend. <br/>
|
||||
/// Binary Format: First byte is used for additional properties, second byte identifies the real type
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PropertyType : byte {
|
||||
/** Used together with another type to indicate that the value can be null */
|
||||
Nullable = 0b10000000,
|
||||
|
||||
/** Used together with another type to indicate that the property is a relation */
|
||||
Relation = 0b01000000,
|
||||
|
||||
/** Used together with another type to indicate that the value is enumerable */
|
||||
List = 0b00100000,
|
||||
|
||||
/** Indicates that the value is numeric */
|
||||
Numeric = 0x01,
|
||||
|
||||
/** Indicates that the value is a boolean */
|
||||
Boolean = 0x02,
|
||||
|
||||
/** Indicates that the value is a timestamp */
|
||||
DateTime = 0x03,
|
||||
|
||||
/** Indicates that the value is a date */
|
||||
DateOnly = 0x04,
|
||||
|
||||
/** Indicates that the value is a time of day */
|
||||
TimeOnly = 0x05,
|
||||
|
||||
/** Indicates that the value is a list of fixed values */
|
||||
Enum = 0x06,
|
||||
|
||||
/** Indicates that the value is a string */
|
||||
Text = 0x07,
|
||||
|
||||
/** Indicates that the value is an email */
|
||||
Email = 0x08,
|
||||
|
||||
/** Indicates that the value is a long string */
|
||||
TextArea = 0x09,
|
||||
|
||||
/** Indicates that the value should be hidden */
|
||||
Password = 0x0A,
|
||||
|
||||
/** Indicates that the value is a phone number */
|
||||
PhoneNumber = 0x0B
|
||||
}
|
||||
|
||||
@@ -4,28 +4,28 @@
|
||||
* The configuration for a table
|
||||
*/
|
||||
public class TableConfig {
|
||||
/** The unique identifier for the table (usually the name of the model) */
|
||||
/** [GENERATED] The unique identifier for the table (usually the name of the model) */
|
||||
public required string Identifier { get; init; }
|
||||
|
||||
/** The configurations for the properties of the model */
|
||||
/** [GENERATED] The configurations for the properties of the model */
|
||||
public IList<PropertyConfig> Properties { get; set; } = new List<PropertyConfig>();
|
||||
|
||||
/** The type of the model */
|
||||
/** [GENERATED] The type of the model */
|
||||
public required Type TableType { get; set; }
|
||||
|
||||
/** The type identifier for the repository */
|
||||
/** [GENERATED] The type identifier for the repository */
|
||||
public required Type RepositoryType { get; set; }
|
||||
|
||||
/** the url of the table page */
|
||||
/** [GENERATED] the url of the table page */
|
||||
public required string Route { get; set; }
|
||||
|
||||
/** The displayed name of the table */
|
||||
/** [GENERATED] The displayed name of the table */
|
||||
public required string DisplayName { get; set; }
|
||||
|
||||
/** A short description for the table */
|
||||
public string? Description { get; set; }
|
||||
|
||||
/** The place (from top to bottom) that the table will appear in on the sidebar */
|
||||
/** [GENERATED] The place (from top to bottom) that the table will appear in on the sidebar */
|
||||
public int OrderIndex { get; set; }
|
||||
|
||||
internal TableConfig() {}
|
||||
|
||||
@@ -13,6 +13,8 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv
|
||||
/** The internal config that is modified */
|
||||
public HopFrameConfig Config { get; } = config;
|
||||
|
||||
internal IServiceCollection Services { get; } = services;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new table to the configuration based on the provided repository
|
||||
/// </summary>
|
||||
@@ -22,7 +24,7 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv
|
||||
public HopFrameConfigurator AddRepository<TRepository, TModel>(Action<TableConfigurator<TModel>>? configurator = null) where TRepository : IHopFrameRepository where TModel : notnull {
|
||||
var table = ConfigurationHelper.InitializeTable(Config, typeof(TRepository), typeof(TModel));
|
||||
Config.Tables.Add(table);
|
||||
services.TryAddScoped(typeof(TRepository));
|
||||
Services.TryAddScoped(typeof(TRepository));
|
||||
configurator?.Invoke(new TableConfigurator<TModel>(table));
|
||||
return this;
|
||||
}
|
||||
@@ -44,7 +46,7 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv
|
||||
throw new ArgumentException($"Table '{config.Identifier}' has some validation errors:\n\t{string.Join("\n\t", errors)}");
|
||||
|
||||
Config.Tables.Add(config);
|
||||
services.TryAddScoped(config.RepositoryType);
|
||||
Services.TryAddScoped(config.RepositoryType);
|
||||
configurator?.Invoke(new TableConfigurator<TModel>(config));
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -45,15 +45,18 @@ public class PropertyConfigurator(PropertyConfig config) {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** <inheritdoc cref="PropertyConfig.DisplayValue" /> */
|
||||
public PropertyConfigurator DisplayValue(bool displayValue) {
|
||||
Config.DisplayValue = displayValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** <inheritdoc cref="PropertyConfig.OrderIndex" /> */
|
||||
public PropertyConfigurator SetOrderIndex(int index) {
|
||||
Config.OrderIndex = index;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the property type. The predefined modifiers (like nullable) persist.
|
||||
/// If the property is a list or any other generic type, please use the enumerated type.
|
||||
/// </summary>
|
||||
public PropertyConfigurator SetType(PropertyType type) {
|
||||
Config.PropertyType = (PropertyType)(((byte)Config.PropertyType & 0xF0) | ((byte)type & 0x0F));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
34
src/HopFrame.Core/EFCore/DbConfigPopulator.cs
Normal file
34
src/HopFrame.Core/EFCore/DbConfigPopulator.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Reflection;
|
||||
using HopFrame.Core.Configuration;
|
||||
using HopFrame.Core.Helpers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HopFrame.Core.EFCore;
|
||||
|
||||
internal static class DbConfigPopulator {
|
||||
|
||||
public static string ConfigureRepository(HopFrameConfig global, IServiceCollection services, Type contextType, PropertyInfo tableProperty) {
|
||||
var modelType = tableProperty.PropertyType.GenericTypeArguments.First();
|
||||
var repoType = typeof(EfCoreRepository<,>).MakeGenericType(modelType, contextType);
|
||||
|
||||
services.AddScoped(repoType);
|
||||
|
||||
var table = ConfigurationHelper.InitializeTable(global, repoType, modelType);
|
||||
global.Tables.Add(table);
|
||||
return table.Identifier;
|
||||
}
|
||||
|
||||
public static void CheckForRelations(HopFrameConfig global, TableConfig table) {
|
||||
foreach (var property in table.Properties) {
|
||||
var type = property.Type;
|
||||
|
||||
if ((property.PropertyType & PropertyType.List) != 0 && type.IsGenericType) {
|
||||
type = type.GenericTypeArguments.First();
|
||||
}
|
||||
|
||||
if (global.Tables.Any(t => t.TableType == type))
|
||||
property.PropertyType |= PropertyType.Relation;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
47
src/HopFrame.Core/EFCore/EfCoreRepository.cs
Normal file
47
src/HopFrame.Core/EFCore/EfCoreRepository.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using HopFrame.Core.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Core.EFCore;
|
||||
|
||||
/// <summary>
|
||||
/// The generic repository that handles data source communication for managed tables
|
||||
/// </summary>
|
||||
/// <typeparam name="TModel">The model that is managed by the repo</typeparam>
|
||||
/// <typeparam name="TContext">The underlying context that handles database communication</typeparam>
|
||||
public class EfCoreRepository<TModel, TContext>(TContext context) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext {
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) {
|
||||
throw new NotImplementedException(); //TODO: Implement loading functionality
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<int> CountAsync(CancellationToken ct = default) {
|
||||
var table = context.Set<TModel>();
|
||||
return await table.CountAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
|
||||
throw new NotImplementedException(); //TODO: Implement search functionality
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CreateAsync(TModel entry, CancellationToken ct = default) {
|
||||
await context.AddAsync(entry, ct);
|
||||
await context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task UpdateAsync(TModel entry, CancellationToken ct = default) {
|
||||
context.Update(entry);
|
||||
await context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task DeleteAsync(TModel entry, CancellationToken ct = default) {
|
||||
context.Remove(entry);
|
||||
await context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
}
|
||||
44
src/HopFrame.Core/EFCore/HopFrameConfiguratorExtensions.cs
Normal file
44
src/HopFrame.Core/EFCore/HopFrameConfiguratorExtensions.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Linq.Expressions;
|
||||
using HopFrame.Core.Configuration;
|
||||
using HopFrame.Core.Configurators;
|
||||
using HopFrame.Core.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Core.EFCore;
|
||||
|
||||
/** Adds useful extensions to the <see cref="HopFrameConfigurator"/> to add managed <see cref="DbContext"/> repositories */
|
||||
public static class HopFrameConfiguratorExtensions {
|
||||
|
||||
/// <summary>
|
||||
/// Adds managed repositories for the selected (or all if none provided) tables
|
||||
/// </summary>
|
||||
/// <param name="configurator">The configurator for the current <see cref="HopFrameConfig"/></param>
|
||||
/// <param name="includedTables">The tables that should be configured (if none are provided, all tables will be added)</param>
|
||||
/// <typeparam name="TDbContext">The already configured and injectable database context</typeparam>
|
||||
public static HopFrameConfigurator AddDbContext<TDbContext>(this HopFrameConfigurator configurator, params Expression<Func<TDbContext, object>>[] includedTables) where TDbContext : DbContext {
|
||||
var contextType = typeof(TDbContext);
|
||||
var properties = contextType.GetProperties()
|
||||
.Where(p => p.PropertyType.IsGenericType)
|
||||
.Where(p => p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
|
||||
|
||||
if (includedTables.Length != 0) {
|
||||
properties = includedTables.Select(ExpressionHelper.GetPropertyInfo);
|
||||
}
|
||||
|
||||
var tableIdentifiers = new List<string>();
|
||||
foreach (var tableProperty in properties) {
|
||||
var identifier = DbConfigPopulator.ConfigureRepository(configurator.Config, configurator.Services, contextType, tableProperty);
|
||||
tableIdentifiers.Add(identifier);
|
||||
}
|
||||
|
||||
var createdTables = configurator.Config.Tables
|
||||
.Where(t => tableIdentifiers.Contains(t.Identifier))
|
||||
.ToArray();
|
||||
foreach (var createdTable in createdTables) {
|
||||
DbConfigPopulator.CheckForRelations(configurator.Config, createdTable);
|
||||
}
|
||||
|
||||
return configurator;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Reflection;
|
||||
using System.Collections;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using HopFrame.Core.Configuration;
|
||||
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||
|
||||
@@ -28,7 +31,7 @@ internal static class ConfigurationHelper {
|
||||
return config;
|
||||
}
|
||||
|
||||
private static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) {
|
||||
public static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) {
|
||||
var identifier = property.Name;
|
||||
|
||||
if (table.Properties.Any(p => p.Identifier == identifier))
|
||||
@@ -38,12 +41,60 @@ internal static class ConfigurationHelper {
|
||||
Identifier = identifier,
|
||||
Type = property.PropertyType,
|
||||
DisplayName = property.Name,
|
||||
OrderIndex = table.Properties.Count
|
||||
OrderIndex = table.Properties.Count,
|
||||
PropertyType = InferPropertyType(property.PropertyType, property)
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public static PropertyType InferPropertyType(Type realType, PropertyInfo info) {
|
||||
byte modifiers = 0;
|
||||
|
||||
if (Nullable.GetUnderlyingType(realType) != null) {
|
||||
modifiers |= (byte)PropertyType.Nullable;
|
||||
realType = Nullable.GetUnderlyingType(realType)!;
|
||||
}
|
||||
|
||||
if (info.CustomAttributes.Any(a => a.AttributeType == typeof(NullableAttribute))) {
|
||||
modifiers |= (byte)PropertyType.Nullable;
|
||||
}
|
||||
|
||||
if ((realType.IsAssignableTo(typeof(IEnumerable)) || realType.IsAssignableTo(typeof(IEnumerable<>))) && realType != typeof(string)) {
|
||||
modifiers |= (byte)PropertyType.List;
|
||||
}
|
||||
|
||||
if (realType.IsGenericType) {
|
||||
realType = realType.GenericTypeArguments.First();
|
||||
}
|
||||
|
||||
var type = PropertyType.Text;
|
||||
var realTypeCode = Type.GetTypeCode(realType);
|
||||
|
||||
if (realTypeCode == TypeCode.Boolean)
|
||||
type = PropertyType.Boolean;
|
||||
|
||||
if (realTypeCode == TypeCode.DateTime)
|
||||
type = PropertyType.DateTime;
|
||||
|
||||
if (realType == typeof(DateOnly))
|
||||
type = PropertyType.DateOnly;
|
||||
|
||||
if (realType == typeof(TimeOnly))
|
||||
type = PropertyType.TimeOnly;
|
||||
|
||||
if (realType.IsEnum)
|
||||
type = PropertyType.Enum;
|
||||
|
||||
if (realType.IsNumeric())
|
||||
type = PropertyType.Numeric;
|
||||
|
||||
if (info.CustomAttributes.Any(a => a.AttributeType == typeof(EmailAddressAttribute)))
|
||||
type = PropertyType.Email;
|
||||
|
||||
return (PropertyType)((byte)type | modifiers);
|
||||
}
|
||||
|
||||
public static IEnumerable<string> ValidateTable(HopFrameConfig global, TableConfig config) {
|
||||
if (global.Tables.Any(t => t.Identifier == config.Identifier))
|
||||
yield return $"Table identifier '{config.Identifier}' is not unique";
|
||||
|
||||
23
src/HopFrame.Core/Helpers/TypeExtensions.cs
Normal file
23
src/HopFrame.Core/Helpers/TypeExtensions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace HopFrame.Core.Helpers;
|
||||
|
||||
internal static class TypeExtensions {
|
||||
public static bool IsNumeric(this Type o) {
|
||||
if (o.IsEnum) return false;
|
||||
switch (Type.GetTypeCode(o)) {
|
||||
case TypeCode.Byte:
|
||||
case TypeCode.SByte:
|
||||
case TypeCode.UInt16:
|
||||
case TypeCode.UInt32:
|
||||
case TypeCode.UInt64:
|
||||
case TypeCode.Int16:
|
||||
case TypeCode.Int32:
|
||||
case TypeCode.Int64:
|
||||
case TypeCode.Decimal:
|
||||
case TypeCode.Double:
|
||||
case TypeCode.Single:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace HopFrame.Core.Repositories;
|
||||
|
||||
/** The base repository that provides access to the model dataset */
|
||||
public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TModel : notnull {
|
||||
public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TModel : class {
|
||||
|
||||
/** <inheritdoc cref="LoadPageGenericAsync"/> */
|
||||
public abstract Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default);
|
||||
@@ -15,18 +15,21 @@ public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TMo
|
||||
public abstract Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
|
||||
|
||||
/** <inheritdoc cref="CreateGenericAsync"/> */
|
||||
public abstract Task CreateAsync(TModel entry, CancellationToken ct);
|
||||
public abstract Task CreateAsync(TModel entry, CancellationToken ct = default);
|
||||
|
||||
/** <inheritdoc cref="UpdateGenericAsync"/> */
|
||||
public abstract Task UpdateAsync(TModel entry, CancellationToken ct = default);
|
||||
|
||||
/** <inheritdoc cref="DeleteGenericAsync"/> */
|
||||
public abstract Task DeleteAsync(TModel entry, CancellationToken ct);
|
||||
public abstract Task DeleteAsync(TModel entry, CancellationToken ct = default);
|
||||
|
||||
/** <inheritdoc/> */
|
||||
public async Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
|
||||
public async Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
|
||||
return await LoadPageAsync(page, perPage, ct);
|
||||
}
|
||||
|
||||
/** <inheritdoc/> */
|
||||
public async Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
|
||||
public async Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
|
||||
return await SearchAsync(searchTerm, page, perPage, ct);
|
||||
}
|
||||
|
||||
@@ -34,7 +37,12 @@ public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TMo
|
||||
public Task CreateGenericAsync(object entry, CancellationToken ct) {
|
||||
return CreateAsync((TModel)entry, ct);
|
||||
}
|
||||
|
||||
|
||||
/** <inheritdoc/> */
|
||||
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
|
||||
return UpdateAsync((TModel)entry, ct);
|
||||
}
|
||||
|
||||
/** <inheritdoc/> */
|
||||
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
|
||||
return DeleteAsync((TModel)entry, ct);
|
||||
|
||||
@@ -11,12 +11,12 @@ public interface IHopFrameRepository {
|
||||
/// </summary>
|
||||
/// <param name="page">The index of the current page (starts at 0)</param>
|
||||
/// <param name="perPage">The amount of entries that should be loaded</param>
|
||||
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default);
|
||||
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total amount of entries in the dataset
|
||||
/// </summary>
|
||||
public Task<int> CountAsync(CancellationToken ct = default);
|
||||
public Task<int> CountAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Searches through the whole dataset and returns a page of matching entries
|
||||
@@ -24,7 +24,7 @@ public interface IHopFrameRepository {
|
||||
/// <param name="searchTerm">The search text provided by the user</param>
|
||||
/// <param name="page">The index of the current page (starts at 0)</param>
|
||||
/// <param name="perPage">The amount of entries that should be loaded</param>
|
||||
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
|
||||
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct);
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -32,6 +32,12 @@ public interface IHopFrameRepository {
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry that needs to be saved</param>
|
||||
public Task CreateGenericAsync(object entry, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the changes made to the entry to the dataset
|
||||
/// </summary>
|
||||
/// <param name="entry">The modified entry</param>
|
||||
public Task UpdateGenericAsync(object entry, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the provided entry from the dataset
|
||||
|
||||
Reference in New Issue
Block a user