9 Commits

87 changed files with 22282 additions and 2683 deletions

Binary file not shown.

BIN
.vs/ипРома/v17/.wsuo Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
{
"Version": 1,
"WorkspaceRootPath": "C:\\Users\\busla\\Desktop\\\u0438\u043F\u0420\u043E\u043C\u0430\\",
"Documents": [],
"DocumentGroupContainers": [
{
"Orientation": 0,
"VerticalTabListWidth": 256,
"DocumentGroups": []
}
]
}

View File

@@ -0,0 +1,12 @@
{
"Version": 1,
"WorkspaceRootPath": "C:\\Users\\busla\\Desktop\\\u0438\u043F\u0420\u043E\u043C\u0430\\",
"Documents": [],
"DocumentGroupContainers": [
{
"Orientation": 0,
"VerticalTabListWidth": 256,
"DocumentGroups": []
}
]
}

View File

@@ -0,0 +1,35 @@
apply plugin: "com.github.node-gradle.node"
logger.quiet("Configure front builder")
ext {
frontDir = file("${project.projectDir}/front")
logger.quiet("Webapp dir is ${frontDir}")
}
node {
version = "22.17.1"
npmVersion = "10.9.2"
download = true
}
tasks.register("frontDepsInstall", NpmTask) {
group = "front"
description = "Installs dependencies from package.json"
logger.quiet(description)
workingDir = frontDir
args = ["install"]
}
tasks.register("frontBuild", NpmTask) {
group = "front"
description = "Build frontend webapp"
logger.quiet(description)
workingDir = frontDir
dependsOn frontDepsInstall
args = ["run", "build"]
}
if (frontDir.exists()) {
processResources.finalizedBy frontBuild
}

View File

