Skip to content

Conversation

@jo-elimu
Copy link
Member

Issue Number

Purpose

Technical Details

Testing Instructions

Screenshots


Format Checks

Note

Files in PRs are automatically checked for format violations with mvn spotless:check.

If this PR contains files with format violations, run mvn spotless:apply to fix them.

@jo-elimu jo-elimu self-assigned this May 10, 2025
@jo-elimu jo-elimu requested a review from a team as a code owner May 10, 2025 08:12
@jo-elimu jo-elimu requested review from alexander-kuruvilla, eymaal and vrudas and removed request for a team May 10, 2025 08:12
@jo-elimu jo-elimu linked an issue May 10, 2025 that may be closed by this pull request
4 tasks
@codecov
Copy link

codecov bot commented May 10, 2025

Codecov Report

Attention: Patch coverage is 2.38095% with 246 lines in your changes missing coverage. Please review.

Project coverage is 14.62%. Comparing base (504382a) to head (c117eba).
Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
...limu/web/analytics/students/StudentController.java 1.31% 75 Missing ⚠️
...i/elimu/util/csv/CsvAnalyticsExtractionHelper.java 0.00% 43 Missing ⚠️
...alytics/StoryBookLearningEventImportScheduler.java 2.77% 35 Missing ⚠️
...ks/analytics/WordLearningEventImportScheduler.java 2.77% 35 Missing ⚠️
...web/analytics/students/StudentsListController.java 3.84% 25 Missing ⚠️
.../ai/elimu/web/servlet/CustomDispatcherServlet.java 0.00% 10 Missing ⚠️
...s/analytics/VideoLearningEventImportScheduler.java 0.00% 6 Missing ⚠️
src/main/java/ai/elimu/dao/jpa/StudentDaoJpa.java 16.66% 5 Missing ⚠️
...nalytics/LetterAssessmentEventsRestController.java 0.00% 2 Missing ⚠️
...ytics/LetterSoundLearningEventsRestController.java 0.00% 2 Missing ⚠️
... and 4 more
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2186      +/-   ##
============================================
- Coverage     15.06%   14.62%   -0.45%     
- Complexity      391      397       +6     
============================================
  Files           232      238       +6     
  Lines          6258     6491     +233     
  Branches        717      753      +36     
============================================
+ Hits            943      949       +6     
- Misses         5264     5491     +227     
  Partials         51       51              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented May 10, 2025

Caution

Review failed

The pull request is closed.

## Walkthrough

This change introduces a new `Student` entity, associated DAO interfaces and implementations, and a controller/view for listing students in the analytics dashboard. The legacy `Device` entity and related DAOs are marked as deprecated. The analytics layout JSP and related CSS are removed, and a new JSP is added for the student list. Several REST controllers handling CSV uploads were modified to write files using `FileUtils.writeByteArrayToFile` and log file existence. New scheduled importers for word, storybook, and video learning events create `Student` entities if missing.

## Changes

