Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 9 additions & 1 deletion study/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .models import (
Course, Topic, Flashcard, StudySession, FlashcardProgress,
Skill, MultipleChoiceOption, CardTemplate, CourseEnrollment,
StudyPreference, TopicScore, CardSuggestion
StudyPreference, TopicScore, CardSuggestion, SpacedRepetitionSettings
)

# Register your models here.
Expand Down Expand Up @@ -160,3 +160,11 @@ class CardSuggestionAdmin(admin.ModelAdmin):
def question_preview(self, obj):
return obj.question[:60] + '…' if len(obj.question) > 60 else obj.question
question_preview.short_description = 'Question'


@admin.register(SpacedRepetitionSettings)
class SpacedRepetitionSettingsAdmin(admin.ModelAdmin):
list_display = ['user', 'ease_modifier', 'max_interval_days', 'daily_new_cards', 'updated_at']
list_filter = ['ease_modifier']
search_fields = ['user__username']
ordering = ['-updated_at']
48 changes: 48 additions & 0 deletions study/migrations/0041_spaced_repetition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 6.0.3 on 2026-03-12 21:51

import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('study', '0040_merge_20260309_1337'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name='flashcardprogress',
name='easiness_factor',
field=models.FloatField(default=2.5, help_text='SM-2 easiness factor (≥1.3). Higher = longer intervals between reviews.', validators=[django.core.validators.MinValueValidator(1.3)]),
),
migrations.AddField(
model_name='flashcardprogress',
name='interval_days',
field=models.IntegerField(default=0, help_text='Current interval in days (0 = new card, never reviewed via SM-2).', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='flashcardprogress',
name='next_review_date',
field=models.DateField(blank=True, help_text='Date this card is next due for review. Null means the card is new.', null=True),
),
migrations.AddField(
model_name='flashcardprogress',
name='sm2_repetitions',
field=models.IntegerField(default=0, help_text='Consecutive successful SM-2 recalls (resets to 0 on failure).', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.CreateModel(
name='SpacedRepetitionSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ease_modifier', models.IntegerField(choices=[(-2, 'Much slower — review cards very frequently'), (-1, 'Slower — more frequent reviews'), (0, 'Normal — standard SM-2 schedule'), (1, 'Faster — longer gaps between reviews'), (2, 'Much faster — aggressive spacing')], default=0, help_text='Shifts how quickly review intervals grow. Increase if you remember things faster than average; decrease if slower.', validators=[django.core.validators.MinValueValidator(-2), django.core.validators.MaxValueValidator(2)])),
('max_interval_days', models.IntegerField(default=365, help_text='Maximum days between reviews (30–730). Cap prevents intervals from growing too large.', validators=[django.core.validators.MinValueValidator(30), django.core.validators.MaxValueValidator(730)])),
('daily_new_cards', models.IntegerField(default=20, help_text='Maximum new (never reviewed) cards to introduce per review session (1–100).', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sr_settings', to=settings.AUTH_USER_MODEL)),
],
),
]
19 changes: 19 additions & 0 deletions study/migrations/0042_alter_easiness_factor_min_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.29 on 2026-03-12 23:14

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('study', '0041_spaced_repetition'),
]

operations = [
migrations.AlterField(
model_name='flashcardprogress',
name='easiness_factor',
field=models.FloatField(default=2.5, help_text='SM-2 easiness factor (≥1.1). Higher = longer intervals between reviews.', validators=[django.core.validators.MinValueValidator(1.1)]),
),
]
77 changes: 77 additions & 0 deletions study/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.contrib.auth.models import User
import secrets
import string
import datetime

BADGE_DEFINITIONS = [
{'slug': 'first_session', 'name': 'First Steps', 'icon': '🎯', 'description': 'Complete your first study session', 'type': 'sessions', 'threshold': 1},
Expand Down Expand Up @@ -391,6 +392,28 @@ class FlashcardProgress(models.Model):
help_text="0-5 confidence level for spaced repetition"
)

# SM-2 spaced repetition fields
easiness_factor = models.FloatField(
default=2.5,
help_text="SM-2 easiness factor (≥1.1). Higher = longer intervals between reviews.",
validators=[MinValueValidator(1.1)],
)
interval_days = models.IntegerField(
default=0,
help_text="Current interval in days (0 = new card, never reviewed via SM-2).",
validators=[MinValueValidator(0)],
)
sm2_repetitions = models.IntegerField(
default=0,
help_text="Consecutive successful SM-2 recalls (resets to 0 on failure).",
validators=[MinValueValidator(0)],
)
next_review_date = models.DateField(
null=True,
blank=True,
help_text="Date this card is next due for review. Null means the card is new.",
)

class Meta:
unique_together = ['user', 'flashcard', 'step_index']
ordering = ['-last_reviewed']
Expand All @@ -404,8 +427,62 @@ def success_rate(self):
return 0
return (self.times_correct / self.times_reviewed) * 100

@property
def is_due(self):
"""Return True if the card is due for review today or overdue."""
if self.next_review_date is None:
return False
return self.next_review_date <= datetime.date.today()


class SpacedRepetitionSettings(models.Model):
"""Per-user settings for the SM-2 spaced repetition algorithm."""

SPEED_CHOICES = [
(-2, 'Much slower — review cards very frequently'),
(-1, 'Slower — more frequent reviews'),
(0, 'Normal — standard SM-2 schedule'),
(1, 'Faster — longer gaps between reviews'),
(2, 'Much faster — aggressive spacing'),
]

DEFAULT_EASE_MODIFIER = 0
DEFAULT_MAX_INTERVAL_DAYS = 365
DEFAULT_DAILY_NEW_CARDS = 20

user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='sr_settings',
)
ease_modifier = models.IntegerField(
default=DEFAULT_EASE_MODIFIER,
choices=SPEED_CHOICES,
help_text=(
"Shifts how quickly review intervals grow. "
"Increase if you remember things faster than average; decrease if slower."
),
validators=[MinValueValidator(-2), MaxValueValidator(2)],
)
max_interval_days = models.IntegerField(
default=DEFAULT_MAX_INTERVAL_DAYS,
help_text="Maximum days between reviews (30–730). Cap prevents intervals from growing too large.",
validators=[MinValueValidator(30), MaxValueValidator(730)],
)
daily_new_cards = models.IntegerField(
default=DEFAULT_DAILY_NEW_CARDS,
help_text="Maximum new (never reviewed) cards to introduce per review session (1–100).",
validators=[MinValueValidator(1), MaxValueValidator(100)],
)
updated_at = models.DateTimeField(auto_now=True)

def __str__(self):
return f"{self.user.username} SR settings (ease_modifier={self.ease_modifier:+d})"

def get_min_easiness(self):
"""Return the minimum easiness factor after applying user's speed modifier."""
# Each step shifts min EF by 0.1: -2→1.1, -1→1.2, 0→1.3, +1→1.4, +2→1.5
return round(1.3 + self.ease_modifier * 0.1, 1)
class StudyGoal(models.Model):
"""User's daily card study target"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='study_goal')
Expand Down
1 change: 1 addition & 0 deletions study/templates/study/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="dropdown__menu">
<div class="dropdown__item"><a href="{% url 'spaced_repetition_settings' %}">🧠 Spaced Repetition</a></div>
<div class="dropdown__item">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
Expand Down
Loading
Loading