init
This commit is contained in:
commit
8045480513
24
.config/hakari.toml
Normal file
24
.config/hakari.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
# This file contains settings for `cargo hakari`.
|
||||
# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options.
|
||||
|
||||
hakari-package = "my-workspace-hack"
|
||||
|
||||
# Format version for hakari's output. Version 4 requires cargo-hakari 0.9.22 or above.
|
||||
dep-format-version = "4"
|
||||
|
||||
# Setting workspace.resolver = "2" in the root Cargo.toml is HIGHLY recommended.
|
||||
# Hakari works much better with the new feature resolver.
|
||||
# For more about the new feature resolver, see:
|
||||
# https://blog.rust-lang.org/2021/03/25/Rust-1.51.0.html#cargos-new-feature-resolver
|
||||
resolver = "2"
|
||||
|
||||
# Add triples corresponding to platforms commonly used by developers here.
|
||||
# https://doc.rust-lang.org/rustc/platform-support.html
|
||||
platforms = [
|
||||
# "x86_64-unknown-linux-gnu",
|
||||
# "x86_64-apple-darwin",
|
||||
# "x86_64-pc-windows-msvc",
|
||||
]
|
||||
|
||||
# Write out exact versions rather than a semver range. (Defaults to false.)
|
||||
# exact-versions = true
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
result*
|
3551
Cargo.lock
generated
Normal file
3551
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [ "common", "consumer",
|
||||
"producer",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.metadata.crane]
|
||||
name = "producer-consumer"
|
1
common/.gitignore
vendored
Normal file
1
common/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
10
common/Cargo.toml
Normal file
10
common/Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = {version="1.0.203", features=["derive"]}
|
||||
serde_json = "1.0.117"
|
||||
derive-getters = "0.4.0"
|
||||
derive_builder = "0.20.0"
|
15
common/src/lib.rs
Normal file
15
common/src/lib.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
use derive_builder::Builder;
|
||||
use derive_getters::Getters;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Builder, Getters, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EmailJob {
|
||||
to: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Builder, Getters, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SMSJob {
|
||||
number: String,
|
||||
content: String,
|
||||
}
|
1
consumer/.gitignore
vendored
Normal file
1
consumer/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
26
consumer/Cargo.toml
Normal file
26
consumer/Cargo.toml
Normal file
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "consumer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
amiquip = "0.4.2"
|
||||
serde = {version="1.0.203", features=["derive"]}
|
||||
serde_json = "1.0.117"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = "0.7.11"
|
||||
apalis = { version = "0.6.0-rc.3", features = ["layers", "sentry", ] }
|
||||
apalis-redis = { version = "0.6.0-rc.3" }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
env_logger = "0.11.3"
|
||||
log = "0.4.21"
|
||||
derive-getters = "0.4.0"
|
||||
derive_builder = "0.20.0"
|
||||
dotenv = "0.15.0"
|
||||
lapin = "2.5.0"
|
||||
uuid = { version = "1.10.0", features = ["v4"] }
|
||||
futures-lite = "2.3.0"
|
||||
|
||||
[dependencies.common]
|
||||
path = "../common"
|
||||
|
73
consumer/src/main.rs
Normal file
73
consumer/src/main.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
// Port of https://www.rabbitmq.com/tutorials/tutorial-six-python.html. Start this
|
||||
// example in one shell, then the rpc_client example in another.
|
||||
use amiquip::{
|
||||
AmqpProperties, Connection, ConsumerMessage, ConsumerOptions, Exchange, Publish,
|
||||
QueueDeclareOptions, Result,
|
||||
};
|
||||
|
||||
fn fib(n: u64) -> u64 {
|
||||
match n {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
n => fib(n - 1) + fib(n - 2),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
// Open connection.
|
||||
let mut connection = Connection::insecure_open("amqp://guest:guest@localhost:5672")?;
|
||||
|
||||
// Open a channel - None says let the library choose the channel ID.
|
||||
let channel = connection.open_channel(None)?;
|
||||
|
||||
// Get a handle to the default direct exchange.
|
||||
let exchange = Exchange::direct(&channel);
|
||||
|
||||
// Declare the queue that will receive RPC requests.
|
||||
let queue = channel.queue_declare("rpc_queue", QueueDeclareOptions::default())?;
|
||||
|
||||
// Start a consumer.
|
||||
let consumer = queue.consume(ConsumerOptions::default())?;
|
||||
println!("Awaiting RPC requests");
|
||||
|
||||
for (i, message) in consumer.receiver().iter().enumerate() {
|
||||
match message {
|
||||
ConsumerMessage::Delivery(delivery) => {
|
||||
let body = String::from_utf8_lossy(&delivery.body);
|
||||
println!("({:>3}) fib({})", i, body);
|
||||
|
||||
let (reply_to, corr_id) = match (
|
||||
delivery.properties.reply_to(),
|
||||
delivery.properties.correlation_id(),
|
||||
) {
|
||||
(Some(r), Some(c)) => (r.clone(), c.clone()),
|
||||
_ => {
|
||||
println!("received delivery without reply_to or correlation_id");
|
||||
consumer.ack(delivery)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response = match body.parse() {
|
||||
Ok(n) => format!("{}", fib(n)),
|
||||
Err(_) => "invalid input".to_string(),
|
||||
};
|
||||
|
||||
exchange.publish(Publish::with_properties(
|
||||
response.as_bytes(),
|
||||
reply_to,
|
||||
AmqpProperties::default().with_correlation_id(corr_id),
|
||||
))?;
|
||||
consumer.ack(delivery)?;
|
||||
}
|
||||
other => {
|
||||
println!("Consumer ended: {:?}", other);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.close()
|
||||
}
|
11
deny.toml
Normal file
11
deny.toml
Normal file
|
@ -0,0 +1,11 @@
|
|||
[bans]
|
||||
multiple-versions = 'allow'
|
||||
|
||||
[licenses]
|
||||
private = { ignore = true }
|
||||
allow = [
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"MIT",
|
||||
"Unicode-DFS-2016",
|
||||
]
|
7
docker-compose.yml
Normal file
7
docker-compose.yml
Normal file
|
@ -0,0 +1,7 @@
|
|||
services:
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management-alpine
|
||||
container_name: "rabbitmq"
|
||||
ports:
|
||||
- 5672:5672
|
||||
- 15672:15672
|
121
flake.lock
Normal file
121
flake.lock
Normal file
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"nodes": {
|
||||
"advisory-db": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1724421788,
|
||||
"narHash": "sha256-kKWEQL4x6FFhDc7xRtoAlgB57DjigoqBerO2PG/IgS4=",
|
||||
"owner": "rustsec",
|
||||
"repo": "advisory-db",
|
||||
"rev": "1bc15cb78d189f47729ae15bf58f62ad91b45b9c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rustsec",
|
||||
"repo": "advisory-db",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724377159,
|
||||
"narHash": "sha256-ixjje1JO8ucKT41hs6n2NCde1Vc0+Zc2p2gUbJpCsMw=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "3e47b7a86c19142bd3675da49d6acef488b4dac1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"fenix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-analyzer-src": []
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724394478,
|
||||
"narHash": "sha256-JSiv2uwI4UJo/4pxt4255BfzWCiEberJmBC6pCxnzas=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "2e7bcdc4ef73aa56abd2f970e5a8b9c0cc87e614",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1724363052,
|
||||
"narHash": "sha256-Nf/iQWamRVAwAPFccQMfm5Qcf+rLLnU1rWG3f9orDVE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5de1564aed415bf9d0f281461babc2d101dd49ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"advisory-db": "advisory-db",
|
||||
"crane": "crane",
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
192
flake.nix
Normal file
192
flake.nix
Normal file
|
@ -0,0 +1,192 @@
|
|||
{
|
||||
description = "Producer and consumer in rust using rabbitmq";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
crane = {
|
||||
url = "github:ipetkov/crane";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
fenix = {
|
||||
url = "github:nix-community/fenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.rust-analyzer-src.follows = "";
|
||||
};
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
advisory-db = {
|
||||
url = "github:rustsec/advisory-db";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
inherit (pkgs) lib;
|
||||
|
||||
craneLib = crane.mkLib pkgs;
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
|
||||
# Common arguments can be set here to avoid repeating them later
|
||||
commonArgs = {
|
||||
inherit src;
|
||||
strictDeps = true;
|
||||
|
||||
buildInputs = [
|
||||
# Add additional build inputs here
|
||||
] ++ lib.optionals pkgs.stdenv.isDarwin [
|
||||
# Additional darwin specific inputs can be set here
|
||||
pkgs.libiconv
|
||||
];
|
||||
|
||||
# Additional environment variables can be set directly
|
||||
# MY_CUSTOM_VAR = "some value";
|
||||
};
|
||||
|
||||
craneLibLLvmTools = craneLib.overrideToolchain
|
||||
(fenix.packages.${system}.complete.withComponents [
|
||||
"cargo"
|
||||
"llvm-tools"
|
||||
"rustc"
|
||||
]);
|
||||
|
||||
# Build *just* the cargo dependencies (of the entire workspace),
|
||||
# so we can reuse all of that work (e.g. via cachix) when running in CI
|
||||
# It is *highly* recommended to use something like cargo-hakari to avoid
|
||||
# cache misses when building individual top-level-crates
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
individualCrateArgs = commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
|
||||
# NB: we disable tests since we'll run them all via cargo-nextest
|
||||
doCheck = false;
|
||||
};
|
||||
|
||||
fileSetForCrate = crate: lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
./Cargo.toml
|
||||
./Cargo.lock
|
||||
./producer
|
||||
./consumer
|
||||
crate
|
||||
];
|
||||
};
|
||||
|
||||
# Build the top-level crates of the workspace as individual derivations.
|
||||
# This allows consumers to only depend on (and build) only what they need.
|
||||
# Though it is possible to build the entire workspace as a single derivation,
|
||||
# so this is left up to you on how to organize things
|
||||
producer = craneLib.buildPackage (individualCrateArgs // {
|
||||
pname = "producer";
|
||||
cargoExtraArgs = "-p producer";
|
||||
src = fileSetForCrate ./producer;
|
||||
});
|
||||
consumer = craneLib.buildPackage (individualCrateArgs // {
|
||||
pname = "consumer";
|
||||
cargoExtraArgs = "-p consumer";
|
||||
src = fileSetForCrate ./consumer;
|
||||
});
|
||||
in
|
||||
{
|
||||
checks = {
|
||||
# Build the crates as part of `nix flake check` for convenience
|
||||
inherit producer consumer;
|
||||
|
||||
# Run clippy (and deny all warnings) on the workspace source,
|
||||
# again, reusing the dependency artifacts from above.
|
||||
#
|
||||
# Note that this is done as a separate derivation so that
|
||||
# we can block the CI if there are issues here, but not
|
||||
# prevent downstream consumers from building our crate by itself.
|
||||
producer-consumer-clippy = craneLib.cargoClippy (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
|
||||
});
|
||||
|
||||
producer-consumer-doc = craneLib.cargoDoc (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
});
|
||||
|
||||
# Check formatting
|
||||
producer-consumer-fmt = craneLib.cargoFmt {
|
||||
inherit src;
|
||||
};
|
||||
|
||||
# Audit dependencies
|
||||
producer-consumer-audit = craneLib.cargoAudit {
|
||||
inherit src advisory-db;
|
||||
};
|
||||
|
||||
# Audit licenses
|
||||
producer-consumer-deny = craneLib.cargoDeny {
|
||||
inherit src;
|
||||
};
|
||||
|
||||
# Run tests with cargo-nextest
|
||||
# Consider setting `doCheck = false` on other crate derivations
|
||||
# if you do not want the tests to run twice
|
||||
producer-consumer-nextest = craneLib.cargoNextest (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
partitions = 1;
|
||||
partitionType = "count";
|
||||
});
|
||||
|
||||
# Ensure that cargo-hakari is up to date
|
||||
producer-consumer-hakari = craneLib.mkCargoDerivation {
|
||||
inherit src;
|
||||
pname = "producer-consumer-hakari";
|
||||
cargoArtifacts = null;
|
||||
doInstallCargoArtifacts = false;
|
||||
|
||||
buildPhaseCargoCommand = ''
|
||||
cargo hakari generate --diff # workspace-hack Cargo.toml is up-to-date
|
||||
cargo hakari manage-deps --dry-run # all workspace crates depend on workspace-hack
|
||||
cargo hakari verify
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.cargo-hakari
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
packages = {
|
||||
inherit consumer producer;
|
||||
} // lib.optionalAttrs (!pkgs.stdenv.isDarwin) {
|
||||
producer-consumer-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
});
|
||||
};
|
||||
|
||||
apps = {
|
||||
producer = flake-utils.lib.mkApp {
|
||||
drv = producer;
|
||||
};
|
||||
consumer = flake-utils.lib.mkApp {
|
||||
drv = consumer;
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
# Inherit inputs from checks.
|
||||
checks = self.checks.${system};
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages_17.libclang.lib}/lib";
|
||||
RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}";
|
||||
packages = with pkgs; [
|
||||
openssl
|
||||
pkg-config
|
||||
libiconv
|
||||
cargo-hakari
|
||||
cargo-nextest # testing
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
1
producer/.gitignore
vendored
Normal file
1
producer/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
27
producer/Cargo.toml
Normal file
27
producer/Cargo.toml
Normal file
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "producer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
amiquip = "0.4.2"
|
||||
serde = {version="1.0.203", features=["derive"]}
|
||||
serde_json = "1.0.117"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = "0.7.11"
|
||||
apalis = { version = "0.6.0-rc.3", features = ["layers", "sentry", ] }
|
||||
apalis-redis = { version = "0.6.0-rc.3" }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
env_logger = "0.11.3"
|
||||
log = "0.4.21"
|
||||
derive-getters = "0.4.0"
|
||||
derive_builder = "0.20.0"
|
||||
dotenv = "0.15.0"
|
||||
lapin = "2.5.0"
|
||||
uuid = { version = "1.10.0", features = ["v4"] }
|
||||
futures-lite = "2.3.0"
|
||||
|
||||
[dependencies.common]
|
||||
path = "../common"
|
||||
|
||||
|
53
producer/src/main.rs
Normal file
53
producer/src/main.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use std::{thread, time::Duration};
|
||||
|
||||
use common::EmailJobBuilder;
|
||||
use lapin::{
|
||||
options::*, publisher_confirm::Confirmation, types::FieldTable, BasicProperties, Connection,
|
||||
ConnectionProperties, Result,
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use uuid::Uuid;
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
let addr = std::env::var("AMQP_ADDR").unwrap_or_else(|_| "amqp://127.0.0.1:5672/%2f".into());
|
||||
let conn = Connection::connect(&addr, ConnectionProperties::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_a = conn.create_channel().await.unwrap();
|
||||
|
||||
let email = EmailJobBuilder::default()
|
||||
.to("someone@example.com".into())
|
||||
.content("test".into())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
loop {
|
||||
let request = Uuid::new_v4();
|
||||
let confirm = channel_a
|
||||
.basic_publish(
|
||||
"",
|
||||
"email",
|
||||
BasicPublishOptions::default(),
|
||||
serde_json::to_string(&email).unwrap().as_bytes(),
|
||||
BasicProperties::default().with_correlation_id(request.to_string().into()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
log::info!(
|
||||
"Got response for :{} \n {:?}",
|
||||
request,
|
||||
confirm.take_message()
|
||||
);
|
||||
|
||||
thread::sleep(Duration::from_millis(250))
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue