All done!
This commit is contained in:
parent
43a093afd1
commit
0a3edeb4a8
@ -4,7 +4,7 @@ version = "0.0.0"
|
||||
edition = "2021"
|
||||
description = "Rust Challenge"
|
||||
repository = "/myrddin/satellite"
|
||||
authors = ["CyberMages LLC <Software@CyberMagesLLC.com>", "Jason Travis Smith <Myrddin@CyberMages.tech>"]
|
||||
authors = ["Jason Travis Smith <Myrddin@CyberMages.tech>"]
|
||||
readme = "README.md"
|
||||
license-file = "LICENSE.md"
|
||||
|
||||
|
69
LICENSE.md
69
LICENSE.md
@ -1,69 +0,0 @@
|
||||
Copyright (c) <2025> CyberMages LLC
|
||||
|
||||
Redistribution and use in source and binary forms, with or
|
||||
without modification, are permitted provided that the
|
||||
following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above
|
||||
copyright notice, this list of conditions and the
|
||||
following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the
|
||||
following disclaimer in the documentation and/or other
|
||||
materials provided with the distribution.
|
||||
|
||||
Subject to the terms and conditions of this license, each
|
||||
copyright holder and contributor hereby grants to those
|
||||
receiving rights under this license a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except for failure to satisfy the conditions
|
||||
of this license) patent license to make, have made, use,
|
||||
offer to sell, sell, import, and otherwise transfer this
|
||||
software, where such license applies only to those patent
|
||||
claims, already acquired or hereafter acquired, licensable
|
||||
by such copyright holder or contributor that are
|
||||
necessarily infringed by:
|
||||
|
||||
(a) their Contribution(s) (the licensed copyrights of
|
||||
copyright holders and non-copyrightable additions
|
||||
of contributors, in source or binary form) alone;
|
||||
|
||||
-- or --
|
||||
|
||||
(b) combination of their Contribution(s) with the work
|
||||
of authorship to which such Contribution(s) was
|
||||
added by such copyright holder or contributor, if,
|
||||
at the time the Contribution is added, such
|
||||
addition causes such combination to be necessarily
|
||||
infringed. The patent license shall not apply to
|
||||
any other combinations which include the
|
||||
Contribution.
|
||||
|
||||
Except as expressly stated above, no rights or licenses
|
||||
from any copyright holder or contributor is granted under
|
||||
this license, whether expressly, by implication, estoppel
|
||||
or otherwise.
|
||||
|
||||
|
||||
DISCLAIMER
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
|
||||
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
|
||||
IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
YOU ACKNOWLEDGE THAT THIS SOFTWARE IS NOT DESIGNED,
|
||||
LICENSED OR INTENDED FOR USE IN THE DESIGN, CONSTRUCTION,
|
||||
OPERATION OR MAINTENANCE OF ANY MILITARY FACILITY OR
|
||||
RELIGIOUS INSTITUTION.
|
89
README.md
89
README.md
@ -1,3 +1,88 @@
|
||||
# satellite
|
||||
# Satellite Propulsion System
|
||||
|
||||
Rust Challenge
|
||||
## Approach
|
||||
This program will be using standard I/O for getting data.
|
||||
|
||||
Tackling this problem requires the use of asynchronous code. This is because
|
||||
the user will constantly be giving input on the command line yet time needs
|
||||
to keep moving forward to fire any propulsion commands.
|
||||
|
||||
To handle this the program uses two separate tasks. One for handling
|
||||
the input and one for running the commands given. The input, coming from
|
||||
standard I/O, will run on a separate thread because it will use blocking
|
||||
functions. The command processing can be handled in an asynchronous
|
||||
task since it can use completely asynchronous functions.
|
||||
|
||||
The two tasks are linked with message passing channels. One channel is for
|
||||
passing commands from the input task to the command processing task. The
|
||||
other channel is a watch channel on a boolean value that the input task
|
||||
will flip from true to false. This will designate that the program should
|
||||
no longer be running and needs to terminate.
|
||||
|
||||
The input reading task is the main driver of the program. It will loop until
|
||||
the program is told to stop running. This is done by the user typing "exit",
|
||||
"stop", or "quit". It will then trigger termination through the watch
|
||||
channel. If the user types a whole number between 0 and i32::MAX, it
|
||||
will create and send a propulsion command with a delay time of the given
|
||||
number, as seconds, and send it over the command channel. If the user types
|
||||
a '-1', it will create a cancel command and send it over the command
|
||||
channel. Cancel is handled as a separate command because it is a different
|
||||
action and it is clearer to handle it separately.
|
||||
|
||||
The command processing task will loop until the termination channel tells
|
||||
it to end. It will handle two async jobs. The first is processing
|
||||
commands from the command channel, either creating a propulsion interval
|
||||
countdown or clearing any that are already running. The second job is
|
||||
to run the current propulsion firing interval until it completes one firing.
|
||||
|
||||
|
||||
|
||||
## Instructions
|
||||
### Build
|
||||
To build the program you must have rustc and cargo installed.
|
||||
The instructions to do so can be found
|
||||
[here](https://www.rust-lang.org/tools/install).
|
||||
|
||||
Then to build the program navigate to the directory and run the
|
||||
following command.
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
If you would like to include debugging symbols then you can run the
|
||||
following command.
|
||||
```
|
||||
cargo build
|
||||
```
|
||||
|
||||
This will pull all the required dependencies from crates.io.
|
||||
Below is the list of dependencies; this can be verified in Cargo.toml.
|
||||
* tokio
|
||||
|
||||
|
||||
### Run
|
||||
To run the program type the following command.
|
||||
```
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
If you want to run a version with all the debugging symbols then run the
|
||||
following command.
|
||||
```
|
||||
cargo run
|
||||
```
|
||||
|
||||
You can also run the currently built version from the 'target' subdirectory.
|
||||
Debug builds can be found in target/debug.
|
||||
Release builds can be found in target/release.
|
||||
The executable's name is satellite.
|
||||
|
||||
### Operation
|
||||
Once the program is running it will expect input commands from the user.
|
||||
The commands can either be:
|
||||
* A whole number - To specify the amount of delay, in seconds, before the
|
||||
next time the satellite will fire its engine.
|
||||
* The number '-1' - To specify that all current propulsion commands should
|
||||
be canceled.
|
||||
* The any one of the case sensitive words
|
||||
"quit", "stop", or "exit" - To specify that the program should terminate.
|
||||
|
@ -1,64 +0,0 @@
|
||||
# Problem Statement
|
||||
You are a member of the flight software team at Umbra and are responsible
|
||||
for writing code that manages the satellite’s propulsion system.
|
||||
|
||||
Firing the propulsion system involves waiting for a certain period of time before ignition.
|
||||
|
||||
The following is an example usage of this system:
|
||||
|
||||
* At absolute time t = 0, send a command to the computer to fire the propulsion in 15 seconds
|
||||
* At absolute time t = 2, send a command to the computer to fire the propulsion in 30 seconds
|
||||
* At absolute time t = 32, the computer begins firing the propulsion
|
||||
|
||||
The flight computer should accept a command with a relative time of when to fire
|
||||
the propulsion - once that time has elapsed, the program should print out “firing now!”.
|
||||
If another command is received before the propulsion is fired then the most recently
|
||||
received relative time should overwrite any existing commands.
|
||||
|
||||
More formally, if a command _A_ is waiting to fire and another command _B_ is received
|
||||
before A has fired, then B should replace _A_ as the pending command and _A_ should never fire.
|
||||
|
||||
If a time of -1 is given, any outstanding commands to fire the propulsion are cancelled.
|
||||
|
||||
Note that the flight computer should be able to fire the thruster multiple times in a
|
||||
single execution of the program.
|
||||
|
||||
You may use exactly one of the following interfaces for getting data into and out of your program:
|
||||
* Standard input/standard output
|
||||
* TCP
|
||||
|
||||
You can use whichever is more convenient - we note that some languages make asynchronous
|
||||
IO with standard input/standard output cumbersome and have thus included TCP.
|
||||
|
||||
If you do choose to use TCP, please have your server listen on port `8124`.
|
||||
|
||||
A sample TCP client, `propulsion_tcp_client.py`, is provided that plumbs standard input
|
||||
to TCP writes and likewise plumbs TCP reads to standard output.
|
||||
|
||||
Commands should be delineated by newlines.
|
||||
|
||||
A sample _complete_ execution of your program using standard input and output is shown below
|
||||
|
||||
```
|
||||
./your_program
|
||||
15
|
||||
30
|
||||
firing now!
|
||||
```
|
||||
|
||||
# Submitting your code
|
||||
You may complete this assignment in any programming language that you wish.
|
||||
We're most familiar with Rust and Python at Umbra, but we always enjoy learning
|
||||
new languages and seeing solutions with different tools!
|
||||
|
||||
Your final submission for this exercise should be a zip file including:
|
||||
* The source code of your program.
|
||||
* A brief README with a description of your approach and instructions on how to
|
||||
build and run the program.
|
||||
* Any libraries or frameworks needed to run your program, or instructions for installing them.
|
||||
|
||||
Please ensure that your code is clear and legible before submitting.
|
||||
|
||||
While we don't expect you to write production-quality code for this exercise,
|
||||
readability goes a long way in helping us review your submission.
|
||||
|
@ -1,18 +0,0 @@
|
||||
use tokio::time::Duration;
|
||||
|
||||
|
||||
|
||||
/// The commands available to send to the Satellite.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Command
|
||||
{
|
||||
/// Fire the engine in T minus x seconds!
|
||||
Propulsion
|
||||
{
|
||||
/// The amount of seconds to wait before we fire the engines.
|
||||
delay: Duration
|
||||
},
|
||||
|
||||
/// Cancel any waiting commands.
|
||||
Cancel
|
||||
}
|
236
src/main.rs
236
src/main.rs
@ -1,104 +1,106 @@
|
||||
//! Rust Challenge
|
||||
|
||||
mod commands;
|
||||
|
||||
|
||||
use std::io::Write;
|
||||
use std::pin::Pin;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::time::{Duration, Interval};
|
||||
|
||||
use crate::commands::Command;
|
||||
|
||||
|
||||
|
||||
/// Just an easier to read type.
|
||||
type IoResult<T> = Result<T, std::io::Error>;
|
||||
|
||||
/// The commands available to send to the Satellite.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Command {
|
||||
/// Fire the engine in T minus x seconds!
|
||||
Propulsion {
|
||||
/// The amount of seconds to wait before we fire the engines.
|
||||
delay: Duration,
|
||||
},
|
||||
|
||||
/// Cancel any waiting commands.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The state of the program.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ProgramState {
|
||||
/// The program is currently running.
|
||||
Running,
|
||||
|
||||
/// The program has switched to shutting down.
|
||||
Terminated,
|
||||
}
|
||||
|
||||
/// Reads input from the user until the termination signal is
|
||||
/// received. It will send command messages every time it reads valid input.
|
||||
fn read_user_input(command_sender: mpsc::Sender<Command>,
|
||||
term_sender: watch::Sender<bool>)
|
||||
-> IoResult<()>
|
||||
{
|
||||
fn read_user_input(
|
||||
command_sender: mpsc::Sender<Command>,
|
||||
term_sender: watch::Sender<ProgramState>,
|
||||
) -> IoResult<()> {
|
||||
let mut running: bool = true;
|
||||
let mut buffer = String::new();
|
||||
|
||||
// Loop and read the user input.
|
||||
while running
|
||||
{
|
||||
while running {
|
||||
// Clearing the last read line and read a new one.
|
||||
buffer.clear();
|
||||
std::io::stdin().read_line(&mut buffer)?;
|
||||
std::io::stdout().flush().unwrap();
|
||||
|
||||
let input = buffer.trim();
|
||||
|
||||
match input
|
||||
{
|
||||
match input {
|
||||
// A termination method wasn't specified in the document, so
|
||||
// this will terminate when either stop, exit, or quit is entered.
|
||||
"exit" | "stop" | "quit" =>
|
||||
{
|
||||
println!("Exiting application.");
|
||||
"exit" | "stop" | "quit" => {
|
||||
println!("Exiting program.");
|
||||
running = false;
|
||||
term_sender.send_replace(running);
|
||||
term_sender.send_replace(ProgramState::Terminated);
|
||||
}
|
||||
|
||||
_ =>
|
||||
{
|
||||
_ => {
|
||||
// Check to see if we were given a number.
|
||||
match input.parse::<i32>()
|
||||
{
|
||||
Ok(seconds) =>
|
||||
{
|
||||
match input.parse::<i32>() {
|
||||
Ok(seconds) => {
|
||||
// Here we are just handling the different possible numbers
|
||||
// passed in. -1 cancels commands, less than -1 is ignored
|
||||
// with a message, and greated than -1 is turned into a
|
||||
// propulsion command.
|
||||
if seconds == -1
|
||||
{
|
||||
match command_sender.blocking_send(Command::Cancel)
|
||||
{
|
||||
Ok(_) =>
|
||||
{
|
||||
println!("Cancelling any outstanding commands.")
|
||||
}
|
||||
Err(e) => println!("Failed to send command: {}", e)
|
||||
if seconds == -1 {
|
||||
match command_sender.blocking_send(Command::Cancel) {
|
||||
Ok(_) => {}
|
||||
Err(e) => eprintln!("Failed to send command: {}", e),
|
||||
}
|
||||
}
|
||||
else if seconds < -1
|
||||
{
|
||||
println!("All propulsion delay times are given in \
|
||||
seconds within the range of [0, {}]",
|
||||
u32::MAX);
|
||||
println!("A value of -1 will cancel any current \
|
||||
propulsion commands.");
|
||||
}
|
||||
else
|
||||
{
|
||||
let larger_seconds: u64 = seconds as u64;
|
||||
} else if seconds < -1 {
|
||||
println!(
|
||||
"All propulsion delay times are given in \
|
||||
seconds within the range of [{}, {}]",
|
||||
0,
|
||||
i32::MAX
|
||||
);
|
||||
println!(
|
||||
"A value of -1 will cancel any current \
|
||||
propulsion commands."
|
||||
);
|
||||
} else {
|
||||
// Time to create the Propulsion command.
|
||||
let delay_secs: u64 = seconds as u64;
|
||||
let delay_duration: Duration =
|
||||
Duration::from_secs(larger_seconds);
|
||||
match command_sender.try_send(Command::Propulsion { delay: delay_duration })
|
||||
{
|
||||
Ok(_) =>
|
||||
{
|
||||
println!("Firing the engines in {} seconds.",
|
||||
seconds)
|
||||
}
|
||||
Err(e) => println!("Failed to send command: {}", e)
|
||||
Duration::from_secs(delay_secs);
|
||||
match command_sender.try_send(Command::Propulsion {
|
||||
delay: delay_duration,
|
||||
}) {
|
||||
Ok(_) => {}
|
||||
Err(e) => eprintln!("Failed to send command: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) =>
|
||||
{
|
||||
Err(e) => {
|
||||
println!("Unable to parse the given input into seconds.");
|
||||
println!("Please specify only seconds until the time to \
|
||||
println!("{:?}\n", e);
|
||||
println!("Please specify only the desired whole seconds until the time to \
|
||||
fire the engines.");
|
||||
}
|
||||
}
|
||||
@ -109,69 +111,80 @@ fn read_user_input(command_sender: mpsc::Sender<Command>,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Determines if there is an interval that needs to be run or if
|
||||
/// there is nothing to really do.
|
||||
///
|
||||
fn maybe_tick<'a>(interval: Option<&'a mut Interval>)
|
||||
-> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
|
||||
{
|
||||
match interval
|
||||
{
|
||||
Some(interval) => Box::pin(async move { interval.tick().await; () }),
|
||||
None => Box::pin(std::future::pending())
|
||||
/// This was the trickiest part due to the syntax.
|
||||
fn maybe_tick<'a>(
|
||||
interval: Option<&'a mut Interval>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
|
||||
match interval {
|
||||
Some(interval) => Box::pin(async move {
|
||||
interval.tick().await;
|
||||
()
|
||||
}),
|
||||
|
||||
None => Box::pin(std::future::pending()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_commands(mut command_receiver: mpsc::Receiver<Command>,
|
||||
mut term_receiver: watch::Receiver<bool>)
|
||||
-> IoResult<()>
|
||||
{
|
||||
let mut propulsion_interval: Option<Interval> = None;
|
||||
/// The command task.
|
||||
///
|
||||
/// Processes the commands sent and fire the engine if a given
|
||||
/// propulsion command completes.
|
||||
async fn process_commands(
|
||||
mut command_receiver: mpsc::Receiver<Command>,
|
||||
mut term_receiver: watch::Receiver<ProgramState>,
|
||||
) -> IoResult<()> {
|
||||
let mut stdout = tokio::io::stdout();
|
||||
|
||||
let mut propulsion_interval: Option<Interval> = None;
|
||||
let mut running: bool = true;
|
||||
while running
|
||||
{
|
||||
|
||||
while running {
|
||||
tokio::select! {
|
||||
// Process commands.
|
||||
Some(command) = command_receiver.recv() =>
|
||||
{
|
||||
match command
|
||||
{
|
||||
Command::Cancel =>
|
||||
{
|
||||
stdout.write_all(b"Received: Cancel\n").await?;
|
||||
stdout.flush().await?;
|
||||
// Dump any interval that is running.
|
||||
propulsion_interval = None;
|
||||
}
|
||||
|
||||
Command::Propulsion { delay } =>
|
||||
{
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
writeln!(&mut buffer, "Received: {:?} delay", delay);
|
||||
stdout.write_all(&buffer).await?;
|
||||
stdout.flush().await?;
|
||||
|
||||
// Overwrite any interval with this great new interval.
|
||||
propulsion_interval = Some(tokio::time::interval(delay));
|
||||
|
||||
// Skip the first immediate tick
|
||||
// Skip the first immediate tick.
|
||||
if let Some(interval) = propulsion_interval.as_mut()
|
||||
{
|
||||
interval.tick().await; // skip first tick
|
||||
interval.tick().await;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the interval timer if there is one.
|
||||
_ = maybe_tick(propulsion_interval.as_mut()) =>
|
||||
{
|
||||
stdout.write_all(b"firing now!\n").await?;
|
||||
stdout.flush().await?;
|
||||
|
||||
// Clear the command out since it is done.
|
||||
propulsion_interval = None;
|
||||
}
|
||||
|
||||
// Listen for termination.
|
||||
_ = term_receiver.changed() =>
|
||||
{
|
||||
stdout.write_all(b"Communication task received shutdown message.").await?;
|
||||
stdout.flush().await?;
|
||||
running = *term_receiver.borrow_and_update();
|
||||
running = match *term_receiver.borrow_and_update()
|
||||
{
|
||||
ProgramState::Running => { true }
|
||||
ProgramState::Terminated => { false }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -181,20 +194,18 @@ async fn process_commands(mut command_receiver: mpsc::Receiver<Command>,
|
||||
|
||||
/// Program entry point.
|
||||
#[tokio::main]
|
||||
async fn main() -> IoResult<()>
|
||||
{
|
||||
async fn main() -> IoResult<()> {
|
||||
// The channel that will be used to send satellite commands between tasks.
|
||||
let (command_sender, mut command_receiver) = mpsc::channel::<Command>(100);
|
||||
let (command_sender, command_receiver) = mpsc::channel::<Command>(100);
|
||||
|
||||
// The channel that will be used to signal termination of the program.
|
||||
// True means the program is running. False means it has been terminated.
|
||||
//
|
||||
// This could be done as a satellite command since the program is driven by
|
||||
// user interaction and it all happens from the input_task, but this is a
|
||||
// program signal so I prefer to keep it seperate from the command messages.
|
||||
// It also allows for moving termination control to another task, say from
|
||||
// Ctrl+C, if the program was changed to use TCP or something else.
|
||||
let (term_sender, mut term_receiver) = watch::channel(true);
|
||||
let (term_sender, term_receiver) = watch::channel(ProgramState::Running);
|
||||
|
||||
// Spawn a task to handle reading input from the user. We use a thread here
|
||||
// because tokio recommends it for the blocking read calls for interactive
|
||||
@ -204,13 +215,42 @@ async fn main() -> IoResult<()>
|
||||
});
|
||||
|
||||
// Spawn a task to handle simulating the satellite.
|
||||
let sim_task = tokio::spawn(async move {
|
||||
let command_task = tokio::spawn(async move {
|
||||
process_commands(command_receiver, term_receiver).await
|
||||
});
|
||||
|
||||
let (sim_result, input_result) = tokio::join!(sim_task, input_task);
|
||||
sim_result?;
|
||||
input_result??;
|
||||
// Run both tasks at the same time.
|
||||
let (command_result, input_result) = tokio::join!(command_task, input_task);
|
||||
|
||||
// Handle errors from the tasks.
|
||||
match command_result {
|
||||
Ok(Ok(())) => {}
|
||||
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Command task failed: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
eprintln!("Command task panicked or was aborted: {}", e);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
}
|
||||
}
|
||||
|
||||
match input_result {
|
||||
Ok(Ok(())) => {}
|
||||
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Input task failed: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
eprintln!("Input task panicked or was aborted: {}", e);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
}
|
||||
}
|
||||
|
||||
println!("Shutdown complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
pub struct Satellite
|
||||
{
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user