| File(s)                                                                                      | Change Summary                                                                                                                                                                                                                                                                                                                                                                                                         |
|---------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `src/main/java/ai/elimu/entity/analytics/students/Student.java`,<br>`src/main/resources/META-INF/jpa-schema-export.sql` | Added new `Student` entity class and corresponding table schema, with a unique `androidId` field.                                                                                                                                                                                                                                                                                                                     |
| `src/main/java/ai/elimu/dao/StudentDao.java`,<br>`src/main/java/ai/elimu/dao/jpa/StudentDaoJpa.java` | Introduced new DAO interface and JPA implementation for `Student` entity, including a method to read by `androidId`.                                                                                                                                                                                                                                                           |
| `src/main/java/ai/elimu/web/analytics/students/StudentsListController.java`,<br>`src/main/webapp/WEB-INF/jsp/analytics/students/list.jsp` | Added Spring MVC controller and JSP view for listing students in the analytics dashboard, displaying redacted Android IDs.                                                                                                                                                                                                                                                      |
| `src/main/java/ai/elimu/web/analytics/students/StudentController.java`,<br>`src/main/webapp/WEB-INF/jsp/analytics/students/id.jsp` | Added Spring MVC controller and JSP view for detailed analytics of individual students, showing monthly event counts with Chart.js visualizations.                                                                                                                                                                                                                   |
| `src/main/java/ai/elimu/web/servlet/CustomDispatcherServlet.java`                            | Modified database population logic to create and persist three sample `Student` entities.                                                                                                                                                                                                                                                                                                                             |
| `src/main/java/ai/elimu/entity/Device.java`,<br>`src/main/java/ai/elimu/dao/DeviceDao.java`,<br>`src/main/java/ai/elimu/dao/jpa/DeviceDaoJpa.java` | Marked legacy `Device` entity and DAO classes as deprecated.                                                                                                                                                                                                                                                                                                                    |
| `src/main/webapp/WEB-INF/jsp/analytics/layout.jsp`,<br>`src/main/webapp/static/css/analytics/styles.css` | Deleted analytics layout JSP and associated CSS file.                                                                                                                                                                                                                                                                                                                           |
| `src/main/webapp/WEB-INF/jsp/layout.jsp`                                                     | Removed conditional inclusion of the deleted analytics layout JSP.                                                                                                                                                                                                                                                                                                              |
| `pom-dependency-tree.txt`                                                                    | Updated project artifact version from `2.6.10-SNAPSHOT` to `2.6.11-SNAPSHOT`.                                                                                                                                                                                                                                                                                                   |
| `src/main/java/ai/elimu/rest/v2/analytics/LetterSoundLearningEventsRestController.java`,<br>`src/main/java/ai/elimu/rest/v2/analytics/StoryBookLearningEventsRestController.java`,<br>`src/main/java/ai/elimu/rest/v2/analytics/VideoLearningEventsRestController.java`,<br>`src/main/java/ai/elimu/rest/v2/analytics/WordLearningEventsRestController.java`,<br>`src/main/java/ai/elimu/rest/v2/analytics/LetterAssessmentEventsRestController.java`,<br>`src/main/java/ai/elimu/rest/v2/analytics/WordAssessmentEventsRestController.java` | Changed CSV file saving method from `multipartFile.transferTo()` to `FileUtils.writeByteArrayToFile()` and added log statements to confirm file existence after writing. No other logic changes.                                                                                                                                                                                                                             |
| `src/main/java/ai/elimu/tasks/analytics/WordLearningEventImportScheduler.java`,<br>`src/main/java/ai/elimu/tasks/analytics/StoryBookLearningEventImportScheduler.java`,<br>`src/main/java/ai/elimu/tasks/analytics/VideoLearningEventImportScheduler.java` | Added new scheduled importers for word, storybook, and video learning events that read CSVs, check for duplicates, create missing `Student` entities, and persist events. Modified video event importer to create `Student` if missing and changed its schedule time.                                                                                                                                                    |
| `src/main/java/ai/elimu/util/csv/CsvAnalyticsExtractionHelper.java`                         | Refactored CSV extraction helper: added method to extract word learning events, renamed and simplified storybook event extraction, removed deprecated DAO dependencies, and improved timestamp handling with UTC timezone.                                                                                                                                                                                                |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant StudentsListController
    participant StudentDao
    participant WordLearningEventDao
    participant StoryBookLearningEventDao
    participant DB
    participant View

    User->>StudentsListController: GET /analytics/students
    StudentsListController->>WordLearningEventDao: readAll()
    WordLearningEventDao->>DB: Query all WordLearningEvents
    DB-->>WordLearningEventDao: List<WordLearningEvent>
    StudentsListController->>StudentDao: read(androidId) for each WordLearningEvent
    StudentDao->>DB: Query Student by androidId
    DB-->>StudentDao: Student or null
    alt Student not found
        StudentsListController->>StudentDao: create new Student with androidId
        StudentDao->>DB: Insert Student
    end
    StudentsListController->>StoryBookLearningEventDao: readAll()
    StoryBookLearningEventDao->>DB: Query all StoryBookLearningEvents
    DB-->>StoryBookLearningEventDao: List<StoryBookLearningEvent>
    StudentsListController->>StudentDao: read(androidId) for each StoryBookLearningEvent
    StudentDao->>DB: Query Student by androidId
    DB-->>StudentDao: Student or null
    alt Student not found
        StudentsListController->>StudentDao: create new Student with androidId
        StudentDao->>DB: Insert Student
    end
    StudentDao->>DB: readAll()
    DB-->>StudentDao: List<Student>
    StudentsListController->>View: Render list.jsp with redacted students
    View-->>User: HTML page with student list

Assessment against linked issues

