维护入口
This commit is contained in:
@@ -27,6 +27,8 @@ import javax.servlet.http.HttpSession;
|
||||
public class BookManagementServlet extends HttpServlet {
|
||||
private static final String MANAGE_JSP = "/WEB-INF/jsp/books/manage.jsp";
|
||||
private static final String FORM_JSP = "/WEB-INF/jsp/books/form.jsp";
|
||||
private static final String CATEGORY_MANAGE_JSP = "/WEB-INF/jsp/books/categories.jsp";
|
||||
private static final String CATEGORY_FORM_JSP = "/WEB-INF/jsp/books/category-form.jsp";
|
||||
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
|
||||
private static final String FLASH_SUCCESS = "flashSuccess";
|
||||
private static final String FLASH_ERROR = "flashError";
|
||||
@@ -50,6 +52,19 @@ public class BookManagementServlet extends HttpServlet {
|
||||
showEditForm(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/book-categories".equals(path)) {
|
||||
showCategoryList(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/book-categories/new".equals(path)) {
|
||||
renderCategoryForm(request, response, "Create category", "/book-categories", new BookCategory(),
|
||||
Collections.emptyMap(), Collections.emptyMap(), null);
|
||||
return;
|
||||
}
|
||||
if ("/book-categories/edit".equals(path)) {
|
||||
showEditCategoryForm(request, response);
|
||||
return;
|
||||
}
|
||||
if (!"/books".equals(path)) {
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
@@ -73,6 +88,18 @@ public class BookManagementServlet extends HttpServlet {
|
||||
deleteBook(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/book-categories".equals(path)) {
|
||||
createCategory(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/book-categories/update".equals(path)) {
|
||||
updateCategory(request, response);
|
||||
return;
|
||||
}
|
||||
if ("/book-categories/delete".equals(path)) {
|
||||
deleteCategory(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
@@ -116,6 +143,32 @@ public class BookManagementServlet extends HttpServlet {
|
||||
Collections.emptyMap(), Collections.emptyMap(), null);
|
||||
}
|
||||
|
||||
private void showCategoryList(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
applyFlash(request);
|
||||
|
||||
ServiceResult<List<BookCategory>> result = bookService.listCategories();
|
||||
request.setAttribute("categories", result.isSuccessful() ? result.getData() : Collections.emptyList());
|
||||
if (!result.isSuccessful()) {
|
||||
request.setAttribute("errorMessage", result.getMessage());
|
||||
}
|
||||
request.getRequestDispatcher(CATEGORY_MANAGE_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private void showEditCategoryForm(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
long id = requiredLong(request.getParameter("id"), -1L);
|
||||
ServiceResult<Optional<BookCategory>> result = bookService.findCategory(id);
|
||||
if (!result.isSuccessful() || !result.getData().isPresent()) {
|
||||
flashError(request, result.isSuccessful() ? "Category was not found." : result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/book-categories");
|
||||
return;
|
||||
}
|
||||
|
||||
renderCategoryForm(request, response, "Edit category", "/book-categories/update", result.getData().get(),
|
||||
Collections.emptyMap(), Collections.emptyMap(), null);
|
||||
}
|
||||
|
||||
private void createBook(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
BookForm form = readBookForm(request, false);
|
||||
if (!form.getErrors().isEmpty()) {
|
||||
@@ -167,6 +220,60 @@ public class BookManagementServlet extends HttpServlet {
|
||||
response.sendRedirect(request.getContextPath() + "/books");
|
||||
}
|
||||
|
||||
private void createCategory(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
CategoryForm form = readCategoryForm(request, false);
|
||||
if (!form.getErrors().isEmpty()) {
|
||||
renderCategoryForm(request, response, "Create category", "/book-categories", form.getCategory(),
|
||||
form.getValues(), form.getErrors(), "Please correct the highlighted category fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceResult<Long> result = bookService.createCategory(currentUser(request), form.getCategory());
|
||||
if (!result.isSuccessful()) {
|
||||
handleCategoryFormFailure(request, response, "Create category", "/book-categories", form, result);
|
||||
return;
|
||||
}
|
||||
|
||||
flashSuccess(request, result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/book-categories");
|
||||
}
|
||||
|
||||
private void updateCategory(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
CategoryForm form = readCategoryForm(request, true);
|
||||
if (!form.getErrors().isEmpty()) {
|
||||
renderCategoryForm(request, response, "Edit category", "/book-categories/update", form.getCategory(),
|
||||
form.getValues(), form.getErrors(), "Please correct the highlighted category fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceResult<Void> result = bookService.updateCategory(currentUser(request), form.getCategory());
|
||||
if (!result.isSuccessful()) {
|
||||
handleCategoryFormFailure(request, response, "Edit category", "/book-categories/update", form, result);
|
||||
return;
|
||||
}
|
||||
|
||||
flashSuccess(request, result.getMessage());
|
||||
response.sendRedirect(request.getContextPath() + "/book-categories");
|
||||
}
|
||||
|
||||
private void deleteCategory(HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException, ServletException {
|
||||
long id = requiredLong(request.getParameter("id"), -1L);
|
||||
ServiceResult<Void> result = bookService.deleteCategory(currentUser(request), id);
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
if (result.isSuccessful()) {
|
||||
flashSuccess(request, result.getMessage());
|
||||
} else {
|
||||
flashError(request, result.getMessage());
|
||||
}
|
||||
response.sendRedirect(request.getContextPath() + "/book-categories");
|
||||
}
|
||||
|
||||
private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title,
|
||||
String action, BookForm form, ServiceResult<?> result)
|
||||
throws ServletException, IOException {
|
||||
@@ -178,6 +285,17 @@ public class BookManagementServlet extends HttpServlet {
|
||||
result.getMessage());
|
||||
}
|
||||
|
||||
private void handleCategoryFormFailure(HttpServletRequest request, HttpServletResponse response, String title,
|
||||
String action, CategoryForm form, ServiceResult<?> result)
|
||||
throws ServletException, IOException {
|
||||
if (isPermissionDenied(result)) {
|
||||
forwardDenied(request, response, result.getMessage());
|
||||
return;
|
||||
}
|
||||
renderCategoryForm(request, response, title, action, form.getCategory(), form.getValues(),
|
||||
result.getErrors(), result.getMessage());
|
||||
}
|
||||
|
||||
private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action,
|
||||
Book book, Map<String, String> formValues, Map<String, String> errors, String errorMessage)
|
||||
throws ServletException, IOException {
|
||||
@@ -199,6 +317,21 @@ public class BookManagementServlet extends HttpServlet {
|
||||
request.getRequestDispatcher(FORM_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private void renderCategoryForm(HttpServletRequest request, HttpServletResponse response, String title,
|
||||
String action, BookCategory category, Map<String, String> formValues,
|
||||
Map<String, String> errors, String errorMessage)
|
||||
throws ServletException, IOException {
|
||||
request.setAttribute("formTitle", title);
|
||||
request.setAttribute("formAction", action);
|
||||
request.setAttribute("category", category);
|
||||
request.setAttribute("formValues", formValues);
|
||||
request.setAttribute("errors", errors);
|
||||
if (errorMessage != null && !errorMessage.isEmpty()) {
|
||||
request.setAttribute("errorMessage", errorMessage);
|
||||
}
|
||||
request.getRequestDispatcher(CATEGORY_FORM_JSP).forward(request, response);
|
||||
}
|
||||
|
||||
private BookForm readBookForm(HttpServletRequest request, boolean requireId) {
|
||||
Map<String, String> values = formValues(request);
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
@@ -237,6 +370,27 @@ public class BookManagementServlet extends HttpServlet {
|
||||
return values;
|
||||
}
|
||||
|
||||
private CategoryForm readCategoryForm(HttpServletRequest request, boolean requireId) {
|
||||
Map<String, String> values = categoryFormValues(request);
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
BookCategory category = new BookCategory();
|
||||
|
||||
if (requireId) {
|
||||
category.setId(parseLong(values.get("id"), "id", "Select a valid category.", errors));
|
||||
}
|
||||
category.setName(values.get("name"));
|
||||
category.setDescription(values.get("description"));
|
||||
return new CategoryForm(category, values, errors);
|
||||
}
|
||||
|
||||
private Map<String, String> categoryFormValues(HttpServletRequest request) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
values.put("id", trim(request.getParameter("id")));
|
||||
values.put("name", trim(request.getParameter("name")));
|
||||
values.put("description", trim(request.getParameter("description")));
|
||||
return values;
|
||||
}
|
||||
|
||||
private BookSearchCriteria searchCriteria(HttpServletRequest request) {
|
||||
return new BookSearchCriteria(
|
||||
request.getParameter("identifier"),
|
||||
@@ -368,4 +522,28 @@ public class BookManagementServlet extends HttpServlet {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CategoryForm {
|
||||
private final BookCategory category;
|
||||
private final Map<String, String> values;
|
||||
private final Map<String, String> errors;
|
||||
|
||||
private CategoryForm(BookCategory category, Map<String, String> values, Map<String, String> errors) {
|
||||
this.category = category;
|
||||
this.values = values;
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
private BookCategory getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
private Map<String, String> getValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
private Map<String, String> getErrors() {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@ import java.util.Optional;
|
||||
public interface BookDao {
|
||||
List<BookCategory> findAllCategories();
|
||||
|
||||
Optional<BookCategory> findCategoryById(long id);
|
||||
|
||||
Optional<BookCategory> findCategoryByName(String name);
|
||||
|
||||
long createCategory(BookCategory category);
|
||||
|
||||
boolean updateCategory(BookCategory category);
|
||||
|
||||
boolean deleteCategory(long id);
|
||||
|
||||
int countBooksByCategoryId(long categoryId);
|
||||
|
||||
List<Book> search(BookSearchCriteria criteria);
|
||||
|
||||
Optional<Book> findById(long id);
|
||||
|
||||
@@ -33,6 +33,32 @@ public class JdbcBookDao implements BookDao {
|
||||
+ "FROM book_categories "
|
||||
+ "ORDER BY name";
|
||||
|
||||
private static final String FIND_CATEGORY_BY_ID = ""
|
||||
+ "SELECT id, name, description "
|
||||
+ "FROM book_categories "
|
||||
+ "WHERE id = ?";
|
||||
|
||||
private static final String FIND_CATEGORY_BY_NAME = ""
|
||||
+ "SELECT id, name, description "
|
||||
+ "FROM book_categories "
|
||||
+ "WHERE name = ?";
|
||||
|
||||
private static final String CREATE_CATEGORY = ""
|
||||
+ "INSERT INTO book_categories (name, description) "
|
||||
+ "VALUES (?, ?)";
|
||||
|
||||
private static final String UPDATE_CATEGORY = ""
|
||||
+ "UPDATE book_categories "
|
||||
+ "SET name = ?, description = ? "
|
||||
+ "WHERE id = ?";
|
||||
|
||||
private static final String DELETE_CATEGORY = "DELETE FROM book_categories WHERE id = ?";
|
||||
|
||||
private static final String COUNT_BOOKS_BY_CATEGORY = ""
|
||||
+ "SELECT COUNT(*) "
|
||||
+ "FROM books "
|
||||
+ "WHERE category_id = ?";
|
||||
|
||||
private static final String FIND_BY_ID = "SELECT " + BOOK_COLUMNS + BOOK_FROM + "WHERE b.id = ?";
|
||||
|
||||
private static final String FIND_BY_IDENTIFIER = "SELECT " + BOOK_COLUMNS + BOOK_FROM
|
||||
@@ -66,6 +92,86 @@ public class JdbcBookDao implements BookDao {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BookCategory> findCategoryById(long id) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(FIND_CATEGORY_BY_ID)) {
|
||||
statement.setLong(1, id);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapCategory(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to load book category by id", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BookCategory> findCategoryByName(String name) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(FIND_CATEGORY_BY_NAME)) {
|
||||
statement.setString(1, name);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(mapCategory(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to load book category by name", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long createCategory(BookCategory category) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(CREATE_CATEGORY, Statement.RETURN_GENERATED_KEYS)) {
|
||||
bindCategory(statement, category);
|
||||
statement.executeUpdate();
|
||||
|
||||
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
|
||||
if (generatedKeys.next()) {
|
||||
return generatedKeys.getLong(1);
|
||||
}
|
||||
}
|
||||
throw new DaoException("Unable to read generated book category id", null);
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to create book category", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateCategory(BookCategory category) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(UPDATE_CATEGORY)) {
|
||||
bindCategory(statement, category);
|
||||
statement.setLong(3, category.getId());
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to update book category", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteCategory(long id) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(DELETE_CATEGORY)) {
|
||||
statement.setLong(1, id);
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to delete book category", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countBooksByCategoryId(long categoryId) {
|
||||
try (Connection connection = JdbcUtil.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(COUNT_BOOKS_BY_CATEGORY)) {
|
||||
statement.setLong(1, categoryId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? resultSet.getInt(1) : 0;
|
||||
}
|
||||
} catch (SQLException ex) {
|
||||
throw new DaoException("Unable to count books by category", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Book> search(BookSearchCriteria criteria) {
|
||||
List<Object> parameters = new ArrayList<>();
|
||||
@@ -194,6 +300,11 @@ public class JdbcBookDao implements BookDao {
|
||||
statement.setString(7, book.getStatus().getCode());
|
||||
}
|
||||
|
||||
private void bindCategory(PreparedStatement statement, BookCategory category) throws SQLException {
|
||||
statement.setString(1, category.getName());
|
||||
statement.setString(2, category.getDescription());
|
||||
}
|
||||
|
||||
private Book mapBook(ResultSet resultSet) throws SQLException {
|
||||
Book book = new Book();
|
||||
book.setId(resultSet.getLong("id"));
|
||||
|
||||
@@ -29,6 +29,7 @@ public class AuthorizationFilter implements Filter {
|
||||
new PathRule("/admin/system-logs", Permission.VIEW_SYSTEM_LOGS),
|
||||
new PathRule("/reports", Permission.VIEW_REPORTS),
|
||||
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
|
||||
new PathRule("/book-categories", Permission.MANAGE_BOOKS),
|
||||
new PathRule("/books", Permission.MANAGE_BOOKS),
|
||||
new PathRule("/readers", Permission.MANAGE_READERS),
|
||||
new PathRule("/catalog", Permission.VIEW_CATALOG),
|
||||
|
||||
@@ -11,6 +11,14 @@ import java.util.Optional;
|
||||
public interface BookService {
|
||||
ServiceResult<List<BookCategory>> listCategories();
|
||||
|
||||
ServiceResult<Optional<BookCategory>> findCategory(long id);
|
||||
|
||||
ServiceResult<Long> createCategory(AuthenticatedUser actor, BookCategory category);
|
||||
|
||||
ServiceResult<Void> updateCategory(AuthenticatedUser actor, BookCategory category);
|
||||
|
||||
ServiceResult<Void> deleteCategory(AuthenticatedUser actor, long id);
|
||||
|
||||
ServiceResult<List<Book>> searchBooks(BookSearchCriteria criteria);
|
||||
|
||||
ServiceResult<Optional<Book>> findBook(long id);
|
||||
|
||||
@@ -23,6 +23,7 @@ public class BookServiceImpl implements BookService {
|
||||
private static final String UNAVAILABLE_MESSAGE =
|
||||
"Book service is temporarily unavailable. Please try again later.";
|
||||
private static final String VALIDATION_MESSAGE = "Please correct the highlighted book fields.";
|
||||
private static final String CATEGORY_VALIDATION_MESSAGE = "Please correct the highlighted category fields.";
|
||||
private static final String DENIED_MESSAGE = "You do not have permission to manage books.";
|
||||
|
||||
private final BookDao bookDao;
|
||||
@@ -47,6 +48,111 @@ public class BookServiceImpl implements BookService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Optional<BookCategory>> findCategory(long id) {
|
||||
if (id <= 0) {
|
||||
return ServiceResult.failure("Select a valid category.");
|
||||
}
|
||||
|
||||
try {
|
||||
return ServiceResult.success(bookDao.findCategoryById(id));
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to load book category id=" + id, ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Long> createCategory(AuthenticatedUser actor, BookCategory category) {
|
||||
if (!canManageBooks(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
normalize(category);
|
||||
Map<String, String> errors = validate(category, false);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
if (bookDao.findCategoryByName(category.getName()).isPresent()) {
|
||||
errors.put("name", "Category name is already in use.");
|
||||
return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
long id = bookDao.createCategory(category);
|
||||
LOGGER.info("Created book category id=" + id + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(id, "Category created.");
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to create book category actorId=" + actor.getId()
|
||||
+ " name=" + safeCategoryName(category), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> updateCategory(AuthenticatedUser actor, BookCategory category) {
|
||||
if (!canManageBooks(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
|
||||
normalize(category);
|
||||
Map<String, String> errors = validate(category, true);
|
||||
if (!errors.isEmpty()) {
|
||||
return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
try {
|
||||
Optional<BookCategory> existingWithName = bookDao.findCategoryByName(category.getName());
|
||||
if (existingWithName.isPresent() && existingWithName.get().getId() != category.getId()) {
|
||||
errors.put("name", "Category name is already in use.");
|
||||
return ServiceResult.validationFailure(CATEGORY_VALIDATION_MESSAGE, errors);
|
||||
}
|
||||
|
||||
if (!bookDao.updateCategory(category)) {
|
||||
return ServiceResult.failure("Category was not found.");
|
||||
}
|
||||
|
||||
LOGGER.info("Updated book category id=" + category.getId() + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Category updated.");
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to update book category id=" + category.getId()
|
||||
+ " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<Void> deleteCategory(AuthenticatedUser actor, long id) {
|
||||
if (!canManageBooks(actor)) {
|
||||
return ServiceResult.failure(DENIED_MESSAGE);
|
||||
}
|
||||
if (id <= 0) {
|
||||
return ServiceResult.failure("Select a valid category.");
|
||||
}
|
||||
|
||||
try {
|
||||
if (!bookDao.findCategoryById(id).isPresent()) {
|
||||
return ServiceResult.failure("Category was not found.");
|
||||
}
|
||||
if (bookDao.countBooksByCategoryId(id) > 0) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
errors.put("category", "Category is used by existing books and cannot be deleted.");
|
||||
return ServiceResult.validationFailure("Category is used by existing books and cannot be deleted.",
|
||||
errors);
|
||||
}
|
||||
if (!bookDao.deleteCategory(id)) {
|
||||
return ServiceResult.failure("Category was not found.");
|
||||
}
|
||||
|
||||
LOGGER.info("Deleted book category id=" + id + " actorId=" + actor.getId());
|
||||
return ServiceResult.success(null, "Category deleted.");
|
||||
} catch (DaoException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Unable to delete book category id=" + id + " actorId=" + actor.getId(), ex);
|
||||
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceResult<List<Book>> searchBooks(BookSearchCriteria criteria) {
|
||||
BookSearchCriteria normalized = criteria == null ? new BookSearchCriteria() : criteria;
|
||||
@@ -171,6 +277,14 @@ public class BookServiceImpl implements BookService {
|
||||
book.setAuthor(trim(book.getAuthor()));
|
||||
}
|
||||
|
||||
private void normalize(BookCategory category) {
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
category.setName(trim(category.getName()));
|
||||
category.setDescription(trim(category.getDescription()));
|
||||
}
|
||||
|
||||
private Map<String, String> validate(Book book, boolean requireId) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (book == null) {
|
||||
@@ -202,6 +316,27 @@ public class BookServiceImpl implements BookService {
|
||||
return errors;
|
||||
}
|
||||
|
||||
private Map<String, String> validate(BookCategory category, boolean requireId) {
|
||||
Map<String, String> errors = new LinkedHashMap<>();
|
||||
if (category == null) {
|
||||
errors.put("category", "Category details are required.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (requireId && category.getId() <= 0) {
|
||||
errors.put("id", "Select a valid category.");
|
||||
}
|
||||
requireLength(errors, "name", category.getName(), "Category name", 96);
|
||||
if (category.getDescription() != null && category.getDescription().length() > 255) {
|
||||
errors.put("description", "Description must be 255 characters or fewer.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private String safeCategoryName(BookCategory category) {
|
||||
return category == null ? "" : category.getName();
|
||||
}
|
||||
|
||||
private void requireLength(Map<String, String> errors, String field, String value, String label, int maxLength) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
errors.put(field, label + " is required.");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Manage Categories - MZH Library</title>
|
||||
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||
<main class="page-shell">
|
||||
<section class="dashboard-hero catalog-hero" aria-labelledby="category-title">
|
||||
<div>
|
||||
<p class="eyebrow">Book Management</p>
|
||||
<h1 id="category-title">Manage categories</h1>
|
||||
<p>Maintain catalog groupings used by book records and search filters.</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="button button-primary" href="${pageContext.request.contextPath}/book-categories/new">New category</a>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Manage books</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<c:if test="${not empty successMessage}">
|
||||
<div class="message message-success" role="status">
|
||||
<c:out value="${successMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
<c:if test="${not empty errorMessage}">
|
||||
<div class="message message-error" role="alert">
|
||||
<c:out value="${errorMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
|
||||
<section class="table-panel" aria-labelledby="category-results-title">
|
||||
<h2 id="category-results-title">Category records</h2>
|
||||
<c:choose>
|
||||
<c:when test="${empty categories}">
|
||||
<p class="empty-state">No categories have been created yet.</p>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table category-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<c:forEach var="category" items="${categories}">
|
||||
<tr>
|
||||
<td><c:out value="${category.name}" /></td>
|
||||
<td>
|
||||
<c:choose>
|
||||
<c:when test="${empty category.description}">
|
||||
<span class="muted-text">No description</span>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
<c:out value="${category.description}" />
|
||||
</c:otherwise>
|
||||
</c:choose>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<a class="button button-secondary"
|
||||
href="${pageContext.request.contextPath}/book-categories/edit?id=${category.id}">Edit</a>
|
||||
<form action="${pageContext.request.contextPath}/book-categories/delete"
|
||||
method="post"
|
||||
onsubmit="return confirm('Delete this category?');">
|
||||
<input type="hidden" name="id" value="${category.id}">
|
||||
<button class="button button-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</c:forEach>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</c:otherwise>
|
||||
</c:choose>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><c:out value="${formTitle}" /> - MZH Library</title>
|
||||
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
|
||||
<main class="page-shell">
|
||||
<section class="form-panel" aria-labelledby="category-form-title">
|
||||
<p class="eyebrow">Book Management</p>
|
||||
<h1 id="category-form-title"><c:out value="${formTitle}" /></h1>
|
||||
|
||||
<c:if test="${not empty errorMessage}">
|
||||
<div class="message message-error" role="alert">
|
||||
<c:out value="${errorMessage}" />
|
||||
</div>
|
||||
</c:if>
|
||||
|
||||
<c:set var="hasFormValues" value="${not empty formValues}" />
|
||||
<c:set var="nameValue" value="${hasFormValues ? formValues.name : category.name}" />
|
||||
<c:set var="descriptionValue" value="${hasFormValues ? formValues.description : category.description}" />
|
||||
|
||||
<form class="category-form" action="${pageContext.request.contextPath}${formAction}" method="post" novalidate>
|
||||
<c:if test="${category.id > 0}">
|
||||
<input type="hidden" name="id" value="${category.id}">
|
||||
</c:if>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label for="name">Category name</label>
|
||||
<input id="name" name="name" type="text" value="${fn:escapeXml(nameValue)}" required>
|
||||
<c:if test="${not empty errors.name}">
|
||||
<span class="field-error"><c:out value="${errors.name}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
|
||||
<div class="form-field form-field-wide">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="4">${fn:escapeXml(descriptionValue)}</textarea>
|
||||
<c:if test="${not empty errors.description}">
|
||||
<span class="field-error"><c:out value="${errors.description}" /></span>
|
||||
</c:if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<c:if test="${not empty errors.category}">
|
||||
<div class="message message-error" role="alert">
|
||||
<c:out value="${errors.category}" />
|
||||
</div>
|
||||
</c:if>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="button button-primary" type="submit">Save</button>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/book-categories">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,10 @@
|
||||
<p class="eyebrow">Book Management</p>
|
||||
<h1 id="manage-title">Manage books</h1>
|
||||
<p>Create, update, delete, and review inventory for catalog records.</p>
|
||||
<a class="button button-primary" href="${pageContext.request.contextPath}/books/new">New book</a>
|
||||
<div class="hero-actions">
|
||||
<a class="button button-primary" href="${pageContext.request.contextPath}/books/new">New book</a>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/book-categories">Categories</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<c:if test="${not empty successMessage}">
|
||||
@@ -65,6 +68,7 @@
|
||||
<button class="button button-primary" type="submit">Search</button>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Clear</a>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">View catalog</a>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/book-categories">Categories</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
|
||||
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
|
||||
<a href="${pageContext.request.contextPath}/books">Books</a>
|
||||
<a href="${pageContext.request.contextPath}/book-categories">Categories</a>
|
||||
<a href="${pageContext.request.contextPath}/readers">Readers</a>
|
||||
<a href="${pageContext.request.contextPath}/borrowing">Borrowing</a>
|
||||
<a href="${pageContext.request.contextPath}/reports">Reports</a>
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Open</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Category Maintenance</h2>
|
||||
<p>Maintain catalog categories used by book records and search filters.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/book-categories">Open</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Reader Management</h2>
|
||||
<p>Create, update, deactivate, and review reader eligibility records.</p>
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Manage books</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Category Maintenance</h2>
|
||||
<p>Create, update, and retire catalog categories used by book records.</p>
|
||||
<a class="button button-secondary" href="${pageContext.request.contextPath}/book-categories">Manage categories</a>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<h2>Reader Management</h2>
|
||||
<p>Create, update, deactivate, and review eligibility fields for reader records.</p>
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
<url-pattern>/books/edit</url-pattern>
|
||||
<url-pattern>/books/update</url-pattern>
|
||||
<url-pattern>/books/delete</url-pattern>
|
||||
<url-pattern>/book-categories</url-pattern>
|
||||
<url-pattern>/book-categories/new</url-pattern>
|
||||
<url-pattern>/book-categories/edit</url-pattern>
|
||||
<url-pattern>/book-categories/update</url-pattern>
|
||||
<url-pattern>/book-categories/delete</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
<servlet>
|
||||
|
||||
@@ -313,6 +313,12 @@ h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-panel,
|
||||
.table-panel,
|
||||
.form-panel {
|
||||
@@ -350,6 +356,8 @@ h2 {
|
||||
.search-form select,
|
||||
.book-form input,
|
||||
.book-form select,
|
||||
.category-form input,
|
||||
.category-form textarea,
|
||||
.reader-form input,
|
||||
.reader-form select,
|
||||
.user-form input,
|
||||
@@ -368,6 +376,8 @@ h2 {
|
||||
.search-form select:focus,
|
||||
.book-form input:focus,
|
||||
.book-form select:focus,
|
||||
.category-form input:focus,
|
||||
.category-form textarea:focus,
|
||||
.reader-form input:focus,
|
||||
.reader-form select:focus,
|
||||
.user-form input:focus,
|
||||
@@ -394,7 +404,8 @@ h2 {
|
||||
}
|
||||
|
||||
.user-table,
|
||||
.system-log-table {
|
||||
.system-log-table,
|
||||
.category-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
@@ -504,6 +515,7 @@ h2 {
|
||||
}
|
||||
|
||||
.book-form,
|
||||
.category-form,
|
||||
.reader-form,
|
||||
.user-form,
|
||||
.borrow-form {
|
||||
@@ -522,6 +534,15 @@ h2 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.category-form textarea {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.mzh.library.exception.DaoException;
|
||||
import com.mzh.library.service.impl.BookServiceImpl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -48,6 +47,12 @@ public final class BookServiceCheck {
|
||||
require("You do not have permission to manage books.".equals(denied.getMessage()),
|
||||
"reader write should return permission message");
|
||||
|
||||
ServiceResult<Long> deniedCategory = service.createCategory(reader,
|
||||
category(0L, "Reader Category", "Denied category"));
|
||||
require(!deniedCategory.isSuccessful(), "reader category create should fail");
|
||||
require("You do not have permission to manage books.".equals(deniedCategory.getMessage()),
|
||||
"reader category write should return permission message");
|
||||
|
||||
ServiceResult<Long> created = service.createBook(librarian,
|
||||
book(0L, "BK-1002", "Service Test", "Test Author", 1L, 2, 1, BookStatus.AVAILABLE));
|
||||
require(created.isSuccessful(), "librarian should create a valid book");
|
||||
@@ -63,6 +68,13 @@ public final class BookServiceCheck {
|
||||
require(updated.isSuccessful(), "librarian should update a valid book");
|
||||
require(dao.findById(createdId).get().getAvailableCopies() == 3, "update should persist available copies");
|
||||
|
||||
ServiceResult<Void> deleteUsedCategory = service.deleteCategory(librarian, 1L);
|
||||
require(!deleteUsedCategory.isSuccessful(), "used category delete should fail");
|
||||
require("Category is used by existing books and cannot be deleted.".equals(deleteUsedCategory.getMessage()),
|
||||
"used category delete should return a safe specific message");
|
||||
require(deleteUsedCategory.getErrors().containsKey("category"),
|
||||
"used category delete should return a category-level field error");
|
||||
|
||||
ServiceResult<List<Book>> search = service.searchBooks(new BookSearchCriteria("BK-1003", "", "", null));
|
||||
require(search.isSuccessful(), "search should succeed");
|
||||
require(search.getData().size() == 1, "search should find updated identifier");
|
||||
@@ -71,6 +83,26 @@ public final class BookServiceCheck {
|
||||
require(deleted.isSuccessful(), "librarian should delete a book");
|
||||
require(!dao.findById(createdId).isPresent(), "delete should remove the record");
|
||||
|
||||
ServiceResult<Long> createdCategory = service.createCategory(librarian,
|
||||
category(0L, "Architecture", "Design and systems"));
|
||||
require(createdCategory.isSuccessful(), "librarian should create a category");
|
||||
long categoryId = createdCategory.getData();
|
||||
|
||||
ServiceResult<Long> duplicateCategory = service.createCategory(librarian,
|
||||
category(0L, "Architecture", "Duplicate category"));
|
||||
require(!duplicateCategory.isSuccessful(), "duplicate category should fail");
|
||||
require(duplicateCategory.getErrors().containsKey("name"), "duplicate category should target name field");
|
||||
|
||||
ServiceResult<Void> updatedCategory = service.updateCategory(librarian,
|
||||
category(categoryId, "Software Architecture", "Updated category"));
|
||||
require(updatedCategory.isSuccessful(), "librarian should update a category");
|
||||
require("Software Architecture".equals(dao.findCategoryById(categoryId).get().getName()),
|
||||
"category update should persist the new name");
|
||||
|
||||
ServiceResult<Void> deletedCategory = service.deleteCategory(librarian, categoryId);
|
||||
require(deletedCategory.isSuccessful(), "unused category should be deleted");
|
||||
require(!dao.findCategoryById(categoryId).isPresent(), "category delete should remove unused category");
|
||||
|
||||
BookService failingService = new BookServiceImpl(new FailingBookDao());
|
||||
ServiceResult<List<Book>> unavailable = failingService.searchBooks(new BookSearchCriteria());
|
||||
require(!unavailable.isSuccessful(), "DAO failure should not escape service");
|
||||
@@ -98,6 +130,14 @@ public final class BookServiceCheck {
|
||||
return book;
|
||||
}
|
||||
|
||||
private static BookCategory category(long id, String name, String description) {
|
||||
BookCategory category = new BookCategory();
|
||||
category.setId(id);
|
||||
category.setName(name);
|
||||
category.setDescription(description);
|
||||
return category;
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message);
|
||||
@@ -106,14 +146,70 @@ public final class BookServiceCheck {
|
||||
|
||||
private static final class InMemoryBookDao implements BookDao {
|
||||
private final Map<Long, Book> books = new LinkedHashMap<>();
|
||||
private final Map<Long, BookCategory> categories = new LinkedHashMap<>();
|
||||
private long nextId = 1L;
|
||||
private long nextCategoryId = 2L;
|
||||
|
||||
private InMemoryBookDao() {
|
||||
categories.put(1L, category(1L, "Computer Science", "Programming books"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BookCategory> findAllCategories() {
|
||||
BookCategory category = new BookCategory();
|
||||
category.setId(1L);
|
||||
category.setName("Computer Science");
|
||||
return Collections.singletonList(category);
|
||||
List<BookCategory> results = new ArrayList<>();
|
||||
for (BookCategory category : categories.values()) {
|
||||
results.add(copy(category));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BookCategory> findCategoryById(long id) {
|
||||
return Optional.ofNullable(categories.get(id)).map(this::copy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BookCategory> findCategoryByName(String name) {
|
||||
for (BookCategory category : categories.values()) {
|
||||
if (category.getName().equals(name)) {
|
||||
return Optional.of(copy(category));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long createCategory(BookCategory category) {
|
||||
long id = nextCategoryId++;
|
||||
BookCategory stored = copy(category);
|
||||
stored.setId(id);
|
||||
categories.put(id, stored);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateCategory(BookCategory category) {
|
||||
if (!categories.containsKey(category.getId())) {
|
||||
return false;
|
||||
}
|
||||
categories.put(category.getId(), copy(category));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteCategory(long id) {
|
||||
return categories.remove(id) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countBooksByCategoryId(long categoryId) {
|
||||
int count = 0;
|
||||
for (Book book : books.values()) {
|
||||
if (book.getCategoryId() == categoryId) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -178,6 +274,10 @@ public final class BookServiceCheck {
|
||||
copy.setCategoryName(source.getCategoryName());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private BookCategory copy(BookCategory source) {
|
||||
return category(source.getId(), source.getName(), source.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingBookDao implements BookDao {
|
||||
@@ -186,6 +286,36 @@ public final class BookServiceCheck {
|
||||
throw new DaoException("Simulated category failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BookCategory> findCategoryById(long id) {
|
||||
throw new DaoException("Simulated category find failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BookCategory> findCategoryByName(String name) {
|
||||
throw new DaoException("Simulated category find failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long createCategory(BookCategory category) {
|
||||
throw new DaoException("Simulated category create failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateCategory(BookCategory category) {
|
||||
throw new DaoException("Simulated category update failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteCategory(long id) {
|
||||
throw new DaoException("Simulated category delete failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countBooksByCategoryId(long categoryId) {
|
||||
throw new DaoException("Simulated category count failure", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Book> search(BookSearchCriteria criteria) {
|
||||
throw new DaoException("Simulated search failure", null);
|
||||
|
||||
Reference in New Issue
Block a user