Merge branch 'features/poll-validation' into 'develop'
Poll and votes pipeline ingestion Closes #1362 and #1852 See merge request pleroma/pleroma!2635
This commit is contained in:
commit
34cbe9f44a
29 changed files with 1033 additions and 207 deletions
|
|
@ -19,6 +19,7 @@ defmodule Pleroma.Emails.MailerTest do
|
|||
test "not send email when mailer is disabled" do
|
||||
Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false)
|
||||
Mailer.deliver(@email)
|
||||
:timer.sleep(100)
|
||||
|
||||
refute_email_sent(
|
||||
from: {"Pleroma", "noreply@example.com"},
|
||||
|
|
@ -30,6 +31,7 @@ defmodule Pleroma.Emails.MailerTest do
|
|||
|
||||
test "send email" do
|
||||
Mailer.deliver(@email)
|
||||
:timer.sleep(100)
|
||||
|
||||
assert_email_sent(
|
||||
from: {"Pleroma", "noreply@example.com"},
|
||||
|
|
@ -41,6 +43,7 @@ defmodule Pleroma.Emails.MailerTest do
|
|||
|
||||
test "perform" do
|
||||
Mailer.perform(:deliver_async, @email, [])
|
||||
:timer.sleep(100)
|
||||
|
||||
assert_email_sent(
|
||||
from: {"Pleroma", "noreply@example.com"},
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
"en": "<p>Why is Tenshi eating a corndog so cute?</p>"
|
||||
},
|
||||
"endTime": "2019-05-11T09:03:36Z",
|
||||
"closed": "2019-05-11T09:03:36Z",
|
||||
"attachment": [],
|
||||
"tag": [],
|
||||
"replies": {
|
||||
|
|
|
|||
99
test/fixtures/tesla_mock/poll_attachment.json
vendored
Normal file
99
test/fixtures/tesla_mock/poll_attachment.json
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://patch.cx/schemas/litepub-0.1.jsonld",
|
||||
{
|
||||
"@language": "und"
|
||||
}
|
||||
],
|
||||
"actor": "https://patch.cx/users/rin",
|
||||
"anyOf": [],
|
||||
"attachment": [
|
||||
{
|
||||
"mediaType": "image/jpeg",
|
||||
"name": "screenshot_mpv:Totoro@01:18:44.345.jpg",
|
||||
"type": "Document",
|
||||
"url": "https://shitposter.club/media/3bb4c4d402f8fdcc7f80963c3d7cf6f10f936897fd374922ade33199d2f86d87.jpg?name=screenshot_mpv%3ATotoro%4001%3A18%3A44.345.jpg"
|
||||
}
|
||||
],
|
||||
"attributedTo": "https://patch.cx/users/rin",
|
||||
"cc": [
|
||||
"https://patch.cx/users/rin/followers"
|
||||
],
|
||||
"closed": "2020-06-19T23:22:02.754678Z",
|
||||
"content": "<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"9vwjTNzEWEM1TfkBGq\" href=\"https://mastodon.sdf.org/users/rinpatch\" rel=\"ugc\">@<span>rinpatch</span></a></span>",
|
||||
"closed": "2019-09-19T00:32:36.785333",
|
||||
"content": "can you vote on this poll?",
|
||||
"id": "https://patch.cx/objects/tesla_mock/poll_attachment",
|
||||
"oneOf": [
|
||||
{
|
||||
"name": "a",
|
||||
"replies": {
|
||||
"totalItems": 0,
|
||||
"type": "Collection"
|
||||
},
|
||||
"type": "Note"
|
||||
},
|
||||
{
|
||||
"name": "A",
|
||||
"replies": {
|
||||
"totalItems": 0,
|
||||
"type": "Collection"
|
||||
},
|
||||
"type": "Note"
|
||||
},
|
||||
{
|
||||
"name": "Aa",
|
||||
"replies": {
|
||||
"totalItems": 0,
|
||||
"type": "Collection"
|
||||
},
|
||||
"type": "Note"
|
||||
},
|
||||
{
|
||||
"name": "AA",
|
||||
"replies": {
|
||||
"totalItems": 0,
|
||||
"type": "Collection"
|
||||
},
|
||||
"type": "Note"
|
||||
},
|
||||
{
|
||||
"name": "AAa",
|
||||
"replies": {
|
||||
"totalItems": 1,
|
||||
"type": "Collection"
|
||||
},
|
||||
"type": "Note"
|
||||
},
|
||||
{
|
||||
"name": "AAA",
|
||||
"replies": {
|
||||
"totalItems": 3,
|
||||
"type": "Collection"
|
||||
},
|
||||
"type": "Note"
|
||||
}
|
||||
],
|
||||
"published": "2020-06-19T23:12:02.786113Z",
|
||||
"sensitive": false,
|
||||
"summary": "",
|
||||
"tag": [
|
||||
{
|
||||
"href": "https://mastodon.sdf.org/users/rinpatch",
|
||||
"name": "@rinpatch@mastodon.sdf.org",
|
||||
"type": "Mention"
|
||||
}
|
||||
],
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public",
|
||||
"https://mastodon.sdf.org/users/rinpatch"
|
||||
],
|
||||
"type": "Question",
|
||||
"voters": [
|
||||
"https://shitposter.club/users/moonman",
|
||||
"https://skippers-bin.com/users/7v1w1r8ce6",
|
||||
"https://mastodon.sdf.org/users/rinpatch",
|
||||
"https://mastodon.social/users/emelie"
|
||||
]
|
||||
}
|
||||
|
|
@ -177,6 +177,13 @@ defmodule Pleroma.Object.FetcherTest do
|
|||
"https://mastodon.example.org/users/userisgone404"
|
||||
)
|
||||
end
|
||||
|
||||
test "it can fetch pleroma polls with attachments" do
|
||||
{:ok, object} =
|
||||
Fetcher.fetch_object_from_id("https://patch.cx/objects/tesla_mock/poll_attachment")
|
||||
|
||||
assert object
|
||||
end
|
||||
end
|
||||
|
||||
describe "pruning" do
|
||||
|
|
|
|||
|
|
@ -82,6 +82,14 @@ defmodule HttpRequestMock do
|
|||
}}
|
||||
end
|
||||
|
||||
def get("https://patch.cx/objects/tesla_mock/poll_attachment", _, _, _) do
|
||||
{:ok,
|
||||
%Tesla.Env{
|
||||
status: 200,
|
||||
body: File.read!("test/fixtures/tesla_mock/poll_attachment.json")
|
||||
}}
|
||||
end
|
||||
|
||||
def get(
|
||||
"https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/emelie",
|
||||
_,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidationTest do
|
|||
|
||||
{:error, cng} = ObjectValidator.validate(invalid_other_actor, [])
|
||||
|
||||
assert {:actor, {"is not allowed to delete object", []}} in cng.errors
|
||||
assert {:actor, {"is not allowed to modify object", []}} in cng.errors
|
||||
end
|
||||
|
||||
test "it's valid if the actor of the object is a local superuser",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.ActivityPub.Transmogrifier.AnswerHandlingTest do
|
||||
use Pleroma.DataCase
|
||||
|
||||
alias Pleroma.Activity
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Web.ActivityPub.Transmogrifier
|
||||
alias Pleroma.Web.CommonAPI
|
||||
|
||||
import Pleroma.Factory
|
||||
|
||||
setup_all do
|
||||
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "incoming, rewrites Note to Answer and increments vote counters" do
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, activity} =
|
||||
CommonAPI.post(user, %{
|
||||
status: "suya...",
|
||||
poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
|
||||
})
|
||||
|
||||
object = Object.normalize(activity)
|
||||
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-vote.json")
|
||||
|> Poison.decode!()
|
||||
|> Kernel.put_in(["to"], user.ap_id)
|
||||
|> Kernel.put_in(["object", "inReplyTo"], object.data["id"])
|
||||
|> Kernel.put_in(["object", "to"], user.ap_id)
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
answer_object = Object.normalize(activity)
|
||||
assert answer_object.data["type"] == "Answer"
|
||||
assert answer_object.data["inReplyTo"] == object.data["id"]
|
||||
|
||||
new_object = Object.get_by_ap_id(object.data["id"])
|
||||
assert new_object.data["replies_count"] == object.data["replies_count"]
|
||||
|
||||
assert Enum.any?(
|
||||
new_object.data["oneOf"],
|
||||
fn
|
||||
%{"name" => "suya..", "replies" => %{"totalItems" => 1}} -> true
|
||||
_ -> false
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
test "outgoing, rewrites Answer to Note" do
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, poll_activity} =
|
||||
CommonAPI.post(user, %{
|
||||
status: "suya...",
|
||||
poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
|
||||
})
|
||||
|
||||
poll_object = Object.normalize(poll_activity)
|
||||
# TODO: Replace with CommonAPI vote creation when implemented
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-vote.json")
|
||||
|> Poison.decode!()
|
||||
|> Kernel.put_in(["to"], user.ap_id)
|
||||
|> Kernel.put_in(["object", "inReplyTo"], poll_object.data["id"])
|
||||
|> Kernel.put_in(["object", "to"], user.ap_id)
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
|
||||
|
||||
assert data["object"]["type"] == "Note"
|
||||
end
|
||||
end
|
||||
123
test/web/activity_pub/transmogrifier/question_handling_test.exs
Normal file
123
test/web/activity_pub/transmogrifier/question_handling_test.exs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do
|
||||
use Pleroma.DataCase
|
||||
|
||||
alias Pleroma.Activity
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Web.ActivityPub.Transmogrifier
|
||||
alias Pleroma.Web.CommonAPI
|
||||
|
||||
import Pleroma.Factory
|
||||
|
||||
setup_all do
|
||||
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "Mastodon Question activity" do
|
||||
data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!()
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
|
||||
object = Object.normalize(activity, false)
|
||||
|
||||
assert object.data["closed"] == "2019-05-11T09:03:36Z"
|
||||
|
||||
assert object.data["context"] == activity.data["context"]
|
||||
|
||||
assert object.data["context"] ==
|
||||
"tag:mastodon.sdf.org,2019-05-10:objectId=15095122:objectType=Conversation"
|
||||
|
||||
assert object.data["context_id"]
|
||||
|
||||
assert object.data["anyOf"] == []
|
||||
|
||||
assert Enum.sort(object.data["oneOf"]) ==
|
||||
Enum.sort([
|
||||
%{
|
||||
"name" => "25 char limit is dumb",
|
||||
"replies" => %{"totalItems" => 0, "type" => "Collection"},
|
||||
"type" => "Note"
|
||||
},
|
||||
%{
|
||||
"name" => "Dunno",
|
||||
"replies" => %{"totalItems" => 0, "type" => "Collection"},
|
||||
"type" => "Note"
|
||||
},
|
||||
%{
|
||||
"name" => "Everyone knows that!",
|
||||
"replies" => %{"totalItems" => 1, "type" => "Collection"},
|
||||
"type" => "Note"
|
||||
},
|
||||
%{
|
||||
"name" => "I can't even fit a funny",
|
||||
"replies" => %{"totalItems" => 1, "type" => "Collection"},
|
||||
"type" => "Note"
|
||||
}
|
||||
])
|
||||
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, reply_activity} = CommonAPI.post(user, %{status: "hewwo", in_reply_to_id: activity.id})
|
||||
|
||||
reply_object = Object.normalize(reply_activity, false)
|
||||
|
||||
assert reply_object.data["context"] == object.data["context"]
|
||||
assert reply_object.data["context_id"] == object.data["context_id"]
|
||||
end
|
||||
|
||||
test "Mastodon Question activity with HTML tags in plaintext" do
|
||||
options = [
|
||||
%{
|
||||
"type" => "Note",
|
||||
"name" => "<input type=\"date\">",
|
||||
"replies" => %{"totalItems" => 0, "type" => "Collection"}
|
||||
},
|
||||
%{
|
||||
"type" => "Note",
|
||||
"name" => "<input type=\"date\"/>",
|
||||
"replies" => %{"totalItems" => 0, "type" => "Collection"}
|
||||
},
|
||||
%{
|
||||
"type" => "Note",
|
||||
"name" => "<input type=\"date\" />",
|
||||
"replies" => %{"totalItems" => 1, "type" => "Collection"}
|
||||
},
|
||||
%{
|
||||
"type" => "Note",
|
||||
"name" => "<input type=\"date\"></input>",
|
||||
"replies" => %{"totalItems" => 1, "type" => "Collection"}
|
||||
}
|
||||
]
|
||||
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-question-activity.json")
|
||||
|> Poison.decode!()
|
||||
|> Kernel.put_in(["object", "oneOf"], options)
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
object = Object.normalize(activity, false)
|
||||
|
||||
assert Enum.sort(object.data["oneOf"]) == Enum.sort(options)
|
||||
end
|
||||
|
||||
test "returns an error if received a second time" do
|
||||
data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!()
|
||||
|
||||
assert {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
|
||||
assert {:error, {:validate_object, {:error, _}}} = Transmogrifier.handle_incoming(data)
|
||||
end
|
||||
|
||||
test "accepts a Question with no content" do
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-question-activity.json")
|
||||
|> Poison.decode!()
|
||||
|> Kernel.put_in(["object", "content"], "")
|
||||
|
||||
assert {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(data)
|
||||
end
|
||||
end
|
||||
|
|
@ -225,23 +225,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
assert Enum.at(object.data["tag"], 2) == "moo"
|
||||
end
|
||||
|
||||
test "it works for incoming questions" do
|
||||
data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!()
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
|
||||
object = Object.normalize(activity)
|
||||
|
||||
assert Enum.all?(object.data["oneOf"], fn choice ->
|
||||
choice["name"] in [
|
||||
"Dunno",
|
||||
"Everyone knows that!",
|
||||
"25 char limit is dumb",
|
||||
"I can't even fit a funny"
|
||||
]
|
||||
end)
|
||||
end
|
||||
|
||||
test "it works for incoming listens" do
|
||||
data = %{
|
||||
"@context" => "https://www.w3.org/ns/activitystreams",
|
||||
|
|
@ -271,38 +254,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
assert object.data["length"] == 180_000
|
||||
end
|
||||
|
||||
test "it rewrites Note votes to Answers and increments vote counters on question activities" do
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, activity} =
|
||||
CommonAPI.post(user, %{
|
||||
status: "suya...",
|
||||
poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
|
||||
})
|
||||
|
||||
object = Object.normalize(activity)
|
||||
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-vote.json")
|
||||
|> Poison.decode!()
|
||||
|> Kernel.put_in(["to"], user.ap_id)
|
||||
|> Kernel.put_in(["object", "inReplyTo"], object.data["id"])
|
||||
|> Kernel.put_in(["object", "to"], user.ap_id)
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
answer_object = Object.normalize(activity)
|
||||
assert answer_object.data["type"] == "Answer"
|
||||
object = Object.get_by_ap_id(object.data["id"])
|
||||
|
||||
assert Enum.any?(
|
||||
object.data["oneOf"],
|
||||
fn
|
||||
%{"name" => "suya..", "replies" => %{"totalItems" => 1}} -> true
|
||||
_ -> false
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
test "it works for incoming notices with contentMap" do
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!()
|
||||
|
|
@ -677,7 +628,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
%{
|
||||
"href" =>
|
||||
"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
|
||||
"mediaType" => "video/mp4"
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Link"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -696,7 +648,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
%{
|
||||
"href" =>
|
||||
"https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4",
|
||||
"mediaType" => "video/mp4"
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Link"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1269,30 +1222,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
end
|
||||
end
|
||||
|
||||
test "Rewrites Answers to Notes" do
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, poll_activity} =
|
||||
CommonAPI.post(user, %{
|
||||
status: "suya...",
|
||||
poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
|
||||
})
|
||||
|
||||
poll_object = Object.normalize(poll_activity)
|
||||
# TODO: Replace with CommonAPI vote creation when implemented
|
||||
data =
|
||||
File.read!("test/fixtures/mastodon-vote.json")
|
||||
|> Poison.decode!()
|
||||
|> Kernel.put_in(["to"], user.ap_id)
|
||||
|> Kernel.put_in(["object", "inReplyTo"], poll_object.data["id"])
|
||||
|> Kernel.put_in(["object", "to"], user.ap_id)
|
||||
|
||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data)
|
||||
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
|
||||
|
||||
assert data["object"]["type"] == "Note"
|
||||
end
|
||||
|
||||
describe "fix_explicit_addressing" do
|
||||
setup do
|
||||
user = insert(:user)
|
||||
|
|
@ -1540,8 +1469,13 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
"attachment" => [
|
||||
%{
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Document",
|
||||
"url" => [
|
||||
%{"href" => "https://peertube.moe/stat-480.mp4", "mediaType" => "video/mp4"}
|
||||
%{
|
||||
"href" => "https://peertube.moe/stat-480.mp4",
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Link"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1558,14 +1492,24 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
|
|||
"attachment" => [
|
||||
%{
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Document",
|
||||
"url" => [
|
||||
%{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"}
|
||||
%{
|
||||
"href" => "https://pe.er/stat-480.mp4",
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Link"
|
||||
}
|
||||
]
|
||||
},
|
||||
%{
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Document",
|
||||
"url" => [
|
||||
%{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"}
|
||||
%{
|
||||
"href" => "https://pe.er/stat-480.mp4",
|
||||
"mediaType" => "video/mp4",
|
||||
"type" => "Link"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -135,4 +135,33 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
|
|||
assert result[:expires_at] == nil
|
||||
assert result[:expired] == false
|
||||
end
|
||||
|
||||
test "doesn't strips HTML tags" do
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, activity} =
|
||||
CommonAPI.post(user, %{
|
||||
status: "What's with the smug face?",
|
||||
poll: %{
|
||||
options: [
|
||||
"<input type=\"date\">",
|
||||
"<input type=\"date\" >",
|
||||
"<input type=\"date\"/>",
|
||||
"<input type=\"date\"></input>"
|
||||
],
|
||||
expires_in: 20
|
||||
}
|
||||
})
|
||||
|
||||
object = Object.normalize(activity)
|
||||
|
||||
assert %{
|
||||
options: [
|
||||
%{title: "<input type=\"date\">", votes_count: 0},
|
||||
%{title: "<input type=\"date\" >", votes_count: 0},
|
||||
%{title: "<input type=\"date\"/>", votes_count: 0},
|
||||
%{title: "<input type=\"date\"></input>", votes_count: 0}
|
||||
]
|
||||
} = PollView.render("show.json", %{object: object})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue