From 27cfc8a476911e2df559ac2a15dc66ed6365aea0 Mon Sep 17 00:00:00 2001 From: Matthias Stock Date: Wed, 8 Mar 2017 23:41:35 +0100 Subject: [PATCH] Products From Database - Categories - Products - i18n - Templating --- build.gradle | 3 + config/checkstyle/checkstyle.xml | 4 +- .../monolith/config/HibernateConfig.java | 15 +++ .../de/mstock/monolith/config/I18nConfig.java | 32 +++-- .../de/mstock/monolith/domain/Category.java | 61 ++++++++++ .../mstock/monolith/domain/CategoryI18n.java | 37 ++++++ .../monolith/domain/CategoryI18nPk.java | 52 ++++++++ .../monolith/domain/CategoryRepository.java | 18 +++ .../de/mstock/monolith/domain/Product.java | 58 +++++++++ .../mstock/monolith/domain/ProductI18n.java | 64 ++++++++++ .../mstock/monolith/domain/ProductI18nPk.java | 52 ++++++++ .../monolith/domain/ProductRepository.java | 12 ++ .../monolith/domain/ProductWeightUnit.java | 5 + .../monolith/service/NotFoundException.java | 11 ++ .../mstock/monolith/service/ShopService.java | 85 +++++++++++++ .../monolith/web/CategoryController.java | 25 +++- .../de/mstock/monolith/web/CategoryDTO.java | 21 ++++ .../monolith/web/HomepageController.java | 18 ++- .../monolith/web/ProductController.java | 23 +++- .../de/mstock/monolith/web/ProductDTO.java | 57 +++++++++ src/main/resources/application.properties | 3 + .../migration/V1__create_initial_schema.sql | 37 ++++++ .../db/migration/V2__insert_data.sql | 41 +++++++ src/main/resources/messages.properties | 11 +- src/main/resources/messages_de.properties | 11 +- src/main/resources/templates/category.html | 113 +----------------- .../templates/fragments/skeleton.html | 9 +- src/main/resources/templates/homepage.html | 6 +- src/main/resources/templates/product.html | 6 +- .../MonolithApplicationTests.java | 8 +- .../monolith/service/ShopServiceTest.java | 64 ++++++++++ 31 files changed, 802 insertions(+), 160 deletions(-) create mode 100644 src/main/java/de/mstock/monolith/config/HibernateConfig.java create mode 100644 src/main/java/de/mstock/monolith/domain/Category.java create mode 100644 src/main/java/de/mstock/monolith/domain/CategoryI18n.java create mode 100644 src/main/java/de/mstock/monolith/domain/CategoryI18nPk.java create mode 100644 src/main/java/de/mstock/monolith/domain/CategoryRepository.java create mode 100644 src/main/java/de/mstock/monolith/domain/Product.java create mode 100644 src/main/java/de/mstock/monolith/domain/ProductI18n.java create mode 100644 src/main/java/de/mstock/monolith/domain/ProductI18nPk.java create mode 100644 src/main/java/de/mstock/monolith/domain/ProductRepository.java create mode 100644 src/main/java/de/mstock/monolith/domain/ProductWeightUnit.java create mode 100644 src/main/java/de/mstock/monolith/service/NotFoundException.java create mode 100644 src/main/java/de/mstock/monolith/service/ShopService.java create mode 100644 src/main/java/de/mstock/monolith/web/CategoryDTO.java create mode 100644 src/main/java/de/mstock/monolith/web/ProductDTO.java create mode 100644 src/main/resources/db/migration/V1__create_initial_schema.sql create mode 100644 src/main/resources/db/migration/V2__insert_data.sql rename src/test/java/de/mstock/{ => monolith}/MonolithApplicationTests.java (68%) create mode 100644 src/test/java/de/mstock/monolith/service/ShopServiceTest.java diff --git a/build.gradle b/build.gradle index 4fc4331..39cec65 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,10 @@ dependencies { compile('org.springframework.boot:spring-boot-starter-security') compile('org.springframework.boot:spring-boot-starter-thymeleaf') compile('org.springframework.boot:spring-boot-starter-web') + compile('org.apache.commons:commons-lang3:3.5') runtime('org.springframework.boot:spring-boot-devtools') runtime('org.postgresql:postgresql') testCompile('org.springframework.boot:spring-boot-starter-test') + testCompile('org.mockito:mockito-core:2.7.14') + testCompile('org.hamcrest:hamcrest-core:1.3') } diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index a984474..8fc903a 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -159,12 +159,12 @@ - + - + diff --git a/src/main/java/de/mstock/monolith/config/HibernateConfig.java b/src/main/java/de/mstock/monolith/config/HibernateConfig.java new file mode 100644 index 0000000..c69a2ab --- /dev/null +++ b/src/main/java/de/mstock/monolith/config/HibernateConfig.java @@ -0,0 +1,15 @@ +package de.mstock.monolith.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.orm.jpa.vendor.HibernateJpaSessionFactoryBean; + +@Configuration +public class HibernateConfig { + + @Bean + public HibernateJpaSessionFactoryBean sessionFactory() { + return new HibernateJpaSessionFactoryBean(); + } + +} diff --git a/src/main/java/de/mstock/monolith/config/I18nConfig.java b/src/main/java/de/mstock/monolith/config/I18nConfig.java index e18191b..4e63724 100644 --- a/src/main/java/de/mstock/monolith/config/I18nConfig.java +++ b/src/main/java/de/mstock/monolith/config/I18nConfig.java @@ -1,34 +1,32 @@ package de.mstock.monolith.config; +import java.util.Arrays; +import java.util.List; import java.util.Locale; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.LocaleResolver; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; -import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; -import org.springframework.web.servlet.i18n.SessionLocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; @Configuration public class I18nConfig extends WebMvcConfigurerAdapter { + private static final List SUPPORTED_LOCALES = + Arrays.asList(new Locale("de_DE"), new Locale("en_US")); + + /** + * Creates a Bean, managed by Spring. + * + * @return A configured LocaleResolver + */ @Bean public LocaleResolver localeResolver() { - SessionLocaleResolver slr = new SessionLocaleResolver(); - slr.setDefaultLocale(Locale.US); - return slr; + AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver(); + localeResolver.setSupportedLocales(SUPPORTED_LOCALES); + localeResolver.setDefaultLocale(Locale.US); + return localeResolver; } - @Bean - public LocaleChangeInterceptor localeChangeInterceptor() { - LocaleChangeInterceptor lci = new LocaleChangeInterceptor(); - lci.setParamName("lang"); - return lci; - } - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(localeChangeInterceptor()); - } } diff --git a/src/main/java/de/mstock/monolith/domain/Category.java b/src/main/java/de/mstock/monolith/domain/Category.java new file mode 100644 index 0000000..b7e1abf --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/Category.java @@ -0,0 +1,61 @@ +package de.mstock.monolith.domain; + +import java.util.List; +import java.util.Map; + +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.JoinTable; +import javax.persistence.ManyToMany; +import javax.persistence.MapKey; +import javax.persistence.OneToMany; +import javax.persistence.Table; + +@Entity +@Table(name = "categories") +public class Category { + + @Id + private int id; + private int ordinal; + @OneToMany(mappedBy = "category") + @MapKey(name = "categoryI18nPk.localeLanguage") + private Map i18n; + @ManyToMany + @JoinTable(name = "category_products", inverseJoinColumns = @JoinColumn(name = "product_id")) + private List products; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public int getOrdinal() { + return ordinal; + } + + public void setOrdinal(int ordinal) { + this.ordinal = ordinal; + } + + public Map getI18n() { + return i18n; + } + + public void setI18n(Map i18n) { + this.i18n = i18n; + } + + public List getProducts() { + return products; + } + + public void setProducts(List products) { + this.products = products; + } + +} diff --git a/src/main/java/de/mstock/monolith/domain/CategoryI18n.java b/src/main/java/de/mstock/monolith/domain/CategoryI18n.java new file mode 100644 index 0000000..379dea2 --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/CategoryI18n.java @@ -0,0 +1,37 @@ +package de.mstock.monolith.domain; + +import javax.persistence.EmbeddedId; +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.MapsId; +import javax.persistence.Table; + +@Entity +@Table(name = "categories_i18n") +public class CategoryI18n { + + @EmbeddedId + private CategoryI18nPk categoryI18nPk; + @ManyToOne + @MapsId("categoryId") + private Category category; + private String name; + private String prettyUrlFragment; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPrettyUrlFragment() { + return prettyUrlFragment; + } + + public void setPrettyUrlFragment(String prettyUrlFragment) { + this.prettyUrlFragment = prettyUrlFragment; + } + +} diff --git a/src/main/java/de/mstock/monolith/domain/CategoryI18nPk.java b/src/main/java/de/mstock/monolith/domain/CategoryI18nPk.java new file mode 100644 index 0000000..faf2bfc --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/CategoryI18nPk.java @@ -0,0 +1,52 @@ +package de.mstock.monolith.domain; + +import java.io.Serializable; +import java.util.Objects; + +import javax.persistence.Embeddable; + +@Embeddable +public class CategoryI18nPk implements Serializable { + + private static final long serialVersionUID = 6985884171403931363L; + private int categoryId; + private String localeLanguage; + + @Override + public int hashCode() { + return Objects.hash(categoryId, localeLanguage); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (getClass() != obj.getClass()) { + return false; + } + CategoryI18nPk rhs = (CategoryI18nPk) obj; + return Objects.equals(categoryId, rhs.categoryId) + && Objects.equals(localeLanguage, rhs.localeLanguage); + } + + public int getCategoryId() { + return categoryId; + } + + public void setCategoryId(int categoryId) { + this.categoryId = categoryId; + } + + public String getLocaleLanguage() { + return localeLanguage; + } + + public void setLocaleLanguage(String localeLanguage) { + this.localeLanguage = localeLanguage; + } + +} diff --git a/src/main/java/de/mstock/monolith/domain/CategoryRepository.java b/src/main/java/de/mstock/monolith/domain/CategoryRepository.java new file mode 100644 index 0000000..bad86af --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/CategoryRepository.java @@ -0,0 +1,18 @@ +package de.mstock.monolith.domain; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +public interface CategoryRepository extends Repository { + + @Query("select distinct c from Category c join fetch c.i18n i " + + "where key(i) = ?1 order by c.ordinal") + List findAllOrdered(String language); + + @Query("select c from Category c join fetch c.i18n i join fetch c.products " + + "where key(i) = ?1 and lower(i.prettyUrlFragment) = ?2") + Category findByPrettyUrlFragment(String language, String prettyUrlFragment); + +} diff --git a/src/main/java/de/mstock/monolith/domain/Product.java b/src/main/java/de/mstock/monolith/domain/Product.java new file mode 100644 index 0000000..52e5131 --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/Product.java @@ -0,0 +1,58 @@ +package de.mstock.monolith.domain; + +import java.util.Map; + +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Id; +import javax.persistence.MapKey; +import javax.persistence.OneToMany; +import javax.persistence.Table; + +@Entity +@Table(name = "products") +public class Product { + + @Id + private int id; + private String itemNumber; + @Enumerated(EnumType.ORDINAL) + private ProductWeightUnit unit; + @OneToMany(mappedBy = "product") + @MapKey(name = "productI18nPk.localeLanguage") + private Map i18n; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getItemNumber() { + return itemNumber; + } + + public void setItemNumber(String itemNumber) { + this.itemNumber = itemNumber; + } + + public ProductWeightUnit getUnit() { + return unit; + } + + public void setUnit(ProductWeightUnit unit) { + this.unit = unit; + } + + public Map getI18n() { + return i18n; + } + + public void setI18n(Map i18n) { + this.i18n = i18n; + } + +} diff --git a/src/main/java/de/mstock/monolith/domain/ProductI18n.java b/src/main/java/de/mstock/monolith/domain/ProductI18n.java new file mode 100644 index 0000000..3ccc205 --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/ProductI18n.java @@ -0,0 +1,64 @@ +package de.mstock.monolith.domain; + +import java.math.BigDecimal; + +import javax.persistence.EmbeddedId; +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.MapsId; +import javax.persistence.Table; + +@Entity +@Table(name = "products_i18n") +public class ProductI18n { + + @EmbeddedId + private ProductI18nPk productI18nPk; + @ManyToOne + @MapsId("productId") + private Product product; + private String name; + private String prettyUrlFragment; + private BigDecimal price; + private String description; + + public ProductI18nPk getProductI18nPk() { + return productI18nPk; + } + + public void setProductI18nPk(ProductI18nPk productI18nPk) { + this.productI18nPk = productI18nPk; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPrettyUrlFragment() { + return prettyUrlFragment; + } + + public void setPrettyUrlFragment(String prettyUrlFragment) { + this.prettyUrlFragment = prettyUrlFragment; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/src/main/java/de/mstock/monolith/domain/ProductI18nPk.java b/src/main/java/de/mstock/monolith/domain/ProductI18nPk.java new file mode 100644 index 0000000..90e72aa --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/ProductI18nPk.java @@ -0,0 +1,52 @@ +package de.mstock.monolith.domain; + +import java.io.Serializable; +import java.util.Objects; + +import javax.persistence.Embeddable; + +@Embeddable +public class ProductI18nPk implements Serializable { + + private static final long serialVersionUID = 4916705045560501228L; + private int productId; + private String localeLanguage; + + @Override + public int hashCode() { + return Objects.hash(productId, localeLanguage); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (getClass() != obj.getClass()) { + return false; + } + ProductI18nPk rhs = (ProductI18nPk) obj; + return Objects.equals(productId, rhs.productId) + && Objects.equals(localeLanguage, rhs.localeLanguage); + } + + public int getProductId() { + return productId; + } + + public void setProductId(int productId) { + this.productId = productId; + } + + public String getLocaleLanguage() { + return localeLanguage; + } + + public void setLocaleLanguage(String localeLanguage) { + this.localeLanguage = localeLanguage; + } + +} diff --git a/src/main/java/de/mstock/monolith/domain/ProductRepository.java b/src/main/java/de/mstock/monolith/domain/ProductRepository.java new file mode 100644 index 0000000..b0259e0 --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/ProductRepository.java @@ -0,0 +1,12 @@ +package de.mstock.monolith.domain; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +public interface ProductRepository extends Repository { + + @Query("select p from Product p join fetch p.i18n i " + + "where key(i) = ?1 and lower(i.name) = ?2") + Product findByI18nName(String language, String name); + +} diff --git a/src/main/java/de/mstock/monolith/domain/ProductWeightUnit.java b/src/main/java/de/mstock/monolith/domain/ProductWeightUnit.java new file mode 100644 index 0000000..7e7c0f7 --- /dev/null +++ b/src/main/java/de/mstock/monolith/domain/ProductWeightUnit.java @@ -0,0 +1,5 @@ +package de.mstock.monolith.domain; + +public enum ProductWeightUnit { + UNKNOWN, PIECE, GRAMM_100, KILO_1 +} diff --git a/src/main/java/de/mstock/monolith/service/NotFoundException.java b/src/main/java/de/mstock/monolith/service/NotFoundException.java new file mode 100644 index 0000000..76c277e --- /dev/null +++ b/src/main/java/de/mstock/monolith/service/NotFoundException.java @@ -0,0 +1,11 @@ +package de.mstock.monolith.service; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class NotFoundException extends RuntimeException { + + private static final long serialVersionUID = 4799626622256800091L; + +} diff --git a/src/main/java/de/mstock/monolith/service/ShopService.java b/src/main/java/de/mstock/monolith/service/ShopService.java new file mode 100644 index 0000000..b2d79a3 --- /dev/null +++ b/src/main/java/de/mstock/monolith/service/ShopService.java @@ -0,0 +1,85 @@ +package de.mstock.monolith.service; + +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import de.mstock.monolith.domain.Category; +import de.mstock.monolith.domain.CategoryI18n; +import de.mstock.monolith.domain.CategoryRepository; +import de.mstock.monolith.domain.Product; +import de.mstock.monolith.domain.ProductI18n; +import de.mstock.monolith.domain.ProductRepository; +import de.mstock.monolith.web.CategoryDTO; +import de.mstock.monolith.web.ProductDTO; + +@Service +public class ShopService { + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductRepository productRepository; + + /** + * Gets all categories of the current language. + * + * @return A simplified Data Transfer Object. + */ + public List getCategories(Locale locale) { + String language = locale.getLanguage(); + List categories = new ArrayList<>(); + for (Category category : categoryRepository.findAllOrdered(language)) { + CategoryI18n i18n = category.getI18n().get(language); + categories.add(new CategoryDTO(i18n.getName(), i18n.getPrettyUrlFragment())); + } + return Collections.unmodifiableList(categories); + } + + /** + * Gets all products for a category in the current language. + * + * @return A simplified Data Transfer Object. + */ + public List getProductsForCategory(Locale locale, String prettyUrlFragment) { + String language = locale.getLanguage(); + Category category = categoryRepository.findByPrettyUrlFragment(language, prettyUrlFragment); + if (category == null) { + throw new NotFoundException(); + } + List products = new ArrayList<>(); + NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale); + for (Product product : category.getProducts()) { + ProductI18n i18n = product.getI18n().get(language); + String price = currencyFormat.format(i18n.getPrice()); + products.add(new ProductDTO(product.getItemNumber(), product.getUnit(), i18n.getName(), + i18n.getPrettyUrlFragment(), price, i18n.getDescription())); + } + return Collections.unmodifiableList(products); + } + + /** + * Gets a products in the current language. + * + * @return A simplified Data Transfer Object. + */ + public ProductDTO getProduct(Locale locale, String prettyUrlFragment) { + String language = locale.getLanguage(); + Product product = productRepository.findByI18nName(language, prettyUrlFragment); + if (product == null) { + throw new NotFoundException(); + } + ProductI18n i18n = product.getI18n().get(language); + NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale); + String price = currencyFormat.format(i18n.getPrice()); + return new ProductDTO(product.getItemNumber(), product.getUnit(), i18n.getName(), + i18n.getPrettyUrlFragment(), price, i18n.getDescription()); + } + +} diff --git a/src/main/java/de/mstock/monolith/web/CategoryController.java b/src/main/java/de/mstock/monolith/web/CategoryController.java index 3e05b26..045d859 100644 --- a/src/main/java/de/mstock/monolith/web/CategoryController.java +++ b/src/main/java/de/mstock/monolith/web/CategoryController.java @@ -1,20 +1,37 @@ package de.mstock.monolith.web; +import java.util.Locale; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import de.mstock.monolith.service.ShopService; + @Controller public class CategoryController { private static final String TEMPLATE = "category"; - @RequestMapping(value = "/categories/{name:[\\w-]+}", method = RequestMethod.GET) - public String homepage(@PathVariable String name, Model model) { - model.addAttribute("name", name); - model.addAttribute("activeCategory", name); + @Autowired + private ShopService shopService; + + /** + * Category page + * + * @param prettyUrlFragment Pretty URL fragment + * @param model Template model + * @param locale Current locale + * @return The template's name. + */ + @RequestMapping(value = "/categories/{prettyUrlFragment:[\\w-]+}", method = RequestMethod.GET) + public String category(@PathVariable String prettyUrlFragment, Model model, Locale locale) { + model.addAttribute("categories", shopService.getCategories(locale)); + model.addAttribute("products", shopService.getProductsForCategory(locale, prettyUrlFragment)); + model.addAttribute("prettyUrlFragment", prettyUrlFragment); return TEMPLATE; } } diff --git a/src/main/java/de/mstock/monolith/web/CategoryDTO.java b/src/main/java/de/mstock/monolith/web/CategoryDTO.java new file mode 100644 index 0000000..eccd3d1 --- /dev/null +++ b/src/main/java/de/mstock/monolith/web/CategoryDTO.java @@ -0,0 +1,21 @@ +package de.mstock.monolith.web; + +public class CategoryDTO { + + private final String name; + private final String prettyUrlFragment; + + public CategoryDTO(String name, String prettyUrlFragment) { + this.name = name; + this.prettyUrlFragment = prettyUrlFragment; + } + + public String getName() { + return name; + } + + public String getPrettyUrlFragment() { + return prettyUrlFragment; + } + +} diff --git a/src/main/java/de/mstock/monolith/web/HomepageController.java b/src/main/java/de/mstock/monolith/web/HomepageController.java index 9e48a99..90a0acc 100644 --- a/src/main/java/de/mstock/monolith/web/HomepageController.java +++ b/src/main/java/de/mstock/monolith/web/HomepageController.java @@ -1,17 +1,33 @@ package de.mstock.monolith.web; +import java.util.Locale; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import de.mstock.monolith.service.ShopService; + @Controller public class HomepageController { private static final String TEMPLATE = "homepage"; + @Autowired + private ShopService shopService; + + /** + * Homepage + * + * @param model Template model + * @param locale Current locale + * @return The template's name. + */ @RequestMapping(value = "/", method = RequestMethod.GET) - public String homepage(Model model) { + public String homepage(Model model, Locale locale) { + model.addAttribute("categories", shopService.getCategories(locale)); return TEMPLATE; } } diff --git a/src/main/java/de/mstock/monolith/web/ProductController.java b/src/main/java/de/mstock/monolith/web/ProductController.java index 470a363..373f7f3 100644 --- a/src/main/java/de/mstock/monolith/web/ProductController.java +++ b/src/main/java/de/mstock/monolith/web/ProductController.java @@ -1,19 +1,36 @@ package de.mstock.monolith.web; +import java.util.Locale; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import de.mstock.monolith.service.ShopService; + @Controller public class ProductController { private static final String TEMPLATE = "product"; - @RequestMapping(value = "/products/{name:[\\w-]+}", method = RequestMethod.GET) - public String homepage(@PathVariable String name, Model model) { - model.addAttribute("name", name); + @Autowired + private ShopService shopService; + + /** + * Product page + * + * @param prettyUrlFragment Pretty URL fragment + * @param model Template model + * @param locale Current locale + * @return The template's name. + */ + @RequestMapping(value = "/products/{prettyUrlFragment:[\\w-]+}", method = RequestMethod.GET) + public String homepage(@PathVariable String prettyUrlFragment, Model model, Locale locale) { + model.addAttribute("categories", shopService.getCategories(locale)); + model.addAttribute("product", shopService.getProduct(locale, prettyUrlFragment)); return TEMPLATE; } } diff --git a/src/main/java/de/mstock/monolith/web/ProductDTO.java b/src/main/java/de/mstock/monolith/web/ProductDTO.java new file mode 100644 index 0000000..e4a7302 --- /dev/null +++ b/src/main/java/de/mstock/monolith/web/ProductDTO.java @@ -0,0 +1,57 @@ +package de.mstock.monolith.web; + +import de.mstock.monolith.domain.ProductWeightUnit; + +public class ProductDTO { + + private final String itemNumber; + private final ProductWeightUnit unit; + private final String name; + private final String prettyUrlFragment; + private final String price; + private final String description; + + /** + * A simplified representation of a product. + * + * @param itemNumber A product's unique item number. + * @param unit The unit of a product that relates to its price. + * @param name The localized name. + * @param price The product's price per unit. + * @param description The description of the product. + */ + public ProductDTO(String itemNumber, ProductWeightUnit unit, String name, + String prettyUrlFragment, String price, String description) { + this.itemNumber = itemNumber; + this.unit = unit; + this.name = name; + this.prettyUrlFragment = prettyUrlFragment; + this.price = price; + this.description = description; + } + + public String getItemNumber() { + return itemNumber; + } + + public ProductWeightUnit getUnit() { + return unit; + } + + public String getName() { + return name; + } + + public String getPrice() { + return price; + } + + public String getDescription() { + return description; + } + + public String getPrettyUrlFragment() { + return prettyUrlFragment; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0ec6399..05eb0f1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,3 +3,6 @@ spring.datasource.driverClassName=org.postgresql.Driver spring.datasource.url=jdbc:postgresql://${LAB_MONOLITH_DB_IP}:15432/monolith spring.datasource.username=monolith spring.datasource.password=${LAB_MONOLITH_DB_PASSWORD} + +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=true diff --git a/src/main/resources/db/migration/V1__create_initial_schema.sql b/src/main/resources/db/migration/V1__create_initial_schema.sql new file mode 100644 index 0000000..7bc637b --- /dev/null +++ b/src/main/resources/db/migration/V1__create_initial_schema.sql @@ -0,0 +1,37 @@ +create table categories ( + id serial primary key, + ordinal integer not null +); + +create table categories_i18n ( + category_id integer, + locale_language varchar(2) not null, + name varchar(80) not null, + pretty_url_fragment varchar(31) not null, + primary key (category_id, locale_language) +); + +create table products ( + id serial primary key, + item_number varchar(8) not null, + unit integer not null +); + +create table products_i18n ( + product_id integer, + locale_language varchar(2) not null, + name varchar(80) not null, + pretty_url_fragment varchar(31) not null, + price numeric(10, 2) not null check (price > 0), + description varchar(1000), + primary key (product_id, locale_language) +); + +create table category_products ( + category_id integer references categories on delete cascade, + product_id integer references products on delete cascade, + ordinal integer, + primary key (category_id, product_id) +); + +create unique index i_product_cascade on category_products(product_id, category_id); diff --git a/src/main/resources/db/migration/V2__insert_data.sql b/src/main/resources/db/migration/V2__insert_data.sql new file mode 100644 index 0000000..b476945 --- /dev/null +++ b/src/main/resources/db/migration/V2__insert_data.sql @@ -0,0 +1,41 @@ +insert into categories (id, ordinal) VALUES + (1, 10), + (2, 20); + +insert into categories_i18n (category_id, locale_language, name, pretty_url_fragment) values + (1, 'en', 'Fruits', 'fruits'), + (1, 'de', 'Obst', 'obst'), + (2, 'en', 'Vegetables', 'vegetables'), + (2, 'de', 'Gemüse', 'gemuese'); + +insert into products (id, item_number, unit) values + (1, 'AS-62653', 1), + (2, 'KP-77763', 2), + (3, 'KL-58727', 2), + (4, 'RP-87973', 3), + (5, 'LC-52364', 2), + (6, 'WL-59573', 1); + +insert into products_i18n (product_id, locale_language, name, pretty_url_fragment, price, description) values + (1, 'en', 'Kiwis', 'kiwis', 0.21, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (1, 'de', 'Kiwis', 'kiwis', 0.19, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (2, 'en', 'Blueberries', 'blueberries', 2.68, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (2, 'de', 'Heidelbeeren', 'heidelbeeren', 2.52, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (3, 'en', 'Cherries', 'cherries', 1.67, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (3, 'de', 'Kirschen', 'kirschen', 1.57, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (4, 'en', 'Potatoes', 'potatoes', 1.75, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (4, 'de', 'Kartoffeln', 'kartoffeln', 1.65, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (5, 'en', 'Tomatoes', 'tomatoes', 1.18, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (5, 'de', 'Tomaten', 'tomaten', 1.11, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (6, 'en', 'Rhubarb', 'rhubarb', 1.52, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'), + (6, 'de', 'Rhabarber', 'rhabarber', 1.43, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut.'); + +insert into category_products (category_id, product_id, ordinal) values + (1, 1, null), + (1, 2, null), + (1, 3, 10), + (1, 5, null), + (1, 6, null), + (2, 4, 10), + (2, 5, 20), + (2, 6, null); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 8390823..dc69cb4 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -2,12 +2,13 @@ company = Mosh general.backToTop = Back to top general.sampleImage = Sample image navigation.toggle = Toggle navigation -navigation.fruits = Fruits -navigation.vegetables = Vegetables navigation.legalNotice = Legal Notice -products.kiwis = Kiwis -products.blueberries = Blueberries -products.cherries = Cherries +products.kiwis=Kiwis +products.blueberries=Blueberries +products.cherries=Cherries +products.kiwis.prettyUrlFragment=kiwis +products.blueberries.prettyUrlFragment=blueberries +products.cherries.prettyUrlFragment=cherries products.goto = Go to product features.headline1 = Delicious Fruits. features.subheadline1 = It'll blow your mind. diff --git a/src/main/resources/messages_de.properties b/src/main/resources/messages_de.properties index c811366..b19516f 100644 --- a/src/main/resources/messages_de.properties +++ b/src/main/resources/messages_de.properties @@ -1,12 +1,13 @@ general.backToTop = Zurück nach oben general.sampleImage = Beispielfoto navigation.toggle = Navigation umschalten -navigation.fruits = Obst -navigation.vegetables = Gemüse navigation.legalNotice = Impressum -products.kiwis = Kiwis -products.blueberries = Heidelbeeren -products.cherries = Kirschen +products.kiwis=Kiwis +products.blueberries=Blaubeeren +products.cherries=Kirschen +products.kiwis.prettyUrlFragment=kiwis +products.blueberries.prettyUrlFragment=heidelbeeren +products.cherries.prettyUrlFragment=kirschen products.goto = Zum Produkt features.headline1 = Frisches Obst. features.subheadline1 = Es wird dich umhauen. diff --git a/src/main/resources/templates/category.html b/src/main/resources/templates/category.html index 185e42d..db9e1df 100644 --- a/src/main/resources/templates/category.html +++ b/src/main/resources/templates/category.html @@ -17,8 +17,8 @@
-
- Product + Product
-

0,00 Euro

-
-
- Generic placeholder image -
- - [[#{general.sampleImage}]]: Rach. New Packaging. CC BY 2.0 - -
-
- Product -
-

0,00 Euro

-
-
- Generic placeholder image -
- - [[#{general.sampleImage}]]: Rach. New Packaging. CC BY 2.0 - -
-
- Product -
-

0,00 Euro

-
-
- Generic placeholder image -
- - [[#{general.sampleImage}]]: Rach. New Packaging. CC BY 2.0 - -
-
- Product -
-

0,00 Euro

-
-
- -
-
- Generic placeholder image -
- - [[#{general.sampleImage}]]: Rach. New Packaging. CC BY 2.0 - -
-
- Product -
-

0,00 Euro

-
-
- Generic placeholder image -
- - [[#{general.sampleImage}]]: Rach. New Packaging. CC BY 2.0 - -
-
- Product -
-

0,00 Euro

-
-
- Generic placeholder image -
- - [[#{general.sampleImage}]]: Rach. New Packaging. CC BY 2.0 - -
-
- Product -
-

0,00 Euro

+

1,47 Euro

diff --git a/src/main/resources/templates/fragments/skeleton.html b/src/main/resources/templates/fragments/skeleton.html index 6ead40e..9282eb1 100644 --- a/src/main/resources/templates/fragments/skeleton.html +++ b/src/main/resources/templates/fragments/skeleton.html @@ -36,12 +36,9 @@ diff --git a/src/main/resources/templates/homepage.html b/src/main/resources/templates/homepage.html index a8928ca..00f021d 100644 --- a/src/main/resources/templates/homepage.html +++ b/src/main/resources/templates/homepage.html @@ -109,7 +109,7 @@ class="text-emphasized">Kiwi. CC BY 2.0

- Go to product

@@ -128,7 +128,7 @@ class="text-emphasized">Blueberries. CC BY 2.0

- Go to product

@@ -148,7 +148,7 @@ class="text-emphasized">Cherries. CC BY 2.0

- Go to product

diff --git a/src/main/resources/templates/product.html b/src/main/resources/templates/product.html index 0b4d509..0d7bc31 100644 --- a/src/main/resources/templates/product.html +++ b/src/main/resources/templates/product.html @@ -30,9 +30,9 @@
-

Product Name

-

0,00 Euro

-

Description.

+

Product Name

+

0,00 Euro

+

Description.

diff --git a/src/test/java/de/mstock/MonolithApplicationTests.java b/src/test/java/de/mstock/monolith/MonolithApplicationTests.java similarity index 68% rename from src/test/java/de/mstock/MonolithApplicationTests.java rename to src/test/java/de/mstock/monolith/MonolithApplicationTests.java index c5fee0e..57e04c8 100644 --- a/src/test/java/de/mstock/MonolithApplicationTests.java +++ b/src/test/java/de/mstock/monolith/MonolithApplicationTests.java @@ -1,4 +1,6 @@ -package de.mstock; +package de.mstock.monolith; + +import static org.junit.Assert.assertTrue; import org.junit.Test; import org.junit.runner.RunWith; @@ -10,6 +12,8 @@ import org.springframework.test.context.junit4.SpringRunner; public class MonolithApplicationTests { @Test - public void contextLoads() {} + public void contextLoads() { + assertTrue(true); + } } diff --git a/src/test/java/de/mstock/monolith/service/ShopServiceTest.java b/src/test/java/de/mstock/monolith/service/ShopServiceTest.java new file mode 100644 index 0000000..92cba32 --- /dev/null +++ b/src/test/java/de/mstock/monolith/service/ShopServiceTest.java @@ -0,0 +1,64 @@ +package de.mstock.monolith.service; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import de.mstock.monolith.domain.Category; +import de.mstock.monolith.domain.CategoryRepository; +import de.mstock.monolith.domain.Product; +import de.mstock.monolith.domain.ProductRepository; +import de.mstock.monolith.web.CategoryDTO; +import de.mstock.monolith.web.ProductDTO; + +@RunWith(MockitoJUnitRunner.class) +public class ShopServiceTest { + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private ShopService shopService; + + @Test + public void shouldGetDataTransferObjectForEveryEntity() { + Locale locale = new Locale("de"); + Category category = mock(Category.class, RETURNS_DEEP_STUBS); + List categoryEntities = Arrays.asList(category, category, category); + when(categoryRepository.findAllOrdered(eq(locale.getLanguage()))).thenReturn(categoryEntities); + List categories = shopService.getCategories(locale); + assertThat("Same amount of categories", categories.size(), + is(equalTo(categoryEntities.size()))); + } + + @Test + public void shouldFormatPrice() { + Locale locale = new Locale("en", "US"); + Product product = mock(Product.class, RETURNS_DEEP_STUBS); + when(product.getI18n().get(anyString()).getPrice()).thenReturn(BigDecimal.valueOf(1.47)); + when(productRepository.findByI18nName(eq(locale.getLanguage()), anyString())) + .thenReturn(product); + ProductDTO productDTO = shopService.getProduct(locale, "foo"); + assertThat("Product has a formatted price", productDTO.getPrice(), is(equalTo("$1.47"))); + } + +}