Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom-dependency-tree.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ai.elimu:webapp:war:2.6.10-SNAPSHOT
ai.elimu:webapp:war:2.6.11-SNAPSHOT
+- ai.elimu:model:jar:model-2.0.97:compile
| \- com.google.code.gson:gson:jar:2.13.0:compile
| \- com.google.errorprone:error_prone_annotations:jar:2.37.0:compile
Expand Down
1 change: 1 addition & 0 deletions src/main/java/ai/elimu/dao/DeviceDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import ai.elimu.entity.Device;

@Deprecated
public interface DeviceDao extends GenericDao<Device> {

Device read(String androidId) throws DataAccessException;
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/ai/elimu/dao/StudentDao.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ai.elimu.dao;

import org.springframework.dao.DataAccessException;
import ai.elimu.entity.analytics.students.Student;

public interface StudentDao extends GenericDao<Student> {

Student read(String androidId) throws DataAccessException;
}
1 change: 1 addition & 0 deletions src/main/java/ai/elimu/dao/jpa/DeviceDaoJpa.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import org.springframework.dao.DataAccessException;

@Deprecated
public class DeviceDaoJpa extends GenericDaoJpa<Device> implements DeviceDao {

@Override
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/ai/elimu/dao/jpa/StudentDaoJpa.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ai.elimu.dao.jpa;

import jakarta.persistence.NoResultException;
import ai.elimu.dao.StudentDao;
import ai.elimu.entity.analytics.students.Student;
import org.springframework.dao.DataAccessException;

public class StudentDaoJpa extends GenericDaoJpa<Student> implements StudentDao {

@Override
public Student read(String androidId) throws DataAccessException {
try {
return (Student) em.createQuery(
"SELECT s " +
"FROM Student s " +
"WHERE s.androidId = :androidId")
.setParameter("androidId", androidId)
.getSingleResult();
} catch (NoResultException e) {
return null;
}
}
}
1 change: 1 addition & 0 deletions src/main/java/ai/elimu/entity/Device.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
@Entity
@Getter
@Setter
@Deprecated
public class Device extends BaseEntity {

@NotNull
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/ai/elimu/entity/analytics/students/Student.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ai.elimu.entity.analytics.students;

import ai.elimu.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Student extends BaseEntity {

/**
* A 64-bit number (expressed as a hexadecimal string), unique to each combination of
* app-signing key, user, and device.
*
* See https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID
*/
@NotNull
@Column(unique = true)
private String androidId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.apache.commons.io.FileUtils;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -83,7 +84,8 @@ public String handleUploadCsvRequest(
letterAssessmentEventsDir.mkdirs();
File csvFile = new File(letterAssessmentEventsDir, originalFilename);
log.info("Storing CSV file at " + csvFile);
multipartFile.transferTo(csvFile);
FileUtils.writeByteArrayToFile(csvFile, bytes);
log.info("csvFile.exists(): " + csvFile.exists());

// Iterate each row in the CSV file
Path csvFilePath = Paths.get(csvFile.toURI());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.io.File;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.io.FileUtils;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -68,7 +70,8 @@ public String handleUploadCsvRequest(
letterSoundLearningEventsDir.mkdirs();
File csvFile = new File(letterSoundLearningEventsDir, originalFilename);
log.info("Storing CSV file at " + csvFile);
multipartFile.transferTo(csvFile);
FileUtils.writeByteArrayToFile(csvFile, bytes);
log.info("csvFile.exists(): " + csvFile.exists());

jsonResponseObject.put("result", "success");
jsonResponseObject.put("successMessage", "The CSV file was uploaded");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.io.File;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.io.FileUtils;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -68,7 +70,8 @@ public String handleUploadCsvRequest(
storyBookLearningEventsDir.mkdirs();
File csvFile = new File(storyBookLearningEventsDir, originalFilename);
log.info("Storing CSV file at " + csvFile);
multipartFile.transferTo(csvFile);
FileUtils.writeByteArrayToFile(csvFile, bytes);
log.info("csvFile.exists(): " + csvFile.exists());

jsonResponseObject.put("result", "success");
jsonResponseObject.put("successMessage", "The CSV file was uploaded");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.io.File;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.io.FileUtils;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -68,7 +70,8 @@ public String handleUploadCsvRequest(
videoLearningEventsDir.mkdirs();
File csvFile = new File(videoLearningEventsDir, originalFilename);
log.info("Storing CSV file at " + csvFile);
multipartFile.transferTo(csvFile);
FileUtils.writeByteArrayToFile(csvFile, bytes);
log.info("csvFile.exists(): " + csvFile.exists());

jsonResponseObject.put("result", "success");
jsonResponseObject.put("successMessage", "The CSV file was uploaded");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.apache.commons.io.FileUtils;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -83,7 +84,8 @@ public String handleUploadCsvRequest(
wordAssessmentEventsDir.mkdirs();
File csvFile = new File(wordAssessmentEventsDir, originalFilename);
log.info("Storing CSV file at " + csvFile);
multipartFile.transferTo(csvFile);
FileUtils.writeByteArrayToFile(csvFile, bytes);
log.info("csvFile.exists(): " + csvFile.exists());

// Iterate each row in the CSV file
Path csvFilePath = Paths.get(csvFile.toURI());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.io.File;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.io.FileUtils;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -68,7 +70,8 @@ public String handleUploadCsvRequest(
wordLearningEventsDir.mkdirs();
File csvFile = new File(wordLearningEventsDir, originalFilename);
log.info("Storing CSV file at " + csvFile);
multipartFile.transferTo(csvFile);
FileUtils.writeByteArrayToFile(csvFile, bytes);
log.info("csvFile.exists(): " + csvFile.exists());

jsonResponseObject.put("result", "success");
jsonResponseObject.put("successMessage", "The CSV file was uploaded");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package ai.elimu.tasks.analytics;

import ai.elimu.dao.StudentDao;
import ai.elimu.dao.StoryBookLearningEventDao;
import ai.elimu.entity.analytics.StoryBookLearningEvent;
import ai.elimu.entity.analytics.students.Student;
import ai.elimu.model.v2.enums.Language;
import ai.elimu.rest.v2.analytics.StoryBookLearningEventsRestController;
import ai.elimu.util.ConfigHelper;
import ai.elimu.util.csv.CsvAnalyticsExtractionHelper;
import java.io.File;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

/**
* Extracts learning events from CSV files previously received by the {@link StoryBookLearningEventsRestController}, and imports them into the database.
* <p/>
* <p>
* Expected folder structure:
* <pre>
* ├── lang-ENG
* │   ├── analytics
* │   │   ├── android-id-e387e38700000001
* │   │   │   └── version-code-3001018
* │   │   │   └── storybook-learning-events
* │   │   │   ├── e387e38700000001_3001018_storybook-learning-events_2024-10-09.csv
* │   │   │   ├── e387e38700000001_3001018_storybook-learning-events_2024-10-10.csv
* │   │   │   ├── e387e38700000001_3001018_storybook-learning-events_2024-10-11.csv
* │   │   │   ├── e387e38700000001_3001018_storybook-learning-events_2024-10-14.csv
* │   │   │   ├── e387e38700000001_3001018_storybook-learning-events_2024-10-18.csv
* │   │   │   └── e387e38700000001_3001018_storybook-learning-events_2024-10-20.csv
* │   │   ├── android-id-e387e38700000002
* │   │   │   └── version-code-3001018
* │   │   │   └── storybook-learning-events
* │   │   │   ├── e387e38700000002_3001018_storybook-learning-events_2024-10-09.csv
* │   │   │   ├── e387e38700000002_3001018_storybook-learning-events_2024-10-10.csv
* │   │   │   ├── e387e38700000002_3001018_storybook-learning-events_2024-10-11.csv
* </pre>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class StoryBookLearningEventImportScheduler {

private final StoryBookLearningEventDao storyBookLearningEventDao;

private final StudentDao studentDao;

@Scheduled(cron = "00 35 * * * *") // 35 minutes past every hour
public synchronized void execute() {
log.info("execute");

// Lookup CSV files stored on the filesystem
File elimuAiDir = new File(System.getProperty("user.home"), ".elimu-ai");
File languageDir = new File(elimuAiDir, "lang-" + Language.valueOf(ConfigHelper.getProperty("content.language")));
File analyticsDir = new File(languageDir, "analytics");
log.info("analyticsDir: " + analyticsDir);
analyticsDir.mkdirs();
for (File analyticsDirFile : analyticsDir.listFiles()) {
if (analyticsDirFile.getName().startsWith("android-id-")) {
File androidIdDir = new File(analyticsDir, analyticsDirFile.getName());
for (File androidIdDirFile : androidIdDir.listFiles()) {
if (androidIdDirFile.getName().startsWith("version-code-")) {
File versionCodeDir = new File(androidIdDir, androidIdDirFile.getName());
for (File versionCodeDirFile : versionCodeDir.listFiles()) {
if (versionCodeDirFile.getName().equals("storybook-learning-events")) {
File storyBookLearningEventsDir = new File(versionCodeDir, versionCodeDirFile.getName());
for (File csvFile : storyBookLearningEventsDir.listFiles()) {
log.info("csvFile: " + csvFile);

// Convert from CSV to Java
List<StoryBookLearningEvent> events = CsvAnalyticsExtractionHelper.extractStoryBookLearningEvents(csvFile);
log.info("events.size(): " + events.size());

// Store in database
for (StoryBookLearningEvent event : events) {
// Check if the event has already been stored in the database
StoryBookLearningEvent existingStoryBookLearningEvent = storyBookLearningEventDao.read(event.getTimestamp(), event.getAndroidId(), event.getApplication(), event.getStoryBook());
if (existingStoryBookLearningEvent != null) {
log.warn("The event has already been stored in the database. Skipping data import.");
continue;
}

// Generate Student ID
Student existingStudent = studentDao.read(event.getAndroidId());
if (existingStudent == null) {
Student student = new Student();
student.setAndroidId(event.getAndroidId());
studentDao.create(student);
log.info("Stored Student in database with ID " + student.getId());
}

// Store the event in the database
storyBookLearningEventDao.create(event);
log.info("Stored event in database with ID " + event.getId());
}
}
}
}
}
}
}
}
Comment on lines +62 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add null checks for listFiles() results.

The code calls listFiles() multiple times without checking if the result is null, which could lead to NullPointerExceptions if a directory is empty or inaccessible.

-  for (File analyticsDirFile : analyticsDir.listFiles()) {
+  File[] analyticsDirFiles = analyticsDir.listFiles();
+  if (analyticsDirFiles != null) {
+    for (File analyticsDirFile : analyticsDirFiles) {
       // existing code
-    }
+    }
+  }

Similar changes should be made for all other listFiles() calls in this file.


log.info("execute complete");
}
}
Comment on lines +1 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Significant code duplication across scheduler classes.

This class is almost identical to WordLearningEventImportScheduler and very similar to VideoLearningEventImportScheduler, with just minor differences in event types and variable names.

Consider creating an abstract base class that handles the common functionality, with concrete implementations for each event type. This would eliminate most of the duplication and improve maintainability.

public abstract class LearningEventImportScheduler<T> {
    protected abstract String getEventDirectoryName();
    protected abstract List<T> extractEventsFromCsv(File csvFile);
    protected abstract T findExistingEvent(T event);
    protected abstract void createEvent(T event);
    
    protected final StudentDao studentDao;
    
    // Common execute method with template pattern
    public synchronized void execute() {
        // Common directory traversal code
        // Call abstract methods for type-specific behavior
    }
    
    // Common method to create Student if needed
    protected void ensureStudentExists(String androidId) {
        // Implementation
    }
}

// Concrete implementation example
@Service
@RequiredArgsConstructor
@Slf4j
public class StoryBookLearningEventImportScheduler extends LearningEventImportScheduler<StoryBookLearningEvent> {
    private final StoryBookLearningEventDao storyBookLearningEventDao;
    
    @Override
    protected String getEventDirectoryName() {
        return "storybook-learning-events";
    }
    
    @Override
    protected List<StoryBookLearningEvent> extractEventsFromCsv(File csvFile) {
        return CsvAnalyticsExtractionHelper.extractStoryBookLearningEvents(csvFile);
    }
    
    // Other overridden methods
    
    @Scheduled(cron = "00 35 * * * *")
    @Override
    public synchronized void execute() {
        super.execute();
    }
}

Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package ai.elimu.tasks.analytics;

import ai.elimu.dao.StudentDao;
import ai.elimu.dao.VideoLearningEventDao;
import ai.elimu.entity.analytics.VideoLearningEvent;
import ai.elimu.entity.analytics.students.Student;
import ai.elimu.model.v2.enums.Language;
import ai.elimu.rest.v2.analytics.VideoLearningEventsRestController;
import ai.elimu.util.ConfigHelper;
Expand Down Expand Up @@ -45,7 +47,9 @@ public class VideoLearningEventImportScheduler {

private final VideoLearningEventDao videoLearningEventDao;

@Scheduled(cron = "00 30 * * * *") // Half past every hour
private final StudentDao studentDao;

@Scheduled(cron = "00 40 * * * *") // 40 minutes past every hour
public synchronized void execute() {
log.info("execute");

Expand Down Expand Up @@ -80,6 +84,15 @@ public synchronized void execute() {
continue;
}

// Generate Student ID
Student existingStudent = studentDao.read(event.getAndroidId());
if (existingStudent == null) {
Student student = new Student();
student.setAndroidId(event.getAndroidId());
studentDao.create(student);
log.info("Stored Student in database with ID " + student.getId());
}

// Store the event in the database
videoLearningEventDao.create(event);
log.info("Stored event in database with ID " + event.getId());
Expand Down
Loading