@@ -2,7 +2,10 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.5.5'
id 'io.spring.dependency-management' version '1.1.7'
id "com.github.node-gradle.node" version "7.1.0" apply false
id 'org.liquibase.gradle' version '2.2.0'
}
group = 'ru.ulstu.is'
version = '0.0.1-SNAPSHOT'
description = 'Movie API (Lab 1)'
@@ -18,16 +21,98 @@ java {
repositories { mavenCentral() }
ext {
springdocVersion = '2.8.11'
springProfiles = []
springdocVersion = '2.8.14'
springProfiles = []
if (project.hasProperty("front")) {
springProfiles.add("front")
}
if (project.hasProperty("prod")) {
springProfiles.add("prod")
} else {
springProfiles.add("dev")
}
currentProfiles = springProfiles.join(",")
logger.quiet("Current profiles are: " + currentProfiles)
}
liquibase {
activities {
dev {
url 'jdbc:h2:file:./data/appdb'
username 'sa'
driver 'org.h2.Driver'
changelogFile 'src/main/resources/db/changelog/db.changelog-master.yaml'
}
prod {
url 'jdbc:postgresql://127.0.0.1:5432/internet_lab4'
username 'postgres'
password 'postgres'
driver 'org.postgresql.Driver'
changelogFile 'src/main/resources/db/changelog/db.changelog-master.yaml'
}
}
runList = project.hasProperty('prod') ? 'prod' : 'dev'
}
configurations {
liquibaseRuntime {
extendsFrom runtimeClasspath
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.liquibase:liquibase-core:4.30.0'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation "org.springframework.boot:spring-boot-starter-aop"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
liquibaseRuntime 'org.liquibase:liquibase-core:4.30.0'
liquibaseRuntime 'info.picocli:picocli:4.7.6'
liquibaseRuntime 'com.h2database:h2:2.2.224'
if (springProfiles.contains("prod")) {
runtimeOnly "org.postgresql:postgresql:42.7.4"
} else {
runtimeOnly "org.postgresql:postgresql:42.7.4"
runtimeOnly "com.h2database:h2:2.2.224"
}
}
tasks.named('test') {
useJUnitPlatform()
}
if (springProfiles.contains("front")) {
apply from: "build.front.gradle"
}
bootRun {
def currentArgs = ["--spring.profiles.active=" + currentProfiles]
if (project.hasProperty("args")) {
currentArgs.addAll(project.args.tokenize())
}
args currentArgs
}
test {
useJUnitPlatform()
systemProperty "spring.profiles.active", currentProfiles
}

BIN
backend/data/appdb.mv.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
2025-11-28 11:36:35.062950+04:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Таблица "DATABASECHANGELOGLOCK" не найдена
Table "DATABASECHANGELOGLOCK" not found; SQL statement:
SELECT COUNT(*) FROM PUBLIC.DATABASECHANGELOGLOCK [42102-224]
2025-11-28 14:36:26.320793+04:00 jdbc[13]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Синтаксическая ошибка в выражении SQL "[*]USERS SELECT * FROM USERS"; ожидалось "UPDATE"
Syntax error in SQL statement "[*]USERS SELECT * FROM USERS"; expected "UPDATE"; SQL statement:
USERS SELECT * FROM USERS [42001-224]

6
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,35 +0,0 @@
package ru.ulstu.is.server.api;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.service.MovieService;
import java.util.List;
@RestController
@RequestMapping("/api/1.0/movies")
public class MovieController {
private final MovieService service;
public MovieController(MovieService service) { this.service = service; }
@GetMapping
public List<MovieRs> all() { return service.getAll(); }
@GetMapping("/{id}")
public MovieRs one(@PathVariable Long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public MovieRs create(@RequestBody @Valid MovieRq rq) { return service.create(rq); }
@PutMapping("/{id}")
public MovieRs update(@PathVariable Long id, @RequestBody @Valid MovieRq rq) { return service.update(id, rq); }
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}

View File

@@ -0,0 +1,71 @@
package ru.ulstu.is.server.api;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import ru.ulstu.is.server.repository.DirectorRepository;
import ru.ulstu.is.server.repository.GenreRepository;
import ru.ulstu.is.server.repository.MovieRepository;
import ru.ulstu.is.server.repository.SubscriptionRepository;
import ru.ulstu.is.server.repository.UserRepository;
@RestController
@RequestMapping("/api/1.0/reports")
@Tag(name = "Reports", description = "Отчёты и агрегированные выборки")
public class ReportController {
private final GenreRepository genreRepo;
private final DirectorRepository directorRepo;
private final UserRepository userRepo;
private final SubscriptionRepository subscriptionRepo;
private final MovieRepository movieRepo;
public ReportController(GenreRepository genreRepo,
DirectorRepository directorRepo,
UserRepository userRepo,
SubscriptionRepository subscriptionRepo,
MovieRepository movieRepo) {
this.genreRepo = genreRepo;
this.directorRepo = directorRepo;
this.userRepo = userRepo;
this.subscriptionRepo = subscriptionRepo;
this.movieRepo = movieRepo;
}
@GetMapping("/genres/stats")
@Operation(summary = "Количество фильмов по жанрам")
public List<GenreRepository.GenreStats> genreStats() {
return genreRepo.getGenreStats();
}
@GetMapping("/directors/top")
@Operation(summary = "Топ режиссёров по числу фильмов")
public List<DirectorRepository.DirectorStats> topDirectors(
@RequestParam(defaultValue = "10") int limit) {
return directorRepo.getDirectorStats();
}
@GetMapping("/subscriptions/usage")
@Operation(summary = "Использование подписок: тариф, цена, число пользователей")
public List<SubscriptionRepository.SubscriptionUsage> subscriptionUsage() {
return subscriptionRepo.usageStats();
}
@GetMapping("/users/by-subscription")
@Operation(summary = "Число пользователей по тарифам")
public List<UserRepository.UsersBySubscription> usersBySubscription() {
return userRepo.countUsersBySubscription();
}
@GetMapping("/users/avg-age-by-subscription")
@Operation(summary = "Средний возраст пользователей по тарифам")
public List<UserRepository.AvgAgeBySubscription> avgAgeBySubscription() {
return userRepo.avgAgeBySubscription();
}
}

View File

@@ -0,0 +1,13 @@
package ru.ulstu.is.server.api;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SpaController {
@GetMapping("/")
public String index() {
return "forward:/index.html";
}
}

View File

@@ -1,33 +0,0 @@
package ru.ulstu.is.server.api;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.dto.SubscriptionRs;
import ru.ulstu.is.server.service.SubscriptionService;
import java.util.List;
@RestController
@RequestMapping("/api/1.0/subscriptions")
public class SubscriptionController {
private final SubscriptionService service;
public SubscriptionController(SubscriptionService service) { this.service = service; }
@GetMapping public List<SubscriptionRs> all() { return service.getAll(); }
@GetMapping("/{id}") public SubscriptionRs one(@PathVariable Long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public SubscriptionRs create(@RequestBody @Valid SubscriptionRq rq) { return service.create(rq); }
@PutMapping("/{id}")
public SubscriptionRs update(@PathVariable Long id, @RequestBody @Valid SubscriptionRq rq) {
return service.update(id, rq);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}

View File

@@ -0,0 +1,16 @@
package ru.ulstu.is.server.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcWebConfiguration implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login").setViewName("login");
registry.addRedirectViewController("/", "/movies");
}
}

View File

@@ -5,22 +5,57 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class MovieRq {
@NotBlank
private String title;
private String image;
private float grade;
@NotNull @Valid
private GenreRefRq genre;
@NotNull
@Valid
private GenreRefRq genre;
@NotNull @Valid
private DirectorRefRq director;
@NotNull
@Valid
private DirectorRefRq director;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public GenreRefRq getGenre() { return genre; }
public void setGenre(GenreRefRq genre) { this.genre = genre; }
public DirectorRefRq getDirector() { return director; }
public void setDirector(DirectorRefRq director) { this.director = director; }
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public float getGrade() {
return grade;
}
public void setGrade(float grade) {
this.grade = grade;
}
public GenreRefRq getGenre() {
return genre;
}
public void setGenre(GenreRefRq genre) {
this.genre = genre;
}
public DirectorRefRq getDirector() {
return director;
}
public void setDirector(DirectorRefRq director) {
this.director = director;
}
}

View File

@@ -1,25 +1,60 @@
package ru.ulstu.is.server.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class MovieRs {
private Long id;
private String title;
private String image;
private float grade;
private GenreRs genre;
private DirectorRs director;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public GenreRs getGenre() { return genre; }
public void setGenre(GenreRs genre) { this.genre = genre; }
public DirectorRs getDirector() { return director; }
public void setDirector(DirectorRs director) { this.director = director; }
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImage() {
return image;
}
public float getGrade() {
return grade;
}
public void setGrade(float grade) {
this.grade = grade;
}
public void setImage(String image) {
this.image = image;
}
public GenreRs getGenre() {
return genre;
}
public void setGenre(GenreRs genre) {
this.genre = genre;
}
public DirectorRs getDirector() {
return director;
}
public void setDirector(DirectorRs director) {
this.director = director;
}
}

View File

@@ -0,0 +1,15 @@
package ru.ulstu.is.server.dto;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public final class PageHelper {
private PageHelper() {
}
public static Pageable toPageable(int page, int size) {
return PageRequest.of(page - 1, size, Sort.by("id"));
}
}

View File

@@ -0,0 +1,34 @@
package ru.ulstu.is.server.dto;
import org.springframework.data.domain.Page;
import java.util.List;
import java.util.function.Function;
public record PageRs<D>(
List<D> items,
int itemsCount,
int currentPage,
int currentSize,
int totalPages,
long totalItems,
boolean isFirst,
boolean isLast,
boolean hasNext,
boolean hasPrevious
) {
public static <D, E> PageRs<D> from(Page<E> page, Function<E, D> mapper) {
return new PageRs<>(
page.getContent().stream().map(mapper).toList(),
page.getNumberOfElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.getTotalElements(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
);
}
}

View File

@@ -1,16 +1,43 @@
package ru.ulstu.is.server.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class SubscriptionRq {
@NotBlank
private String level;
@NotNull @Min(0)
@NotNull
@Min(0)
private Integer price;
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
}
@Min(0)
@Max(100)
private Integer discount;
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
public Integer getDiscount() {
return discount;
}
public void setDiscount(Integer discount) {
this.discount = discount;
}
}

View File

@@ -1,21 +1,62 @@
package ru.ulstu.is.server.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class SubscriptionRs {
private Long id;
private String level;
private Integer price;
public SubscriptionRs() { }
public SubscriptionRs(Long id, String level, Integer price) {
this.id = id; this.level = level; this.price = price;
private Integer discount;
private Integer priceWithDiscount;
public SubscriptionRs() {
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
}
public SubscriptionRs(Long id, String level, Integer price,
Integer discount, Integer priceWithDiscount) {
this.id = id;
this.level = level;
this.price = price;
this.discount = discount;
this.priceWithDiscount = priceWithDiscount;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
public Integer getDiscount() {
return discount;
}
public void setDiscount(Integer discount) {
this.discount = discount;
}
public Integer getPriceWithDiscount() {
return priceWithDiscount;
}
public void setPriceWithDiscount(Integer priceWithDiscount) {
this.priceWithDiscount = priceWithDiscount;
}
}

View File

@@ -1,6 +1,11 @@
package ru.ulstu.is.server.entity;
import jakarta.persistence.*;
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
public BaseEntity() { }

View File

@@ -1,11 +1,33 @@
package ru.ulstu.is.server.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "directors")
public class Director extends BaseEntity {
@Column(nullable = false, unique = true)
private String name;
public Director() { }
public Director(Long id, String name) { super(id); this.name = name; }
@OneToMany(mappedBy = "director", cascade = CascadeType.ALL, orphanRemoval = false)
private List<Movie> movies = new ArrayList<>();
public Director() {}
public Director(Long id, String name) { setId(id); this.name = name; }
public void addMovie(Movie m) {
if (m == null) return;
movies.add(m);
m.setDirector(this);
}
public void removeMovie(Movie m) {
if (m == null) return;
movies.remove(m);
if (m.getDirector() == this) m.setDirector(null);
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Movie> getMovies() { return movies; }
}

View File

@@ -1,11 +1,33 @@
package ru.ulstu.is.server.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "genres")
public class Genre extends BaseEntity {
@Column(nullable = false, unique = true)
private String name;
public Genre() { }
public Genre(Long id, String name) { super(id); this.name = name; }
@OneToMany(mappedBy = "genre", cascade = CascadeType.ALL, orphanRemoval = false)
private List<Movie> movies = new ArrayList<>();
public Genre() {}
public Genre(Long id, String name) { setId(id); this.name = name; }
public void addMovie(Movie m) {
if (m == null) return;
movies.add(m);
m.setGenre(this);
}
public void removeMovie(Movie m) {
if (m == null) return;
movies.remove(m);
if (m.getGenre() == this) m.setGenre(null);
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Movie> getMovies() { return movies; }
}

View File

@@ -1,30 +1,84 @@
package ru.ulstu.is.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "movies")
public class Movie extends BaseEntity {
@Column(nullable = false)
private String title;
private String image;
@Lob
@Column(columnDefinition = "CLOB")
private String image;
@Column(nullable = false)
private float grade;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "genre_id", nullable = false)
private Genre genre;
private Director director;
public Movie() { }
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "director_id", nullable = false)
private Director director;
public Movie(Long id, String title, String image, Genre genre, Director director) {
super(id);
public Movie() {
}
public Movie(Long id, String title, String image, float grade, Genre genre, Director director) {
setId(id);
this.title = title;
this.image = image;
this.grade = grade;
this.genre = genre;
this.director = director;
}
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getTitle() {
return title;
}
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public void setTitle(String title) {
this.title = title;
}
public Genre getGenre() { return genre; }
public void setGenre(Genre genre) { this.genre = genre; }
public String getImage() {
return image;
}
public Director getDirector() { return director; }
public void setDirector(Director director) { this.director = director; }
public void setImage(String image) {
this.image = image;
}
public Genre getGenre() {
return genre;
}
public void setGenre(Genre genre) {
this.genre = genre;
}
public float getGrade() {
return grade;
}
public void setGrade(float grade) {
this.grade = grade;
}
public Director getDirector() {
return director;
}
public void setDirector(Director director) {
this.director = director;
}
}

View File

@@ -1,16 +1,81 @@
package ru.ulstu.is.server.entity;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "subscriptions")
public class Subscription extends BaseEntity {
@Column(nullable = false, unique = true)
private String level;
@Column(nullable = false)
private Integer price;
public Subscription() { }
public Subscription(Long id, String level, Integer price) {
super(id); this.level = level; this.price = price;
@OneToMany(mappedBy = "subscription", orphanRemoval = false)
private List<User> users = new ArrayList<>();
@Column(nullable = false)
private Integer discount = 0;
public Subscription() {
}
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
public Subscription(Long id, String level, Integer price) {
super(id);
this.level = level;
this.price = price;
}
public void addUser(User u) {
if (u == null) {
return;
}
users.add(u);
u.setSubscription(this);
}
public Integer getDiscount() {
return discount;
}
public void setDiscount(Integer discount) {
this.discount = discount != null ? discount : 0;
}
public void removeUser(User u) {
if (u == null) {
return;
}
users.remove(u);
if (u.getSubscription() == this) {
u.setSubscription(null);
}
}
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
public Integer getPrice() {
return price;
}
public void setPrice(Integer price) {
this.price = price;
}
public List<User> getUsers() {
return users;
}
}

View File

@@ -1,55 +1,178 @@
package ru.ulstu.is.server.entity;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class User extends BaseEntity {
private String name;
private String login;
private String password;
private Boolean isAdmin;
private Integer age;
private String gender;
private String avatar;
private Subscription subscription;
private String subscriptionUntil;
private List<Long> watchlist = new ArrayList<>();
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
public User() { }
@Entity
@Table(name = "users")
public class User extends BaseEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String login;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private Boolean isAdmin = false;
@Column
private Integer age;
@Column
private String gender;
@Lob
@Column(columnDefinition = "CLOB")
private String avatar;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "subscription_id")
private Subscription subscription;
@Column(name = "subscription_until")
private LocalDate subscriptionUntil;
@Enumerated(EnumType.STRING)
@Column(name = "ROLE", nullable = false, length = 20)
private UserRole role = UserRole.USER;
@ManyToMany
@JoinTable(
name = "user_watchlist",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "movie_id")
)
private List<Movie> watchlist = new ArrayList<>();
public User() {
}
public User(Long id, String name, String login, String password, Boolean isAdmin) {
super(id);
this.name = name; this.login = login; this.password = password; this.isAdmin = isAdmin;
this.name = name;
this.login = login;
this.password = password;
this.isAdmin = isAdmin;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public void addToWatchlist(Movie m) {
if (m == null) {
return;
}
if (!watchlist.contains(m)) {
watchlist.add(m);
}
}
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public void removeFromWatchlist(Movie m) {
if (m == null) {
return;
}
watchlist.remove(m);
}
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getName() {
return name;
}
public Boolean getIsAdmin() { return isAdmin; }
public void setIsAdmin(Boolean isAdmin) { this.isAdmin = isAdmin; }
public void setName(String name) {
this.name = name;
}
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public String getLogin() {
return login;
}
public String getGender() { return gender; }
public void setGender(String gender) { this.gender = gender; }
public void setLogin(String login) {
this.login = login;
}
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public String getPassword() {
return password;
}
public List<Long> getWatchlist() { return watchlist; }
public void setWatchlist(List<Long> watchlist) { this.watchlist = watchlist; }
public void setPassword(String password) {
this.password = password;
}
public Subscription getSubscription() { return subscription; }
public void setSubscription(Subscription subscription) { this.subscription = subscription; }
public Boolean getIsAdmin() {
return isAdmin;
}
public String getSubscriptionUntil() { return subscriptionUntil; }
public void setSubscriptionUntil(String subscriptionUntil) { this.subscriptionUntil = subscriptionUntil; }
public void setIsAdmin(Boolean isAdmin) {
this.isAdmin = isAdmin;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
public String getAvatar() {
return avatar;
}
public void setAvatar(String avatar) {
this.avatar = avatar;
}
public Subscription getSubscription() {
return subscription;
}
public void setSubscription(Subscription subscription) {
this.subscription = subscription;
}
public LocalDate getSubscriptionUntil() {
return subscriptionUntil;
}
public void setSubscriptionUntil(LocalDate subscriptionUntil) {
this.subscriptionUntil = subscriptionUntil;
}
public List<Movie> getWatchlist() {
return watchlist;
}
public void setWatchlist(List<Movie> watchlist) {
this.watchlist = watchlist != null ? watchlist : new ArrayList<>();
}
public UserRole getRole() {
return role;
}
public void setRole(UserRole role) {
this.role = role;
}
}

View File

@@ -0,0 +1,13 @@
package ru.ulstu.is.server.entity;
import org.springframework.security.core.GrantedAuthority;
public enum UserRole implements GrantedAuthority {
ADMIN,
USER;
@Override
public String getAuthority() {
return "ROLE_" + name();
}
}

View File

@@ -4,6 +4,7 @@ import org.springframework.stereotype.Component;
import ru.ulstu.is.server.dto.DirectorRq;
import ru.ulstu.is.server.dto.DirectorRs;
import ru.ulstu.is.server.entity.Director;
import ru.ulstu.is.server.repository.SubscriptionRepository;
@Component
public class DirectorMapper {

View File

@@ -1,13 +1,22 @@
package ru.ulstu.is.server.mapper;
import org.springframework.stereotype.Component;
import ru.ulstu.is.server.dto.*;
import ru.ulstu.is.server.entity.*;
import ru.ulstu.is.server.dto.DirectorRefRq;
import ru.ulstu.is.server.dto.DirectorRs;
import ru.ulstu.is.server.dto.GenreRefRq;
import ru.ulstu.is.server.dto.GenreRs;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.entity.Director;
import ru.ulstu.is.server.entity.Genre;
import ru.ulstu.is.server.entity.Movie;
import ru.ulstu.is.server.repository.DirectorRepository;
import ru.ulstu.is.server.repository.GenreRepository;
@Component
public class MovieMapper {
private final GenreRepository genreRepo;
private final DirectorRepository directorRepo;
@@ -19,6 +28,7 @@ public class MovieMapper {
public Movie toEntity(MovieRq rq) {
Movie e = new Movie();
e.setTitle(rq.getTitle());
e.setGrade(rq.getGrade());
e.setImage(rq.getImage());
GenreRefRq gRef = rq.getGenre();
@@ -35,34 +45,54 @@ public class MovieMapper {
rs.setId(e.getId());
rs.setTitle(e.getTitle());
rs.setImage(e.getImage());
rs.setGrade(e.getGrade());
Genre g = e.getGenre();
if (g != null) rs.setGenre(new GenreRs(g.getId(), g.getName()));
if (g != null) {
rs.setGenre(new GenreRs(g.getId(), g.getName()));
}
Director d = e.getDirector();
if (d != null) rs.setDirector(new DirectorRs(d.getId(), d.getName()));
if (d != null) {
rs.setDirector(new DirectorRs(d.getId(), d.getName()));
}
return rs;
}
private Genre resolveGenre(GenreRefRq ref) {
if (ref == null) return null;
if (ref == null) {
return null;
}
Long id = parseId(ref.getId());
if (id != null) return genreRepo.findById(id).orElse(null);
if (ref.getName() != null) return new Genre(null, ref.getName());
if (id != null) {
return genreRepo.findById(id).orElse(null);
}
if (ref.getName() != null) {
return new Genre(null, ref.getName());
}
return null;
}
private Director resolveDirector(DirectorRefRq ref) {
if (ref == null) return null;
if (ref == null) {
return null;
}
Long id = parseId(ref.getId());
if (id != null) return directorRepo.findById(id).orElse(null);
if (ref.getName() != null) return new Director(null, ref.getName());
if (id != null) {
return directorRepo.findById(id).orElse(null);
}
if (ref.getName() != null) {
return new Director(null, ref.getName());
}
return null;
}
private Long parseId(String s) {
try { return s == null ? null : Long.parseLong(s); }
catch (NumberFormatException e) { return null; }
try {
return s == null ? null : Long.parseLong(s);
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -1,23 +1,46 @@
package ru.ulstu.is.server.mapper;
import org.springframework.stereotype.Component;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.dto.SubscriptionRs;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.entity.Subscription;
@Component
public class SubscriptionMapper {
public Subscription toEntity(SubscriptionRq rq) {
Subscription s = new Subscription();
s.setLevel(rq.getLevel());
s.setPrice(rq.getPrice());
s.setDiscount(rq.getDiscount() != null ? rq.getDiscount() : 0);
return s;
}
public void updateEntity(Subscription s, SubscriptionRq rq) {
s.setLevel(rq.getLevel());
s.setPrice(rq.getPrice());
s.setDiscount(rq.getDiscount() != null ? rq.getDiscount() : 0);
}
public SubscriptionRs toRs(Subscription s) {
return new SubscriptionRs(s.getId(), s.getLevel(), s.getPrice());
int price = s.getPrice() != null ? s.getPrice() : 0;
int discount = s.getDiscount() != null ? s.getDiscount() : 0;
if (discount < 0) {
discount = 0;
}
if (discount > 100) {
discount = 100;
}
int priceWithDiscount = Math.round(price * (100 - discount) / 100.0f);
return new SubscriptionRs(
s.getId(),
s.getLevel(),
price,
discount,
priceWithDiscount
);
}
}

View File

@@ -2,77 +2,55 @@ package ru.ulstu.is.server.mapper;
import org.springframework.stereotype.Component;
import ru.ulstu.is.server.dto.*;
import ru.ulstu.is.server.entity.Movie;
import ru.ulstu.is.server.entity.Subscription;
import ru.ulstu.is.server.entity.User;
import ru.ulstu.is.server.repository.MovieRepository;
import ru.ulstu.is.server.repository.SubscriptionRepository;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Component
public class UserMapper {
private final SubscriptionRepository subscriptionRepository;
private final MovieRepository movieRepository;
public UserMapper(SubscriptionRepository subscriptionRepository) {
private static final DateTimeFormatter DF = DateTimeFormatter.ISO_DATE;
public UserMapper(SubscriptionRepository subscriptionRepository,
MovieRepository movieRepository) {
this.subscriptionRepository = subscriptionRepository;
this.movieRepository = movieRepository;
}
public User toEntity(UserRq rq) {
User u = new User();
u.setName(rq.getName());
u.setLogin(rq.getLogin());
u.setPassword(rq.getPassword());
u.setIsAdmin(Boolean.TRUE.equals(rq.getIsAdmin()));
u.setAge(rq.getAge());
u.setGender(rq.getGender());
u.setAvatar(rq.getAvatar());
u.setSubscriptionUntil(rq.getSubscriptionUntil());
u.setWatchlist(rq.getWatchlist() != null ? rq.getWatchlist() : new ArrayList<>());
SubscriptionRefRq ref = rq.getSubscription();
if (ref != null) {
Subscription sub = null;
if (ref.getId() != null) {
try {
Long id = Long.parseLong(ref.getId());
sub = subscriptionRepository.findById(id).orElse(null);
} catch (NumberFormatException ignored) {}
}
if (sub == null && (ref.getLevel() != null || ref.getPrice() != null)) {
sub = new Subscription(null, ref.getLevel(), ref.getPrice());
sub = subscriptionRepository.save(sub);
}
u.setSubscription(sub);
}
updateEntity(u, rq);
return u;
}
public void updateEntity(User u, UserRq rq) {
u.setName(rq.getName());
u.setLogin(rq.getLogin());
u.setPassword(rq.getPassword());
u.setIsAdmin(Boolean.TRUE.equals(rq.getIsAdmin()));
u.setAge(rq.getAge());
u.setGender(rq.getGender());
u.setAvatar(rq.getAvatar());
u.setSubscriptionUntil(rq.getSubscriptionUntil());
u.setWatchlist(rq.getWatchlist() != null ? rq.getWatchlist() : new ArrayList<>());
if (rq.getName() != null) u.setName(rq.getName());
if (rq.getLogin() != null) u.setLogin(rq.getLogin());
if (rq.getPassword() != null) u.setPassword(rq.getPassword());
if (rq.getIsAdmin() != null) u.setIsAdmin(rq.getIsAdmin());
if (rq.getAge() != null) u.setAge(rq.getAge());
if (rq.getGender() != null) u.setGender(rq.getGender());
if (rq.getAvatar() != null) u.setAvatar(rq.getAvatar());
SubscriptionRefRq ref = rq.getSubscription();
if (ref != null) {
Subscription sub = null;
if (ref.getId() != null) {
try {
Long id = Long.parseLong(ref.getId());
sub = subscriptionRepository.findById(id).orElse(null);
} catch (NumberFormatException ignored) {}
}
if (sub == null && (ref.getLevel() != null || ref.getPrice() != null)) {
sub = new Subscription(null, ref.getLevel(), ref.getPrice());
sub = subscriptionRepository.save(sub);
}
u.setSubscription(sub);
} else {
u.setSubscription(null);
if (rq.getSubscriptionUntil() != null) {
u.setSubscriptionUntil(parseDate(rq.getSubscriptionUntil()));
}
if (rq.getSubscription() != null) {
u.setSubscription(resolveSubscription(rq.getSubscription()));
}
if (rq.getWatchlist() != null) {
u.setWatchlist(resolveMovies(rq.getWatchlist()));
}
}
@@ -81,20 +59,62 @@ public class UserMapper {
rs.setId(u.getId());
rs.setName(u.getName());
rs.setLogin(u.getLogin());
rs.setIsAdmin(Boolean.TRUE.equals(u.getIsAdmin()));
rs.setIsAdmin(u.getIsAdmin());
rs.setAge(u.getAge());
rs.setGender(u.getGender());
rs.setAvatar(u.getAvatar());
rs.setSubscriptionUntil(u.getSubscriptionUntil());
rs.setWatchlist(u.getWatchlist());
rs.setSubscriptionUntil(formatDate(u.getSubscriptionUntil()));
if (u.getSubscription() != null) {
rs.setSubscription(new SubscriptionRs(
u.getSubscription().getId(),
u.getSubscription().getLevel(),
u.getSubscription().getPrice()
));
Subscription s = u.getSubscription();
SubscriptionRs srs = new SubscriptionRs();
srs.setId(s.getId());
srs.setLevel(s.getLevel());
srs.setPrice(s.getPrice());
rs.setSubscription(srs);
}
if (u.getWatchlist() != null) {
rs.setWatchlist(u.getWatchlist().stream().map(Movie::getId).toList());
} else {
rs.setWatchlist(new ArrayList<>());
}
return rs;
}
private LocalDate parseDate(String s) {
try { return s == null ? null : LocalDate.parse(s, DF); }
catch (Exception ex) { return null; }
}
private String formatDate(LocalDate d) { return d == null ? null : DF.format(d); }
private Subscription resolveSubscription(SubscriptionRefRq ref) {
if (ref == null) return null;
Long id = tryParseLong(ref.getId());
if (id != null) {
return subscriptionRepository.findById(id).orElse(null);
}
if (ref.getLevel() != null) {
return subscriptionRepository.findByLevel(ref.getLevel())
.orElseGet(() -> subscriptionRepository.save(new Subscription(null, ref.getLevel(), ref.getPrice())));
}
return null;
}
private List<Movie> resolveMovies(List<Long> ids) {
List<Movie> list = new ArrayList<>();
if (ids == null) return list;
for (Long id : ids) {
movieRepository.findById(id).ifPresent(list::add);
}
return list;
}
private Long tryParseLong(String s) {
try { return s == null ? null : Long.parseLong(s); }
catch (NumberFormatException e) { return null; }
}
}

View File

@@ -0,0 +1,214 @@
package ru.ulstu.is.server.mvc;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.validation.Valid;
import ru.ulstu.is.server.dto.DirectorRefRq;
import ru.ulstu.is.server.dto.DirectorRs;
import ru.ulstu.is.server.dto.GenreRefRq;
import ru.ulstu.is.server.dto.GenreRs;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.dto.PageHelper;
import ru.ulstu.is.server.dto.PageRs;
import ru.ulstu.is.server.service.DirectorService;
import ru.ulstu.is.server.service.GenreService;
import ru.ulstu.is.server.service.MovieService;
import ru.ulstu.is.server.session.MovieSearchSession;
@Controller
@RequestMapping("/movies")
public class MovieMvcController {
private final MovieService movieService;
private final GenreService genreService;
private final DirectorService directorService;
private final MovieSearchSession movieSearchSession;
public MovieMvcController(MovieService movieService,
GenreService genreService,
DirectorService directorService,
MovieSearchSession movieSearchSession) {
this.movieService = movieService;
this.genreService = genreService;
this.directorService = directorService;
this.movieSearchSession = movieSearchSession;
}
@GetMapping
public String listMovies(
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestParam(required = false) String title,
@RequestParam(required = false) Long genreId,
@RequestParam(required = false) Float minGrade,
@RequestParam(required = false) Boolean exact,
Model model
) {
int pageNumber = (page == null || page < 1) ? 1 : page;
int pageSize = (size == null || size < 1) ? 12 : size;
Boolean exactMatch = exact;
boolean filtersPassedInRequest
= (title != null && !title.isBlank())
|| genreId != null
|| minGrade != null
|| exact != null;
if (!filtersPassedInRequest) {
if (movieSearchSession.getLastTitle() != null) {
title = movieSearchSession.getLastTitle();
}
if (movieSearchSession.getLastGenreId() != null) {
genreId = movieSearchSession.getLastGenreId();
}
if (movieSearchSession.getLastMinGrade() != null) {
minGrade = movieSearchSession.getLastMinGrade();
}
if (movieSearchSession.getLastPageSize() != null) {
pageSize = movieSearchSession.getLastPageSize();
}
if (movieSearchSession.getLastExactMatch() != null) {
exactMatch = movieSearchSession.getLastExactMatch();
}
} else {
movieSearchSession.setLastTitle(title);
movieSearchSession.setLastGenreId(genreId);
movieSearchSession.setLastMinGrade(minGrade);
movieSearchSession.setLastExactMatch(exactMatch);
}
movieSearchSession.setLastPageSize(pageSize);
boolean hasFilters
= (title != null && !title.isBlank())
|| genreId != null
|| minGrade != null
|| Boolean.TRUE.equals(exactMatch);
PageRs<MovieRs> pageRs;
if (hasFilters) {
pageRs = movieService.search(title, genreId, minGrade, exactMatch, pageNumber, pageSize);
} else {
Pageable pageable = PageHelper.toPageable(pageNumber, pageSize);
pageRs = movieService.getPage(pageable);
}
prepareReferenceData(model);
model.addAttribute("page", pageRs);
model.addAttribute("currentPage", pageRs.currentPage());
model.addAttribute("pageSize", pageRs.currentSize());
model.addAttribute("titleFilter", title);
model.addAttribute("genreIdFilter", genreId);
model.addAttribute("minGradeFilter", minGrade);
model.addAttribute("exactMatchFilter", Boolean.TRUE.equals(exactMatch));
return "movies/list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
MovieRq movieRq = new MovieRq();
movieRq.setGenre(new GenreRefRq());
movieRq.setDirector(new DirectorRefRq());
prepareReferenceData(model);
model.addAttribute("movie", movieRq);
model.addAttribute("isEdit", false);
return "movies/form";
}
@PostMapping
public String createMovie(
@Valid @ModelAttribute("movie") MovieRq movieRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
prepareReferenceData(model);
model.addAttribute("isEdit", false);
return "movies/form";
}
movieService.create(movieRq);
return "redirect:/movies";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model) {
MovieRs movie = movieService.get(id);
MovieRq movieRq = new MovieRq();
movieRq.setTitle(movie.getTitle());
movieRq.setImage(movie.getImage());
movieRq.setGrade(movie.getGrade());
GenreRefRq genreRef = new GenreRefRq();
if (movie.getGenre() != null) {
genreRef.setId(movie.getGenre().getId() != null
? movie.getGenre().getId().toString()
: null);
genreRef.setName(movie.getGenre().getName());
}
movieRq.setGenre(genreRef);
DirectorRefRq directorRef = new DirectorRefRq();
if (movie.getDirector() != null) {
directorRef.setId(movie.getDirector().getId() != null
? movie.getDirector().getId().toString()
: null);
directorRef.setName(movie.getDirector().getName());
}
movieRq.setDirector(directorRef);
prepareReferenceData(model);
model.addAttribute("movieId", movie.getId());
model.addAttribute("movie", movieRq);
model.addAttribute("isEdit", true);
return "movies/form";
}
@PostMapping("/{id}")
public String updateMovie(
@PathVariable Long id,
@Valid @ModelAttribute("movie") MovieRq movieRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
prepareReferenceData(model);
model.addAttribute("movieId", id);
model.addAttribute("isEdit", true);
return "movies/form";
}
movieService.update(id, movieRq);
return "redirect:/movies";
}
@GetMapping("/{id}/delete")
public String deleteMovie(@PathVariable Long id) {
movieService.delete(id);
return "redirect:/movies";
}
private void prepareReferenceData(Model model) {
List<GenreRs> genres = genreService.getAll();
List<DirectorRs> directors = directorService.getAll();
model.addAttribute("genres", genres);
model.addAttribute("directors", directors);
}
}

View File

@@ -0,0 +1,100 @@
package ru.ulstu.is.server.mvc;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.PageHelper;
import ru.ulstu.is.server.dto.PageRs;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.dto.SubscriptionRs;
import ru.ulstu.is.server.service.SubscriptionService;
@Controller
@RequestMapping("/subscriptions")
public class SubscriptionMvcController {
private final SubscriptionService subscriptionService;
public SubscriptionMvcController(SubscriptionService subscriptionService) {
this.subscriptionService = subscriptionService;
}
@GetMapping
public String listSubscriptions(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Min(1) int size,
Model model
) {
Pageable pageable = PageHelper.toPageable(page, size);
PageRs<SubscriptionRs> pageRs = subscriptionService.getPage(pageable);
model.addAttribute("page", pageRs);
model.addAttribute("currentPage", pageRs.currentPage());
model.addAttribute("pageSize", pageRs.currentSize());
return "subscriptions/list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
model.addAttribute("subscription", new SubscriptionRq());
model.addAttribute("isEdit", false);
return "subscriptions/form";
}
@PostMapping
public String createSubscription(
@Valid @ModelAttribute("subscription") SubscriptionRq subscriptionRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
model.addAttribute("isEdit", false);
return "subscriptions/form";
}
subscriptionService.create(subscriptionRq);
return "redirect:/subscriptions";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model) {
SubscriptionRs subscription = subscriptionService.get(id);
SubscriptionRq rq = new SubscriptionRq();
rq.setLevel(subscription.getLevel());
rq.setPrice(subscription.getPrice());
model.addAttribute("subscriptionId", subscription.getId());
model.addAttribute("subscription", rq);
model.addAttribute("isEdit", true);
return "subscriptions/form";
}
@PostMapping("/{id}")
public String updateSubscription(
@PathVariable Long id,
@Valid @ModelAttribute("subscription") SubscriptionRq subscriptionRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
model.addAttribute("subscriptionId", id);
model.addAttribute("isEdit", true);
return "subscriptions/form";
}
subscriptionService.update(id, subscriptionRq);
return "redirect:/subscriptions";
}
@GetMapping("/{id}/delete")
public String deleteSubscription(@PathVariable Long id) {
subscriptionService.delete(id);
return "redirect:/subscriptions";
}
}

View File

@@ -0,0 +1,15 @@
package ru.ulstu.is.server.mvc.admin;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/admin")
public class AdminHomeController {
@GetMapping
public String adminHome() {
return "admin/index";
}
}

View File

@@ -0,0 +1,159 @@
package ru.ulstu.is.server.mvc.admin;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import ru.ulstu.is.server.dto.DirectorRefRq;
import ru.ulstu.is.server.dto.DirectorRs;
import ru.ulstu.is.server.dto.GenreRefRq;
import ru.ulstu.is.server.dto.GenreRs;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.service.DirectorService;
import ru.ulstu.is.server.service.GenreService;
import ru.ulstu.is.server.service.MovieService;
@Controller
@RequestMapping("/admin/movies")
public class AdminMovieController {
private final MovieService movieService;
private final GenreService genreService;
private final DirectorService directorService;
public AdminMovieController(MovieService movieService,
GenreService genreService,
DirectorService directorService) {
this.movieService = movieService;
this.genreService = genreService;
this.directorService = directorService;
}
/**
* Список фильмов для админа
*/
@GetMapping
public String list(Model model) {
List<MovieRs> movies = movieService.getAll();
model.addAttribute("movies", movies);
return "admin/movies/list";
}
/**
* Форма создания нового фильма
*/
@GetMapping("/new")
public String createForm(Model model) {
MovieRq movie = new MovieRq();
model.addAttribute("movie", movie);
model.addAttribute("isEdit", false);
prepareReferenceData(model);
return "admin/movies/form";
}
/**
* Сохранение нового фильма
*/
@PostMapping
public String create(@ModelAttribute("movie") @Valid MovieRq movieRq,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("isEdit", false);
prepareReferenceData(model);
return "admin/movies/form";
}
movieService.create(movieRq);
return "redirect:/admin/movies";
}
/**
* Форма редактирования фильма
*/
@GetMapping("/{id}")
public String editForm(@PathVariable Long id, Model model) {
MovieRs movieRs = movieService.get(id);
MovieRq movieRq = toMovieRq(movieRs);
model.addAttribute("movie", movieRq);
model.addAttribute("movieId", id);
model.addAttribute("isEdit", true);
prepareReferenceData(model);
return "admin/movies/form";
}
/**
* Обновление фильма
*/
@PostMapping("/{id}")
public String update(@PathVariable Long id,
@ModelAttribute("movie") @Valid MovieRq movieRq,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("movieId", id);
model.addAttribute("isEdit", true);
prepareReferenceData(model);
return "admin/movies/form";
}
movieService.update(id, movieRq);
return "redirect:/admin/movies";
}
/**
* Удаление фильма
*/
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id) {
movieService.delete(id);
return "redirect:/admin/movies";
}
/**
* Подготовка списков жанров и режиссёров для select'ов в форме
*/
private void prepareReferenceData(Model model) {
List<GenreRs> genres = genreService.getAll();
List<DirectorRs> directors = directorService.getAll();
model.addAttribute("genres", genres);
model.addAttribute("directors", directors);
}
/**
* Конвертация MovieRs (что вернул сервис) в MovieRq (что биндим к форме)
*/
private MovieRq toMovieRq(MovieRs rs) {
MovieRq rq = new MovieRq();
rq.setTitle(rs.getTitle());
rq.setImage(rs.getImage());
rq.setGrade(rs.getGrade());
if (rs.getGenre() != null) {
GenreRefRq g = new GenreRefRq();
g.setId(rs.getGenre().getId().toString());
g.setName(rs.getGenre().getName());
rq.setGenre(g);
}
if (rs.getDirector() != null) {
DirectorRefRq d = new DirectorRefRq();
d.setId(rs.getDirector().getId().toString());
d.setName(rs.getDirector().getName());
rq.setDirector(d);
}
return rq;
}
}

View File

@@ -0,0 +1,82 @@
package ru.ulstu.is.server.mvc.admin;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.service.SubscriptionService;
@Controller
@RequestMapping("/admin/subscriptions")
@PreAuthorize("hasRole('ADMIN')")
public class AdminSubscriptionController {
private final SubscriptionService subscriptionService;
public AdminSubscriptionController(SubscriptionService subscriptionService) {
this.subscriptionService = subscriptionService;
}
@GetMapping
public String list(Model model) {
// можно брать либо сущности, либо Rs-дто — главное, чтобы в шаблоне совпадали поля
model.addAttribute("subscriptions", subscriptionService.getAll());
return "admin/subscriptions/list";
}
@GetMapping("/new")
public String createForm(Model model) {
model.addAttribute("subscription", new SubscriptionRq());
model.addAttribute("isEdit", false);
return "admin/subscriptions/form";
}
@PostMapping
public String create(@Valid @ModelAttribute("subscription") SubscriptionRq rq,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("isEdit", false);
return "admin/subscriptions/form";
}
subscriptionService.create(rq);
return "redirect:/admin/subscriptions";
}
@GetMapping("/{id}")
public String editForm(@PathVariable Long id, Model model) {
SubscriptionRq rq = subscriptionService.getSubscriptionRqById(id);
model.addAttribute("subscription", rq);
model.addAttribute("subscriptionId", id);
model.addAttribute("isEdit", true);
return "admin/subscriptions/form";
}
@PostMapping("/{id}")
public String update(@PathVariable Long id,
@Valid @ModelAttribute("subscription") SubscriptionRq rq,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("subscriptionId", id);
model.addAttribute("isEdit", true);
return "admin/subscriptions/form";
}
subscriptionService.update(id, rq);
return "redirect:/admin/subscriptions";
}
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id) {
subscriptionService.delete(id);
return "redirect:/admin/subscriptions";
}
}

View File

@@ -0,0 +1,75 @@
package ru.ulstu.is.server.mvc.auth;
import java.util.Objects;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import ru.ulstu.is.server.entity.User;
import ru.ulstu.is.server.entity.UserRole;
import ru.ulstu.is.server.repository.UserRepository;
@Controller
@RequestMapping
public class AuthMvcController {
private static final String ATTR_USER = "user";
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AuthMvcController(UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@GetMapping("/signup")
public String getSignup(Model model) {
model.addAttribute(ATTR_USER, new SignupForm());
return "signup";
}
@PostMapping("/signup")
public String postSignup(@ModelAttribute(ATTR_USER) @Valid SignupForm form,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
return "signup";
}
if (userRepository.findByLoginIgnoreCase(form.getLogin()).isPresent()) {
bindingResult.rejectValue("login", "signup.login.exists",
"Пользователь с таким логином уже существует");
return "signup";
}
if (!Objects.equals(form.getPassword(), form.getPasswordConfirm())) {
bindingResult.rejectValue("passwordConfirm", "signup.password.mismatch",
"Пароли не совпадают");
return "signup";
}
User user = new User();
user.setName(form.getName());
user.setLogin(form.getLogin());
user.setPassword(passwordEncoder.encode(form.getPassword()));
user.setRole(UserRole.USER);
try {
user.getClass().getDeclaredField("isAdmin");
user.getClass().getMethod("setIsAdmin", boolean.class).invoke(user, false);
} catch (Exception ignored) {
}
userRepository.save(user);
return "redirect:/login?signup";
}
}

View File

@@ -0,0 +1,65 @@
package ru.ulstu.is.server.mvc.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class SignupForm {
@NotBlank
@Size(min = 2, max = 50)
private String name;
@NotBlank
@Size(min = 3, max = 20)
private String login;
@NotBlank
@Size(min = 3, max = 60)
private String password;
@NotBlank
@Size(min = 3, max = 60)
private String passwordConfirm;
public SignupForm() {
}
public SignupForm(String name, String login, String password, String passwordConfirm) {
this.name = name;
this.login = login;
this.password = password;
this.passwordConfirm = passwordConfirm;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getPasswordConfirm() {
return passwordConfirm;
}
public void setPasswordConfirm(String passwordConfirm) {
this.passwordConfirm = passwordConfirm;
}
}

View File

@@ -1,15 +0,0 @@
package ru.ulstu.is.server.repository;
import ru.ulstu.is.server.entity.BaseEntity;
import java.util.List;
import java.util.Optional;
public interface CommonRepository<T extends BaseEntity> {
List<T> findAll();
Optional<T> findById(Long id);
T save(T entity);
void deleteById(Long id);
void delete(T entity);
long count();
}

View File

@@ -1,5 +1,41 @@
package ru.ulstu.is.server.repository;
import org.springframework.stereotype.Repository;
@Repository
public class DirectorRepository extends MapRepository<ru.ulstu.is.server.entity.Director> { }
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import ru.ulstu.is.server.entity.Director;
public interface DirectorRepository extends JpaRepository<Director, Long> {
Optional<Director> findByName(String name);
@Query("""
select
d.name as director,
count(m.id) as moviesCount,
avg(m.grade) as avgMovieGrade,
max(m.grade) as maxMovieGrade,
min(m.grade) as minMovieGrade
from Director d
left join d.movies m
group by d.name
order by moviesCount desc
""")
List<DirectorStats> getDirectorStats();
interface DirectorStats {
String getDirector();
long getMoviesCount();
float getAvgMovieGrade();
float getMaxMovieGrade();
float getMinMovieGrade();
}
}

View File

@@ -1,6 +1,41 @@
package ru.ulstu.is.server.repository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import ru.ulstu.is.server.entity.Genre;
@Repository
public class GenreRepository extends MapRepository<Genre> { }
public interface GenreRepository extends JpaRepository<Genre, Long> {
Optional<Genre> findByName(String name);
@Query("""
select
g.name as genre,
count(m.id) as moviesCount,
avg(m.grade) as avgMovieGrade,
max(m.grade) as maxMovieGrade,
min(m.grade) as minMovieGrade
from Genre g
left join g.movies m
group by g.name
order by moviesCount desc
""")
List<GenreStats> getGenreStats();
interface GenreStats {
String getGenre();
long getMoviesCount();
float getAvgMovieGrade();
float getMaxMovieGrade();
float getMinMovieGrade();
}
}

View File

@@ -1,51 +0,0 @@
package ru.ulstu.is.server.repository;
import ru.ulstu.is.server.entity.BaseEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
public abstract class MapRepository<T extends BaseEntity> implements CommonRepository<T> {
protected final ConcurrentSkipListMap<Long, T> storage = new ConcurrentSkipListMap<>();
protected final AtomicLong seq = new AtomicLong(0L);
@Override
public List<T> findAll() {
return new ArrayList<>(storage.values());
}
@Override
public Optional<T> findById(Long id) {
return Optional.ofNullable(storage.get(id));
}
@Override
public T save(T entity) {
if (entity.getId() == null) {
entity.setId(seq.incrementAndGet());
}
storage.put(entity.getId(), entity);
return entity;
}
@Override
public void deleteById(Long id) {
storage.remove(id);
}
@Override
public void delete(T entity) {
if (entity != null && entity.getId() != null) {
storage.remove(entity.getId());
}
}
@Override
public long count() {
return storage.size();
}
}

View File

@@ -1,5 +1,24 @@
package ru.ulstu.is.server.repository;
import org.springframework.stereotype.Repository;
@Repository
public class MovieRepository extends MapRepository<ru.ulstu.is.server.entity.Movie> { }
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import ru.ulstu.is.server.entity.Movie;
import java.util.List;
public interface MovieRepository extends JpaRepository<Movie, Long> {
Page<Movie> findAll(Pageable pageable);
List<Movie> findByGenre_Name(String name);
List<Movie> findByDirector_Name(String name);
@Query("""
select m.director.name as director, count(m.id) as moviesCount
from Movie m group by m.director.name order by moviesCount desc
""")
List<TopDirectors> findTopDirectors();
interface TopDirectors { String getDirector(); long getMoviesCount(); }
}

View File

@@ -1,5 +1,26 @@
package ru.ulstu.is.server.repository;
import org.springframework.stereotype.Repository;
@Repository
public class SubscriptionRepository extends MapRepository<ru.ulstu.is.server.entity.Subscription> { }
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import ru.ulstu.is.server.entity.Subscription;
import java.util.List;
import java.util.Optional;
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
Optional<Subscription> findByLevel(String level);
@Query("""
select s.level as level, s.price as price, count(u.id) as usersCount
from Subscription s left join s.users u
group by s.level, s.price
order by usersCount desc
""")
List<SubscriptionUsage> usageStats();
interface SubscriptionUsage {
String getLevel();
Integer getPrice();
long getUsersCount();
}
}

View File

@@ -1,5 +1,53 @@
package ru.ulstu.is.server.repository;
import org.springframework.stereotype.Repository;
@Repository
public class UserRepository extends MapRepository<ru.ulstu.is.server.entity.User> { }
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import ru.ulstu.is.server.entity.User;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByLogin(String login);
List<User> findByIsAdmin(boolean isAdmin);
List<User> findBySubscription_Level(String level);
Optional<User> findByLoginIgnoreCase(String login);
List<User> findByAgeGreaterThanEqual(Integer age);
@Query("""
select s.level as level, count(u.id) as usersCount
from User u join u.subscription s
group by s.level
order by usersCount desc
""")
List<UsersBySubscription> countUsersBySubscription();
interface UsersBySubscription {
String getLevel();
long getUsersCount();
}
@Query("""
select s.level as level, avg(u.age) as avgAge
from User u join u.subscription s
where u.age is not null
group by s.level
order by avgAge desc
""")
List<AvgAgeBySubscription> avgAgeBySubscription();
interface AvgAgeBySubscription {
String getLevel();
Double getAvgAge();
}
}

