Added admin functions for adding and removing tradeables.

This commit is contained in:
Andrew Lalis 2022-02-27 12:50:46 +01:00
parent db2bcb576f
commit 279f73af03
12 changed files with 265 additions and 5 deletions

View File

@ -0,0 +1,56 @@
package nl.andrewl.coyotecredit.ctl;
import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.AddTradeablePayload;
import nl.andrewl.coyotecredit.model.User;
import nl.andrewl.coyotecredit.service.TradeableService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping(path = "/admin")
@RequiredArgsConstructor
public class AdminController {
private final TradeableService tradeableService;
@GetMapping
public String get(@AuthenticationPrincipal User user) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return "admin/admin";
}
@GetMapping(path = "/manageTradeables")
public String getManageTradeablesPage(Model model, @AuthenticationPrincipal User user) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
model.addAttribute("tradeables", tradeableService.getAllPublicTradeables(user));
return "admin/manage_tradeables";
}
@GetMapping(path = "/manageTradeables/add")
public String getAddTradeablePage(@AuthenticationPrincipal User user) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return "admin/add_tradeable";
}
@PostMapping(path = "/manageTradeables/add")
public String addTradeable(@AuthenticationPrincipal User user, @ModelAttribute AddTradeablePayload payload) {
tradeableService.addTradeable(user, payload);
return "redirect:/admin/manageTradeables";
}
@GetMapping(path = "/manageTradeables/remove/{tradeableId}")
public String getRemoveTradeablePage(@AuthenticationPrincipal User user, @PathVariable long tradeableId) {
tradeableService.checkRemovePage(user, tradeableId);
return "admin/remove_tradeable";
}
@PostMapping(path = "/manageTradeables/remove/{tradeableId}")
public String removeTradeable(@AuthenticationPrincipal User user, @PathVariable long tradeableId) {
tradeableService.removeTradeable(user, tradeableId);
return "redirect:/admin/manageTradeables";
}
}

View File

@ -0,0 +1,4 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record AddTradeablePayload(String name, String symbol, String type, String description) {
}

View File

@ -2,8 +2,10 @@ package nl.andrewl.coyotecredit.dao;
import nl.andrewl.coyotecredit.model.Account; import nl.andrewl.coyotecredit.model.Account;
import nl.andrewl.coyotecredit.model.Exchange; import nl.andrewl.coyotecredit.model.Exchange;
import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.User; import nl.andrewl.coyotecredit.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@ -18,4 +20,9 @@ public interface AccountRepository extends JpaRepository<Account, Long> {
boolean existsByUserAndExchange(User user, Exchange exchange); boolean existsByUserAndExchange(User user, Exchange exchange);
List<Account> findAllByExchange(Exchange e); List<Account> findAllByExchange(Exchange e);
@Query("SELECT a FROM Account a " +
"LEFT JOIN FETCH a.balances bal " +
"WHERE bal.tradeable = :t")
List<Account> findAllWithBalanceForTradeable(Tradeable t);
} }

View File

@ -9,4 +9,5 @@ import java.util.List;
@Repository @Repository
public interface TradeableRepository extends JpaRepository<Tradeable, Long> { public interface TradeableRepository extends JpaRepository<Tradeable, Long> {
List<Tradeable> findAllByExchangeNull(); List<Tradeable> findAllByExchangeNull();
boolean existsByExchangeNullAndSymbol(String symbol);
} }

View File

@ -1,12 +1,19 @@
package nl.andrewl.coyotecredit.dao; package nl.andrewl.coyotecredit.dao;
import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.Transaction; import nl.andrewl.coyotecredit.model.Transaction;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface TransactionRepository extends JpaRepository<Transaction, Long> { public interface TransactionRepository extends JpaRepository<Transaction, Long> {
Page<Transaction> findAllByAccountNumberOrderByTimestampDesc(String accountNumber, Pageable pageable); Page<Transaction> findAllByAccountNumberOrderByTimestampDesc(String accountNumber, Pageable pageable);
@Modifying
@Query("DELETE FROM Transaction tx WHERE tx.from = :t OR tx.to = :t")
void deleteAllWithTradeable(Tradeable t);
} }

