Added improved submission model.

This commit is contained in:
Andrew Lalis 2023-02-07 14:50:24 +01:00
parent 184491b9ea
commit 080c44c467
14 changed files with 211 additions and 45 deletions

View File

@ -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);
}
}

View File

@ -1,7 +1,6 @@
package nl.andrewlalis.gymboard_api.domains.api.dto;
public record ExerciseSubmissionPayload(
String name,
String exerciseShortName,
float weight,
String weightUnit,

View File

@ -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(),

View File

@ -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 <em>gymboard-cdn</em> 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() {

View File

@ -32,7 +32,13 @@ public class CdnClient {
.GET()
.build();
HttpResponse<String> 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> T postFile(String urlPath, Path filePath, String contentType, Class<T> responseType) throws IOException, InterruptedException {

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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<String> 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<Gym> gyms = gymRepository.findAll();
List<User> users = userRepository.findAll();
List<Exercise> 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<Gym> gyms,
List<User> users,
List<Exercise> exercises,
List<String> 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<Class<? extends SampleDataGenerator>> dependencies() {
return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class);
return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class, SampleGymGenerator.class);
}
private <T> T randomChoice(List<T> 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));
}
}

View File

@ -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;
}

View File

@ -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<Array<UserSearchResult>> {
const response = await api.get('/search/users?q=' + query);
return response.data;
}

View File

@ -8,6 +8,11 @@
icon="person"
>
<q-list>
<q-item clickable v-close-popup :to="'/users/' + authStore.user?.id">
<q-item-section>
<q-item-label>{{ $t('accountMenuItem.myAccount') }}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
<q-item-section>
<q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>

View File

@ -41,6 +41,7 @@ export default {
},
accountMenuItem: {
logIn: 'Login',
myAccount: 'My Account',
logOut: 'Log out',
},
};

View File

@ -0,0 +1,41 @@
<template>
<q-page>
<StandardCenteredPage v-if="user">
<h3>{{ user?.name }}</h3>
<p>{{ user?.email }}</p>
<p v-if="isOwnUser">This is your account!</p>
</StandardCenteredPage>
</q-page>
</template>
<script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {onMounted, ref, Ref} from 'vue';
import {User} from 'src/api/main/auth';
import api from 'src/api/main';
import {useRoute} from 'vue-router';
import {useAuthStore} from 'stores/auth-store';
const route = useRoute();
const authStore = useAuthStore();
/**
* The user that this page displays information about.
*/
const user: Ref<User | undefined> = ref();
/**
* Flag that tells whether this user is the currently authenticated user.
*/
const isOwnUser = ref(false);
onMounted(async () => {
const userId = route.params.userId as string;
user.value = await api.auth.fetchUser(userId, authStore);
isOwnUser.value = user.value.id === authStore.user?.id;
});
</script>
<style scoped>
</style>

View File

@ -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,