View File

@@ -0,0 +1,15 @@
package ru.ulstu.is.server.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,53 @@
package ru.ulstu.is.server.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
public class SecurityMvcConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable());
http.cors(Customizer.withDefaults());
http.authorizeHttpRequests(req -> req
.requestMatchers(
"/css/**",
"/js/**",
"/images/**",
"/webjars/**",
"/icon.svg"
).permitAll()
.requestMatchers("/login", "/signup").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
http.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/movies", true)
.permitAll()
);
http.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.deleteCookies("JSESSIONID")
.permitAll()
);
http.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
return http.build();
}
}

View File

@@ -0,0 +1,29 @@
package ru.ulstu.is.server.security;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.ulstu.is.server.entity.User;
import ru.ulstu.is.server.repository.UserRepository;
@Service
public class UserAuthService implements UserDetailsService {
private final UserRepository userRepository;
public UserAuthService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByLoginIgnoreCase(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new UserPrincipal(user);
}
}

View File

@@ -0,0 +1,71 @@
package ru.ulstu.is.server.security;
import java.util.Collection;
import java.util.Set;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import ru.ulstu.is.server.entity.User;
import ru.ulstu.is.server.entity.UserRole;
public class UserPrincipal implements UserDetails {
private final Long id;
private final String username;
private final String password;
private final Set<? extends GrantedAuthority> authorities;
private final boolean active;
public UserPrincipal(User user) {
this.id = user.getId();
this.username = user.getLogin();
this.password = user.getPassword();
UserRole role = user.getRole();
if (role == null) {
role = UserRole.USER;
}
this.authorities = Set.of(role);
this.active = true;
}
public Long getId() {
return id;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return active;
}
}

