From 080c44c46714e2afcea9b0cc14fa8dd1b6c78654 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Tue, 7 Feb 2023 14:50:24 +0100 Subject: [PATCH] Added improved submission model. --- .../domains/api/controller/GymController.java | 5 +- .../api/dto/ExerciseSubmissionPayload.java | 1 - .../api/dto/ExerciseSubmissionResponse.java | 9 +- .../model/exercise/ExerciseSubmission.java | 24 ++-- .../api/service/cdn_client/CdnClient.java | 8 +- .../api/service/cdn_client/UploadsClient.java | 12 ++ .../submission/ExerciseSubmissionService.java | 16 ++- .../SampleSubmissionGenerator.java | 113 ++++++++++++++---- gymboard-app/src/api/main/submission.ts | 6 +- gymboard-app/src/api/search/index.ts | 13 +- .../src/components/AccountMenuItem.vue | 5 + gymboard-app/src/i18n/en-US/index.ts | 1 + gymboard-app/src/pages/UserPage.vue | 41 +++++++ gymboard-app/src/router/routes.ts | 2 + 14 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 gymboard-app/src/pages/UserPage.vue diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java index 927e86c..b23b722 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java @@ -6,6 +6,8 @@ import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse; import nl.andrewlalis.gymboard_api.domains.api.service.GymService; import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -37,8 +39,9 @@ public class GymController { @PostMapping(path = "/submissions") public ExerciseSubmissionResponse createSubmission( @PathVariable String compoundId, + @AuthenticationPrincipal User user, @RequestBody ExerciseSubmissionPayload payload ) { - return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload); + return submissionService.createSubmission(CompoundGymId.parse(compoundId), user.getId(), payload); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java index b7c1d95..5fffa6e 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java @@ -1,7 +1,6 @@ package nl.andrewlalis.gymboard_api.domains.api.dto; public record ExerciseSubmissionPayload( - String name, String exerciseShortName, float weight, String weightUnit, diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java index fb5e91d..897a5b5 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java @@ -1,17 +1,17 @@ package nl.andrewlalis.gymboard_api.domains.api.dto; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission; +import nl.andrewlalis.gymboard_api.domains.auth.dto.UserResponse; import nl.andrewlalis.gymboard_api.util.StandardDateFormatter; -import java.time.format.DateTimeFormatter; - public record ExerciseSubmissionResponse( String id, String createdAt, GymSimpleResponse gym, ExerciseResponse exercise, + UserResponse user, + String performedAt, String videoFileId, - String submitterName, double rawWeight, String weightUnit, double metricWeight, @@ -23,8 +23,9 @@ public record ExerciseSubmissionResponse( StandardDateFormatter.format(submission.getCreatedAt()), new GymSimpleResponse(submission.getGym()), new ExerciseResponse(submission.getExercise()), + new UserResponse(submission.getUser()), + StandardDateFormatter.format(submission.getPerformedAt()), submission.getVideoFileId(), - submission.getSubmitterName(), submission.getRawWeight().doubleValue(), submission.getWeightUnit().name(), submission.getMetricWeight().doubleValue(), diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java index 7f8fc86..57ba2f8 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java @@ -3,9 +3,11 @@ package nl.andrewlalis.gymboard_api.domains.api.model.exercise; import jakarta.persistence.*; import nl.andrewlalis.gymboard_api.domains.api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; import org.hibernate.annotations.CreationTimestamp; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; @Entity @@ -24,6 +26,12 @@ public class ExerciseSubmission { @ManyToOne(optional = false, fetch = FetchType.LAZY) private Exercise exercise; + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private User user; + + @Column(nullable = false) + private LocalDateTime performedAt; + /** * The id of the video file that was submitted for this submission. It lives * on the gymboard-cdn service as a stored file, which can be @@ -32,9 +40,6 @@ public class ExerciseSubmission { @Column(nullable = false, updatable = false, length = 26) private String videoFileId; - @Column(nullable = false, updatable = false, length = 63) - private String submitterName; - @Column(nullable = false, precision = 7, scale = 2) private BigDecimal rawWeight; @@ -50,12 +55,13 @@ public class ExerciseSubmission { public ExerciseSubmission() {} - public ExerciseSubmission(String id, Gym gym, Exercise exercise, String videoFileId, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) { + public ExerciseSubmission(String id, Gym gym, Exercise exercise, User user, LocalDateTime performedAt, String videoFileId, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) { this.id = id; this.gym = gym; this.exercise = exercise; this.videoFileId = videoFileId; - this.submitterName = submitterName; + this.user = user; + this.performedAt = performedAt; this.rawWeight = rawWeight; this.weightUnit = unit; this.metricWeight = metricWeight; @@ -82,8 +88,12 @@ public class ExerciseSubmission { return videoFileId; } - public String getSubmitterName() { - return submitterName; + public User getUser() { + return user; + } + + public LocalDateTime getPerformedAt() { + return performedAt; } public BigDecimal getRawWeight() { diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java index 8029b94..9f355de 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java @@ -32,7 +32,13 @@ public class CdnClient { .GET() .build(); HttpResponse response = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); - return objectMapper.readValue(response.body(), responseType); + if (response.statusCode() == 200) { + return objectMapper.readValue(response.body(), responseType); + } else if (response.statusCode() == 404) { + return null; + } else { + throw new IOException("Request failed with code " + response.statusCode()); + } } public T postFile(String urlPath, Path filePath, String contentType, Class responseType) throws IOException, InterruptedException { diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java index fc8a954..a08b25d 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java @@ -6,6 +6,14 @@ public record UploadsClient(CdnClient client) { public record FileUploadResponse(String id) {} public record VideoProcessingTaskStatusResponse(String status) {} + public record FileMetadataResponse( + String filename, + String mimeType, + long size, + String uploadedAt, + boolean availableForDownload + ) {} + public FileUploadResponse uploadVideo(Path filePath, String contentType) throws Exception { return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class); } @@ -13,4 +21,8 @@ public record UploadsClient(CdnClient client) { public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) throws Exception { return client.get("/uploads/video/" + id + "/status", VideoProcessingTaskStatusResponse.class); } + + public FileMetadataResponse getFileMetadata(String id) throws Exception { + return client.get("/files/" + id + "/metadata", FileMetadataResponse.class); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java index c08a34e..f3e6860 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java @@ -10,6 +10,8 @@ import nl.andrewlalis.gymboard_api.domains.api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission; +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; import nl.andrewlalis.gymboard_api.util.ULID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,6 +21,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; /** * Service which handles the rather mundane tasks associated with exercise @@ -29,15 +33,17 @@ public class ExerciseSubmissionService { private static final Logger log = LoggerFactory.getLogger(ExerciseSubmissionService.class); private final GymRepository gymRepository; + private final UserRepository userRepository; private final ExerciseRepository exerciseRepository; private final ExerciseSubmissionRepository exerciseSubmissionRepository; private final ULID ulid; public ExerciseSubmissionService(GymRepository gymRepository, - ExerciseRepository exerciseRepository, + UserRepository userRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionRepository exerciseSubmissionRepository, ULID ulid) { this.gymRepository = gymRepository; + this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; this.exerciseSubmissionRepository = exerciseSubmissionRepository; this.ulid = ulid; @@ -53,11 +59,14 @@ public class ExerciseSubmissionService { /** * Handles the creation of a new exercise submission. * @param id The gym id. + * @param userId The user's id. * @param payload The submission data. * @return The saved submission. */ @Transactional - public ExerciseSubmissionResponse createSubmission(CompoundGymId id, ExerciseSubmissionPayload payload) { + public ExerciseSubmissionResponse createSubmission(CompoundGymId id, String userId, ExerciseSubmissionPayload payload) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN)); Gym gym = gymRepository.findByCompoundId(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); Exercise exercise = exerciseRepository.findById(payload.exerciseShortName()) @@ -76,8 +85,9 @@ public class ExerciseSubmissionService { ulid.nextULID(), gym, exercise, + user, + LocalDateTime.now(), payload.videoFileId(), - payload.name(), rawWeight, weightUnit, metricWeight, diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java index 90d0f43..33e34cc 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java @@ -1,61 +1,124 @@ package nl.andrewlalis.gymboard_api.util.sample_data; +import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository; -import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId; -import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload; +import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseSubmissionRepository; +import nl.andrewlalis.gymboard_api.domains.api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; +import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise; +import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient; import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService; -import nl.andrewlalis.gymboard_api.util.CsvUtil; +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.util.ULID; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.nio.file.Path; -import java.util.Collection; -import java.util.Set; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; @Component @Profile("development") public class SampleSubmissionGenerator implements SampleDataGenerator { + private final GymRepository gymRepository; + private final UserRepository userRepository; private final ExerciseRepository exerciseRepository; private final ExerciseSubmissionService submissionService; + private final ExerciseSubmissionRepository submissionRepository; + private final ULID ulid; @Value("${app.cdn-origin}") private String cdnOrigin; - public SampleSubmissionGenerator(ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService) { + public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService, ExerciseSubmissionRepository submissionRepository, ULID ulid) { + this.gymRepository = gymRepository; + this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; this.submissionService = submissionService; + this.submissionRepository = submissionRepository; + this.ulid = ulid; } @Override public void generate() throws Exception { final CdnClient cdnClient = new CdnClient(cdnOrigin); - CsvUtil.load(Path.of("sample_data", "submissions.csv"), r -> { - var exercise = exerciseRepository.findById(r.get("exercise-short-name")).orElseThrow(); - BigDecimal weight = new BigDecimal(r.get("raw-weight")); - WeightUnit unit = WeightUnit.parse(r.get("weight-unit")); - int reps = Integer.parseInt(r.get("reps")); - String name = r.get("submitter-name"); - CompoundGymId gymId = CompoundGymId.parse(r.get("gym-id")); - String videoFilename = r.get("video-filename"); - var video = cdnClient.uploads.uploadVideo(Path.of("sample_data", videoFilename), "video/mp4"); - submissionService.createSubmission(gymId, new ExerciseSubmissionPayload( - name, - exercise.getShortName(), - weight.floatValue(), - unit.name(), - reps, - video.id() - )); - }); + List videoIds = new ArrayList<>(); + var video1 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4"); + var video2 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4"); + videoIds.add(video1.id()); + videoIds.add(video2.id()); + + List gyms = gymRepository.findAll(); + List users = userRepository.findAll(); + List exercises = exerciseRepository.findAll(); + + final int count = 10000; + final LocalDateTime earliestSubmission = LocalDateTime.now().minusYears(3); + final LocalDateTime latestSubmission = LocalDateTime.now(); + + Random random = new Random(1); + for (int i = 0; i < count; i++) { + generateRandomSubmission( + gyms, + users, + exercises, + videoIds, + earliestSubmission, + latestSubmission, + random + ); + } + } + + private void generateRandomSubmission( + List gyms, + List users, + List exercises, + List videoIds, + LocalDateTime earliestSubmission, + LocalDateTime latestSubmission, + Random random + ) { + LocalDateTime time = randomTime(earliestSubmission, latestSubmission, random); + BigDecimal metricWeight = new BigDecimal(random.nextInt(20, 250)); + BigDecimal rawWeight = new BigDecimal(metricWeight.toString()); + WeightUnit weightUnit = WeightUnit.KILOGRAMS; + if (random.nextDouble() > 0.5) { + weightUnit = WeightUnit.POUNDS; + rawWeight = metricWeight.multiply(new BigDecimal("2.2046226218")); + } + + submissionRepository.save(new ExerciseSubmission( + ulid.nextULID(), + randomChoice(gyms, random), + randomChoice(exercises, random), + randomChoice(users, random), + time, + randomChoice(videoIds, random), + rawWeight, + weightUnit, + metricWeight, + random.nextInt(13) + )); } @Override public Collection> dependencies() { - return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class); + return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class, SampleGymGenerator.class); + } + + private T randomChoice(List items, Random rand) { + return items.get(rand.nextInt(items.size())); + } + + private LocalDateTime randomTime(LocalDateTime start, LocalDateTime end, Random rand) { + Duration dur = Duration.between(start, end); + return start.plusSeconds(rand.nextLong(dur.toSeconds() + 1)); } } diff --git a/gymboard-app/src/api/main/submission.ts b/gymboard-app/src/api/main/submission.ts index 93ea35c..dbca033 100644 --- a/gymboard-app/src/api/main/submission.ts +++ b/gymboard-app/src/api/main/submission.ts @@ -3,6 +3,7 @@ import { Exercise } from 'src/api/main/exercises'; import { api } from 'src/api/main/index'; import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing'; import { DateTime } from 'luxon'; +import {User} from "src/api/main/auth"; /** * The data that's sent when creating a submission. @@ -33,8 +34,9 @@ export interface ExerciseSubmission { createdAt: DateTime; gym: SimpleGym; exercise: Exercise; + user: User; + performedAt: DateTime; videoFileId: string; - submitterName: string; rawWeight: number; weightUnit: WeightUnit; metricWeight: number; @@ -43,7 +45,7 @@ export interface ExerciseSubmission { export function parseSubmission(data: any): ExerciseSubmission { data.createdAt = DateTime.fromISO(data.createdAt); - console.log(data); + data.performedAt = DateTime.fromISO(data.performedAt); return data as ExerciseSubmission; } diff --git a/gymboard-app/src/api/search/index.ts b/gymboard-app/src/api/search/index.ts index 78dedb0..800bcbc 100644 --- a/gymboard-app/src/api/search/index.ts +++ b/gymboard-app/src/api/search/index.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { GymSearchResult } from 'src/api/search/models'; +import {GymSearchResult, UserSearchResult} from 'src/api/search/models'; const api = axios.create({ baseURL: 'http://localhost:8081', @@ -15,3 +15,14 @@ export async function searchGyms( const response = await api.get('/search/gyms?q=' + query); return response.data; } + +/** + * Searches for users using the given query, and eventually returns results. + * Note that only users whose accounts are not private will be included in + * search results. + * @param query The query to use. + */ +export async function searchUsers(query: string): Promise> { + const response = await api.get('/search/users?q=' + query); + return response.data; +} diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue index 6dfc2b1..3126d2e 100644 --- a/gymboard-app/src/components/AccountMenuItem.vue +++ b/gymboard-app/src/components/AccountMenuItem.vue @@ -8,6 +8,11 @@ icon="person" > + + + {{ $t('accountMenuItem.myAccount') }} + + {{ $t('accountMenuItem.logOut') }} diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index e1c11e2..a36ca18 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -41,6 +41,7 @@ export default { }, accountMenuItem: { logIn: 'Login', + myAccount: 'My Account', logOut: 'Log out', }, }; diff --git a/gymboard-app/src/pages/UserPage.vue b/gymboard-app/src/pages/UserPage.vue new file mode 100644 index 0000000..5f792fc --- /dev/null +++ b/gymboard-app/src/pages/UserPage.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts index 611732a..b6220b3 100644 --- a/gymboard-app/src/router/routes.ts +++ b/gymboard-app/src/router/routes.ts @@ -12,6 +12,7 @@ import RegisterPage from 'pages/auth/RegisterPage.vue'; import RegistrationSuccessPage from 'pages/auth/RegistrationSuccessPage.vue'; import ActivationPage from 'pages/auth/ActivationPage.vue'; import SubmissionPage from 'pages/SubmissionPage.vue'; +import UserPage from 'pages/UserPage.vue'; const routes: RouteRecordRaw[] = [ // Auth-related pages, which live outside the main layout. @@ -27,6 +28,7 @@ const routes: RouteRecordRaw[] = [ children: [ { path: '', component: IndexPage }, { path: 'testing', component: TestingPage }, + { path: 'users/:userId', component: UserPage }, { path: 'gyms/:countryCode/:cityShortName/:gymShortName', component: GymPage,