Objective Addressed Explanation
Redact Android IDs (#2103)
Display Student ID in Analytics dashboard (/analytics) (#2103) Student ID is not displayed; Android ID is still shown (redacted) in the analytics dashboard.
Generate Student ID during data collection (*ImportScheduler.java) (#2103) New import schedulers create Student entities if missing during event import.
Include Student ID in CSV exports (learning events & assessment events) (#2103) No changes related to CSV exports or inclusion of Student ID.

Possibly related PRs

Suggested reviewers

  • vrudas
  • eymaal
  • alexander-kuruvilla

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> - CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> 
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 8b761f33ec1e2f0f9b1ee9c1a823a439dfa20095 and c117eba16c36edafe2b48f26b4eefdf1b17f80ca.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/main/java/ai/elimu/web/analytics/students/StudentController.java` (1 hunks)
* `src/main/webapp/WEB-INF/jsp/analytics/students/id.jsp` (1 hunks)

</details>

</details>
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=elimu-ai/webapp&utm_content=2186):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (10)
src/main/java/ai/elimu/dao/DeviceDao.java (1)

7-7: Add JavaDoc @deprecated tag: The @Deprecated annotation is applied correctly, but consider updating the interface Javadoc with a @deprecated tag that references StudentDao so consumers know the preferred replacement.

src/main/java/ai/elimu/entity/Device.java (1)

15-15: Document entity deprecation: You’ve marked the Device entity as deprecated; consider adding a Javadoc @deprecated comment to indicate migration to Student and any relevant data migration steps.

src/main/java/ai/elimu/dao/jpa/DeviceDaoJpa.java (1)

9-9: Enhance deprecation annotation: Consider using @Deprecated(forRemoval = true) and adding a Javadoc @deprecated note referencing StudentDaoJpa to guide users toward the new implementation and signal eventual removal.

src/main/java/ai/elimu/entity/analytics/students/Student.java (2)

10-13: Explicit table mapping recommended: The Student entity relies on default table naming. For clarity and to avoid naming collisions, consider adding @Table(name = "students"). A brief Javadoc summary of the entity’s purpose would also aid future maintainers.


21-23: Specify column constraints for androidId: You enforce uniqueness and non-nullability on androidId, but consider adding a length attribute to @Column to match the expected hexadecimal string size and ensure a database index exists for faster lookups.

src/main/java/ai/elimu/dao/StudentDao.java (1)

1-9: Well-structured DAO interface

The StudentDao interface properly extends GenericDao and includes a method to retrieve a Student by androidId. This follows the same pattern used by other DAO interfaces in the system.

Consider using Java 8's Optional for the return type to better represent the possible absence of a value:

-Student read(String androidId) throws DataAccessException;
+Optional<Student> read(String androidId) throws DataAccessException;
src/main/java/ai/elimu/dao/jpa/StudentDaoJpa.java (1)

1-23: Clean implementation of StudentDao

The implementation correctly extends GenericDaoJpa and provides a proper JPQL query to find a Student by androidId. The error handling for "no result found" is implemented correctly.

Two suggestions for improvement:

  1. Use a TypedQuery to avoid casting:
-return (Student) em.createQuery(
+return em.createQuery(
    "SELECT s " +
    "FROM Student s " +
    "WHERE s.androidId = :androidId")
    .setParameter("androidId", androidId)
+   .getSingleResult();
  1. Consider returning Optional instead of null for better null safety:
-public Student read(String androidId) throws DataAccessException {
+public Optional<Student> read(String androidId) throws DataAccessException {
    try {
-       return (Student) em.createQuery(
+       Student result = em.createQuery(
            "SELECT s " +
            "FROM Student s " +
            "WHERE s.androidId = :androidId", Student.class)
            .setParameter("androidId", androidId)
            .getSingleResult();
+       return Optional.of(result);
    } catch (NoResultException e) {
-       return null;
+       return Optional.empty();
    }
}
src/main/resources/META-INF/jpa-schema-export.sql (1)

484-488: Student table creation

The Student table is created with an id primary key and androidId column. Consider adding a NOT NULL constraint to the androidId column if this is a required field:

create table Student (
   id bigint not null auto_increment,
-   androidId varchar(255),
+   androidId varchar(255) not null,
    primary key (id)
) type=MyISAM;

Also, consider adding metadata columns such as creation timestamp if you need to track when student records were created.

src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (1)

24-35: Mark controller method as read-only transactional
Annotate handleRequest with @Transactional(readOnly = true) to optimize data access and document intent.

-  @GetMapping
+  @GetMapping
+  @Transactional(readOnly = true)

Don’t forget to import org.springframework.transaction.annotation.Transactional.

src/main/webapp/WEB-INF/jsp/analytics/students/list.jsp (1)

5-22: Replace <br /> hacks with CSS spacing
A sequence of <br /> for vertical gaps is brittle. Instead, define a CSS utility class:

/* in a shared stylesheet */
.banner-spacer { padding-top: 4rem; /* adjust as needed */ }

Then simplify JSP:

<content:banner cssClass="banner-spacer" />
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b7a34ba and dc4efd6.

⛔ Files ignored due to path filters (1)
  • src/main/webapp/WEB-INF/spring/applicationContext-jpa.xml is excluded by !**/*.xml
📒 Files selected for processing (14)
  • pom-dependency-tree.txt (1 hunks)
  • src/main/java/ai/elimu/dao/DeviceDao.java (1 hunks)
  • src/main/java/ai/elimu/dao/StudentDao.java (1 hunks)
  • src/main/java/ai/elimu/dao/jpa/DeviceDaoJpa.java (1 hunks)
  • src/main/java/ai/elimu/dao/jpa/StudentDaoJpa.java (1 hunks)
  • src/main/java/ai/elimu/entity/Device.java (1 hunks)
  • src/main/java/ai/elimu/entity/analytics/students/Student.java (1 hunks)
  • src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (1 hunks)
  • src/main/java/ai/elimu/web/servlet/CustomDispatcherServlet.java (2 hunks)
  • src/main/resources/META-INF/jpa-schema-export.sql (3 hunks)
  • src/main/webapp/WEB-INF/jsp/analytics/layout.jsp (0 hunks)
  • src/main/webapp/WEB-INF/jsp/analytics/students/list.jsp (1 hunks)
  • src/main/webapp/WEB-INF/jsp/layout.jsp (0 hunks)
  • src/main/webapp/static/css/analytics/styles.css (0 hunks)
💤 Files with no reviewable changes (3)
  • src/main/webapp/static/css/analytics/styles.css
  • src/main/webapp/WEB-INF/jsp/layout.jsp
  • src/main/webapp/WEB-INF/jsp/analytics/layout.jsp
🧰 Additional context used
🧬 Code Graph Analysis (4)
src/main/java/ai/elimu/entity/Device.java (1)
src/main/java/ai/elimu/dao/jpa/DeviceDaoJpa.java (1)
  • Deprecated (9-25)
src/main/java/ai/elimu/dao/DeviceDao.java (1)
src/main/java/ai/elimu/dao/jpa/DeviceDaoJpa.java (1)
  • Deprecated (9-25)
src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (2)
src/main/java/ai/elimu/util/AnalyticsHelper.java (1)
  • AnalyticsHelper (6-43)
src/main/java/ai/elimu/web/servlet/CustomDispatcherServlet.java (1)
  • Slf4j (67-315)
src/main/java/ai/elimu/entity/analytics/students/Student.java (1)
src/main/java/ai/elimu/entity/Device.java (1)
  • Entity (12-40)
⏰ Context from checks skipped due to timeout of 90000ms (10)
  • GitHub Check: test_ui
  • GitHub Check: build (windows-latest, 17)
  • GitHub Check: test_rest
  • GitHub Check: test_rest
  • GitHub Check: build (windows-latest, 21)
  • GitHub Check: build (ubuntu-latest, 17)
  • GitHub Check: build (macos-latest, 17)
  • GitHub Check: build (macos-latest, 21)
  • GitHub Check: build (ubuntu-latest, 21)
  • GitHub Check: test_rest
🔇 Additional comments (6)
pom-dependency-tree.txt (1)

1-1: Project version updated: bumping artifact version to 2.6.11-SNAPSHOT appropriately reflects the new Student entity functionality.

src/main/java/ai/elimu/web/servlet/CustomDispatcherServlet.java (2)

13-16: New entity imports added for Student functionality

The import statements for StudentDao and Student entity have been correctly added to support the new student entity functionality.


299-313: Properly implemented database population for Student entities

The implementation creates three Student entities with unique Android IDs, following the same pattern as other entities in this method. This ensures that the test data is properly populated for the new Student entity.

src/main/resources/META-INF/jpa-schema-export.sql (2)

78-78: Student table drop statement added

The drop statement for the Student table has been correctly added to the schema.


635-636: Unique constraint for androidId

The unique constraint on the androidId column has been properly added, which ensures each Student has a unique identifier.

src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (1)

16-23: Constructor injection with Lombok is clean
Using @RequiredArgsConstructor for the final studentDao field promotes immutability and testability.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (2)

36-56: Consider refactoring duplicate student creation logic.

The student creation code is duplicated for both word learning events and storybook learning events. Consider extracting this logic into a helper method to improve maintainability.

@GetMapping
public String handleRequest(Model model) {
  log.info("handleRequest");

  // Generate Student IDs for pre-existing learning events
-  for (WordLearningEvent wordLearningEvent : wordLearningEventDao.readAll()) {
-    log.info("wordLearningEvent.getAndroidId(): " + wordLearningEvent.getAndroidId());
-    Student existingStudent = studentDao.read(wordLearningEvent.getAndroidId());
-    if (existingStudent == null) {
-      Student student = new Student();
-      student.setAndroidId(wordLearningEvent.getAndroidId());
-      studentDao.create(student);
-      log.info("Stored Student in database with ID " + student.getId());
-    }
-  }
-  for (StoryBookLearningEvent storyBookLearningEvent : storyBookLearningEventDao.readAll()) {
-    log.info("storyBookLearningEvent.getAndroidId(): " + storyBookLearningEvent.getAndroidId());
-    Student existingStudent = studentDao.read(storyBookLearningEvent.getAndroidId());
-    if (existingStudent == null) {
-      Student student = new Student();
-      student.setAndroidId(storyBookLearningEvent.getAndroidId());
-      studentDao.create(student);
-      log.info("Stored Student in database with ID " + student.getId());
-    }
-  }
+  createStudentsFromWordLearningEvents();
+  createStudentsFromStoryBookLearningEvents();
  
  List<Student> students = studentDao.readAll();
  for (Student student : students) {
    student.setAndroidId(AnalyticsHelper.redactAndroidId(student.getAndroidId()));
  }
  model.addAttribute("students", students);

  return "analytics/students/list";
}

+ private void createStudentsFromWordLearningEvents() {
+   for (WordLearningEvent wordLearningEvent : wordLearningEventDao.readAll()) {
+     createStudentIfMissing(wordLearningEvent.getAndroidId());
+   }
+ }
+
+ private void createStudentsFromStoryBookLearningEvents() {
+   for (StoryBookLearningEvent storyBookLearningEvent : storyBookLearningEventDao.readAll()) {
+     createStudentIfMissing(storyBookLearningEvent.getAndroidId());
+   }
+ }
+
+ private void createStudentIfMissing(String androidId) {
+   log.info("Checking for student with androidId: " + androidId);
+   Student existingStudent = studentDao.read(androidId);
+   if (existingStudent == null) {
+     Student student = new Student();
+     student.setAndroidId(androidId);
+     studentDao.create(student);
+     log.info("Stored Student in database with ID " + student.getId());
+   }
+ }

32-65: Consider separating data initialization from display logic.

The current implementation mixes two concerns: initializing missing students and displaying student data. Consider separating these concerns by moving the student creation logic to a service class or a separate initialization method that could be called during application startup.

This would allow the controller to focus solely on its primary responsibility - preparing and displaying data.

src/main/java/ai/elimu/util/csv/CsvAnalyticsExtractionHelper.java (1)

30-92: Consider refactoring to reduce code duplication

The three extraction methods share a lot of similar code for file handling, CSV parsing, and error handling. Consider refactoring to extract common functionality into private helper methods to improve maintainability and reduce duplication.

For example, you could create a method like:

private static <T> List<T> extractEventsFromCsv(
        File csvFile, 
        String methodName, 
        String[] headers, 
        Function<CSVRecord, T> recordProcessor) {
    
    log.info(methodName);
    List<T> events = new ArrayList<>();
    Path csvFilePath = Paths.get(csvFile.toURI());
    log.info("csvFilePath: " + csvFilePath);
    
    try (Reader reader = Files.newBufferedReader(csvFilePath);
         CSVParser csvParser = new CSVParser(reader, CSVFormat.DEFAULT
                 .withHeader(headers)
                 .withSkipHeaderRecord())) {
        
        log.info("header: " + Arrays.toString(csvParser.getHeaderMap().keySet().toArray()));
        
        for (CSVRecord csvRecord : csvParser) {
            log.info("csvRecord: " + csvRecord);
            T event = recordProcessor.apply(csvRecord);
            events.add(event);
        }
    } catch (IOException ex) {
        log.error("Failed to process CSV file: " + csvFilePath, ex);
    }
    
    return events;
}

Then each specific extraction method could call this helper with the appropriate record processor.

Also applies to: 94-156, 158-220

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 701a411 and 8b761f3.

📒 Files selected for processing (5)
  • src/main/java/ai/elimu/tasks/analytics/StoryBookLearningEventImportScheduler.java (1 hunks)
  • src/main/java/ai/elimu/tasks/analytics/VideoLearningEventImportScheduler.java (3 hunks)
  • src/main/java/ai/elimu/tasks/analytics/WordLearningEventImportScheduler.java (1 hunks)
  • src/main/java/ai/elimu/util/csv/CsvAnalyticsExtractionHelper.java (2 hunks)
  • src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (1)
Learnt from: jo-elimu
PR: elimu-ai/webapp#2186
File: src/main/java/ai/elimu/web/analytics/students/StudentsListController.java:28-31
Timestamp: 2025-05-10T08:18:07.214Z
Learning: In the elimu-ai webapp project, temporary modifications to entity objects in read-only controllers (like redacting Android IDs in StudentsListController) are acceptable as they won't be persisted back to the database.
🧬 Code Graph Analysis (2)
src/main/java/ai/elimu/tasks/analytics/StoryBookLearningEventImportScheduler.java (3)
src/main/java/ai/elimu/util/csv/CsvAnalyticsExtractionHelper.java (1)
  • Slf4j (27-221)
src/main/java/ai/elimu/tasks/analytics/VideoLearningEventImportScheduler.java (1)
  • Service (43-110)
src/main/java/ai/elimu/tasks/analytics/WordLearningEventImportScheduler.java (1)
  • Service (43-110)
src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (1)
src/main/java/ai/elimu/util/AnalyticsHelper.java (1)
  • AnalyticsHelper (6-43)
⏰ Context from checks skipped due to timeout of 90000ms (4)
  • GitHub Check: test_rest
  • GitHub Check: test_rest
  • GitHub Check: test_rest
  • GitHub Check: test_rest
🔇 Additional comments (16)
src/main/java/ai/elimu/tasks/analytics/VideoLearningEventImportScheduler.java (4)

3-3: Imports look good for Student integration.

The necessary imports for StudentDao and Student entity have been added to support the new Student entity integration.

Also applies to: 6-6


50-51: Dependency injection for StudentDao is properly configured.

Good use of final field with Lombok's @RequiredArgsConstructor for dependency injection of the StudentDao.


52-52: Scheduler timing adjusted to stagger importers.

The scheduling time has been modified from 30 to 40 minutes past the hour, which helps stagger the different import jobs (Word at 30, StoryBook at 35, Video at 40 minutes past the hour), avoiding potential resource contention.


87-95: Student entity creation looks good.

The implementation follows the same pattern used in the other importers to create Student entities when they don't exist. The code properly checks for an existing student before creating a new one, and includes appropriate logging.

src/main/java/ai/elimu/web/analytics/students/StudentsListController.java (3)

20-24: Controller setup is well structured.

Good use of Spring annotations and Lombok to create a clean controller definition with proper request mapping.


26-31: Dependencies are properly injected.

Good use of final fields with Lombok's @RequiredArgsConstructor for dependency injection of all required DAOs.


58-61: Redacting Android IDs on entity objects is acceptable per project standards.

Based on the retrieved learning, temporarily modifying entity objects in read-only controllers (like redacting Android IDs) is acceptable in this project as they won't be persisted back to the database.

src/main/java/ai/elimu/tasks/analytics/WordLearningEventImportScheduler.java (3)

43-51: Service structure looks good.

The class is properly annotated with Spring and Lombok annotations, and dependencies are correctly injected via constructor injection.


52-52: Scheduler timing is well coordinated.

The cron expression is set to run at 30 minutes past every hour, which works well with the staggered approach (Word at 30, StoryBook at 35, Video at 40 minutes past the hour).


87-95: Student entity creation is properly implemented.

The code correctly checks for an existing student before creating a new one, and includes appropriate logging.

src/main/java/ai/elimu/tasks/analytics/StoryBookLearningEventImportScheduler.java (3)

43-51: Service structure looks good.

The class is properly annotated with Spring and Lombok annotations, and dependencies are correctly injected via constructor injection.


52-52: Scheduler timing is well coordinated.

The cron expression is set to run at 35 minutes past every hour, which works well with the staggered approach (Word at 30, StoryBook at 35, Video at 40 minutes past the hour).


87-95: Student entity creation is properly implemented.

The code correctly checks for an existing student before creating a new one, and includes appropriate logging.

src/main/java/ai/elimu/util/csv/CsvAnalyticsExtractionHelper.java (3)

23-26: Good documentation addition!

Adding clear documentation that explains the purpose of the helper class and its relationship to the Analytics Android app is a helpful improvement.


145-147: Good addition of additional_data handling

The addition of support for the additional_data field in the StoryBookLearningEvent extraction is good and makes the implementation consistent with the other event extraction methods.


150-150: Good resource management

Properly closing the CSVParser after use is a good practice to prevent resource leaks.

Comment on lines +53 to +108
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("word-learning-events")) {
File wordLearningEventsDir = new File(versionCodeDir, versionCodeDirFile.getName());
for (File csvFile : wordLearningEventsDir.listFiles()) {
log.info("csvFile: " + csvFile);

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

// Store in database
for (WordLearningEvent event : events) {
// Check if the event has already been stored in the database
WordLearningEvent existingWordLearningEvent = wordLearningEventDao.read(event.getTimestamp(), event.getAndroidId(), event.getApplication(), event.getWord());
if (existingWordLearningEvent != 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
wordLearningEventDao.create(event);
log.info("Stored event in database with ID " + event.getId());
}
}
}
}
}
}
}
}

log.info("execute complete");
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

Consider extracting the nested directory traversal logic.

The file contains deeply nested loops (7 levels deep) to traverse the directory structure, which makes the code hard to read and maintain. This pattern is also duplicated across all three scheduler classes.

Consider extracting this logic into a reusable utility method that takes a directory type (e.g., "word-learning-events") and a callback function to process each CSV file found. This would significantly improve code readability and maintainability.

// Example utility method signature
private void processAnalyticsFiles(String eventType, BiConsumer<File, List<T>> processor) {
    // Directory traversal code with callback for each CSV file
}

// Example usage
@Scheduled(cron = "00 30 * * * *")
public synchronized void execute() {
    log.info("execute");
    
    processAnalyticsFiles("word-learning-events", (csvFile, events) -> {
        // Process each event from the CSV file
        for (WordLearningEvent event : events) {
            // Existing event processing logic
        }
    });
    
    log.info("execute complete");
}

Comment on lines +62 to +106
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("word-learning-events")) {
File wordLearningEventsDir = new File(versionCodeDir, versionCodeDirFile.getName());
for (File csvFile : wordLearningEventsDir.listFiles()) {
log.info("csvFile: " + csvFile);

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

// Store in database
for (WordLearningEvent event : events) {
// Check if the event has already been stored in the database
WordLearningEvent existingWordLearningEvent = wordLearningEventDao.read(event.getTimestamp(), event.getAndroidId(), event.getApplication(), event.getWord());
if (existingWordLearningEvent != 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
wordLearningEventDao.create(event);
log.info("Stored event in database with ID " + event.getId());
}
}
}
}
}
}
}
}
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.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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("word-learning-events")) {
File wordLearningEventsDir = new File(versionCodeDir, versionCodeDirFile.getName());
for (File csvFile : wordLearningEventsDir.listFiles()) {
log.info("csvFile: " + csvFile);
// Convert from CSV to Java
List<WordLearningEvent> events = CsvAnalyticsExtractionHelper.extractWordLearningEvents(csvFile);
log.info("events.size(): " + events.size());
// Store in database
for (WordLearningEvent event : events) {
// Check if the event has already been stored in the database
WordLearningEvent existingWordLearningEvent = wordLearningEventDao.read(event.getTimestamp(), event.getAndroidId(), event.getApplication(), event.getWord());
if (existingWordLearningEvent != 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
wordLearningEventDao.create(event);
log.info("Stored event in database with ID " + event.getId());
}
}
}
}
}
}
}
}
// Add null-check for analyticsDir.listFiles()
File[] analyticsDirFiles = analyticsDir.listFiles();
if (analyticsDirFiles != null) {
for (File analyticsDirFile : analyticsDirFiles) {
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("word-learning-events")) {
File wordLearningEventsDir = new File(versionCodeDir, versionCodeDirFile.getName());
for (File csvFile : wordLearningEventsDir.listFiles()) {
log.info("csvFile: " + csvFile);
// Convert from CSV to Java
List<WordLearningEvent> events = CsvAnalyticsExtractionHelper.extractWordLearningEvents(csvFile);
log.info("events.size(): " + events.size());
// Store in database
for (WordLearningEvent event : events) {
// Check if the event has already been stored in the database
WordLearningEvent existingWordLearningEvent = wordLearningEventDao.read(
event.getTimestamp(),
event.getAndroidId(),
event.getApplication(),
event.getWord()
);
if (existingWordLearningEvent != 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
wordLearningEventDao.create(event);
log.info("Stored event in database with ID " + event.getId());
}
}
}
}
}
}
}
}
}

Comment on lines +1 to +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());
}
}
}
}
}
}
}
}

log.info("execute complete");
}
}
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();
    }
}

Comment on lines +62 to +106
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());
}
}
}
}
}
}
}
}
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.

Comment on lines +30 to +92
public static List<WordLearningEvent> extractWordLearningEvents(File csvFile) {
log.info("extractWordLearningEvents");

List<WordLearningEvent> wordLearningEvents = new ArrayList<>();

// Iterate each row in the CSV file
Path csvFilePath = Paths.get(csvFile.toURI());
log.info("csvFilePath: " + csvFilePath);
try {
Reader reader = Files.newBufferedReader(csvFilePath);
CSVFormat csvFormat = CSVFormat.DEFAULT
.withHeader(
"id", // The Android database ID
"timestamp",
"android_id",
"package_name",
"word_id",
"word_text",
"learning_event_type",
"additional_data"
)
.withSkipHeaderRecord();
log.info("header: " + Arrays.toString(csvFormat.getHeader()));
CSVParser csvParser = new CSVParser(reader, csvFormat);
for (CSVRecord csvRecord : csvParser) {
log.info("csvRecord: " + csvRecord);

// Convert from CSV to Java

WordLearningEvent wordLearningEvent = new WordLearningEvent();

long timestampInMillis = Long.valueOf(csvRecord.get("time"));
Calendar timestamp = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
timestamp.setTimeInMillis(timestampInMillis);
wordLearningEvent.setTimestamp(timestamp);

String androidId = csvRecord.get("android_id");
wordLearningEvent.setAndroidId(androidId);

String packageName = csvRecord.get("package_name");
wordLearningEvent.setPackageName(packageName);

Long wordId = Long.valueOf(csvRecord.get("word_id"));
// wordLearningEvent.setWordId(wordId);

String wordText = csvRecord.get("word_text");
wordLearningEvent.setWordText(wordText);

LearningEventType learningEventType = LearningEventType.valueOf(csvRecord.get("learning_event_type"));
wordLearningEvent.setLearningEventType(learningEventType);

String additionalData = csvRecord.get("additional_data");
wordLearningEvent.setAdditionalData(additionalData);

wordLearningEvents.add(wordLearningEvent);
}
csvParser.close();
} catch (IOException ex) {
log.error(ex.getMessage());
}

return wordLearningEvents;
}
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

Consider enhancing error handling

All three extraction methods catch IOExceptions but only log the error message. Consider either re-throwing the exception or implementing more robust error handling, especially since this code is likely part of a data import pipeline where failures need to be properly reported.

         } catch (IOException ex) {
-            log.error(ex.getMessage());
+            log.error("Failed to parse CSV file: " + csvFilePath, ex);
+            // Consider handling the error more robustly, e.g.:
+            // throw new RuntimeException("Failed to parse CSV file: " + csvFilePath, ex);
         }

Also applies to: 94-156, 158-220


💡 Verification agent

❓ Verification inconclusive

Fix timestamp field name mismatch in WordLearningEvent extraction

There's a field name mismatch in the extractWordLearningEvents method. Line 61 tries to retrieve the timestamp using csvRecord.get("time"), but the header defined in lines 41-50 uses "timestamp" instead.

-                long timestampInMillis = Long.valueOf(csvRecord.get("time"));
+                long timestampInMillis = Long.valueOf(csvRecord.get("timestamp"));

Additionally, I notice that line 73 parses the word ID but it's commented out: // wordLearningEvent.setWordId(wordId);. Is this intentional? If the ID is needed for future functionality, consider adding a TODO comment to explain why it's parsed but not used.


Fix timestamp header key mismatch in extractWordLearningEvents

The CSV header defines "timestamp", but the code is calling csvRecord.get("time"), which will fail at runtime. Also, you’re parsing wordId but never setting it on the event—either set it or add a comment explaining why it’s omitted.

Please update in src/main/java/ai/elimu/util/csv/CsvAnalyticsExtractionHelper.java:

  • Line ~61: change the key from "time" to "timestamp".
  • Line ~73: either uncomment and call wordLearningEvent.setWordId(wordId); or add a // TODO explaining why you’re parsing but not using it.

Suggested diff:

-    long timestampInMillis = Long.valueOf(csvRecord.get("time"));
+    long timestampInMillis = Long.valueOf(csvRecord.get("timestamp"));

-    // wordLearningEvent.setWordId(wordId);
+    wordLearningEvent.setWordId(wordId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static List<WordLearningEvent> extractWordLearningEvents(File csvFile) {
log.info("extractWordLearningEvents");
List<WordLearningEvent> wordLearningEvents = new ArrayList<>();
// Iterate each row in the CSV file
Path csvFilePath = Paths.get(csvFile.toURI());
log.info("csvFilePath: " + csvFilePath);
try {
Reader reader = Files.newBufferedReader(csvFilePath);
CSVFormat csvFormat = CSVFormat.DEFAULT
.withHeader(
"id", // The Android database ID
"timestamp",
"android_id",
"package_name",
"word_id",
"word_text",
"learning_event_type",
"additional_data"
)
.withSkipHeaderRecord();
log.info("header: " + Arrays.toString(csvFormat.getHeader()));
CSVParser csvParser = new CSVParser(reader, csvFormat);
for (CSVRecord csvRecord : csvParser) {
log.info("csvRecord: " + csvRecord);
// Convert from CSV to Java
WordLearningEvent wordLearningEvent = new WordLearningEvent();
long timestampInMillis = Long.valueOf(csvRecord.get("time"));
Calendar timestamp = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
timestamp.setTimeInMillis(timestampInMillis);
wordLearningEvent.setTimestamp(timestamp);
String androidId = csvRecord.get("android_id");
wordLearningEvent.setAndroidId(androidId);
String packageName = csvRecord.get("package_name");
wordLearningEvent.setPackageName(packageName);
Long wordId = Long.valueOf(csvRecord.get("word_id"));
// wordLearningEvent.setWordId(wordId);
String wordText = csvRecord.get("word_text");
wordLearningEvent.setWordText(wordText);
LearningEventType learningEventType = LearningEventType.valueOf(csvRecord.get("learning_event_type"));
wordLearningEvent.setLearningEventType(learningEventType);
String additionalData = csvRecord.get("additional_data");
wordLearningEvent.setAdditionalData(additionalData);
wordLearningEvents.add(wordLearningEvent);
}
csvParser.close();
} catch (IOException ex) {
log.error(ex.getMessage());
}
return wordLearningEvents;
}
public static List<WordLearningEvent> extractWordLearningEvents(File csvFile) {
log.info("extractWordLearningEvents");
List<WordLearningEvent> wordLearningEvents = new ArrayList<>();
// Iterate each row in the CSV file
Path csvFilePath = Paths.get(csvFile.toURI());
log.info("csvFilePath: " + csvFilePath);
try {
Reader reader = Files.newBufferedReader(csvFilePath);
CSVFormat csvFormat = CSVFormat.DEFAULT
.withHeader(
"id", // The Android database ID
"timestamp",
"android_id",
"package_name",
"word_id",
"word_text",
"learning_event_type",
"additional_data"
)
.withSkipHeaderRecord();
log.info("header: " + Arrays.toString(csvFormat.getHeader()));
CSVParser csvParser = new CSVParser(reader, csvFormat);
for (CSVRecord csvRecord : csvParser) {
log.info("csvRecord: " + csvRecord);
// Convert from CSV to Java
WordLearningEvent wordLearningEvent = new WordLearningEvent();
- long timestampInMillis = Long.valueOf(csvRecord.get("time"));
+ long timestampInMillis = Long.valueOf(csvRecord.get("timestamp"));
Calendar timestamp = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
timestamp.setTimeInMillis(timestampInMillis);
wordLearningEvent.setTimestamp(timestamp);
String androidId = csvRecord.get("android_id");
wordLearningEvent.setAndroidId(androidId);
String packageName = csvRecord.get("package_name");
wordLearningEvent.setPackageName(packageName);
Long wordId = Long.valueOf(csvRecord.get("word_id"));
- // wordLearningEvent.setWordId(wordId);
+ wordLearningEvent.setWordId(wordId);
String wordText = csvRecord.get("word_text");
wordLearningEvent.setWordText(wordText);
LearningEventType learningEventType = LearningEventType.valueOf(csvRecord.get("learning_event_type"));
wordLearningEvent.setLearningEventType(learningEventType);
String additionalData = csvRecord.get("additional_data");
wordLearningEvent.setAdditionalData(additionalData);
wordLearningEvents.add(wordLearningEvent);
}
csvParser.close();
} catch (IOException ex) {
log.error(ex.getMessage());
}
return wordLearningEvents;
}

@jo-elimu jo-elimu merged commit bd56011 into main May 10, 2025
13 checks passed
@jo-elimu jo-elimu deleted the 2103-add-student-entity branch May 10, 2025 10:29
@coderabbitai coderabbitai bot mentioned this pull request Jul 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace android_id with student_id

2 participants