View File

@@ -7,10 +7,12 @@ import ru.ulstu.is.server.dto.DirectorRs;
import ru.ulstu.is.server.entity.Director;
import ru.ulstu.is.server.mapper.DirectorMapper;
import ru.ulstu.is.server.repository.DirectorRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class DirectorService {
private final DirectorRepository repo;
private final DirectorMapper mapper;
@@ -19,10 +21,12 @@ public class DirectorService {
this.repo = repo; this.mapper = mapper;
}
@Transactional(readOnly = true)
public List<DirectorRs> getAll() {
return repo.findAll().stream().map(mapper::toRs).toList();
}
@Transactional(readOnly = true)
public DirectorRs get(Long id) {
return mapper.toRs(getEntity(id));
}

View File

@@ -7,10 +7,12 @@ import ru.ulstu.is.server.dto.GenreRs;
import ru.ulstu.is.server.entity.Genre;
import ru.ulstu.is.server.mapper.GenreMapper;
import ru.ulstu.is.server.repository.GenreRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class GenreService {
private final GenreRepository repo;
private final GenreMapper mapper;
@@ -19,10 +21,12 @@ public class GenreService {
this.repo = repo; this.mapper = mapper;
}
@Transactional(readOnly = true)
public List<GenreRs> getAll() {
return repo.findAll().stream().map(mapper::toRs).toList();
}
@Transactional(readOnly = true)
public GenreRs get(Long id) {
return mapper.toRs(getEntity(id));
}

View File

@@ -1,55 +1,210 @@
package ru.ulstu.is.server.service;
import org.springframework.stereotype.Service;
import ru.ulstu.is.server.api.NotFoundException;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.entity.Movie;
import ru.ulstu.is.server.mapper.MovieMapper;
import ru.ulstu.is.server.repository.MovieRepository;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.ulstu.is.server.api.NotFoundException;
import ru.ulstu.is.server.dto.DirectorRefRq;
import ru.ulstu.is.server.dto.GenreRefRq;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.entity.Director;
import ru.ulstu.is.server.entity.Genre;
import org.springframework.data.domain.Pageable;
import ru.ulstu.is.server.dto.PageHelper;
import ru.ulstu.is.server.dto.PageRs;
import ru.ulstu.is.server.entity.Movie;
import ru.ulstu.is.server.mapper.MovieMapper;
import ru.ulstu.is.server.repository.DirectorRepository;
import ru.ulstu.is.server.repository.GenreRepository;
import ru.ulstu.is.server.repository.MovieRepository;
@Service
@Transactional
public class MovieService {
private final MovieRepository repo;
private final MovieRepository movies;
private final GenreRepository genres;
private final DirectorRepository directors;
private final MovieMapper mapper;
public MovieService(MovieRepository repo, MovieMapper mapper) {
this.repo = repo; this.mapper = mapper;
public MovieService(MovieRepository movies, GenreRepository genres,
DirectorRepository directors, MovieMapper mapper) {
this.movies = movies;
this.genres = genres;
this.directors = directors;
this.mapper = mapper;
}
@Transactional(readOnly = true)
public List<MovieRs> getAll() {
return repo.findAll().stream().map(mapper::toRs).toList();
return movies.findAll().stream().map(mapper::toRs).toList();
}
@Transactional(readOnly = true)
public PageRs<MovieRs> getPage(Pageable pageable) {
return PageRs.from(movies.findAll(pageable), mapper::toRs);
}
@Transactional(readOnly = true)
public PageRs<MovieRs> search(String title,
Long genreId,
Float minGrade,
Boolean exactMatch,
int page,
int size) {
List<MovieRs> all = getAll();
var stream = all.stream();
if (title != null && !title.isBlank()) {
if (Boolean.TRUE.equals(exactMatch)) {
String t = title;
stream = stream.filter(m
-> m.getTitle() != null && m.getTitle().equalsIgnoreCase(t)
);
} else {
String lower = title.toLowerCase();
stream = stream.filter(m
-> m.getTitle() != null && m.getTitle().toLowerCase().contains(lower)
);
}
}
if (genreId != null) {
stream = stream.filter(m
-> m.getGenre() != null
&& m.getGenre().getId() != null
&& m.getGenre().getId().equals(genreId)
);
}
if (minGrade != null) {
stream = stream.filter(m -> m.getGrade() >= minGrade);
}
List<MovieRs> filtered = stream.toList();
int totalItems = filtered.size();
if (size <= 0) {
size = 10;
}
int totalPages = totalItems == 0
? 1
: (int) Math.ceil(totalItems / (double) size);
int currentPage = page < 1 ? 1 : Math.min(page, totalPages);
int fromIndex = (currentPage - 1) * size;
if (fromIndex > totalItems) {
fromIndex = totalItems;
}
int toIndex = Math.min(fromIndex + size, totalItems);
List<MovieRs> pageItems = filtered.subList(fromIndex, toIndex);
boolean isFirst = currentPage == 1;
boolean isLast = currentPage >= totalPages;
boolean hasNext = currentPage < totalPages;
boolean hasPrevious = currentPage > 1;
return new PageRs<>(
pageItems,
pageItems.size(),
currentPage,
size,
totalPages,
totalItems,
isFirst,
isLast,
hasNext,
hasPrevious
);
}
@Transactional(readOnly = true)
public MovieRs get(Long id) {
return mapper.toRs(getEntity(id));
Movie m = movies.findById(id).orElseThrow(() -> new NotFoundException(ru.ulstu.is.server.entity.Movie.class, "movie " + id + " not found"));
return mapper.toRs(m);
}
public MovieRs create(MovieRq rq) {
Movie e = mapper.toEntity(rq);
e = repo.save(e);
return mapper.toRs(e);
Movie e = new Movie();
e.setTitle(rq.getTitle());
e.setImage(rq.getImage());
e.setGrade(rq.getGrade());
Genre g = resolveGenre(rq.getGenre());
Director d = resolveDirector(rq.getDirector());
e.setGenre(g);
e.setDirector(d);
Movie saved = movies.save(e);
return mapper.toRs(saved);
}
public MovieRs update(Long id, MovieRq rq) {
Movie existing = getEntity(id);
existing.setTitle(rq.getTitle());
existing.setImage(rq.getImage());
Movie tmp = mapper.toEntity(rq);
existing.setGenre(tmp.getGenre());
existing.setDirector(tmp.getDirector());
existing = repo.save(existing);
return mapper.toRs(existing);
Movie e = movies.findById(id).orElseThrow(() -> new NotFoundException(ru.ulstu.is.server.entity.Movie.class, "movie " + id + " not found"));
e.setTitle(rq.getTitle());
e.setImage(rq.getImage());
e.setGrade(rq.getGrade());
if (rq.getGenre() != null) {
e.setGenre(resolveGenre(rq.getGenre()));
}
if (rq.getDirector() != null) {
e.setDirector(resolveDirector(rq.getDirector()));
}
return mapper.toRs(movies.save(e));
}
public void delete(Long id) {
getEntity(id);
repo.deleteById(id);
movies.deleteById(id);
}
Movie getEntity(Long id) {
return repo.findById(id).orElseThrow(() -> new NotFoundException(Movie.class, String.valueOf(id)));
private Genre resolveGenre(GenreRefRq ref) {
if (ref == null) {
return null;
}
if (ref.getId() != null) {
Long id = tryParse(ref.getId());
if (id != null) {
return genres.findById(id).orElseThrow();
}
}
if (ref.getName() != null) {
return genres.findByName(ref.getName()).orElseGet(()
-> genres.save(new Genre(null, ref.getName())));
}
return null;
}
private Director resolveDirector(DirectorRefRq ref) {
if (ref == null) {
return null;
}
if (ref.getId() != null) {
Long id = tryParse(ref.getId());
if (id != null) {
return directors.findById(id).orElseThrow();
}
}
if (ref.getName() != null) {
return directors.findByName(ref.getName()).orElseGet(()
-> directors.save(new Director(null, ref.getName())));
}
return null;
}
private Long tryParse(String s) {
try {
return s == null ? null : Long.parseLong(s);
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -1,38 +1,81 @@
package ru.ulstu.is.server.service;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.ulstu.is.server.api.NotFoundException;
import ru.ulstu.is.server.dto.PageRs;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.dto.SubscriptionRs;
import ru.ulstu.is.server.entity.Subscription;
import ru.ulstu.is.server.mapper.SubscriptionMapper;
import ru.ulstu.is.server.repository.SubscriptionRepository;
import java.util.List;
@Service
@Transactional
public class SubscriptionService {
private final SubscriptionRepository repo;
private final SubscriptionMapper mapper;
public SubscriptionService(SubscriptionRepository repo, SubscriptionMapper mapper) {
this.repo = repo; this.mapper = mapper;
this.repo = repo;
this.mapper = mapper;
}
@Transactional(readOnly = true)
public List<SubscriptionRs> getAll() {
return repo.findAll().stream().map(mapper::toRs).toList();
}
@Transactional(readOnly = true)
public SubscriptionRs get(Long id) {
return mapper.toRs(getEntity(id));
}
@Transactional(readOnly = true)
public SubscriptionRq getSubscriptionRqById(Long id) {
Subscription s = getEntity(id);
SubscriptionRq rq = new SubscriptionRq();
rq.setLevel(s.getLevel());
rq.setPrice(s.getPrice());
rq.setDiscount(s.getDiscount());
return rq;
}
public SubscriptionRs create(SubscriptionRq rq) {
Subscription s = mapper.toEntity(rq);
s = repo.save(s);
return mapper.toRs(s);
}
public PageRs<SubscriptionRs> getPage(Pageable pageable) {
var page = repo.findAll(pageable);
var items = page.getContent()
.stream()
.map(mapper::toRs)
.toList();
return new PageRs<>(
items,
page.getNumberOfElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.getTotalElements(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
);
}
public SubscriptionRs update(Long id, SubscriptionRq rq) {
Subscription s = getEntity(id);
mapper.updateEntity(s, rq);

View File

@@ -7,10 +7,12 @@ import ru.ulstu.is.server.dto.UserRs;
import ru.ulstu.is.server.entity.User;
import ru.ulstu.is.server.mapper.UserMapper;
import ru.ulstu.is.server.repository.UserRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class UserService {
private final UserRepository repo;
private final UserMapper mapper;
@@ -19,10 +21,12 @@ public class UserService {
this.repo = repo; this.mapper = mapper;
}
@Transactional(readOnly = true)
public List<UserRs> getAll() {
return repo.findAll().stream().map(mapper::toRs).toList();
}
@Transactional(readOnly = true)
public UserRs get(Long id) {
return mapper.toRs(getEntity(id));
}

View File

@@ -0,0 +1,55 @@
package ru.ulstu.is.server.session;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
@Component
@SessionScope
public class MovieSearchSession {
private String lastTitle;
private Long lastGenreId;
private Float lastMinGrade;
private Integer lastPageSize;
private Boolean lastExactMatch;
public String getLastTitle() {
return lastTitle;
}
public void setLastTitle(String lastTitle) {
this.lastTitle = lastTitle;
}
public Long getLastGenreId() {
return lastGenreId;
}
public void setLastGenreId(Long lastGenreId) {
this.lastGenreId = lastGenreId;
}
public Float getLastMinGrade() {
return lastMinGrade;
}
public void setLastMinGrade(Float lastMinGrade) {
this.lastMinGrade = lastMinGrade;
}
public Integer getLastPageSize() {
return lastPageSize;
}
public void setLastPageSize(Integer lastPageSize) {
this.lastPageSize = lastPageSize;
}
public Boolean getLastExactMatch() {
return lastExactMatch;
}
public void setLastExactMatch(Boolean lastExactMatch) {
this.lastExactMatch = lastExactMatch;
}
}

View File

@@ -0,0 +1,19 @@
spring:
datasource:
url: jdbc:h2:file:./data/appdb
username: sa
password:
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.springdoc: WARN

View File

@@ -0,0 +1,4 @@
spring:
web:
resources:
add-mappings: true

View File

@@ -0,0 +1,9 @@
spring:
datasource:
url: jdbc:postgresql://127.0.0.1:5432/internet_lab4
username: postgres
password: 1234
driver-class-name: org.postgresql.Driver
jpa:
show-sql: false

View File

@@ -1,6 +0,0 @@
spring.main.banner-mode=off
spring.application.name=server
server.port=8080
# logging
logging.level.ru.ulstu.is.server=DEBUG

View File

@@ -0,0 +1,19 @@
spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
application:
name: server
main:
banner-mode: off
profiles:
active: dev
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
server:
port: 8080
servlet:
session:
tracking-modes: cookie

View File

@@ -0,0 +1,26 @@
databaseChangeLog:
- changeSet:
id: add-review-table
author: busla
changes:
- createTable:
tableName: reviews
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: movie_id
type: BIGINT
constraints:
nullable: false
- column:
name: text
type: VARCHAR(1000)
- column:
name: rating
type: INT

View File

@@ -0,0 +1,14 @@
databaseChangeLog:
- changeSet:
id: 4-add-subscription-discount
author: busla
changes:
- addColumn:
tableName: SUBSCRIPTIONS
columns:
- column:
name: DISCOUNT
type: INT
defaultValueNumeric: 0
constraints:
nullable: false

View File

@@ -0,0 +1,14 @@
databaseChangeLog:
- changeSet:
id: 7-add-user-role
author: you
changes:
- addColumn:
tableName: USERS
columns:
- column:
name: ROLE
type: VARCHAR(20)
defaultValue: 'USER'
constraints:
nullable: false

View File

@@ -0,0 +1,334 @@
databaseChangeLog:
- changeSet:
id: 1763112391251-1
author: busla (generated)
changes:
- createTable:
columns:
- column:
autoIncrement: true
constraints:
nullable: false
primaryKey: true
primaryKeyName: CONSTRAINT_6
name: ID
type: BIGINT
- column:
constraints:
nullable: false
name: NAME
type: VARCHAR(255)
tableName: DIRECTORS
- changeSet:
id: 1763112391251-2
author: busla (generated)
changes:
- createTable:
columns:
- column:
autoIncrement: true
constraints:
nullable: false
primaryKey: true
primaryKeyName: CONSTRAINT_7
name: ID
type: BIGINT
- column:
constraints:
nullable: false
name: NAME
type: VARCHAR(255)
tableName: GENRES
- changeSet:
id: 1763112391251-3
author: busla (generated)
changes:
- createTable:
columns:
- column:
autoIncrement: true
constraints:
nullable: false
primaryKey: true
primaryKeyName: CONSTRAINT_8
name: ID
type: BIGINT
- column:
constraints:
nullable: false
name: GRADE
type: REAL
- column:
name: IMAGE
type: CHARACTER LARGE OBJECT
- column:
constraints:
nullable: false
name: TITLE
type: VARCHAR(255)
- column:
constraints:
nullable: false
name: DIRECTOR_ID
type: BIGINT
- column:
constraints:
nullable: false
name: GENRE_ID
type: BIGINT
tableName: MOVIES
- changeSet:
id: 1763112391251-4
author: busla (generated)
changes:
- createTable:
columns:
- column:
autoIncrement: true
constraints:
nullable: false
primaryKey: true
primaryKeyName: CONSTRAINT_3
name: ID
type: BIGINT
- column:
constraints:
nullable: false
name: LEVEL
type: VARCHAR(255)
- column:
constraints:
nullable: false
name: PRICE
type: INT
tableName: SUBSCRIPTIONS
- changeSet:
id: 1763112391251-5
author: busla (generated)
changes:
- createTable:
columns:
- column:
autoIncrement: true
constraints:
nullable: false
primaryKey: true
primaryKeyName: CONSTRAINT_4
name: ID
type: BIGINT
- column:
name: AGE
type: INT
- column:
name: AVATAR
type: CHARACTER LARGE OBJECT
- column:
name: GENDER
type: VARCHAR(255)
- column:
constraints:
nullable: false
name: IS_ADMIN
type: BOOLEAN
- column:
constraints:
nullable: false
name: LOGIN
type: VARCHAR(255)
- column:
constraints:
nullable: false
name: NAME
type: VARCHAR(255)
- column:
constraints:
nullable: false
name: PASSWORD
type: VARCHAR(255)
- column:
name: SUBSCRIPTION_UNTIL
type: date
- column:
name: SUBSCRIPTION_ID
type: BIGINT
tableName: USERS
- changeSet:
id: 1763112391251-6
author: busla (generated)
changes:
- createTable:
columns:
- column:
constraints:
nullable: false
name: USER_ID
type: BIGINT
- column:
constraints:
nullable: false
name: MOVIE_ID
type: BIGINT
tableName: USER_WATCHLIST
- changeSet:
id: 1763112391251-7
author: busla (generated)
changes:
- addUniqueConstraint:
columnNames: LEVEL
constraintName: UKAVRYNEIAOOG6H0RWOC9QAHFGU
tableName: SUBSCRIPTIONS
- changeSet:
id: 1763112391251-8
author: busla (generated)
changes:
- addUniqueConstraint:
columnNames: LOGIN
constraintName: UKOW0GAN20590JRB00UPG3VA2FN
tableName: USERS
- changeSet:
id: 1763112391251-9
author: busla (generated)
changes:
- addUniqueConstraint:
columnNames: NAME
constraintName: UKPE1A9WOIK1K97L87CIEGUYHH4
tableName: GENRES
- changeSet:
id: 1763112391251-10
author: busla (generated)
changes:
- addUniqueConstraint:
columnNames: NAME
constraintName: UKT6U48CMKTDMYIEUC2B6Y4UMN
tableName: DIRECTORS
- changeSet:
id: 1763112391251-11
author: busla (generated)
changes:
- createIndex:
associatedWith: ''
columns:
- column:
name: USER_ID
indexName: FK1A2SF5HA20F8A3SQGO3N4H3W6_INDEX_5
tableName: USER_WATCHLIST
- changeSet:
id: 1763112391251-12
author: busla (generated)
changes:
- createIndex:
associatedWith: ''
columns:
- column:
name: DIRECTOR_ID
indexName: FK5FT3U8K962BMJD8RN2MR77J8D_INDEX_8
tableName: MOVIES
- changeSet:
id: 1763112391251-13
author: busla (generated)
changes:
- createIndex:
associatedWith: ''
columns:
- column:
name: MOVIE_ID
indexName: FK9FHBUF2RH3U2D0CITGJX86ID9_INDEX_5
tableName: USER_WATCHLIST
- changeSet:
id: 1763112391251-14
author: busla (generated)
changes:
- createIndex:
associatedWith: ''
columns:
- column:
name: SUBSCRIPTION_ID
indexName: FKFWX079XWW5UYFBPI9U8GWAM34_INDEX_4
tableName: USERS
- changeSet:
id: 1763112391251-15
author: busla (generated)
changes:
- createIndex:
associatedWith: ''
columns:
- column:
name: GENRE_ID
indexName: FKJP8FSY8A0KKMDI04I81V05C6A_INDEX_8
tableName: MOVIES
- changeSet:
id: 1763112391251-16
author: busla (generated)
changes:
- addForeignKeyConstraint:
baseColumnNames: USER_ID
baseTableName: USER_WATCHLIST
constraintName: FK1A2SF5HA20F8A3SQGO3N4H3W6
deferrable: false
initiallyDeferred: false
onDelete: RESTRICT
onUpdate: RESTRICT
referencedColumnNames: ID
referencedTableName: USERS
validate: true
- changeSet:
id: 1763112391251-17
author: busla (generated)
changes:
- addForeignKeyConstraint:
baseColumnNames: DIRECTOR_ID
baseTableName: MOVIES
constraintName: FK5FT3U8K962BMJD8RN2MR77J8D
deferrable: false
initiallyDeferred: false
onDelete: RESTRICT
onUpdate: RESTRICT
referencedColumnNames: ID
referencedTableName: DIRECTORS
validate: true
- changeSet:
id: 1763112391251-18
author: busla (generated)
changes:
- addForeignKeyConstraint:
baseColumnNames: MOVIE_ID
baseTableName: USER_WATCHLIST
constraintName: FK9FHBUF2RH3U2D0CITGJX86ID9
deferrable: false
initiallyDeferred: false
onDelete: RESTRICT
onUpdate: RESTRICT
referencedColumnNames: ID
referencedTableName: MOVIES
validate: true
- changeSet:
id: 1763112391251-19
author: busla (generated)
changes:
- addForeignKeyConstraint:
baseColumnNames: SUBSCRIPTION_ID
baseTableName: USERS
constraintName: FKFWX079XWW5UYFBPI9U8GWAM34
deferrable: false
initiallyDeferred: false
onDelete: RESTRICT
onUpdate: RESTRICT
referencedColumnNames: ID
referencedTableName: SUBSCRIPTIONS
validate: true
- changeSet:
id: 1763112391251-20
author: busla (generated)
changes:
- addForeignKeyConstraint:
baseColumnNames: GENRE_ID
baseTableName: MOVIES
constraintName: FKJP8FSY8A0KKMDI04I81V05C6A
deferrable: false
initiallyDeferred: false
onDelete: RESTRICT
onUpdate: RESTRICT
referencedColumnNames: ID
referencedTableName: GENRES
validate: true

View File

@@ -0,0 +1,13 @@
databaseChangeLog:
- include:
file: db.changelog-001-initial.yaml
relativeToChangelogFile: true
- include:
file: changeset-3-add-review.yaml
relativeToChangelogFile: true
- include:
file: changeset-4-add-subscription-discount.yaml
relativeToChangelogFile: true
- include:
file: changeset-7-sec-users.yaml
relativeToChangelogFile: true

View File

@@ -0,0 +1,193 @@
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #181818;
color: #fff;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.8);
transition: color 0.3s ease;
}
.card-title{
color: white;
}
.navbar-dark .navbar-nav .nav-link:hover,
.navbar-dark .navbar-nav .nav-link:focus {
color: #FF0033;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Стили для карточек фильмов */
.movie-card {
background-color: #282828;
border: 1px solid #404040;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.movie-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(255, 0, 51, 0.2);
}
.movie-card img {
width: 100%;
height: 260px;
object-fit: cover;
}
/* Стили для таблиц */
.table-dark {
background-color: #282828;
border-color: #404040;
}
.table-dark th,
.table-dark td {
border-color: #404040;
vertical-align: middle;
}
/* Стили для форм */
.form-control,
.form-select {
background-color: #333;
border: 1px solid #555;
color: #fff;
}
.form-control:focus,
.form-select:focus {
background-color: #3a3a3a;
border-color: #FF0033;
color: #fff;
box-shadow: 0 0 0 0.25rem rgba(255, 0, 51, 0.25);
}
.form-label {
color: #e9ecef;
font-weight: 500;
}
/* Стили для кнопок */
.btn-primary {
background-color: #FF0033;
border-color: #FF0033;
}
.btn-primary:hover {
background-color: #cc0029;
border-color: #cc0029;
}
.btn-outline-light:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Стили для пагинации */
.page-link {
background-color: #282828;
border-color: #404040;
color: #fff;
}
.page-link:hover {
background-color: #333;
border-color: #555;
color: #fff;
}
.page-item.active .page-link {
background-color: #FF0033;
border-color: #FF0033;
}
/* Стили для пользовательского фото */
.user-photo {
height: 130px;
width: 130px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #FF0033;
}
/* Стили для выпадающего меню пользователя */
.dropdown-menu-dark {
background-color: #282828;
border: 1px solid #404040;
}
.dropdown-item {
color: rgba(255, 255, 255, 0.8);
transition: background-color 0.2s ease;
}
.dropdown-item:hover {
background-color: #FF0033;
color: #fff;
}
/* Стили для футера */
footer {
background-color: #202020;
border-top: 1px solid #404040;
}
/* Кастомные утилиты */
.bg-custom-dark {
background-color: #181818 !important;
}
.bg-custom-gray {
background-color: #282828 !important;
}
.border-custom {
border-color: #404040 !important;
}
/* Адаптивность */
@media (max-width: 768px) {
.movie-card img {
height: 200px;
}
.user-photo {
height: 100px;
width: 100px;
}
}
@media (max-width: 576px) {
.container {
padding-left: 15px;
padding-right: 15px;
}
.movie-card {
margin-bottom: 1rem;
}
}
.auth-wrapper {
min-height: calc(100vh - 160px); /* высота окна минус шапка+футер, можно подрегулировать */
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 60px; /* отступ сверху, чтобы не прилипало к меню */
padding-bottom: 40px; /* немного воздуха над футером */
}
/* Карточка формы входа/регистрации */
.auth-card {
background-color: #222;
border-radius: 18px;
border: 1px solid #333;
color: #fff;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.65);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Админка</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div th:insert="layout :: siteHeader"></div>
<div class="container mt-4">
<h2 class="mb-4">Панель администратора</h2>
<div class="list-group">
<a class="list-group-item list-group-item-action bg-custom-gray text-light"
th:href="@{/admin/movies}">
Управление фильмами
</a>
<a class="list-group-item list-group-item-action bg-custom-gray text-light"
th:href="@{/admin/subscriptions}">
Управление подписками
</a>
</div>
</div>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title th:text="${isEdit} ? 'Редактирование фильма' : 'Новый фильм'">Фильм</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0"
th:text="${isEdit} ? 'Редактирование фильма' : 'Новый фильм'">
Фильм
</h1>
<!-- назад именно в админский список -->
<a th:href="@{/admin/movies}" class="btn btn-outline-light">
<i class="bi bi-arrow-left"></i>
Назад к списку
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body">
<form th:object="${movie}"
th:action="${isEdit} ? @{/admin/movies/{id}(id=${movieId})} : @{/admin/movies}"
method="post"
class="row g-3">
<!-- Название -->
<div class="col-12">
<label class="form-label text-light">Название</label>
<input type="text" th:field="*{title}" class="form-control"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">
</div>
</div>
<!-- URL постера -->
<div class="col-12">
<label class="form-label text-light">URL постера</label>
<input type="text" th:field="*{image}" class="form-control"/>
</div>
<!-- Оценка -->
<div class="col-md-3">
<label class="form-label text-light">Оценка</label>
<input type="number" step="0.1" th:field="*{grade}"
class="form-control"/>
</div>
<!-- Жанр -->
<div class="col-md-4">
<label class="form-label text-light">Жанр</label>
<select th:field="*{genre.id}" class="form-select">
<option value="">-- выберите жанр --</option>
<option th:each="g : ${genres}"
th:value="${g.id}"
th:text="${g.name}">
</option>
</select>
</div>
<!-- Режиссёр -->
<div class="col-md-5">
<label class="form-label text-light">Режиссёр</label>
<select th:field="*{director.id}" class="form-select">
<option value="">-- выберите режиссёра --</option>
<option th:each="d : ${directors}"
th:value="${d.id}"
th:text="${d.name}">
</option>
</select>
</div>
<!-- Кнопка -->
<div class="col-12 d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-primary"
th:text="${isEdit} ? 'Сохранить' : 'Создать'">
Сохранить
</button>
</div>
</form>
</div>
</div>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
lang="ru">
<head>
<meta charset="UTF-8">
<title>Управление фильмами</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="~{layout :: siteHeader}"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Управление фильмами</h1>
<a th:href="@{/admin/movies/new}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i> Добавить фильм
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Жанр</th>
<th>Режиссёр</th>
<th>Оценка</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<!-- ВАЖНО: тут именно movies, а не page.items -->
<tr th:each="movie : ${movies}">
<td th:text="${movie.id}">1</td>
<td th:text="${movie.title}">Название</td>
<td th:text="${movie.genre != null ? movie.genre.name : '—'}">Жанр</td>
<td th:text="${movie.director != null ? movie.director.name : '—'}">Режиссёр</td>
<td th:text="${movie.grade}">0.0</td>
<td class="text-end">
<a th:href="@{/admin/movies/{id}(id=${movie.id})}"
class="btn btn-sm btn-outline-light me-1">
Редактировать
</a>
<form th:action="@{/admin/movies/{id}/delete(id=${movie.id})}"
method="post"
class="d-inline"
onsubmit="return confirm('Удалить фильм?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
Удалить
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(movies)}">
<td colspan="6" class="text-center py-4">
Фильмы не найдены.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<div th:insert="~{layout :: siteFooter}"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title th:text="${isEdit} ? 'Редактирование подписки' : 'Новая подписка'">Подписка</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="~{layout :: siteHeader}"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0"
th:text="${isEdit} ? 'Редактирование подписки' : 'Новая подписка'">
Подписка
</h1>
<a th:href="@{/admin/subscriptions}" class="btn btn-outline-light">
<i class="bi bi-arrow-left"></i>
Назад к списку
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body">
<form th:object="${subscription}"
th:action="${isEdit} ? @{/admin/subscriptions/{id}(id=${subscriptionId})} : @{/admin/subscriptions}"
method="post"
class="row g-3">
<!-- Уровень -->
<div class="col-12">
<label class="form-label text-light">Уровень</label>
<input type="text" th:field="*{level}" class="form-control"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('level')}"
th:errors="*{level}">
</div>
</div>
<!-- Цена -->
<div class="col-md-4">
<label class="form-label text-light">Цена (₽)</label>
<input type="number" min="0" th:field="*{price}" class="form-control"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('price')}"
th:errors="*{price}">
</div>
</div>
<!-- Скидка -->
<div class="col-md-4">
<label class="form-label text-light">Скидка (%)</label>
<input type="number" min="0" max="100" th:field="*{discount}" class="form-control"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('discount')}"
th:errors="*{discount}">
</div>
</div>
<!-- Кнопка -->
<div class="col-12 d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-primary"
th:text="${isEdit} ? 'Сохранить' : 'Создать'">
Сохранить
</button>
</div>
</form>
</div>
</div>
</main>
<div th:insert="~{layout :: siteFooter}"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
lang="ru">
<head>
<meta charset="UTF-8">
<title>Управление подписками</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="~{layout :: siteHeader}"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Управление подписками</h1>
<a th:href="@{/admin/subscriptions/new}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i> Добавить подписку
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Уровень</th>
<th>Цена, ₽</th>
<th>Скидка, %</th>
<th>Цена со скидкой, ₽</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="s : ${subscriptions}">
<td th:text="${s.id}">1</td>
<td th:text="${s.level}">Basic</td>
<td th:text="${s.price}">0</td>
<td th:text="${s.discount}">0</td>
<td th:text="${s.priceWithDiscount}">0</td>
<td class="text-end">
<a th:href="@{/admin/subscriptions/{id}(id=${s.id})}"
class="btn btn-sm btn-outline-light me-1">
Редактировать
</a>
<form th:action="@{/admin/subscriptions/{id}/delete(id=${s.id})}"
method="post"
class="d-inline"
onsubmit="return confirm('Удалить подписку?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
Удалить
</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(subscriptions)}">
<td colspan="6" class="text-center py-4">
Подписки не найдены.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<div th:insert="~{layout :: siteFooter}"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Ошибка</title>
</head>
<body style="font-family: system-ui, sans-serif;">
<h1>Что-то пошло не так</h1>
<p><strong>Путь:</strong> <span th:text="${path}">/path</span></p>
<p><strong>Статус:</strong> <span th:text="${status}">500</span></p>
<p><strong>Ошибка:</strong> <span th:text="${error}">Error</span></p>
<p><strong>Сообщение:</strong> <span th:text="${message}">Message</span></p>
</body>
</html>

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>Онлайн-кинотеатр</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{../css/style.css}">
</head>
<body class="bg-dark text-light">
<header th:fragment="siteHeader">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" th:href="@{/}">
<img th:src="@{/img/logo.png}" alt="Логотип" width="40"/>
<span class="fw-semibold">Cinema</span>
</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/}">Главная</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/movies}">Фильмы</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/subscriptions}">Подписки</a>
</li>
<li class="nav-item" sec:authorize="hasRole('ADMIN')">
<a class="nav-link" th:href="@{/admin}">Админка</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<!-- Если пользователь НЕ вошёл -->
<li class="nav-item" sec:authorize="isAnonymous()">
<a class="nav-link" th:href="@{/login}">Войти</a>
</li>
<li class="nav-item" sec:authorize="isAnonymous()">
<a class="nav-link" th:href="@{/signup}">Регистрация</a>
</li>
<!-- Если пользователь ВОШЁЛ -->
<li class="nav-item dropdown" sec:authorize="isAuthenticated()">
<a class="nav-link dropdown-toggle" href="#" id="userMenu"
role="button" data-bs-toggle="dropdown" aria-expanded="false">
<!-- Показываем имя -->
<span sec:authentication="principal.username"></span>
</a>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
<li>
<a class="dropdown-item" th:href="@{/user}">Профиль</a>
</li>
<li>
<hr class="dropdown-divider"/>
</li>
<li>
<form th:action="@{/logout}" method="post">
<button class="dropdown-item" type="submit">Выйти</button>
</form>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
<footer th:fragment="siteFooter" class="bg-dark text-light mt-4 py-3 border-top border-secondary">
<div class="container text-center small">
<p class="mb-1">Контакты: cinemaSupport@gmail.com</p>
<p class="mb-1">Адрес: г. Москва, ул. Киношная, д. 5</p>
<p class="mb-0">Часы работы: 10:00 22:00</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>Вход</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" th:href="@{../css/style.css}">
</head>
<body>
<div th:insert="layout :: siteHeader"></div>
<div class="auth-wrapper">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card auth-card shadow-lg">
<div class="card-body p-4">
<h3 class="mb-4 text-center">Вход в аккаунт</h3>
<div th:if="${param.error}" class="alert alert-danger py-2">
Неверный логин или пароль.
</div>
<div th:if="${param.logout}" class="alert alert-success py-2">
Вы вышли из аккаунта.
</div>
<div th:if="${param.signup}" class="alert alert-success py-2">
Регистрация прошла успешно. Войдите в аккаунт.
</div>
<form th:action="@{/login}" method="post">
<div class="mb-3">
<label class="form-label">Логин</label>
<input type="text"
name="username"
class="form-control"
placeholder="Введите логин"
required
autofocus/>
</div>
<div class="mb-3">
<label class="form-label">Пароль</label>
<input type="password"
name="password"
class="form-control"
placeholder="Введите пароль"
required/>
</div>
<div class="mb-3 form-check">
<input class="form-check-input"
type="checkbox"
name="remember-me"
id="rememberMeCheck"/>
<label class="form-check-label" for="rememberMeCheck">
Запомнить меня
</label>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary" type="submit">Войти</button>
</div>
<hr class="border-secondary my-3"/>
<div class="text-center small">
Нет аккаунта?
<a th:href="@{/signup}">Зарегистрироваться</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title th:text="${isEdit} ? 'Редактирование фильма' : 'Новый фильм'">Фильм</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0"
th:text="${isEdit} ? 'Редактирование фильма' : 'Новый фильм'">
Фильм
</h1>
<a th:href="@{/movies}" class="btn btn-outline-light">
<i class="bi bi-arrow-left"></i>
Назад к списку
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body">
<form th:object="${movie}"
th:action="${isEdit} ? @{|/movies/${movieId}|} : @{/movies}"
method="post"
class="row g-3">
<!-- Название -->
<div class="col-12">
<label class="form-label text-light">Название</label>
<input type="text" th:field="*{title}" class="form-control"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">
</div>
</div>
<!-- URL постера -->
<div class="col-12">
<label class="form-label text-light">URL постера</label>
<input type="text" th:field="*{image}" class="form-control"/>
</div>
<!-- Оценка -->
<div class="col-md-3">
<label class="form-label text-light">Оценка</label>
<input type="number" step="0.1" th:field="*{grade}"
class="form-control"/>
</div>
<!-- Жанр -->
<div class="col-md-4">
<label class="form-label text-light">Жанр</label>
<select th:field="*{genre.id}" class="form-select">
<option value="">-- выберите жанр --</option>
<option th:each="g : ${genres}"
th:value="${g.id}"
th:text="${g.name}">
</option>
</select>
</div>
<!-- Режиссёр -->
<div class="col-md-5">
<label class="form-label text-light">Режиссёр</label>
<select th:field="*{director.id}" class="form-select">
<option value="">-- выберите режиссёра --</option>
<option th:each="d : ${directors}"
th:value="${d.id}"
th:text="${d.name}">
</option>
</select>
</div>
<!-- Кнопка -->
<div class="col-12 d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-primary"
th:text="${isEdit} ? 'Сохранить' : 'Создать'">
Сохранить
</button>
</div>
</form>
</div>
</div>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Фильмы</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:replace="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Фильмы</h1>
</div>
<form class="card card-body mb-4 bg-secondary bg-opacity-10"
th:action="@{/movies}" method="get">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label text-light">Название содержит</label>
<input type="text" name="title" class="form-control"
th:value="${titleFilter}">
</div>
<div class="col-md-3">
<label class="form-label text-light">Жанр</label>
<select name="genreId" class="form-select">
<option value="">-- любой --</option>
<option th:each="g : ${genres}"
th:value="${g.id}"
th:text="${g.name}"
th:selected="${genreIdFilter != null and g.id == genreIdFilter}">
</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label text-light">Мин. оценка</label>
<input type="number" step="0.1" name="minGrade"
class="form-control"
th:value="${minGradeFilter}">
</div>
<div class="col-md-2">
<label class="form-label text-light">Размер страницы</label>
<input type="number" min="1" name="size"
class="form-control"
th:value="${pageSize}">
<input type="hidden" name="page" value="1">
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="exact"
th:checked="${exactMatchFilter}">
<label class="form-check-label text-light">
Точный поиск
</label>
</div>
</div>
<div class="col-md-1 d-grid">
<button type="submit" class="btn btn-success">OK</button>
</div>
</div>
</form>
<div class="row g-4">
<div class="col-12 col-sm-6 col-md-4 col-lg-3"
th:each="movie : ${page.items}">
<div class="card h-100 bg-dark border-secondary shadow-sm">
<img th:if="${movie.image != null and !#strings.isEmpty(movie.image)}"
th:src="${movie.image}"
class="card-img-top"
alt="Постер"
style="object-fit: cover; height: 260px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title" th:text="${movie.title}">Название</h5>
<p class="card-text text-secondary mb-1">
Жанр
<span th:text="${movie.genre != null ? movie.genre.name : 'Без жанра'}">
Жанр
</span>
</p>
<p class="card-text text-secondary mb-1">
Режиссёр
<span th:text="${movie.director != null ? movie.director.name : 'Без режиссёра'}">
Режиссёр
</span>
</p>
<p class="card-text text-warning mb-2">
<i class="bi bi-star-fill me-1"></i>
<span th:text="${movie.grade}">0</span>
</p>
</div>
</div>
</div>
</div>
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${page.isFirst} ? ' disabled'">
<a class="page-link"
th:href="@{/movies(
page=${page.currentPage - 1},
size=${page.currentSize},
title=${titleFilter},
genreId=${genreIdFilter},
minGrade=${minGradeFilter}
)}">
Предыдущая
</a>
</li>
<li class="page-item"
th:each="p : ${#numbers.sequence(1, page.totalPages)}"
th:classappend="${p == page.currentPage} ? ' active'">
<a class="page-link"
th:text="${p}"
th:href="@{/movies(
page=${p},
size=${page.currentSize},
title=${titleFilter},
genreId=${genreIdFilter},
minGrade=${minGradeFilter}
)}">1</a>
</li>
<li class="page-item" th:classappend="${page.isLast} ? ' disabled'">
<a class="page-link"
th:href="@{/movies(
page=${page.currentPage + 1},
size=${page.currentSize},
title=${titleFilter},
genreId=${genreIdFilter},
minGrade=${minGradeFilter}
)}">
Следующая
</a>
</li>
</ul>
</nav>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>Регистрация</title>
<!-- Bootstrap, если не подключён в layout -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div th:insert="layout :: siteHeader"></div>
<div class="auth-wrapper">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card auth-card shadow-lg">
<div class="card-body p-4">
<h3 class="mb-4 text-center">Регистрация</h3>
<form th:action="@{/signup}" th:object="${user}" method="post">
<!-- Имя -->
<div class="mb-3">
<label class="form-label">Имя</label>
<input type="text"
th:field="*{name}"
class="form-control"
placeholder="Как к вам обращаться?"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('name')}"
th:errors="*{name}"></div>
</div>
<!-- Логин -->
<div class="mb-3">
<label class="form-label">Логин</label>
<input type="text"
th:field="*{login}"
class="form-control"
placeholder="Придумайте логин"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('login')}"
th:errors="*{login}"></div>
</div>
<!-- Пароль -->
<div class="mb-3">
<label class="form-label">Пароль</label>
<input type="password"
th:field="*{password}"
class="form-control"
placeholder="Придумайте пароль"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('password')}"
th:errors="*{password}"></div>
</div>
<!-- Подтверждение пароля -->
<div class="mb-3">
<label class="form-label">Подтверждение пароля</label>
<input type="password"
th:field="*{passwordConfirm}"
class="form-control"
placeholder="Повторите пароль"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('passwordConfirm')}"
th:errors="*{passwordConfirm}"></div>
</div>
<!-- Глобальные ошибки -->
<div class="mb-2 text-danger small"
th:if="${#fields.hasGlobalErrors()}">
<div th:each="err : ${#fields.globalErrors()}"
th:text="${err}"></div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
Зарегистрироваться
</button>
</div>
<hr class="border-secondary my-3"/>
<div class="text-center small">
Уже есть аккаунт?
<a th:href="@{/login}">Войти</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title th:text="${isEdit} ? 'Редактирование подписки' : 'Новая подписка'">Подписка</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0"
th:text="${isEdit} ? 'Редактирование подписки' : 'Новая подписка'">
Подписка
</h1>
<a th:href="@{/subscriptions}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад
</a>
</div>
<!-- Форма -->
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body">
<form th:object="${subscription}"
th:action="${isEdit} ? @{|/subscriptions/${subscriptionId}|} : @{/subscriptions}"
method="post">
<!-- Уровень -->
<div class="mb-3">
<label class="form-label">Уровень</label>
<input type="text"
th:field="*{level}"
class="form-control bg-secondary text-light border-0"/>
<div class="text-danger mt-1"
th:if="${#fields.hasErrors('level')}"
th:errors="*{level}">
</div>
</div>
<!-- Цена -->
<div class="mb-3">
<label class="form-label">Цена (₽)</label>
<input type="number"
min="0"
th:field="*{price}"
class="form-control bg-secondary text-light border-0"/>
<div class="text-danger mt-1"
th:if="${#fields.hasErrors('price')}"
th:errors="*{price}">
</div>
</div>
<!-- 🔹 Скидка -->
<div class="mb-3">
<label class="form-label">Скидка (%)</label>
<input type="number"
min="0" max="100"
th:field="*{discount}"
class="form-control bg-secondary text-light border-0"
placeholder="0"/>
<div class="text-danger mt-1"
th:if="${#fields.hasErrors('discount')}"
th:errors="*{discount}">
</div>
</div>
<!-- Кнопки -->
<div class="d-flex justify-content-end gap-2">
<a th:href="@{/subscriptions}"
class="btn btn-outline-light">
Отмена
</a>
<button type="submit"
class="btn btn-success"
th:text="${isEdit} ? 'Сохранить' : 'Создать'">
Сохранить
</button>
</div>
</form>
</div>
</div>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Подписки</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Подписки</h1>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr>
<th>ID</th>
<th>Уровень</th>
<th>Цена, ₽</th>
<th>Скидка, %</th>
<th>Цена со скидкой, ₽</th>
</tr>
</thead>
<tbody>
<tr th:each="sub : ${page.items}">
<td th:text="${sub.id}">1</td>
<td th:text="${sub.level}">Базовая</td>
<td th:text="${sub.price}">299</td>
<td th:text="${sub.discount}">0</td>
<td th:text="${sub.priceWithDiscount}">299</td>
</tr>
<tr th:if="${page.items.isEmpty()}">
<td colspan="4" class="text-center text-secondary py-4">
Подписок пока нет
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Пагинация -->
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${page.isFirst} ? ' disabled'">
<a class="page-link"
th:href="@{/subscriptions(page=${page.currentPage - 1}, size=${page.currentSize})}">
Предыдущая
</a>
</li>
<li class="page-item"
th:each="p : ${#numbers.sequence(1, page.totalPages)}"
th:classappend="${p == page.currentPage} ? ' active'">
<a class="page-link"
th:text="${p}"
th:href="@{/subscriptions(page=${p}, size=${page.currentSize})}">
1
</a>
</li>
<li class="page-item" th:classappend="${page.isLast} ? ' disabled'">
<a class="page-link"
th:href="@{/subscriptions(page=${page.currentPage + 1}, size=${page.currentSize})}">
Следующая
</a>
</li>
</ul>
</nav>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -1,3 +0,0 @@
logging.level.ru.ulstu.is.server=DEBUG
spring.datasource.url=jdbc:h2:mem:testdb

