From 184491b9eaf4a99a33684d44793e86442739e258 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Tue, 7 Feb 2023 12:01:41 +0100 Subject: [PATCH] Added improved sample data generation. --- .../gymboard_api/util/CsvUtil.java | 27 +++ .../gymboard_api/util/SampleDataLoader.java | 223 ------------------ .../gymboard_api/util/ThrowableConsumer.java | 5 + .../util/sample_data/SampleDataGenerator.java | 24 ++ .../util/sample_data/SampleDataLoader.java | 70 ++++++ .../sample_data/SampleExerciseGenerator.java | 26 ++ .../sample_data/SampleGeoDataGenerator.java | 34 +++ .../util/sample_data/SampleGymGenerator.java | 52 ++++ .../SampleSubmissionGenerator.java | 61 +++++ .../util/sample_data/SampleUserGenerator.java | 98 ++++++++ gymboard-app/src/api/search/models.ts | 5 + .../gymboardcdn/service/FileService.java | 39 ++- 12 files changed, 439 insertions(+), 225 deletions(-) create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/CsvUtil.java delete mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ThrowableConsumer.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataGenerator.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleExerciseGenerator.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGeoDataGenerator.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGymGenerator.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleUserGenerator.java diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/CsvUtil.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/CsvUtil.java new file mode 100644 index 0000000..6476844 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/CsvUtil.java @@ -0,0 +1,27 @@ +package nl.andrewlalis.gymboard_api.util; + +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVRecord; + +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Path; + +public final class CsvUtil { + private CsvUtil() {} + + public static void load(Path csvFile, ThrowableConsumer recordConsumer) throws IOException { + var reader = new FileReader(csvFile.toFile()); + CSVFormat format = CSVFormat.DEFAULT.builder() + .setHeader() + .setSkipHeaderRecord(true) + .build(); + for (var record : format.parse(reader)) { + try { + recordConsumer.accept(record); + } catch (Exception e) { + e.printStackTrace(); + } + } + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java deleted file mode 100644 index f52cc49..0000000 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/SampleDataLoader.java +++ /dev/null @@ -1,223 +0,0 @@ -package nl.andrewlalis.gymboard_api.util; - -import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository; -import nl.andrewlalis.gymboard_api.domains.api.dao.CountryRepository; -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.model.*; -import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise; -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.domains.auth.dao.RoleRepository; -import nl.andrewlalis.gymboard_api.domains.auth.dao.UserPersonalDetailsRepository; -import nl.andrewlalis.gymboard_api.domains.auth.dao.UserPreferencesRepository; -import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; -import nl.andrewlalis.gymboard_api.domains.auth.dto.UserCreationPayload; -import nl.andrewlalis.gymboard_api.domains.auth.model.Role; -import nl.andrewlalis.gymboard_api.domains.auth.model.User; -import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails; -import nl.andrewlalis.gymboard_api.domains.auth.service.UserService; -import org.apache.commons.csv.CSVFormat; -import org.apache.commons.csv.CSVRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationListener; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.io.FileReader; -import java.io.IOException; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDate; - -/** - * Simple component that loads sample data that's useful when testing the application. - */ -@Component -public class SampleDataLoader implements ApplicationListener { - private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class); - private final CountryRepository countryRepository; - private final CityRepository cityRepository; - private final GymRepository gymRepository; - private final ExerciseRepository exerciseRepository; - private final ExerciseSubmissionService submissionService; - private final RoleRepository roleRepository; - private final UserRepository userRepository; - private final UserPersonalDetailsRepository personalDetailsRepository; - private final UserPreferencesRepository preferencesRepository; - private final UserService userService; - - @Value("${app.cdn-origin}") - private String cdnOrigin; - - public SampleDataLoader( - CountryRepository countryRepository, - CityRepository cityRepository, - GymRepository gymRepository, - ExerciseRepository exerciseRepository, - ExerciseSubmissionService submissionService, - RoleRepository roleRepository, UserRepository userRepository, UserPersonalDetailsRepository personalDetailsRepository, UserPreferencesRepository preferencesRepository, UserService userService) { - this.countryRepository = countryRepository; - this.cityRepository = cityRepository; - this.gymRepository = gymRepository; - this.exerciseRepository = exerciseRepository; - this.submissionService = submissionService; - this.roleRepository = roleRepository; - this.userRepository = userRepository; - this.personalDetailsRepository = personalDetailsRepository; - this.preferencesRepository = preferencesRepository; - this.userService = userService; - } - - @Override - public void onApplicationEvent(ContextRefreshedEvent event) { - Path markerFile = Path.of(".sample_data"); - if (Files.exists(markerFile)) return; - - log.info("Generating sample data."); - try { - generateSampleData(); - secondPassGenerateSampleData(); - Files.writeString(markerFile, "Yes"); - } catch (Exception e) { - e.printStackTrace(); - } - } - - @Transactional - protected void generateSampleData() throws Exception { - loadCsv("exercises", record -> { - exerciseRepository.save(new Exercise(record.get("short-name"), record.get("name"))); - }); - loadCsv("countries", record -> { - countryRepository.save(new Country(record.get("code"), record.get("name"))); - }); - loadCsv("cities", record -> { - var country = countryRepository.findById(record.get("country-code")).orElseThrow(); - String shortName = record.get("short-name"); - String name = record.get("name"); - cityRepository.save(new City(shortName, name, country)); - }); - loadCsv("gyms", record -> { - var city = cityRepository.findByShortNameAndCountryCode( - record.get("city-short-name"), - record.get("country-code") - ).orElseThrow(); - gymRepository.save(new Gym( - city, - record.get("short-name"), - record.get("name"), - record.get("website-url"), - new GeoPoint( - new BigDecimal(record.get("latitude")), - new BigDecimal(record.get("longitude")) - ), - record.get("street-address") - )); - }); - - // Loading sample submissions involves sending content to the Gymboard CDN service. - // We upload a video for each submission, and wait until all uploads are processed before continuing. - - final CdnClient cdnClient = new CdnClient(cdnOrigin); - - loadCsv("submissions", record -> { - var exercise = exerciseRepository.findById(record.get("exercise-short-name")).orElseThrow(); - BigDecimal weight = new BigDecimal(record.get("raw-weight")); - WeightUnit unit = WeightUnit.parse(record.get("weight-unit")); - int reps = Integer.parseInt(record.get("reps")); - String name = record.get("submitter-name"); - CompoundGymId gymId = CompoundGymId.parse(record.get("gym-id")); - String videoFilename = record.get("video-filename"); - - log.info("Uploading video {} to CDN...", videoFilename); - 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() - )); - }); - - loadCsv("users", record -> { - String email = record.get("email"); - String password = record.get("password"); - String name = record.get("name"); - String[] roleNames = record.get("roles").split("\\s*\\n\\s*"); - LocalDate birthDate = LocalDate.parse(record.get("birth-date")); - BigDecimal currentWeight = new BigDecimal(record.get("current-weight")); - WeightUnit currentWeightUnit = WeightUnit.parse(record.get("current-weight-unit")); - BigDecimal metricWeight = new BigDecimal(currentWeight.toString()); - if (currentWeightUnit == WeightUnit.POUNDS) { - metricWeight = WeightUnit.toKilograms(metricWeight); - } - UserPersonalDetails.PersonSex sex = UserPersonalDetails.PersonSex.parse(record.get("sex")); - - UserCreationPayload payload = new UserCreationPayload(email, password, name); - var resp = userService.createUser(payload, false); - User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow(); - for (var roleName : roleNames) { - if (roleName.isBlank()) continue; - Role role = roleRepository.findById(roleName.strip().toLowerCase()) - .orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase()))); - user.getRoles().add(role); - } - userRepository.save(user); - var pd = personalDetailsRepository.findById(user.getId()).orElseThrow(); - pd.setBirthDate(birthDate); - pd.setCurrentWeight(currentWeight); - pd.setCurrentWeightUnit(currentWeightUnit); - pd.setCurrentMetricWeight(metricWeight); - pd.setSex(sex); - personalDetailsRepository.save(pd); - var p = preferencesRepository.findById(user.getId()).orElseThrow(); - p.setLocale(record.get("locale")); - p.setAccountPrivate(Boolean.parseBoolean(record.get("account-private"))); - preferencesRepository.save(p); - }); - } - - @Transactional - protected void secondPassGenerateSampleData() throws Exception { - loadCsv("users", record -> { - String email = record.get("email"); - String[] followingEmails = record.get("following").split("\\s*\\n\\s*"); - User user = userRepository.findByEmail(email).orElseThrow(); - for (String followingEmail : followingEmails) { - User userToFollow = userRepository.findByEmail(followingEmail).orElseThrow(); - userService.followUser(user.getId(), userToFollow.getId()); - } - }); - } - - @FunctionalInterface - interface ThrowableConsumer { - void accept(T item) throws Exception; - } - - private void loadCsv(String csvName, ThrowableConsumer recordConsumer) throws IOException { - String path = "sample_data/" + csvName + ".csv"; - log.info("Loading data from {}...", path); - var reader = new FileReader(path); - CSVFormat format = CSVFormat.DEFAULT.builder() - .setHeader() - .setSkipHeaderRecord(true) - .build(); - for (var record : format.parse(reader)) { - try { - recordConsumer.accept(record); - } catch (Exception e) { - e.printStackTrace(); - } - } - } -} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ThrowableConsumer.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ThrowableConsumer.java new file mode 100644 index 0000000..a2de092 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ThrowableConsumer.java @@ -0,0 +1,5 @@ +package nl.andrewlalis.gymboard_api.util; + +public interface ThrowableConsumer { + void accept(T item) throws Exception; +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataGenerator.java new file mode 100644 index 0000000..1c34bdd --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataGenerator.java @@ -0,0 +1,24 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +import java.util.Collection; +import java.util.Collections; + +/** + * Interface that defines a component that can generate sample data for testing + * the application. It must define a method to generate data, and optionally, it + * can specify a collection of other sample data generator classes that + * it depends on. For example, a gym generator might depend on a generator that + * creates countries and cities. + *

+ * Note that all classes which implement this interface should be annotated + * as a @Component and @Profile("development") to + * ensure that we keep sample data generation away from production. + *

+ */ +public interface SampleDataGenerator { + void generate() throws Exception; + + default Collection> dependencies() { + return Collections.emptySet(); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java new file mode 100644 index 0000000..285e174 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java @@ -0,0 +1,70 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Simple component that loads sample data that's useful when testing the application. + */ +@Component +@Profile("development") +public class SampleDataLoader implements ApplicationListener { + private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class); + + /** + * The list of all sample data generators that the application has loaded. + */ + private final List generators; + + public SampleDataLoader(List generators) { + this.generators = generators; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + Path markerFile = Path.of(".sample_data"); + if (Files.exists(markerFile)) return; + log.info("Generating sample data."); + try { + Set completedGenerators = new HashSet<>(); + for (var gen : generators) { + runGenerator(gen, completedGenerators); + } + Files.writeString(markerFile, "Yes"); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void runGenerator(SampleDataGenerator gen, Set completed) { + for (var dep : gen.dependencies()) { + runGenerator(getGeneratorByClass(dep), completed); + } + if (!completed.contains(gen)) { + try { + log.info("Running sample data generator: {}", gen.getClass().getSimpleName()); + gen.generate(); + completed.add(gen); + } catch (Exception e) { + throw new RuntimeException("Generator failed: " + gen.getClass().getSimpleName()); + } + } + } + + private SampleDataGenerator getGeneratorByClass(Class c) { + for (var gen : generators) { + if (gen.getClass().equals(c)) return gen; + } + throw new RuntimeException("Missing generator: " + c.getSimpleName()); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleExerciseGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleExerciseGenerator.java new file mode 100644 index 0000000..05a83a4 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleExerciseGenerator.java @@ -0,0 +1,26 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository; +import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise; +import nl.andrewlalis.gymboard_api.util.CsvUtil; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; + +@Component +@Profile("development") +public class SampleExerciseGenerator implements SampleDataGenerator { + private final ExerciseRepository exerciseRepository; + + public SampleExerciseGenerator(ExerciseRepository exerciseRepository) { + this.exerciseRepository = exerciseRepository; + } + + @Override + public void generate() throws Exception { + CsvUtil.load(Path.of("sample_data", "exercises.csv"), r -> { + exerciseRepository.save(new Exercise(r.get("short-name"), r.get("name"))); + }); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGeoDataGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGeoDataGenerator.java new file mode 100644 index 0000000..a75d9ea --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGeoDataGenerator.java @@ -0,0 +1,34 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository; +import nl.andrewlalis.gymboard_api.domains.api.dao.CountryRepository; +import nl.andrewlalis.gymboard_api.domains.api.model.City; +import nl.andrewlalis.gymboard_api.domains.api.model.Country; +import nl.andrewlalis.gymboard_api.util.CsvUtil; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; + +@Component +@Profile("development") +public class SampleGeoDataGenerator implements SampleDataGenerator { + private final CityRepository cityRepository; + private final CountryRepository countryRepository; + + public SampleGeoDataGenerator(CityRepository cityRepository, CountryRepository countryRepository) { + this.cityRepository = cityRepository; + this.countryRepository = countryRepository; + } + + @Override + public void generate() throws Exception { + CsvUtil.load(Path.of("sample_data", "countries.csv"), r -> { + countryRepository.save(new Country(r.get("code"), r.get("name"))); + }); + CsvUtil.load(Path.of("sample_data", "cities.csv"), r -> { + var country = countryRepository.findById(r.get("country-code")).orElseThrow(); + cityRepository.save(new City(r.get("short-name"), r.get("name"), country)); + }); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGymGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGymGenerator.java new file mode 100644 index 0000000..fb22e35 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleGymGenerator.java @@ -0,0 +1,52 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository; +import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository; +import nl.andrewlalis.gymboard_api.domains.api.model.GeoPoint; +import nl.andrewlalis.gymboard_api.domains.api.model.Gym; +import nl.andrewlalis.gymboard_api.util.CsvUtil; +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; + +@Component +@Profile("development") +public class SampleGymGenerator implements SampleDataGenerator { + private final CityRepository cityRepository; + private final GymRepository gymRepository; + + public SampleGymGenerator(CityRepository cityRepository, GymRepository gymRepository) { + this.cityRepository = cityRepository; + this.gymRepository = gymRepository; + } + + @Override + public void generate() throws Exception { + CsvUtil.load(Path.of("sample_data", "gyms.csv"), r -> { + var city = cityRepository.findByShortNameAndCountryCode( + r.get("city-short-name"), + r.get("country-code") + ).orElseThrow(); + gymRepository.save(new Gym( + city, + r.get("short-name"), + r.get("name"), + r.get("website-url"), + new GeoPoint( + new BigDecimal(r.get("latitude")), + new BigDecimal(r.get("longitude")) + ), + r.get("street-address") + )); + }); + } + + @Override + public Collection> dependencies() { + return Set.of(SampleGeoDataGenerator.class); + } +} 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 new file mode 100644 index 0000000..90d0f43 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java @@ -0,0 +1,61 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +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.model.WeightUnit; +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 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; + +@Component +@Profile("development") +public class SampleSubmissionGenerator implements SampleDataGenerator { + private final ExerciseRepository exerciseRepository; + private final ExerciseSubmissionService submissionService; + + @Value("${app.cdn-origin}") + private String cdnOrigin; + + public SampleSubmissionGenerator(ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService) { + this.exerciseRepository = exerciseRepository; + this.submissionService = submissionService; + } + + @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() + )); + }); + } + + @Override + public Collection> dependencies() { + return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleUserGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleUserGenerator.java new file mode 100644 index 0000000..9fd0a27 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleUserGenerator.java @@ -0,0 +1,98 @@ +package nl.andrewlalis.gymboard_api.util.sample_data; + +import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; +import nl.andrewlalis.gymboard_api.domains.auth.dao.RoleRepository; +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowingRepository; +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; +import nl.andrewlalis.gymboard_api.domains.auth.model.Role; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowing; +import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails; +import nl.andrewlalis.gymboard_api.util.CsvUtil; +import nl.andrewlalis.gymboard_api.util.ULID; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.time.LocalDate; + +@Component +@Profile("development") +public class SampleUserGenerator implements SampleDataGenerator { + private final ULID ulid; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final RoleRepository roleRepository; + private final UserFollowingRepository followingRepository; + + public SampleUserGenerator(ULID ulid, UserRepository userRepository, PasswordEncoder passwordEncoder, RoleRepository roleRepository, UserFollowingRepository followingRepository) { + this.ulid = ulid; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.roleRepository = roleRepository; + this.followingRepository = followingRepository; + } + + @Override + public void generate() throws Exception { + Path usersCsvPath = Path.of("sample_data", "users.csv"); + CsvUtil.load(usersCsvPath, r -> { + User user = new User( + ulid.nextULID(), + true, + r.get("email"), + passwordEncoder.encode(r.get("password")), + r.get("name") + ); + String[] roleNames = r.get("roles").split("\\s*\\n\\s*"); + for (var roleName : roleNames) { + if (roleName.isBlank()) continue; + Role role = roleRepository.findById(roleName.strip().toLowerCase()) + .orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase()))); + user.getRoles().add(role); + } + + // Set up the user's personal details. + var pd = user.getPersonalDetails(); + String birthDateStr = r.get("birth-date"); + if (birthDateStr != null && !birthDateStr.isBlank()) { + pd.setBirthDate(LocalDate.parse(birthDateStr)); + } + String currentWeightStr = r.get("current-weight"); + String currentWeightUnitStr = r.get("current-weight-unit"); + if ( + currentWeightStr != null && !currentWeightStr.isBlank() && + currentWeightUnitStr != null && !currentWeightUnitStr.isBlank() + ) { + BigDecimal currentWeight = new BigDecimal(currentWeightStr); + WeightUnit currentWeightUnit = WeightUnit.parse(currentWeightUnitStr); + BigDecimal metricWeight = new BigDecimal(currentWeightStr); + if (currentWeightUnit == WeightUnit.POUNDS) { + metricWeight = WeightUnit.toKilograms(metricWeight); + } + pd.setCurrentWeight(currentWeight); + pd.setCurrentWeightUnit(currentWeightUnit); + pd.setCurrentMetricWeight(metricWeight); + } + pd.setSex(UserPersonalDetails.PersonSex.parse(r.get("sex"))); + + // Set up the user's preferences. + var p = user.getPreferences(); + p.setLocale(r.get("locale")); + p.setAccountPrivate(Boolean.parseBoolean(r.get("account-private"))); + userRepository.save(user); + }); + + // Do a second pass to add follower information. + CsvUtil.load(usersCsvPath, r -> { + User user = userRepository.findByEmail(r.get("email")).orElseThrow(); + String[] followingEmails = r.get("following").split("\\s+"); + for (String followingEmail : followingEmails) { + User userToFollow = userRepository.findByEmail(followingEmail).orElseThrow(); + followingRepository.save(new UserFollowing(userToFollow, user)); + } + }); + } +} diff --git a/gymboard-app/src/api/search/models.ts b/gymboard-app/src/api/search/models.ts index f06810b..56290ba 100644 --- a/gymboard-app/src/api/search/models.ts +++ b/gymboard-app/src/api/search/models.ts @@ -10,3 +10,8 @@ export interface GymSearchResult { latitude: number; longitude: number; } + +export interface UserSearchResult { + id: string; + name: string; +} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java index 9e6d56e..0f11a71 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java @@ -1,18 +1,25 @@ package nl.andrewlalis.gymboardcdn.service; import nl.andrewlalis.gymboardcdn.model.StoredFile; +import nl.andrewlalis.gymboardcdn.model.StoredFileRepository; import nl.andrewlalis.gymboardcdn.util.ULID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; +import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; /** * The service that manages storing and retrieving files from a base filesystem. @@ -27,9 +34,11 @@ public class FileService { @Value("${app.files.temp-dir}") private String tempDir; + private final StoredFileRepository storedFileRepository; private final ULID ulid; - public FileService(ULID ulid) { + public FileService(StoredFileRepository storedFileRepository, ULID ulid) { + this.storedFileRepository = storedFileRepository; this.ulid = ulid; } @@ -78,4 +87,30 @@ public class FileService { } return dir; } + + /** + * Scheduled task that removes any StoredFile entities for which no more + * physical file exists. + */ + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + public void removeOrphanedFiles() { + Pageable pageable = PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "createdAt")); + Page page = storedFileRepository.findAll(pageable); + while (!page.isEmpty()) { + for (var storedFile : page) { + try { + Path filePath = getStoragePathForFile(storedFile); + if (Files.notExists(filePath)) { + log.warn("Removing stored file {} because it no longer exists on the disk.", storedFile.getId()); + storedFileRepository.delete(storedFile); + } + } catch (IOException e) { + log.error("Couldn't get storage path for stored file.", e); + } + } + pageable = pageable.next(); + page = storedFileRepository.findAll(pageable); + } + } }