View File

@ -1,21 +1,27 @@
package nl.andrewl.coyotecredit.service; package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.AddTradeablePayload;
import nl.andrewl.coyotecredit.ctl.dto.TradeableData; import nl.andrewl.coyotecredit.ctl.dto.TradeableData;
import nl.andrewl.coyotecredit.dao.AccountRepository; import nl.andrewl.coyotecredit.dao.*;
import nl.andrewl.coyotecredit.dao.TradeableRepository; import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.model.Tradeable;
import nl.andrewl.coyotecredit.model.User;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class TradeableService { public class TradeableService {
private final TradeableRepository tradeableRepository; private final TradeableRepository tradeableRepository;
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final ExchangeRepository exchangeRepository;
private final TransactionRepository transactionRepository;
private final TradeableUpdateService tradeableUpdateService;
private final UserNotificationRepository notificationRepository;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public TradeableData getTradeable(long id, User user) { public TradeableData getTradeable(long id, User user) {
@ -26,4 +32,66 @@ public class TradeableService {
} }
return new TradeableData(tradeable); return new TradeableData(tradeable);
} }
@Transactional(readOnly = true)
public List<TradeableData> getAllPublicTradeables(User user) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return tradeableRepository.findAllByExchangeNull()
.stream().map(TradeableData::new)
.toList();
}
@Transactional
public void addTradeable(User user, AddTradeablePayload payload) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
if (tradeableRepository.existsByExchangeNullAndSymbol(payload.symbol())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Tradeable with that symbol already exists.");
}
TradeableType type;
try {
type = TradeableType.valueOf(payload.type());
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid type.", e);
}
Tradeable tradeable = new Tradeable(payload.symbol(), type, payload.name(), payload.description(), BigDecimal.ZERO, null);
tradeable = tradeableRepository.save(tradeable);
tradeableUpdateService.updateTradeable(tradeable);
}
@Transactional(readOnly = true)
public void checkRemovePage(User user, long tradeableId) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
Tradeable t = tradeableRepository.findById(tradeableId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (t.getExchange() != null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
if (t.getSymbol().equalsIgnoreCase("USD")) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
@Transactional
public void removeTradeable(User user, long tradeableId) {
if (!user.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
Tradeable t = tradeableRepository.findById(tradeableId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (t.getExchange() != null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove exchange-custom tradeable.");
if (t.getSymbol().equalsIgnoreCase("USD")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot remove USD.");
for (Account account : accountRepository.findAllWithBalanceForTradeable(t)) {
Balance bal = account.getBalanceForTradeable(t);
if (bal != null) {
account.getBalances().remove(bal);
notificationRepository.save(new UserNotification(
account.getUser(),
String.format("Your balance of %s %s in %s has been removed because Coyote Credit has removed support for it.",
bal.getAmount().toPlainString(),
t.getSymbol(),
account.getExchange().getName())
));
}
}
transactionRepository.deleteAllWithTradeable(t);
for (Exchange exchange : exchangeRepository.findAll()) {
exchange.getSupportedTradeables().remove(t);
exchangeRepository.save(exchange);
}
tradeableRepository.delete(t);
}
} }

View File

@ -68,7 +68,7 @@ public class TradeableUpdateService {
} }
} }
private void updateTradeable(Tradeable tradeable) { public void updateTradeable(Tradeable tradeable) {
BigDecimal updatedValue = null; BigDecimal updatedValue = null;
if (tradeable.getType().equals(TradeableType.STOCK)) { if (tradeable.getType().equals(TradeableType.STOCK)) {
updatedValue = fetchStockClosePrice(tradeable.getSymbol()); updatedValue = fetchStockClosePrice(tradeable.getSymbol());

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Add Tradeable', content=~{::#content})}"
>
<div id="content" class="container">
<h1 class="display-4">Add Tradeable Asset</h1>
<p class="lead">Add a new publicly tradeable asset for exchanges to use.</p>
<form th:action="@{/admin/manageTradeables/add}" method="post">
<div class="mb-3">
<label for="nameInput" class="form-label">Name</label>
<input id="nameInput" name="name" class="form-control" type="text" required/>
</div>
<div class="mb-3">
<label for="symbolInput" class="form-label">Symbol</label>
<input id="symbolInput" name="symbol" class="form-control" type="text" required/>
</div>
<div class="mb-3">
<label for="typeSelect" class="form-label">Type</label>
<select id="typeSelect" name="type" class="form-select" required>
<option>FIAT</option>
<option>CRYPTO</option>
<option>STOCK</option>
</select>
</div>
<div class="mb-3">
<label for="descriptionInput" class="form-label">Description</label>
<textarea id="descriptionInput" name="description" class="form-control" rows="3" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Admin', content=~{::#content})}"
>
<div id="content" class="container">
<h1 class="display-4">Admin</h1>
<p class="lead">
Control top-level application settings and data within this admin interface.
</p>
<a class="btn btn-primary" th:href="@{/admin/manageTradeables}">Manage Tradeable Assets</a>
</div>

View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Manage Tradeables', content=~{::#content})}"
>
<div id="content" class="container">
<h1 class="display-4">Manage Tradeable Assets</h1>
<p class="lead">
Manage the global collection of tradeable assets that exchanges may incorporate into their
systems.
</p>
<div class="card text-white bg-dark mb-3">
<div class="card-body">
<h5 class="card-title">Tradeable Assets</h5>
<table class="table table-dark">
<thead>
<tr>
<th>Name</th>
<th>Symbol</th>
<th>Price (USD)</th>
<th>Type</th>
<th>Remove</th>
</tr>
</thead>
<tbody>
<tr th:each="tradeable : ${tradeables}">
<td>
<a class="colored-link" th:href="@{/tradeables/{tId}(tId=${tradeable.id()})}" th:text="${tradeable.name()}"></a>
</td>
<td th:text="${tradeable.symbol()}"></td>
<td class="monospace" th:text="${tradeable.formattedPriceUsd()}"></td>
<td th:text="${tradeable.type()}"></td>
<td>
<a
class="colored-link"
th:href="@{/admin/manageTradeables/remove/{tId}(tId=${tradeable.id()})}"
th:if="${!tradeable.symbol().equalsIgnoreCase('USD')}"
>Remove</a>
</td>
</tr>
</tbody>
</table>
<span>
<a class="btn btn-primary" th:href="@{/admin/manageTradeables/add}">Add</a>
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/basic_page :: layout (title='Remove Tradeable', content=~{::#content})}"
>
<div id="content" class="container">
<h1 class="display-4">Remove Tradeable</h1>
<p class="lead">
Are you sure you want to remove this tradeable? It will be removed from all exchanges, and
all accounts will have their balance of this tradeable removed permanently.
</p>
<form th:action="@{/admin/manageTradeables/remove/{tId}(tId=${tradeableId})}" method="post">
<button type="submit" class="btn btn-danger">Remove</button>
</form>
<a th:href="@{/admin/manageTradeables}" class="btn btn-secondary">Cancel</a>
</div>

View File

@ -36,6 +36,9 @@
></span> ></span>
</a> </a>
</li> </li>
<li class="nav-item" th:if="${#authentication.getPrincipal().isAdmin()}">
<a class="nav-link" th:href="@{/admin}">Admin</a>
</li>
</ul> </ul>
<form class="d-flex" th:action="@{/logout}" th:method="post"> <form class="d-flex" th:action="@{/logout}" th:method="post">
<button class="btn btn-dark" type="submit">Logout</button> <button class="btn btn-dark" type="submit">Logout</button>