View File

@@ -0,0 +1,10 @@
spring:
profiles:
active: dev
jpa:
hibernate:
ddl-auto: create-drop
liquibase:
enabled: false

20303
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,40 @@
import { useEffect, useState } from "react";
import axios from "axios";
export default function useMovies() {
const [movies, setMovies] = useState([]);
export default function useMovies(initialPage = 1, pageSize = 12) {
const [page, setPage] = useState(initialPage);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [reload, setReload] = useState(0);
useEffect(() => {
axios.get("http://localhost:8080/api/1.0/movies")
.then(res => setMovies(res.data))
.catch(setError)
setLoading(true);
axios
.get("http://localhost:8080/api/1.0/movies/page", {
params: { page, size: pageSize },
})
.then((res) => setData(res.data))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);
}, [page, pageSize, reload]);
const remove = async (id) => {
try {
await axios.delete(`http://localhost:8080/api/1.0/movies/${id}`);
setMovies(prev => prev.filter(m => m.id !== id));
setReload((r) => r + 1);
} catch (err) {
console.error("Ошибка при удалении:", err);
}
};
return { movies, loading, error, remove };
return {
pageData: data,
loading,
error,
page,
setPage,
pageSize,
remove,
};
}

View File

@@ -1,12 +1,56 @@
import useMovies from "../hooks/useMovies";
import MovieCard from "../components/MovieCard";
import { Link } from "react-router-dom";
import { Link } from "react-router-dom";
export default function Movies() {
const { movies, loading, error, remove } = useMovies();
const {
pageData,
loading,
error,
page,
setPage,
remove,
} = useMovies(1, 12);
if (loading) return <p className="text-center py-5">Загрузка</p>;
if (error) return <p className="text-danger py-5 text-center">Ошибка: {error.message}</p>;
if (loading) {
return <p className="text-center py-5">Загрузка</p>;
}
if (error) {
return (
<p className="text-danger py-5 text-center">
Ошибка: {error.message}
</p>
);
}
if (!pageData || pageData.items.length === 0) {
return (
<div className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<h1 className="h3 m-0">Каталог фильмов</h1>
<Link to="/new" className="btn btn-success">
+ Добавить
</Link>
</div>
<p>Фильмы не найдены.</p>
</div>
);
}
const { items, totalPages, hasNext, hasPrevious } = pageData;
const goPrev = () => {
if (hasPrevious && page > 1) {
setPage(page - 1);
}
};
const goNext = () => {
if (hasNext && page < totalPages) {
setPage(page + 1);
}
};
return (
<div className="container py-4">
@@ -18,13 +62,36 @@ export default function Movies() {
</Link>
</div>
<div className="row g-4">
{movies.map((m) => (
<div className="row g-4 mb-4">
{items.map((m) => (
<div className="col-6 col-md-4 col-lg-3" key={m.id}>
<MovieCard movie={m} onDelete={remove} />
<MovieCard movie={m} onDelete={() => remove(m.id)} />
</div>
))}
</div>
{}
<div className="d-flex justify-content-center align-items-center gap-3">
<button
className="btn btn-outline-secondary"
onClick={goPrev}
disabled={!hasPrevious}
>
Назад
</button>
<span>
Страница {page} из {totalPages}
</span>
<button
className="btn btn-outline-secondary"
onClick={goNext}
disabled={!hasNext}
>
Вперёд
</button>
</div>
</div>
);
}

View File

@@ -1,17 +1,11 @@
// @ts-ignore
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/movies': 'http://localhost:3000',
'/genres': 'http://localhost:3000',
'/directors': 'http://localhost:3000',
'/users': 'http://localhost:3000',
'/subscriptions': 'http://localhost:3000',
'/purchases': 'http://localhost:3000',
},
build: {
sourcemap: true,
emptyOutDir: true,
outDir: "../build/resources/main/static",
},
});

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "ипРома",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}