Console and CSV output as pugins
This commit is contained in:
1 parent
8400abab18
commit
fd2e4f265c
7 files changed
+300
-97
No files matched your search
+17
-5
@@ -29,8 +29,8 @@ set_target_properties(mmps_host PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
)
|
||||
|
||||
# Функция создаёт один плагин-решатель из переданного исходного файла.
|
||||
function(add_mmps_solver_plugin target source)
|
||||
# Функция создаёт один плагин из переданного исходного файла.
|
||||
function(add_mmps_plugin target source)
|
||||
# MODULE означает динамически загружаемый модуль:
|
||||
# DLL в Windows, SO в Linux и DYLIB в macOS.
|
||||
add_library(${target} MODULE ${source})
|
||||
@@ -52,22 +52,34 @@ function(add_mmps_solver_plugin target source)
|
||||
)
|
||||
endfunction()
|
||||
|
||||
# Каждый метод собирается в отдельную динамическую библиотеку.
|
||||
add_mmps_solver_plugin(
|
||||
# Каждый решатель и способ вывода собирается в отдельную библиотеку.
|
||||
add_mmps_plugin(
|
||||
runge_kutta_plugin
|
||||
plugins/runge_kutta_plugin.cpp
|
||||
)
|
||||
|
||||
add_mmps_solver_plugin(
|
||||
add_mmps_plugin(
|
||||
adams_bashforth_plugin
|
||||
plugins/adams_bashforth_plugin.cpp
|
||||
)
|
||||
|
||||
add_mmps_plugin(
|
||||
console_output_plugin
|
||||
plugins/console_output_plugin.cpp
|
||||
)
|
||||
|
||||
add_mmps_plugin(
|
||||
csv_output_plugin
|
||||
plugins/csv_output_plugin.cpp
|
||||
)
|
||||
|
||||
|
||||
# ЧТО ПРОИСХОДИТ ПРИ СБОРКЕ
|
||||
# src/main.cpp -> mmps_host.exe
|
||||
# plugins/runge_kutta_plugin.cpp -> runge_kutta_plugin.dll
|
||||
# plugins/adams_bashforth_plugin.cpp -> adams_bashforth_plugin.dll
|
||||
# plugins/console_output_plugin.cpp -> console_output_plugin.dll
|
||||
# plugins/csv_output_plugin.cpp -> csv_output_plugin.dll
|
||||
#
|
||||
# Хост и плагины собираются независимо. Здесь намеренно нет команд:
|
||||
# target_link_libraries(mmps_host PRIVATE runge_kutta_plugin)
|
||||
|
||||
+21
-6
@@ -18,7 +18,19 @@ struct SolutionPoint{
|
||||
double x3;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t PLUGIN_API_VERSION = 1;
|
||||
enum PluginType{
|
||||
Solver,
|
||||
Output
|
||||
};
|
||||
|
||||
struct OutputParameters{
|
||||
const char* solver_name;
|
||||
const char* destination_path;
|
||||
const SolutionPoint* points;
|
||||
size_t point_count;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t PLUGIN_API_VERSION = 2;
|
||||
|
||||
#if defined(_WIN32) && defined(PLUGIN_BUILD)
|
||||
#define PLUGIN_EXPORT extern "C" __declspec(dllexport)
|
||||
@@ -29,17 +41,20 @@ inline constexpr std::uint32_t PLUGIN_API_VERSION = 1;
|
||||
#endif
|
||||
|
||||
using plugin_api_version_fn = std::uint32_t (*)();
|
||||
using plugin_type_fn = PluginType (*)();
|
||||
using plugin_text_fn = const char* (*)();
|
||||
using function_run_fn = void (*)(
|
||||
using solver_run_fn = void (*)(
|
||||
const SolverParameters*,
|
||||
SolutionPoint*,
|
||||
std::size_t
|
||||
size_t
|
||||
);
|
||||
using output_write_fn = int (*)(const OutputParameters*);
|
||||
|
||||
inline constexpr const char* SYMBOL_API_VERSION =
|
||||
"plugin_api_version";
|
||||
inline constexpr const char* SYMBOL_TYPE = "plugin_type";
|
||||
inline constexpr const char* SYMBOL_NAME = "plugin_name";
|
||||
inline constexpr const char* SYMBOL_DESCRIPTION =
|
||||
"plugin_description";
|
||||
inline constexpr const char* SYMBOL_FUNCTION = "func";
|
||||
inline constexpr const char* SYMBOL_DESCRIPTION = "plugin_description";
|
||||
inline constexpr const char* SYMBOL_TRANSFORM = "transform";
|
||||
inline constexpr const char* SYMBOL_WRITE_OUTPUT = "write_output";
|
||||
|
||||
@@ -7,6 +7,10 @@ PLUGIN_EXPORT std::uint32_t plugin_api_version(){
|
||||
return PLUGIN_API_VERSION;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT PluginType plugin_type(){
|
||||
return PluginType::Solver;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT const char* plugin_name(){
|
||||
return "Adams-Bashforth 2";
|
||||
}
|
||||
@@ -15,9 +19,9 @@ PLUGIN_EXPORT const char* plugin_description(){
|
||||
return "Explicit second-order Adams-Bashforth method with an RK4 start";
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT void func(const SolverParameters* parameters,
|
||||
PLUGIN_EXPORT void transform(const SolverParameters* parameters,
|
||||
SolutionPoint* output,
|
||||
std::size_t output_size){
|
||||
size_t output_size){
|
||||
if (parameters == nullptr || output == nullptr || output_size == 0)
|
||||
return;
|
||||
|
||||
@@ -30,7 +34,7 @@ PLUGIN_EXPORT void func(const SolverParameters* parameters,
|
||||
State first_state = runge_kutta_step(initial_state, *parameters);
|
||||
output[1] = make_step(parameters->t_begin + parameters->step, first_state);
|
||||
|
||||
for (std::size_t index = 1; index + 1 < output_size; ++index){
|
||||
for (size_t index = 1; index + 1 < output_size; ++index){
|
||||
State current = state_from_point(output[index]);
|
||||
State previous = state_from_point(output[index - 1]);
|
||||
State current_derivative = derivative(current, *parameters);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "api.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
|
||||
PLUGIN_EXPORT std::uint32_t plugin_api_version(){
|
||||
return PLUGIN_API_VERSION;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT PluginType plugin_type(){
|
||||
return PluginType::Output;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT const char* plugin_name(){
|
||||
return "Console output";
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT const char* plugin_description(){
|
||||
return "Prints calculated solution points to the console";
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT int write_output(const OutputParameters* parameters){
|
||||
if (parameters == nullptr || parameters->solver_name == nullptr || parameters->points == nullptr)
|
||||
return 1;
|
||||
|
||||
std::cout << "\nSolver: " << parameters->solver_name << '\n'
|
||||
<< std::setw(12) << "t"
|
||||
<< std::setw(20) << "x1"
|
||||
<< std::setw(20) << "x2"
|
||||
<< std::setw(20) << "x3" << '\n';
|
||||
|
||||
std::cout << std::setprecision(10);
|
||||
for (size_t index = 0; index < parameters->point_count; ++index){
|
||||
const SolutionPoint& point = parameters->points[index];
|
||||
std::cout << std::setw(12) << point.t
|
||||
<< std::setw(20) << point.x1
|
||||
<< std::setw(20) << point.x2
|
||||
<< std::setw(20) << point.x3 << '\n';
|
||||
}
|
||||
|
||||
std::cout.flush();
|
||||
return std::cout ? 0 : 2;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "api.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <locale>
|
||||
|
||||
PLUGIN_EXPORT std::uint32_t plugin_api_version(){
|
||||
return PLUGIN_API_VERSION;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT PluginType plugin_type(){
|
||||
return PluginType::Output;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT const char* plugin_name(){
|
||||
return "CSV output";
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT const char* plugin_description(){
|
||||
return "Writes calculated solution points to a CSV file";
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT int write_output(const OutputParameters* parameters){
|
||||
if (parameters == nullptr || parameters->destination_path == nullptr ||
|
||||
parameters->points == nullptr)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::ofstream stream(parameters->destination_path);
|
||||
if (!stream)
|
||||
return 2;
|
||||
|
||||
stream.imbue(std::locale::classic());
|
||||
stream << std::setprecision(17);
|
||||
stream << "t,x1,x2,x3\n";
|
||||
|
||||
for (size_t index = 0; index < parameters->point_count; ++index){
|
||||
const SolutionPoint& point = parameters->points[index];
|
||||
stream << point.t << ',' << point.x1 << ','
|
||||
<< point.x2 << ',' << point.x3 << '\n';
|
||||
}
|
||||
|
||||
return stream ? 0 : 3;
|
||||
}
|
||||
@@ -7,6 +7,10 @@ PLUGIN_EXPORT std::uint32_t plugin_api_version(){
|
||||
return PLUGIN_API_VERSION;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT PluginType plugin_type(){
|
||||
return PluginType::Solver;
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT const char* plugin_name(){
|
||||
return "Runge-Kutta 4";
|
||||
}
|
||||
@@ -15,16 +19,16 @@ PLUGIN_EXPORT const char* plugin_description(){
|
||||
return "Explicit fourth-order Runge-Kutta method";
|
||||
}
|
||||
|
||||
PLUGIN_EXPORT void func(const SolverParameters* parameters,
|
||||
PLUGIN_EXPORT void transform(const SolverParameters* parameters,
|
||||
SolutionPoint* output,
|
||||
std::size_t output_size){
|
||||
size_t output_size){
|
||||
if (parameters == nullptr || output == nullptr || output_size == 0)
|
||||
return;
|
||||
|
||||
State state{1, 0, 0};
|
||||
output[0] = make_step(parameters->t_begin, state);
|
||||
|
||||
for (std::size_t index = 1; index < output_size; ++index){
|
||||
for (size_t index = 1; index < output_size; ++index){
|
||||
state = runge_kutta_step(state, *parameters);
|
||||
const double t = parameters->t_begin +
|
||||
static_cast<double>(index) * parameters->step;
|
||||
|
||||
+156
-80
@@ -1,15 +1,10 @@
|
||||
#include "api.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <locale>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -33,7 +28,8 @@ public:
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
handle_ = LoadLibraryW(path.c_str());
|
||||
if (handle_ == nullptr){
|
||||
if (handle_ == nullptr)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
"LoadLibraryW failed with error " +
|
||||
std::to_string(GetLastError()));
|
||||
@@ -90,6 +86,12 @@ private:
|
||||
#endif
|
||||
};
|
||||
|
||||
struct PluginInfo{
|
||||
fs::path path;
|
||||
PluginType type;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
bool is_library_candidate(const fs::path& path)
|
||||
{
|
||||
std::string extension = path.extension().string();
|
||||
@@ -113,7 +115,8 @@ std::vector<fs::path> find_candidates(const fs::path& directory){
|
||||
if (!fs::exists(directory) || !fs::is_directory(directory))
|
||||
return candidates;
|
||||
|
||||
for (const auto& entry : fs::directory_iterator(directory)){
|
||||
for (const auto& entry : fs::directory_iterator(directory))
|
||||
{
|
||||
if (entry.is_regular_file() && is_library_candidate(entry.path()))
|
||||
candidates.push_back(fs::absolute(entry.path()));
|
||||
}
|
||||
@@ -123,12 +126,15 @@ std::vector<fs::path> find_candidates(const fs::path& directory){
|
||||
}
|
||||
|
||||
size_t calculate_point_count(const SolverParameters& parameters){
|
||||
const size_t max_point_count = 1000000;
|
||||
double interval = parameters.t_end - parameters.t_begin;
|
||||
double step_count = std::floor(interval / parameters.step + 1e-12);
|
||||
constexpr size_t max_point_count = 1000000;
|
||||
const double interval = parameters.t_end - parameters.t_begin;
|
||||
const double step_count = std::floor(interval / parameters.step + 1e-12);
|
||||
|
||||
if (!std::isfinite(step_count) || step_count > static_cast<double>(max_point_count - 1))
|
||||
if (!std::isfinite(step_count) ||
|
||||
step_count > static_cast<double>(max_point_count - 1))
|
||||
{
|
||||
throw std::runtime_error("the selected h creates too many solution points");
|
||||
}
|
||||
|
||||
return static_cast<size_t>(step_count) + 1;
|
||||
}
|
||||
@@ -141,7 +147,9 @@ void validate_solution(const std::vector<SolutionPoint>& solution,
|
||||
const SolutionPoint& point = solution[index];
|
||||
if (!std::isfinite(point.t) || !std::isfinite(point.x1) ||
|
||||
!std::isfinite(point.x2) || !std::isfinite(point.x3))
|
||||
throw std::runtime_error("solver returned a non-finite value");
|
||||
{
|
||||
throw std::runtime_error("solver returned a non-finite value");
|
||||
}
|
||||
|
||||
const double expected_time = parameters.t_begin +
|
||||
static_cast<double>(index) * parameters.step;
|
||||
@@ -152,72 +160,20 @@ void validate_solution(const std::vector<SolutionPoint>& solution,
|
||||
}
|
||||
}
|
||||
|
||||
void write_csv(const fs::path& path, const std::vector<SolutionPoint>& solution){
|
||||
std::ofstream stream(path);
|
||||
if (!stream)
|
||||
throw std::runtime_error("cannot create CSV file: " + path_text(path));
|
||||
|
||||
stream << std::setprecision(4);
|
||||
stream << "t,x1,x2,x3\n";
|
||||
|
||||
for (const SolutionPoint& point : solution){
|
||||
stream << point.t << ',' << point.x1 << ','
|
||||
<< point.x2 << ',' << point.x3 << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
void inspect_and_run(const fs::path& library_path,
|
||||
const SolverParameters& parameters,
|
||||
size_t point_count,
|
||||
const fs::path& result_directory){
|
||||
SharedLibrary library(library_path);
|
||||
|
||||
const auto api_version = library.require<plugin_api_version_fn>(SYMBOL_API_VERSION);
|
||||
if (api_version() != PLUGIN_API_VERSION)
|
||||
throw std::runtime_error("unsupported API version " + std::to_string(api_version()));
|
||||
|
||||
const auto name = library.require<plugin_text_fn>(SYMBOL_NAME);
|
||||
const auto description = library.require<plugin_text_fn>(SYMBOL_DESCRIPTION);
|
||||
const auto function = library.require<function_run_fn>(SYMBOL_FUNCTION);
|
||||
|
||||
const char* plugin_name = name();
|
||||
const char* plugin_description = description();
|
||||
if (plugin_name == nullptr || plugin_description == nullptr)
|
||||
throw std::runtime_error("plugin returned null metadata");
|
||||
|
||||
std::vector<SolutionPoint> solution(point_count, SolutionPoint{0, 0, 0, 0});
|
||||
|
||||
function(¶meters, solution.data(), solution.size());
|
||||
validate_solution(solution, parameters);
|
||||
|
||||
fs::path csv_filename = library_path.stem();
|
||||
csv_filename += ".csv";
|
||||
const fs::path csv_path = result_directory / csv_filename;
|
||||
write_csv(csv_path, solution);
|
||||
|
||||
std::cout << "[accepted] " << path_text(library_path.filename()) << '\n'
|
||||
<< " name: " << plugin_name << '\n'
|
||||
<< " description: " << plugin_description << '\n'
|
||||
<< " points: " << solution.size() << '\n'
|
||||
<< " CSV: " << path_text(csv_path) << '\n';
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
SolverParameters parameters{};
|
||||
std::string plugin_directory_text;
|
||||
parameters.t_begin = 0.0;
|
||||
parameters.t_end = 10.0;
|
||||
|
||||
int main() {
|
||||
try {
|
||||
std::string plugin_directory_text;
|
||||
std::cout << "Enter plugin directory: ";
|
||||
if (!std::getline(std::cin, plugin_directory_text) || plugin_directory_text.empty())
|
||||
throw std::runtime_error("failed to read plugin directory");
|
||||
throw std::runtime_error("failed to read plugin directory");
|
||||
|
||||
const fs::path plugin_directory = fs::path(plugin_directory_text);
|
||||
|
||||
std::cout << "Enter k1 [1e-5, 1e2], k2 [1e-2, 1] and h: ";
|
||||
SolverParameters parameters{};
|
||||
parameters.t_begin = 0.0;
|
||||
parameters.t_end = 10.0;
|
||||
|
||||
std::cout << "Enter k1 [1e-5, 1e2], k2 [1e-2, 1] and h: ";
|
||||
if (!(std::cin >> parameters.k1 >> parameters.k2 >> parameters.step))
|
||||
throw std::runtime_error("failed to read k1, k2 and h");
|
||||
|
||||
@@ -244,28 +200,148 @@ int main()
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fs::path result_directory = fs::current_path() / "results";
|
||||
fs::create_directories(result_directory);
|
||||
|
||||
size_t accepted = 0;
|
||||
std::vector<PluginInfo> plugins;
|
||||
for (const fs::path& candidate : candidates){
|
||||
try {
|
||||
inspect_and_run(candidate, parameters, point_count,
|
||||
result_directory);
|
||||
accepted++;
|
||||
SharedLibrary library(candidate);
|
||||
|
||||
const auto api_version =
|
||||
library.require<plugin_api_version_fn>(SYMBOL_API_VERSION);
|
||||
const auto type =
|
||||
library.require<plugin_type_fn>(SYMBOL_TYPE);
|
||||
const auto name =
|
||||
library.require<plugin_text_fn>(SYMBOL_NAME);
|
||||
const auto description =
|
||||
library.require<plugin_text_fn>(SYMBOL_DESCRIPTION);
|
||||
|
||||
if (api_version() != PLUGIN_API_VERSION){
|
||||
throw std::runtime_error(
|
||||
"unsupported API version " +
|
||||
std::to_string(api_version()));
|
||||
}
|
||||
|
||||
const PluginType plugin_type = type();
|
||||
const char* plugin_name = name();
|
||||
const char* plugin_description = description();
|
||||
|
||||
if (plugin_type != PluginType::Solver &&
|
||||
plugin_type != PluginType::Output)
|
||||
{
|
||||
throw std::runtime_error("unknown plugin type");
|
||||
}
|
||||
if (plugin_name == nullptr || plugin_description == nullptr)
|
||||
throw std::runtime_error("plugin returned null metadata");
|
||||
|
||||
plugins.push_back({candidate, plugin_type, plugin_name});
|
||||
|
||||
std::cout << "[accepted] "
|
||||
<< path_text(candidate.filename()) << '\n'
|
||||
<< " type: "
|
||||
<< (plugin_type == PluginType::Solver
|
||||
? "solver"
|
||||
: "output") << '\n'
|
||||
<< " name: " << plugin_name << '\n'
|
||||
<< " description: " << plugin_description << '\n';
|
||||
}
|
||||
catch (const std::exception& error){
|
||||
std::cerr << "[rejected] " << path_text(candidate.filename())
|
||||
<< ": " << error.what() << '\n';
|
||||
}
|
||||
}
|
||||
size_t solver_count = 0, output_count = 0;
|
||||
for (auto it = plugins.begin(); it != plugins.end(); it++) {
|
||||
switch (it->type)
|
||||
{
|
||||
case Solver:
|
||||
solver_count++;
|
||||
break;
|
||||
case Output:
|
||||
output_count++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "Accepted solver plugins: " << accepted << " of " << candidates.size() << '\n';
|
||||
std::cout << "Accepted plugins: " << plugins.size()
|
||||
<< " of " << candidates.size() << '\n'
|
||||
<< "Solver plugins: " << solver_count << '\n'
|
||||
<< "Output plugins: " << output_count << '\n';
|
||||
|
||||
if (accepted == 0){
|
||||
if (solver_count == 0){
|
||||
std::cout << "No available solver methods. Calculation is impossible.\n";
|
||||
return 0;
|
||||
}
|
||||
if (output_count == 0){
|
||||
std::cout << "No available output plugins. Results cannot be presented.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::cout.flush();
|
||||
|
||||
const fs::path result_directory = "results";
|
||||
fs::create_directories(result_directory);
|
||||
|
||||
for (const PluginInfo& solver : plugins){
|
||||
if (solver.type != PluginType::Solver)
|
||||
continue;
|
||||
|
||||
try {
|
||||
SharedLibrary solver_library(solver.path);
|
||||
const auto solve =
|
||||
solver_library.require<solver_run_fn>(SYMBOL_TRANSFORM);
|
||||
|
||||
std::vector<SolutionPoint> solution(
|
||||
point_count,
|
||||
SolutionPoint{0, 0, 0, 0});
|
||||
|
||||
solve(¶meters, solution.data(), solution.size());
|
||||
validate_solution(solution, parameters);
|
||||
|
||||
fs::path csv_filename = solver.path.stem();
|
||||
csv_filename += ".csv";
|
||||
const fs::path destination_path =
|
||||
result_directory / csv_filename;
|
||||
const std::string destination = destination_path.string();
|
||||
|
||||
OutputParameters output_parameters{
|
||||
solver.name.c_str(),
|
||||
destination.c_str(),
|
||||
solution.data(),
|
||||
solution.size()
|
||||
};
|
||||
|
||||
for (const PluginInfo& output : plugins){
|
||||
if (output.type != PluginType::Output)
|
||||
continue;
|
||||
|
||||
try {
|
||||
SharedLibrary output_library(output.path);
|
||||
const auto write =
|
||||
output_library.require<output_write_fn>(
|
||||
SYMBOL_WRITE_OUTPUT);
|
||||
|
||||
const int status = write(&output_parameters);
|
||||
if (status != 0){
|
||||
std::cerr << "[output error] " << output.name
|
||||
<< " returned code " << status << '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
std::cout << "Output completed: " << solver.name
|
||||
<< " -> " << output.name << '\n';
|
||||
}
|
||||
catch (const std::exception& error){
|
||||
std::cerr << "[output error] " << output.name
|
||||
<< ": " << error.what() << '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception& error){
|
||||
std::cerr << "[solver error] " << solver.name
|
||||
<< ": " << error.what() << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user