feat: Add notifications
This commit is contained in:
parent
2d2853d363
commit
f9f4bbf19f
15 changed files with 274 additions and 32 deletions
|
@ -6,7 +6,7 @@
|
||||||
"botan-math": "1.0.3",
|
"botan-math": "1.0.3",
|
||||||
"ddmp": "0.0.1-0.dev.3",
|
"ddmp": "0.0.1-0.dev.3",
|
||||||
"diet-ng": "1.5.0",
|
"diet-ng": "1.5.0",
|
||||||
"eventcore": "0.8.39",
|
"eventcore": "0.8.40",
|
||||||
"fluent-asserts": "0.12.3",
|
"fluent-asserts": "0.12.3",
|
||||||
"libasync": "0.8.3",
|
"libasync": "0.8.3",
|
||||||
"libdparse": "0.8.8",
|
"libdparse": "0.8.8",
|
||||||
|
@ -14,9 +14,9 @@
|
||||||
"memutils": "0.4.13",
|
"memutils": "0.4.13",
|
||||||
"mir-linux-kernel": "1.0.1",
|
"mir-linux-kernel": "1.0.1",
|
||||||
"openssl": "1.1.6+1.0.1g",
|
"openssl": "1.1.6+1.0.1g",
|
||||||
"stdx-allocator": "2.77.4",
|
"stdx-allocator": "2.77.5",
|
||||||
"taggedalgebraic": "0.10.12",
|
"taggedalgebraic": "0.10.12",
|
||||||
"vibe-core": "1.4.5",
|
"vibe-core": "1.4.6",
|
||||||
"vibe-d": "0.8.4"
|
"vibe-d": "0.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
module d_webservice_example.application;
|
module d_webservice_example.application;
|
||||||
|
|
||||||
import aermicioi.aedi : locate, singleton;
|
import aermicioi.aedi : Container, locate, singleton;
|
||||||
import vibe.vibe;
|
import vibe.http.router : URLRouter;
|
||||||
|
|
||||||
void main() @safe
|
void main() @safe
|
||||||
{
|
{
|
||||||
import d_webservice_example.component_registration : registerComponents;
|
import d_webservice_example.component_registration : registerComponents;
|
||||||
import d_webservice_example.controller.todo_controller : TodoController;
|
import vibe.core.core : runApplication;
|
||||||
|
import vibe.http.server : HTTPServerSettings, listenHTTP;
|
||||||
|
|
||||||
|
setupLogging;
|
||||||
|
|
||||||
auto container = singleton;
|
auto container = singleton;
|
||||||
scope (exit)
|
scope (exit)
|
||||||
|
@ -15,12 +18,38 @@ void main() @safe
|
||||||
container.registerComponents;
|
container.registerComponents;
|
||||||
container.instantiate;
|
container.instantiate;
|
||||||
|
|
||||||
auto router = new URLRouter;
|
|
||||||
router.registerRestInterface(container.locate!TodoController);
|
|
||||||
auto settings = new HTTPServerSettings;
|
auto settings = new HTTPServerSettings;
|
||||||
settings.port = 8080;
|
settings.port = 8080;
|
||||||
settings.bindAddresses = ["::", "0.0.0.0"];
|
settings.bindAddresses = ["::", "0.0.0.0"];
|
||||||
|
|
||||||
|
auto router = setupRouter(container);
|
||||||
listenHTTP(settings, router);
|
listenHTTP(settings, router);
|
||||||
|
|
||||||
runApplication;
|
runApplication;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setupLogging() nothrow @safe
|
||||||
|
{
|
||||||
|
import vibe.core.log : setLogFormat, FileLogger;
|
||||||
|
|
||||||
|
debug
|
||||||
|
{
|
||||||
|
import vibe.core.log : LogLevel, setLogLevel;
|
||||||
|
|
||||||
|
setLogLevel(LogLevel.diagnostic);
|
||||||
|
}
|
||||||
|
setLogFormat(FileLogger.Format.threadTime, FileLogger.Format.threadTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
URLRouter setupRouter(Container container) @safe
|
||||||
|
{
|
||||||
|
import d_webservice_example.controller.todo_controller : TodoController;
|
||||||
|
import d_webservice_example.controller.todo_notification_controller : TodoNotificationController;
|
||||||
|
import vibe.web.rest : registerRestInterface;
|
||||||
|
import vibe.web.web : registerWebInterface;
|
||||||
|
|
||||||
|
auto router = new URLRouter;
|
||||||
|
router.registerRestInterface(container.locate!TodoController);
|
||||||
|
router.registerWebInterface(container.locate!TodoNotificationController);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
module d_webservice_example.business.todo_notification_service;
|
||||||
|
|
||||||
|
class TodoNotificationService
|
||||||
|
{
|
||||||
|
import d_webservice_example.data.todo_notification : TodoNotification;
|
||||||
|
import d_webservice_example.enums.todo_notification_type : TodoNotificationType;
|
||||||
|
import d_webservice_example.mapper.todo_mapper : asTodoTO;
|
||||||
|
import d_webservice_example.model.todo : Todo;
|
||||||
|
import std.traits : EnumMembers;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool[void delegate(scope TodoNotification) @safe] listeners;
|
||||||
|
|
||||||
|
public:
|
||||||
|
this() const nothrow pure @nogc @safe
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerListener(scope void delegate(scope const TodoNotification) @safe listener) @safe
|
||||||
|
{
|
||||||
|
listeners[listener] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void unRegisterListener(scope void delegate(scope const TodoNotification) @safe listener) @safe
|
||||||
|
{
|
||||||
|
listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
static foreach (method; EnumMembers!TodoNotificationType)
|
||||||
|
{
|
||||||
|
import std.conv: to;
|
||||||
|
import std.format : format;
|
||||||
|
mixin(format!q{
|
||||||
|
void onTodo%1$sd(const Todo todo) @safe
|
||||||
|
{
|
||||||
|
import std.algorithm.iteration : each;
|
||||||
|
|
||||||
|
immutable todoChange = TodoNotification(TodoNotificationType.%1$s, todo);
|
||||||
|
listeners.byKey.each!(listener => listener(todoChange));
|
||||||
|
}
|
||||||
|
}(method.to!string));
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,9 @@ import std.uuid : UUID, randomUUID;
|
||||||
|
|
||||||
class TodoService
|
class TodoService
|
||||||
{
|
{
|
||||||
|
import d_webservice_example.business.todo_notification_service : TodoNotificationService;
|
||||||
import d_webservice_example.data.todo_update_do : TodoUpdateDO;
|
import d_webservice_example.data.todo_update_do : TodoUpdateDO;
|
||||||
|
import vibe.core.log : logInfo;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
TodoRepository todoRepository;
|
TodoRepository todoRepository;
|
||||||
|
@ -17,10 +19,12 @@ public:
|
||||||
this.todoRepository = todoRepository;
|
this.todoRepository = todoRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
Todo createTodo(Todo newTodo) @safe
|
Todo createTodo(const Todo newTodo) @safe
|
||||||
{
|
{
|
||||||
immutable todo = Todo(newTodo.title, newTodo.content, randomUUID);
|
immutable createdTodo = todoRepository.save(Todo(newTodo.title,
|
||||||
return todoRepository.save(todo);
|
newTodo.content, randomUUID));
|
||||||
|
logInfo("Created todo %s", createdTodo);
|
||||||
|
return createdTodo;
|
||||||
}
|
}
|
||||||
|
|
||||||
Todo[] getAllTodos() @safe
|
Todo[] getAllTodos() @safe
|
||||||
|
@ -46,13 +50,17 @@ public:
|
||||||
if (todoUpdate.content != null)
|
if (todoUpdate.content != null)
|
||||||
todo.content = todoUpdate.content;
|
todo.content = todoUpdate.content;
|
||||||
|
|
||||||
return todoRepository.save(todo);
|
immutable updatedTodo = todoRepository.save(todo);
|
||||||
|
logInfo("Updated todo %s", updatedTodo);
|
||||||
|
return updatedTodo;
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteTodo(UUID uuid) @safe
|
Todo deleteTodo(UUID uuid) @safe
|
||||||
{
|
{
|
||||||
immutable todo = getTodoByUuid(uuid);
|
immutable todo = getTodoByUuid(uuid);
|
||||||
todoRepository.remove(todo);
|
todoRepository.remove(todo);
|
||||||
|
logInfo("Deleted todo %s", todo);
|
||||||
|
return todo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,11 +4,20 @@ import aermicioi.aedi;
|
||||||
|
|
||||||
void registerComponents(ConfigurableContainer container) @safe
|
void registerComponents(ConfigurableContainer container) @safe
|
||||||
{
|
{
|
||||||
|
import d_webservice_example.business.todo_notification_service : TodoNotificationService;
|
||||||
import d_webservice_example.business.todo_service : TodoRepository, TodoService;
|
import d_webservice_example.business.todo_service : TodoRepository, TodoService;
|
||||||
import d_webservice_example.controller.todo_controller : TodoController;
|
import d_webservice_example.controller.todo_controller : TodoController;
|
||||||
|
import d_webservice_example.controller.todo_notification_controller : TodoNotificationController;
|
||||||
import d_webservice_example.dataaccess.in_memory_todo_repository : InMemoryTodoRepository;
|
import d_webservice_example.dataaccess.in_memory_todo_repository : InMemoryTodoRepository;
|
||||||
|
import d_webservice_example.facade.todo_facade : TodoFacade;
|
||||||
|
|
||||||
container.configure.register!(TodoRepository, InMemoryTodoRepository);
|
with (container.configure)
|
||||||
container.configure.register!TodoService.autowire;
|
{
|
||||||
container.configure.register!TodoController.autowire;
|
register!TodoService.autowire;
|
||||||
|
register!TodoNotificationService.autowire;
|
||||||
|
register!TodoController.autowire;
|
||||||
|
register!TodoNotificationController.autowire;
|
||||||
|
register!(TodoRepository, InMemoryTodoRepository).autowire;
|
||||||
|
register!TodoFacade.autowire;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,25 +26,26 @@ interface TodoApi
|
||||||
|
|
||||||
class TodoController : TodoApi
|
class TodoController : TodoApi
|
||||||
{
|
{
|
||||||
import d_webservice_example.business.todo_service : TodoService;
|
import d_webservice_example.facade.todo_facade : TodoFacade;
|
||||||
import d_webservice_example.mapper.todo_mapper : asTodoTO;
|
import d_webservice_example.mapper.todo_mapper : asTodoTO;
|
||||||
|
import vibe.core.log : logDiagnostic;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
TodoService todoService;
|
TodoFacade todoFacade;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|
||||||
this(TodoService todoService) nothrow pure @nogc @safe
|
this(TodoFacade todoFacade) nothrow pure @nogc @safe
|
||||||
{
|
{
|
||||||
this.todoService = todoService;
|
this.todoFacade = todoFacade;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: validation
|
|
||||||
override TodoTO addTodo(string title, string content) @safe
|
override TodoTO addTodo(string title, string content) @safe
|
||||||
{
|
{
|
||||||
import d_webservice_example.model.todo : Todo;
|
import d_webservice_example.model.todo : Todo;
|
||||||
|
|
||||||
return todoService.createTodo(Todo(title, content)).asTodoTO;
|
logDiagnostic("Received request to add a todo with title '%s' and content '%s'", title, content);
|
||||||
|
return todoFacade.createTodo(Todo(title, content)).asTodoTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
override TodoTO[] getTodos() @safe
|
override TodoTO[] getTodos() @safe
|
||||||
|
@ -52,29 +53,35 @@ public:
|
||||||
import std.algorithm.iteration : map;
|
import std.algorithm.iteration : map;
|
||||||
import std.array : array;
|
import std.array : array;
|
||||||
|
|
||||||
return todoService.getAllTodos.map!asTodoTO.array;
|
logDiagnostic("Received request to get all todos");
|
||||||
|
return todoFacade.getAllTodos.map!asTodoTO.array;
|
||||||
}
|
}
|
||||||
|
|
||||||
override TodoTO getTodo(UUID _uuid) @safe
|
override TodoTO getTodo(UUID _uuid) @safe
|
||||||
{
|
{
|
||||||
return todoService.getTodoByUuid(_uuid).asTodoTO;
|
|
||||||
|
logDiagnostic("Received request to get todo '%s'", _uuid);
|
||||||
|
return todoFacade.getTodoByUuid(_uuid).asTodoTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
override TodoTO updateTodo(UUID _uuid, Nullable!string title, Nullable!string content) @safe
|
override TodoTO updateTodo(UUID _uuid, Nullable!string title, Nullable!string content) @safe
|
||||||
{
|
{
|
||||||
import d_webservice_example.data.todo_update_do : TodoUpdateDO;
|
import d_webservice_example.data.todo_update_do : TodoUpdateDO;
|
||||||
|
|
||||||
|
logDiagnostic("Received request to update todo '%s' with title '%s' and content '%s'",
|
||||||
|
_uuid, title, content);
|
||||||
TodoUpdateDO update;
|
TodoUpdateDO update;
|
||||||
if (!title.isNull)
|
if (!title.isNull)
|
||||||
update.title = title.get;
|
update.title = title.get;
|
||||||
if (!content.isNull)
|
if (!content.isNull)
|
||||||
update.content = content.get;
|
update.content = content.get;
|
||||||
|
|
||||||
return todoService.updateTodo(_uuid, update).asTodoTO;
|
return todoFacade.updateTodo(_uuid, update).asTodoTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
override void deleteTodo(UUID _uuid) @safe
|
override void deleteTodo(UUID _uuid) @safe
|
||||||
{
|
{
|
||||||
todoService.deleteTodo(_uuid);
|
logDiagnostic("Received request to delete todo '%s'", _uuid);
|
||||||
|
todoFacade.deleteTodo(_uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
module d_webservice_example.controller.todo_notification_controller;
|
||||||
|
|
||||||
|
import vibe.http.websockets : WebSocket;
|
||||||
|
import vibe.web.web : path;
|
||||||
|
|
||||||
|
@path("/api/v1/rt")
|
||||||
|
class TodoNotificationController
|
||||||
|
{
|
||||||
|
import d_webservice_example.business.todo_notification_service : TodoNotificationService;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TodoNotificationService todoNotificationService;
|
||||||
|
|
||||||
|
public:
|
||||||
|
this(TodoNotificationService todoNotificationService) nothrow pure @nogc @safe
|
||||||
|
{
|
||||||
|
this.todoNotificationService = todoNotificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@path("/todos") void getWebsocket(scope WebSocket socket) @safe
|
||||||
|
{
|
||||||
|
import d_webservice_example.data.todo_notification : TodoNotification;
|
||||||
|
import d_webservice_example.transport.todo_notification_cto : TodoNotificationCTO;
|
||||||
|
import vibe.core.log : logDiagnostic;
|
||||||
|
|
||||||
|
logDiagnostic("Received request for todo notifications via web socket connection");
|
||||||
|
|
||||||
|
auto callback = (scope const TodoNotification todoNotification) {
|
||||||
|
import d_webservice_example.mapper.todo_notification_mapper : asTodoNotificationCTO;
|
||||||
|
import vibe.data.json : serializeToJsonString;
|
||||||
|
|
||||||
|
logDiagnostic("Sending todo notification to client");
|
||||||
|
socket.send(todoNotification.asTodoNotificationCTO.serializeToJsonString);
|
||||||
|
};
|
||||||
|
todoNotificationService.registerListener(callback);
|
||||||
|
|
||||||
|
dropWebsocketInput(socket);
|
||||||
|
|
||||||
|
todoNotificationService.unRegisterListener(callback);
|
||||||
|
logDiagnostic("Client disconnected from web socket connection: %s(%s)",
|
||||||
|
socket.closeReason, socket.closeCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
|
||||||
|
void dropWebsocketInput(scope WebSocket socket) @safe
|
||||||
|
{
|
||||||
|
while (socket.connected)
|
||||||
|
{
|
||||||
|
socket.waitForData;
|
||||||
|
if (socket.connected)
|
||||||
|
socket.receive((scope IncomingWebSocketMessage) {});
|
||||||
|
}
|
||||||
|
}
|
10
source/d_webservice_example/data/todo_notification.d
Normal file
10
source/d_webservice_example/data/todo_notification.d
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
module d_webservice_example.data.todo_notification;
|
||||||
|
|
||||||
|
import d_webservice_example.enums.todo_notification_type : TodoNotificationType;
|
||||||
|
import d_webservice_example.model.todo : Todo;
|
||||||
|
|
||||||
|
struct TodoNotification
|
||||||
|
{
|
||||||
|
TodoNotificationType type;
|
||||||
|
Todo todo;
|
||||||
|
}
|
|
@ -17,6 +17,11 @@ private:
|
||||||
Todo[string] todos;
|
Todo[string] todos;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|
||||||
|
this() nothrow pure @nogc @safe
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
override Todo save(Todo todo)
|
override Todo save(Todo todo)
|
||||||
{
|
{
|
||||||
import std.range.primitives : empty;
|
import std.range.primitives : empty;
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
module d_webservice_example.enums.todo_notification_type;
|
||||||
|
|
||||||
|
enum TodoNotificationType
|
||||||
|
{
|
||||||
|
Create = "create",
|
||||||
|
Update = "update",
|
||||||
|
Delete = "delete"
|
||||||
|
}
|
51
source/d_webservice_example/facade/todo_facade.d
Normal file
51
source/d_webservice_example/facade/todo_facade.d
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
module d_webservice_example.facade.todo_facade;
|
||||||
|
|
||||||
|
class TodoFacade
|
||||||
|
{
|
||||||
|
import d_webservice_example.business.todo_notification_service : TodoNotificationService;
|
||||||
|
import d_webservice_example.business.todo_service : TodoService;
|
||||||
|
import d_webservice_example.data.todo_update_do : TodoUpdateDO;
|
||||||
|
import d_webservice_example.model.todo : Todo;
|
||||||
|
import std.uuid : UUID;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TodoNotificationService todoNotificationService;
|
||||||
|
TodoService todoService;
|
||||||
|
|
||||||
|
public:
|
||||||
|
this(TodoNotificationService todoNotificationService, TodoService todoService) nothrow pure @nogc @safe
|
||||||
|
{
|
||||||
|
this.todoNotificationService = todoNotificationService;
|
||||||
|
this.todoService = todoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
Todo createTodo(Todo newTodo) @safe
|
||||||
|
{
|
||||||
|
immutable todo = todoService.createTodo(newTodo);
|
||||||
|
todoNotificationService.onTodoCreated(todo);
|
||||||
|
return todo;
|
||||||
|
}
|
||||||
|
|
||||||
|
Todo[] getAllTodos() @safe
|
||||||
|
{
|
||||||
|
return todoService.getAllTodos;
|
||||||
|
}
|
||||||
|
|
||||||
|
Todo getTodoByUuid(const UUID uuid) @safe
|
||||||
|
{
|
||||||
|
return todoService.getTodoByUuid(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Todo updateTodo(UUID uuid, const TodoUpdateDO todoUpdate) @safe
|
||||||
|
{
|
||||||
|
immutable todo = todoService.updateTodo(uuid, todoUpdate);
|
||||||
|
todoNotificationService.onTodoUpdated(todo);
|
||||||
|
return todo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteTodo(UUID uuid) @safe
|
||||||
|
{
|
||||||
|
immutable todo = todoService.deleteTodo(uuid);
|
||||||
|
todoNotificationService.onTodoDeleted(todo);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ module d_webservice_example.mapper.todo_mapper;
|
||||||
import d_webservice_example.model.todo : Todo;
|
import d_webservice_example.model.todo : Todo;
|
||||||
import d_webservice_example.transport.todo_to : TodoTO;
|
import d_webservice_example.transport.todo_to : TodoTO;
|
||||||
|
|
||||||
TodoTO asTodoTO(Todo todo) nothrow pure @safe @nogc
|
TodoTO asTodoTO(const Todo todo) nothrow pure @safe @nogc
|
||||||
{
|
{
|
||||||
return TodoTO(todo.uuid, todo.title, todo.content);
|
return TodoTO(todo.uuid, todo.title, todo.content);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
module d_webservice_example.mapper.todo_notification_mapper;
|
||||||
|
|
||||||
|
import d_webservice_example.data.todo_notification : TodoNotification;
|
||||||
|
import d_webservice_example.transport.todo_notification_cto : TodoNotificationCTO;
|
||||||
|
|
||||||
|
TodoNotificationCTO asTodoNotificationCTO(const TodoNotification todoNotification) nothrow pure @safe @nogc
|
||||||
|
{
|
||||||
|
import d_webservice_example.mapper.todo_mapper : asTodoTO;
|
||||||
|
|
||||||
|
return TodoNotificationCTO(todoNotification.type, todoNotification.todo.asTodoTO);
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ module d_webservice_example.model.todo;
|
||||||
struct Todo
|
struct Todo
|
||||||
{
|
{
|
||||||
import std.uuid : UUID;
|
import std.uuid : UUID;
|
||||||
import vibe.data.serialization : name;
|
|
||||||
|
|
||||||
this(string title, string content) nothrow pure @safe @nogc
|
this(string title, string content) nothrow pure @safe @nogc
|
||||||
{
|
{
|
||||||
|
@ -28,9 +27,6 @@ struct Todo
|
||||||
|
|
||||||
string title;
|
string title;
|
||||||
string content;
|
string content;
|
||||||
|
|
||||||
UUID uuid;
|
UUID uuid;
|
||||||
|
|
||||||
@name("_id")
|
|
||||||
string id;
|
string id;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
module d_webservice_example.transport.todo_notification_cto;
|
||||||
|
|
||||||
|
import d_webservice_example.enums.todo_notification_type : TodoNotificationType;
|
||||||
|
import d_webservice_example.transport.todo_to : TodoTO;
|
||||||
|
|
||||||
|
struct TodoNotificationCTO
|
||||||
|
{
|
||||||
|
TodoNotificationType type;
|
||||||
|
TodoTO todo;
|
||||||
|
}
|
Loading…
Reference in a new issue