1
0

vault backup: 2025-12-26 02:09:22
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s

This commit is contained in:
2025-12-26 02:09:22 +01:00
parent 3fddadfe50
commit 50366b9b9c
288 changed files with 58893 additions and 750 deletions

45
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,45 @@
## Architecture
- Notes live in [content](content) and ship to [public](public) via Quartz under [quartz](quartz); Django lives in [stroma](stroma) and hosts the quiz and ingest services.
- Static Quartz pages are configured through [quartz.config.ts](quartz.config.ts) and layouts in [quartz.layout.ts](quartz.layout.ts), while Django templates sit in [stroma/templates](stroma/templates) with HTMX-like partials in [stroma/templates/partials](stroma/templates/partials).
- Data flows: Obsidian markdown → question importer ([stroma/quiz/utils/importer.py](stroma/quiz/utils/importer.py#L1-L220)) → relational models ([stroma/quiz/models](stroma/quiz/models)) → quiz/session views → rendered partials front-end.
## Quartz Site Workflow
- Use Node 22+ and npm 10.9+ as enforced in [package.json](package.json#L1-L60); run `npm install` once, then `npm run docs` to build+serve Quartz against the `content` dir.
- The CLI is exposed via `npm run quartz` / `npx quartz ...` and reads custom config/theme in [quartz.config.ts](quartz.config.ts) plus layout definition in [quartz.layout.ts](quartz.layout.ts).
- [content](content) follows Obsidian conventions (Swedish folder names, embedded PDFs/CSV) and Quartz ignores private/templates per the `ignorePatterns` value.
- When editing markdown, keep Obsidian-flavored syntax and frontmatter tags because the quiz importer keys off tags like `frågetyp/mcq`.
## Django Quiz Service
- The project root is [stroma](stroma); run it with `uv run python manage.py runserver` (Python 3.13, deps in [stroma/pyproject.toml](stroma/pyproject.toml)).
- Core models: `QuizSession`, `QuizResult`, `QuizUser`, `Question`, `Tag`, `Exam`, etc. in [stroma/quiz/models](stroma/quiz/models); sessions filter questions via [stroma/quiz/views/get_session_questions_view.py](stroma/quiz/views/get_session_questions_view.py#L1-L35).
- Primary views live in [stroma/quiz/views](stroma/quiz/views) and always expect `request.quiz_user`; e.g. [stroma/quiz/views/get_next_question_view.py](stroma/quiz/views/get_next_question_view.py#L1-L40) renders partials consumed by the `quiz_mode` page.
- URL routing: [stroma/quiz/urls.py](stroma/quiz/urls.py) registers quiz endpoints under `/quiz/`, and [stroma/quiz/quiz_urls.py](stroma/quiz/quiz_urls.py#L1-L25) defines per-view paths (`session/<id>/question`, `/next/`, `/submit/`, etc.).
## Question Import Pipeline
- [stroma/quiz/utils/importer.py](stroma/quiz/utils/importer.py) parses Obsidian markdown, infers question type via `tags`, and stores options/matching_data plus tag slugs; the importer respects file mtimes and tracks stats.
- [stroma/quiz/utils/watcher.py](stroma/quiz/utils/watcher.py) plus [stroma/quiz/apps.py](stroma/quiz/apps.py#L1-L20) spin up a background Watchdog thread on startup (unless running management commands/tests) targeting `settings.QUESTION_WATCH_PATH` from [stroma/settings.py](stroma/settings.py#L1-L70).
- Management commands `uv run python manage.py import_questions [--folder ... --force]` and `populate_exams` live in [stroma/quiz/management/commands](stroma/quiz/management/commands) for bulk ingest/backfilling.
- The importer expects spoiler blocks for answers and tags like `frågetyp/mcq`; missing answers mark the file as TODO and skip DB writes, so keep metadata consistent.
## File Uploads & Assets
- The `file` app exposes `/file/upload/` + `/file/upload/api/` via [stroma/file/urls.py](stroma/file/urls.py); UI renders from [stroma/file/views/upload_files_page_view.py](stroma/file/views/upload_files_page_view.py#L1-L7).
- Upload handling in [stroma/file/views/upload_files_api_view.py](stroma/file/views/upload_files_api_view.py#L1-L130) mirrors the clients folder hierarchy, infers MIME types, stores text content when possible, and persists binaries under `uploads/`.
- Metadata/state for uploaded artifacts sit in [stroma/file/models/file_model.py](stroma/file/models/file_model.py#L1-L40) and are owned by `quiz.QuizUser`.
## Session & Request Patterns
- [stroma/quiz/middleware.py](stroma/quiz/middleware.py#L1-L21) auto-creates `QuizUser` records tied to Django session keys and attaches `request.quiz_user`; every view assumes this, so never bypass the middleware.
- `QuizResult` enforces one row per user+question via `unique_together` ([stroma/quiz/models/quiz_result_model.py](stroma/quiz/models/quiz_result_model.py#L1-L27)) and normalization logic in [submit_answer_view](stroma/quiz/views/submit_answer_view.py#L1-L60); reuse `update_or_create` when recording answers.
- Navigational/partial views such as [quiz_question_view](stroma/quiz/views/quiz_question_view.py#L1-L60) and [navigate_question_view](stroma/quiz/views/navigate_question_view.py#L1-L60) rely on consistent ordering from `get_session_questions`; maintain that helper when altering filtering behavior.
## Testing & Tooling
- Python deps/tests are run through `uv` (`uv sync`, then `uv run pytest`) per [stroma/AGENT.md](stroma/AGENT.md) and versions pinned in [stroma/pyproject.toml](stroma/pyproject.toml#L1-L20).
- Pytest config in [stroma/pytest.ini](stroma/pytest.ini#L1-L20) enables `--reuse-db` and short tracebacks; [stroma/conftest.py](stroma/conftest.py#L1-L70) switches to in-memory SQLite and provides markdown fixtures for parser tests.
- Comprehensive integration coverage exists in [stroma/quiz/tests/test_views.py](stroma/quiz/tests/test_views.py#L1-L210); extend these when touching quiz flows (session creation, submissions, difficulty ratings).
- SQLite WAL is default (see [stroma/settings.py](stroma/settings.py#L30-L65)); when debugging concurrency, clear `db.sqlite3-wal/shm` files before reruns.
## Coding Conventions
- Follow [stroma/AGENT.md](stroma/AGENT.md): Obsidian-flavored Markdown, no extra docstrings/comments unless asked, favor early returns, narrow `try/except`, and keep regexes as upper-case `_RE` constants with inline comments.
- Use type hints everywhere, prefer function-based Django views, and leverage django-stubs/pytest-django for typing/tests.
- When working in markdown ingestion, maintain tag-driven semantics (`frågetyp/*`, completion stats) and keep regex patterns centralized in parser modules.
- Tests should live beside implementations (e.g., `quiz/utils/tests`) and use parametrized `pytest` subtests when iterating over inputs.
- Keep new dependencies declared in `pyproject.toml` (Python) or `package.json` (Quartz) and prefer latest stable versions unless compatibility dictates otherwise.

View File

@@ -13,13 +13,13 @@
"state": {
"type": "markdown",
"state": {
"file": "z-Tech/Mega-App/Project level design goals.md",
"file": "Biokemi/Metabolism/🍕 β-oxidation/Provfrågor.md",
"mode": "source",
"source": false,
"backlinks": false
},
"icon": "lucide-file",
"title": "Project level design goals"
"title": "Provfrågor"
}
}
]
@@ -191,10 +191,11 @@
"agent-client:Open agent client": false
}
},
"active": "b6de1b6650c09ff3",
"active": "ef51d026ab2efaae",
"lastOpenFiles": [
"z-Tech/Mega-App/UX.md",
"Anatomi & Histologi 2/1 Öga anatomi/obligatorisk och viktig.md",
"z-Tech/Mega-App/Project level design goals.md",
"z-Tech/Mega-App/UX.md",
"z-Tech/Quiz-app.md",
"z-Tech/Mega-App",
"Slides.pdf.md",
@@ -220,7 +221,6 @@
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/13.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md",
"attachments/image-48.png",
"Biokemi/Plasmidlabb/Articles/Report guidelines 2025.pdf",
"Biokemi/Plasmidlabb/Protokoll.pdf",

View File

@@ -0,0 +1 @@
obligatorisk och viktig

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,192 +0,0 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from .models import QuizUser, Question, Option, QuizResult, Course, Exam
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
"""Admin interface for Courses"""
list_display = ['id', 'name', 'code', 'exam_count', 'created_at']
search_fields = ['name', 'code']
readonly_fields = ['created_at']
def exam_count(self, obj):
"""Show number of exams"""
return obj.exams.count()
exam_count.short_description = '# Exams'
@admin.register(Exam)
class ExamAdmin(admin.ModelAdmin):
"""Admin interface for Exams"""
list_display = ['id', 'course', 'date', 'question_count', 'folder_path', 'created_at']
list_filter = ['course', 'date']
search_fields = ['name', 'folder_path']
readonly_fields = ['created_at']
def question_count(self, obj):
"""Show number of questions"""
return obj.questions.count()
question_count.short_description = '# Questions'
class OptionInline(admin.TabularInline):
"""Inline admin for question options"""
model = Option
extra = 0
fields = ['letter', 'text']
ordering = ['letter']
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
"""Admin interface for Questions"""
list_display = ['id', 'question_preview', 'exam', 'correct_answer', 'option_count', 'file_source', 'updated_at']
list_filter = ['exam__course', 'exam', 'created_at', 'updated_at']
search_fields = ['text', 'file_path', 'correct_answer']
readonly_fields = ['file_path', 'file_mtime', 'created_at', 'updated_at', 'formatted_mtime']
fieldsets = [
('Question Content', {
'fields': ['exam', 'text', 'correct_answer']
}),
('File Tracking', {
'fields': ['file_path', 'file_mtime', 'formatted_mtime'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
inlines = [OptionInline]
def question_preview(self, obj):
"""Show question text preview"""
return obj.text[:60] + '...' if len(obj.text) > 60 else obj.text
question_preview.short_description = 'Question'
def option_count(self, obj):
"""Show number of options"""
return obj.options.count()
option_count.short_description = '# Options'
def file_source(self, obj):
"""Show file path with folder highlight"""
if obj.file_path:
parts = obj.file_path.split('/')
if len(parts) > 1:
folder = parts[-2]
filename = parts[-1]
return format_html('<span style="color: #666;">{}/</span><strong>{}</strong>', folder, filename)
return obj.file_path or '-'
file_source.short_description = 'Source File'
def formatted_mtime(self, obj):
"""Show formatted modification time"""
if obj.file_mtime:
from datetime import datetime
dt = datetime.fromtimestamp(obj.file_mtime)
return dt.strftime('%Y-%m-%d %H:%M:%S')
return '-'
formatted_mtime.short_description = 'File Modified'
@admin.register(Option)
class OptionAdmin(admin.ModelAdmin):
"""Admin interface for Options"""
list_display = ['id', 'question_preview', 'letter', 'text_preview', 'is_correct']
list_filter = ['letter']
search_fields = ['text', 'question__text']
readonly_fields = ['question']
def question_preview(self, obj):
"""Show question preview"""
return obj.question.text[:40] + '...'
question_preview.short_description = 'Question'
def text_preview(self, obj):
"""Show option text preview"""
return obj.text[:50] + '...' if len(obj.text) > 50 else obj.text
text_preview.short_description = 'Option Text'
def is_correct(self, obj):
"""Highlight if this is the correct answer"""
if obj.question.correct_answer and obj.letter in obj.question.correct_answer:
return format_html('<span style="color: green; font-weight: bold;">✓ Correct</span>')
return format_html('<span style="color: #999;">-</span>')
is_correct.short_description = 'Status'
@admin.register(QuizUser)
class QuizUserAdmin(admin.ModelAdmin):
"""Admin interface for Quiz Users"""
list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at']
list_filter = ['created_at']
search_fields = ['session_key']
readonly_fields = ['session_key', 'created_at', 'full_session_key']
fieldsets = [
('User Info', {
'fields': ['full_session_key', 'created_at']
}),
]
def session_preview(self, obj):
"""Show session key preview"""
return f"{obj.session_key[:12]}..."
session_preview.short_description = 'Session'
def result_count(self, obj):
"""Show number of quiz results"""
return obj.results.count()
result_count.short_description = '# Answers'
def score_percentage(self, obj):
"""Show score percentage"""
total = obj.results.count()
if total == 0:
return '-'
correct = obj.results.filter(is_correct=True).count()
percentage = (correct / total * 100)
color = 'green' if percentage >= 70 else 'orange' if percentage >= 50 else 'red'
return mark_safe(
f'<span style="color: {color}; font-weight: bold;">{percentage:.1f}%</span> ({correct}/{total})'
)
score_percentage.short_description = 'Score'
def full_session_key(self, obj):
"""Show full session key"""
return obj.session_key
full_session_key.short_description = 'Full Session Key'
@admin.register(QuizResult)
class QuizResultAdmin(admin.ModelAdmin):
"""Admin interface for Quiz Results"""
list_display = ['id', 'user_preview', 'question_preview', 'selected_answer', 'correct_answer', 'result_status', 'answered_at']
list_filter = ['is_correct', 'answered_at']
search_fields = ['user__session_key', 'question__text']
readonly_fields = ['user', 'question', 'selected_answer', 'is_correct', 'answered_at']
def user_preview(self, obj):
"""Show user session preview"""
return f"{obj.user.session_key[:8]}..."
user_preview.short_description = 'User'
def question_preview(self, obj):
"""Show question preview"""
return obj.question.text[:40] + '...'
question_preview.short_description = 'Question'
def correct_answer(self, obj):
"""Show correct answer"""
return obj.question.correct_answer
correct_answer.short_description = 'Correct'
def result_status(self, obj):
"""Show visual result status"""
if obj.is_correct:
return mark_safe('<span style="color: green; font-weight: bold;">✓ Correct</span>')
return mark_safe('<span style="color: red; font-weight: bold;">✗ Wrong</span>')
result_status.short_description = 'Result'

View File

@@ -1,130 +0,0 @@
from django.db import models
class QuizUser(models.Model):
session_key = models.CharField(max_length=40, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Quiz User"
verbose_name_plural = "Quiz Users"
def __str__(self):
return f"User {self.session_key[:8]}"
class Course(models.Model):
name = models.CharField(max_length=200, unique=True)
code = models.CharField(max_length=50, blank=True)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Exam(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='exams')
date = models.DateField()
name = models.CharField(max_length=200, blank=True) # e.g., "2022-01-15"
folder_path = models.CharField(max_length=500, blank=True) # Path to exam folder in content
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['course', 'date']
ordering = ['-date']
def __str__(self):
return f"{self.course.name} - {self.date}"
class Question(models.Model):
exam = models.ForeignKey(Exam, on_delete=models.CASCADE, related_name='questions', null=True, blank=True)
file_path = models.CharField(max_length=500, unique=True)
text = models.TextField()
correct_answer = models.CharField(max_length=50) # Support multi-select answers like "A,B,C"
file_mtime = models.FloatField(null=True, blank=True) # Track file modification time
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
tags = models.ManyToManyField('Tag', blank=True, related_name='questions')
# Question type field
question_type = models.CharField(
max_length=20,
default='mcq',
choices=[
('mcq', 'Multiple Choice'),
('scq', 'Single Choice'),
('matching', 'Matching'),
('textalternativ', 'Text Alternative'),
('textfält', 'Text Field'),
]
)
# JSON field for matching questions
matching_data = models.JSONField(
null=True,
blank=True,
help_text="JSON data for matching questions: {left_items: [...], top_items: [...], correct_pairs: [[0,1], [1,2], ...]}"
)
def __str__(self):
return self.text[:50]
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
def __str__(self):
return self.name
class Option(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='options')
letter = models.CharField(max_length=1)
text = models.TextField()
class Meta:
unique_together = ['question', 'letter']
def __str__(self):
return f"{self.letter}. {self.text[:30]}"
class QuizSession(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='quiz_sessions')
course = models.ForeignKey(Course, on_delete=models.SET_NULL, null=True, blank=True)
exams = models.ManyToManyField(Exam, blank=True)
tags = models.ManyToManyField(Tag, blank=True)
question_types = models.JSONField(default=list, blank=True) # Store as list of strings
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"Session {self.id} for {self.user}"
class QuizResult(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results')
quiz_session = models.ForeignKey(QuizSession, on_delete=models.CASCADE, related_name='results', null=True, blank=True)
question = models.ForeignKey(Question, on_delete=models.CASCADE)
selected_answer = models.CharField(max_length=1)
is_correct = models.BooleanField()
difficulty = models.CharField(max_length=10, blank=True, null=True, choices=[
('again', 'Again'),
('hard', 'Hard'),
('good', 'Good'),
('easy', 'Easy'),
])
answered_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['user', 'question']
def __str__(self):
return f"{self.user} - {self.question.text[:30]} - {'' if self.is_correct else ''}"

View File

@@ -1,377 +0,0 @@
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from django.db.models import Q
from .models import Question, QuizResult, Tag, Course, Exam, QuizSession
from .forms import CreateQuizForm
@require_http_methods(["POST"])
def close_quiz(request, session_id):
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
session.is_active = False
session.save()
# If it's an HTMX request, return empty response (card will be removed)
if request.headers.get('HX-Request'):
return HttpResponse('')
return redirect('index')
def handle_tag_filter(request):
tag_slug = request.GET.get('tag')
if tag_slug is not None:
if tag_slug == "":
if 'quiz_tag' in request.session:
del request.session['quiz_tag']
else:
request.session['quiz_tag'] = tag_slug
def create_quiz(request):
if request.method == 'POST':
# Handle quick-start tag-based quiz
tag_slug = request.POST.get('tag_slug')
if tag_slug:
from .models import Tag
try:
tag = Tag.objects.get(slug=tag_slug)
course = Course.objects.first() # Get first course
session = QuizSession.objects.create(
user=request.quiz_user,
course=course,
question_types=[]
)
session.tags.set([tag])
return redirect('quiz_mode', session_id=session.id)
except Tag.DoesNotExist:
pass
# Handle custom form-based quiz
form = CreateQuizForm(request.POST)
if form.is_valid():
course = form.cleaned_data.get('course')
exams = form.cleaned_data.get('exams')
tags = form.cleaned_data.get('tags')
q_types = form.cleaned_data.get('question_type')
session = QuizSession.objects.create(
user=request.quiz_user,
course=course,
question_types=q_types if q_types else []
)
if tags:
session.tags.set(tags)
if exams:
session.exams.set(exams)
return redirect('quiz_mode', session_id=session.id)
else:
form = CreateQuizForm()
return render(request, 'quiz_create.html', {'form': form})
def index(request):
active_sessions = QuizSession.objects.filter(user=request.quiz_user, is_active=True)
total_questions = Question.objects.count()
answered_count = QuizResult.objects.filter(user=request.quiz_user).count()
context = {
'total_questions': total_questions,
'answered_count': answered_count,
'active_sessions': active_sessions,
'form': CreateQuizForm(), # Include form on landing page
}
return render(request, 'index.html', context)
def quiz_mode(request, session_id):
"""Dedicated quiz mode view"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user, is_active=True)
total_questions = get_session_questions(session).count()
return render(request, 'quiz_mode.html', {
'session': session,
'total_questions': total_questions
})
def get_session_questions(session):
"""Helper to get filtered questions for a session"""
questions = Question.objects.all()
if session.course:
questions = questions.filter(exam__course=session.course)
if session.tags.exists():
questions = questions.filter(tags__in=session.tags.all())
if session.exams.exists():
questions = questions.filter(exam__in=session.exams.all())
if session.question_types:
q_objs = Q()
if 'single' in session.question_types:
q_objs |= ~Q(correct_answer__contains=',')
if 'multi' in session.question_types:
q_objs |= Q(correct_answer__contains=',')
if q_objs:
questions = questions.filter(q_objs)
return questions.distinct()
def quiz_question(request, session_id):
"""Get current question in quiz mode"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
# Get all questions for this session
all_questions = get_session_questions(session)
# Get answered questions
answered_ids = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session
).values_list('question_id', flat=True)
# Get unanswered questions
unanswered = all_questions.exclude(id__in=answered_ids)
# Default to first unanswered question, or first question if all answered
if unanswered.exists():
question = unanswered.first()
show_answer = False
else:
# All answered, show first question
question = all_questions.first()
if question:
result = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session,
question=question
).first()
show_answer = result is not None
else:
return render(request, 'partials/complete.html', {'session': session})
# Calculate navigation
all_q_ids = list(all_questions.values_list('id', flat=True))
current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0
current_number = current_index + 1 # 1-based numbering
context = {
'question': question,
'session': session,
'show_answer': show_answer,
'has_previous': current_index > 0,
'has_next': current_index < len(all_q_ids) - 1,
'current_number': current_number,
'total_questions': len(all_q_ids),
}
if show_answer:
result = QuizResult.objects.get(
user=request.quiz_user,
quiz_session=session,
question=question
)
context['is_correct'] = result.is_correct
return render(request, 'partials/quiz_question.html', context)
def navigate_question(request, session_id, direction):
"""Navigate to previous/next question"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
# Get current question from session or query params
current_q_id = request.GET.get('q')
all_questions = get_session_questions(session)
all_q_ids = list(all_questions.values_list('id', flat=True))
if current_q_id:
try:
current_index = all_q_ids.index(int(current_q_id))
except (ValueError, IndexError):
current_index = 0
else:
current_index = 0
# Navigate
if direction == 'previous' and current_index > 0:
new_index = current_index - 1
elif direction == 'next' and current_index < len(all_q_ids) - 1:
new_index = current_index + 1
else:
new_index = current_index
question = all_questions.filter(id=all_q_ids[new_index]).first()
# Check if answered
result = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session,
question=question
).first()
current_number = new_index + 1 # 1-based numbering
context = {
'question': question,
'session': session,
'show_answer': result is not None,
'has_previous': new_index > 0,
'has_next': new_index < len(all_q_ids) - 1,
'current_number': current_number,
'total_questions': len(all_q_ids),
}
if result:
context['is_correct'] = result.is_correct
return render(request, 'partials/quiz_question.html', context)
@require_http_methods(["POST"])
def submit_answer(request, session_id):
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
question_id = request.POST.get('question_id')
selected_answer = request.POST.get('answer')
if not question_id or not selected_answer:
return HttpResponse("Invalid submission", status=400)
try:
question = Question.objects.get(id=question_id)
except Question.DoesNotExist:
return HttpResponse("Question not found", status=404)
# Normalize answers for comparison (sort comma-separated values)
def normalize_answer(ans):
if ',' in ans:
return ','.join(sorted(ans.split(',')))
return ans
is_correct = normalize_answer(selected_answer) == normalize_answer(question.correct_answer)
QuizResult.objects.update_or_create(
user=request.quiz_user,
question=question,
quiz_session=session,
defaults={
'selected_answer': selected_answer,
'is_correct': is_correct,
}
)
# Return the same question but with answer shown
all_questions = get_session_questions(session)
all_q_ids = list(all_questions.values_list('id', flat=True))
current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0
current_number = current_index + 1 # 1-based numbering
context = {
'question': question,
'session': session,
'show_answer': True,
'is_correct': is_correct,
'has_previous': current_index > 0,
'has_next': current_index < len(all_q_ids) - 1,
'current_number': current_number,
'total_questions': len(all_q_ids),
}
return render(request, 'partials/quiz_question.html', context)
@require_http_methods(["POST"])
def submit_difficulty(request, session_id):
"""Record difficulty rating for FSRS"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
question_id = request.POST.get('question_id')
difficulty = request.POST.get('difficulty')
if not question_id or not difficulty:
return HttpResponse("Invalid submission", status=400)
try:
result = QuizResult.objects.get(
user=request.quiz_user,
quiz_session=session,
question_id=question_id
)
result.difficulty = difficulty
result.save()
return HttpResponse("OK")
except QuizResult.DoesNotExist:
return HttpResponse("Result not found", status=404)
def get_next_question(request, session_id):
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
answered_ids = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session
).values_list('question_id', flat=True)
questions = Question.objects.exclude(id__in=answered_ids)
# Apply filters from session
if session.course:
questions = questions.filter(exam__course=session.course)
if session.tags.exists():
questions = questions.filter(tags__in=session.tags.all())
if session.exams.exists():
questions = questions.filter(exam__in=session.exams.all())
if session.question_types:
q_objs = Q()
if 'single' in session.question_types:
q_objs |= ~Q(correct_answer__contains=',')
if 'multi' in session.question_types:
q_objs |= Q(correct_answer__contains=',')
if q_objs:
questions = questions.filter(q_objs)
questions = questions.distinct()
next_question = questions.first()
if not next_question:
return render(request, 'partials/complete.html', {'session': session})
return render(request, 'partials/question.html', {
'question': next_question,
'session': session
})
def stats(request):
results = QuizResult.objects.filter(user=request.quiz_user)
total = results.count()
correct = results.filter(is_correct=True).count()
context = {
'total': total,
'correct': correct,
'percentage': round((correct / total * 100) if total > 0 else 0, 1),
}
return render(request, 'stats.html', context)
def tag_count_api(request, tag_slug):
"""API endpoint to get question count for a tag"""
from django.http import JsonResponse
try:
tag = Tag.objects.get(slug=tag_slug)
count = Question.objects.filter(tags=tag).count()
return JsonResponse({'count': count, 'tag': tag.name})
except Tag.DoesNotExist:
return JsonResponse({'count': 0, 'error': 'Tag not found'}, status=404)

View File

@@ -0,0 +1,4 @@
from .file_admin import FileAdmin
__all__ = ['FileAdmin']

View File

@@ -0,0 +1,88 @@
from django.contrib import admin
from django.utils.html import format_html
from file.models import File
@admin.register(File)
class FileAdmin(admin.ModelAdmin):
"""Admin interface for Files"""
list_display = ['id', 'name_with_icon', 'path_display', 'mime_type_display', 'parent_display', 'children_count', 'created_at']
list_filter = ['mime_type', 'created_at', 'user']
search_fields = ['name', 'path', 'mime_type', 'text']
readonly_fields = ['created_at', 'updated_at', 'text_preview']
fieldsets = [
('File Info', {
'fields': ['name', 'path', 'mime_type', 'parent', 'user']
}),
('Content', {
'fields': ['text_preview', 'external_url'],
'classes': ['collapse']
}),
('Metadata', {
'fields': ['metadata'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def name_with_icon(self, obj):
"""Show name with icon based on mime type"""
icon = '📁' if obj.mime_type == 'application/x-folder' else '📄'
if obj.mime_type.startswith('text/markdown'):
icon = '📝'
elif obj.mime_type.startswith('application/pdf'):
icon = '📕'
elif obj.mime_type.startswith('video/'):
icon = '🎥'
return format_html('{} <strong>{}</strong>', icon, obj.name)
name_with_icon.short_description = 'Name'
def path_display(self, obj):
"""Show path with folder/file distinction"""
if obj.path:
parts = obj.path.split('/')
if len(parts) > 1:
folder_path = '/'.join(parts[:-1])
return format_html('<span style="color: #666;">{}/</span>{}', folder_path, parts[-1])
return obj.path or '-'
path_display.short_description = 'Path'
def mime_type_display(self, obj):
"""Show mime type with color coding"""
color = '#999'
if obj.mime_type == 'application/x-folder':
color = '#3b82f6'
elif obj.mime_type.startswith('text/'):
color = '#10b981'
elif obj.mime_type.startswith('application/pdf'):
color = '#ef4444'
return format_html('<span style="color: {};">{}</span>', color, obj.mime_type)
mime_type_display.short_description = 'MIME Type'
def parent_display(self, obj):
"""Show parent file"""
if obj.parent:
icon = '📁' if obj.parent.mime_type == 'application/x-folder' else '📄'
return format_html('{} {}', icon, obj.parent.name)
return '-'
parent_display.short_description = 'Parent'
def children_count(self, obj):
"""Show number of child files"""
count = obj.children.count()
if count > 0:
return format_html('<span style="color: #3b82f6; font-weight: bold;">{}</span>', count)
return '-'
children_count.short_description = '# Children'
def text_preview(self, obj):
"""Show text content preview"""
if obj.text:
preview = obj.text[:200] + '...' if len(obj.text) > 200 else obj.text
return format_html('<pre style="white-space: pre-wrap;">{}</pre>', preview)
return '-'
text_preview.short_description = 'Text Content'

6
stroma/file/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FileConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'file'

3249
stroma/file/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "stroma-file-editor",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"sass": "^1.83.0",
"tailwindcss": "^4.1.18",
"vite": "^6.0.0"
},
"dependencies": {
"@tiptap/core": "^2.10.3",
"@tiptap/extension-bubble-menu": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/pm": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"remixicon": "^4.5.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,130 @@
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from 'tiptap-markdown';
import Placeholder from '@tiptap/extension-placeholder';
import BubbleMenu from '@tiptap/extension-bubble-menu';
import './styles.css';
import 'remixicon/fonts/remixicon.css';
// DOM Elements
const saveBtn = document.getElementById('save-btn');
const editorContainer = document.getElementById('editor-container');
const bubbleMenuEl = document.getElementById('bubble-menu');
// Editor Instance
let editor;
let currentFileId = null;
// CSRF Helper
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Initialize Editor (only once)
function initEditor() {
// Bubble Menu Buttons
const btnBold = document.getElementById('btn-bold');
const btnItalic = document.getElementById('btn-italic');
const btnStrike = document.getElementById('btn-strike');
const btnCode = document.getElementById('btn-code');
editor = new Editor({
element: document.querySelector('#editor'),
extensions: [
StarterKit,
Markdown,
Placeholder.configure({
placeholder: "Type '/' for commands...",
}),
BubbleMenu.configure({
element: bubbleMenuEl,
tippyOptions: { duration: 100 },
}),
],
content: '',
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg max-w-none focus:outline-none',
},
},
onSelectionUpdate({ editor }) {
btnBold?.classList.toggle('is-active', editor.isActive('bold'));
btnItalic?.classList.toggle('is-active', editor.isActive('italic'));
btnStrike?.classList.toggle('is-active', editor.isActive('strike'));
btnCode?.classList.toggle('is-active', editor.isActive('code'));
}
});
// Bubble Menu Listeners
if (btnBold) btnBold.addEventListener('click', () => editor.chain().focus().toggleBold().run());
if (btnItalic) btnItalic.addEventListener('click', () => editor.chain().focus().toggleItalic().run());
if (btnStrike) btnStrike.addEventListener('click', () => editor.chain().focus().toggleStrike().run());
if (btnCode) btnCode.addEventListener('click', () => editor.chain().focus().toggleCode().run());
// Container Focus
if (editorContainer) {
editorContainer.addEventListener('click', () => {
if (editor && !editor.isFocused) {
editor.chain().focus().run();
}
});
}
// Expose to global scope for SPA usage
window.editorInstance = editor;
window.setEditorFileId = (fileId) => { currentFileId = fileId; };
}
// Save Content
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
if (!editor || !currentFileId) return;
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const markdownContent = editor.storage.markdown.getMarkdown();
const saveUrl = `/file/content/${currentFileId}/save/`;
try {
const response = await fetch(saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ content: markdownContent })
});
if (response.ok) {
saveBtn.textContent = 'Saved!';
setTimeout(() => {
saveBtn.textContent = 'Save Changes';
saveBtn.disabled = false;
}, 1000);
} else {
throw new Error('Save failed');
}
} catch (err) {
alert('Failed to save file');
saveBtn.textContent = 'Save Changes';
saveBtn.disabled = false;
}
});
}
// Initialize editor on load
if (document.querySelector('#editor')) {
initEditor();
}

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import 'remixicon/fonts/remixicon.css';

View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../templates/**/*.html",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
root: 'src',
base: '/static/file/editor_dist/',
build: {
outDir: '../../static/file/editor_dist',
emptyOutDir: true,
manifest: false, // simplified for now
rollupOptions: {
input: path.resolve(__dirname, 'src/editor.js'),
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`
}
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0 on 2025-12-25 13:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('quiz', '0013_delete_file'),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name or filename', max_length=500)),
('path', models.CharField(blank=True, help_text='Path relative to content root', max_length=1000)),
('mime_type', models.CharField(help_text='MIME type of the entity (e.g. application/pdf, application/x-folder)', max_length=100)),
('file_content', models.FileField(blank=True, help_text='Uploaded file content', null=True, upload_to='uploads/')),
('text', models.TextField(blank=True, help_text='Text content, OCR, or embedded query')),
('external_url', models.URLField(blank=True, help_text='External link (e.g. YouTube)')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Frontmatter (created_at, user, etc.)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('parent', models.ForeignKey(blank=True, help_text='Parent folder or parent document (for sidecars/sub-entries)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='file.file')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='quiz.quizuser')),
],
options={
'verbose_name': 'File',
'verbose_name_plural': 'Files',
},
),
]

View File

@@ -0,0 +1,4 @@
from .file_model import File
__all__ = ['File']

View File

@@ -0,0 +1,39 @@
from django.db import models
class File(models.Model):
name = models.CharField(max_length=500, help_text="Display name or filename")
path = models.CharField(max_length=1000, blank=True, help_text="Path relative to content root")
mime_type = models.CharField(max_length=100, help_text="MIME type of the entity (e.g. application/pdf, application/x-folder)")
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children',
help_text="Parent folder or parent document (for sidecars/sub-entries)"
)
# File storage
file_content = models.FileField(upload_to='uploads/', null=True, blank=True, help_text="Uploaded file content")
# Content storage
text = models.TextField(blank=True, help_text="Text content, OCR, or embedded query")
external_url = models.URLField(blank=True, help_text="External link (e.g. YouTube)")
# Metadata
metadata = models.JSONField(default=dict, blank=True, help_text="Frontmatter (created_at, user, etc.)")
# Ownership and house-keeping
user = models.ForeignKey('quiz.QuizUser', on_delete=models.CASCADE, related_name='files')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "File"
verbose_name_plural = "Files"
def __str__(self):
return f"[{self.mime_type}] {self.name}"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stroma File Explorer</title>
{% load static %}
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{% static 'file/editor_dist/assets/editor.css' %}">
<style>
body {
margin: 0;
height: 100vh;
overflow: hidden;
}
.folder-icon::before {
content: '';
display: inline-block;
margin-right: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
transition: transform 0.2s ease;
}
.folder-icon.open::before {
transform: rotate(90deg);
}
.tree-item {
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.15s;
}
.tree-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.tree-item.selected {
background-color: rgba(59, 130, 246, 0.1);
}
.tree-children {
padding-left: 1rem;
display: none;
}
.tree-children.open {
display: block;
}
/* Editor Styles */
.ProseMirror {
outline: none;
padding: 1rem 2rem;
min-height: 100%;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Bubble Menu Styles */
.bubble-menu {
background-color: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
padding: 0.25rem;
gap: 0.25rem;
}
.bubble-menu button {
border: none;
background: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
color: #4a5568;
}
.bubble-menu button:hover,
.bubble-menu button.is-active {
background-color: #edf2f7;
color: #2d3748;
}
</style>
</head>
<body class="bg-gray-50">
<div class="flex h-full">
<!-- Sidebar -->
<div class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-800">Explorer</h2>
<a href="/upload/" class="text-blue-500 text-xs hover:underline">Upload</a>
</div>
<div class="p-2" id="file-tree">
<div class="p-4 text-gray-400 text-sm">Loading structure...</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 flex flex-col bg-white">
<!-- Empty State -->
<div id="empty-state" class="flex flex-col items-center justify-center h-full text-gray-400">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p>Select a file to view or edit</p>
</div>
<!-- Markdown Editor -->
<div id="markdown-container" class="flex-1 flex-col hidden">
<!-- Header -->
<div class="bg-white border-b border-gray-200 px-4 py-3 flex justify-between items-center shrink-0 z-10">
<h3 id="markdown-title" class="text-sm font-medium text-gray-700 truncate mr-4"></h3>
<button id="save-btn"
class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 px-4 rounded transition">
Save Changes
</button>
</div>
<!-- Bubble Menu -->
<div id="bubble-menu" class="bubble-menu hidden">
<button id="btn-bold" class="font-bold">B</button>
<button id="btn-italic" class="italic">i</button>
<button id="btn-strike" class="line-through">S</button>
<button id="btn-code" class="font-mono">&lt;&gt;</button>
</div>
<!-- Editor Container -->
<div class="flex-1 overflow-auto relative cursor-text" id="editor-container">
<div id="editor" class="prose prose-sm sm:prose lg:prose-lg max-w-none h-full"></div>
</div>
</div>
<!-- PDF Viewer -->
<div id="pdf-container" class="flex-1 hidden">
<iframe id="pdf-iframe" class="w-full h-full border-none"></iframe>
</div>
</div>
</div>
<script>
const fileTree = document.getElementById('file-tree');
const emptyState = document.getElementById('empty-state');
const markdownContainer = document.getElementById('markdown-container');
const pdfContainer = document.getElementById('pdf-container');
const pdfIframe = document.getElementById('pdf-iframe');
const markdownTitle = document.getElementById('markdown-title');
let selectedFileId = null;
let selectedElement = null;
let currentFileType = null;
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
async function loadTree() {
try {
const response = await fetch('{% url "file:tree_api" %}');
const structure = await response.json();
fileTree.innerHTML = '';
structure.forEach(item => createTreeItem(item, fileTree));
} catch (err) {
fileTree.innerHTML = '<div class="p-4 text-red-500 text-sm">Failed to load structure</div>';
}
}
function createTreeItem(item, parentElement) {
const itemDiv = document.createElement('div');
if (item.type === 'folder') {
const folderDiv = document.createElement('div');
folderDiv.className = 'tree-item folder-icon flex items-center';
folderDiv.innerHTML = `<span class="truncate">${item.name}</span>`;
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children';
folderDiv.addEventListener('click', (e) => {
e.stopPropagation();
folderDiv.classList.toggle('open');
childrenDiv.classList.toggle('open');
});
itemDiv.appendChild(folderDiv);
itemDiv.appendChild(childrenDiv);
item.children.forEach(child => createTreeItem(child, childrenDiv));
} else {
const fileDiv = document.createElement('div');
fileDiv.className = 'tree-item truncate';
fileDiv.textContent = item.name;
fileDiv.dataset.fileId = item.id;
fileDiv.dataset.filePath = item.path;
fileDiv.dataset.fileName = item.name;
fileDiv.dataset.mimeType = item.mime_type;
fileDiv.addEventListener('click', async (e) => {
e.stopPropagation();
if (selectedElement) selectedElement.classList.remove('selected');
fileDiv.classList.add('selected');
selectedElement = fileDiv;
loadFile(item.id, item.name, item.mime_type, item.path);
});
itemDiv.appendChild(fileDiv);
}
parentElement.appendChild(itemDiv);
}
async function loadFile(id, name, mimeType, path) {
selectedFileId = id;
// Update URL hash with the file path, replacing spaces with underscores
const hashPath = path.replace(/ /g, '_');
window.location.hash = `#${hashPath}`;
// Hide all containers
emptyState.classList.add('hidden');
markdownContainer.classList.add('hidden');
pdfContainer.classList.add('hidden');
if (mimeType === 'application/pdf') {
// Show PDF viewer
currentFileType = 'pdf';
pdfIframe.src = `{% url "file:pdf_viewer" 0 %}`.replace('0', id);
pdfContainer.classList.remove('hidden');
} else {
// Show markdown editor and load content
currentFileType = 'markdown';
markdownTitle.textContent = name;
markdownContainer.classList.remove('hidden');
// Set current file ID for editor
if (window.setEditorFileId) {
window.setEditorFileId(id);
}
// Load content via API
try {
const response = await fetch(`{% url "file:get_content" 0 %}`.replace('0', id));
const data = await response.json();
// Update editor content if editor is initialized
if (window.editorInstance) {
window.editorInstance.commands.setContent(data.content || '');
}
} catch (err) {
console.error('Failed to load file content:', err);
}
}
}
function loadFileFromHash() {
const hash = window.location.hash.substring(1); // Remove #
if (hash) {
// Convert underscores back to spaces to match the actual file path
const filePath = hash.replace(/_/g, ' ');
// Find the file in the tree and load it
const fileElement = findFileElement(filePath);
if (fileElement) {
// Expand all parent folders
expandParentFolders(fileElement);
// Click the file to load it
fileElement.click();
}
}
}
function findFileElement(filePath) {
// Search through all file tree items
const allItems = fileTree.querySelectorAll('.tree-item');
for (let item of allItems) {
// Skip folder items
if (item.classList.contains('folder-icon')) continue;
// Check if this item has the matching file path
if (item.dataset.filePath === filePath) {
return item;
}
}
return null;
}
function expandParentFolders(element) {
let parent = element.parentElement;
while (parent && parent !== fileTree) {
if (parent.classList.contains('tree-children')) {
parent.classList.add('open');
// Find the folder div and add open class to it
const folderDiv = parent.previousElementSibling;
if (folderDiv && folderDiv.classList.contains('folder-icon')) {
folderDiv.classList.add('open');
}
}
parent = parent.parentElement;
}
}
// Initialize
loadTree().then(() => {
// After tree is loaded, check for hash and load file if present
loadFileFromHash();
});
// Listen for hash changes (e.g., browser back/forward)
window.addEventListener('hashchange', loadFileFromHash);
</script>
<!-- Load editor module after DOM is ready -->
<script type="module" src="{% static 'file/editor_dist/assets/editor.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Editor</title>
<link rel="stylesheet" href="{% static 'file/editor_dist/assets/editor.css' %}">
<style>
body {
margin: 0;
height: 100vh;
overflow: auto;
background-color: #ffffff;
}
/* Editor Styles */
.ProseMirror {
outline: none;
padding: 1rem 2rem;
min-height: 100%;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Basic Menu Styles (will be enhanced) */
.bubble-menu {
background-color: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
padding: 0.25rem;
gap: 0.25rem;
}
.bubble-menu button {
border: none;
background: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
color: #4a5568;
}
.bubble-menu button:hover,
.bubble-menu button.is-active {
background-color: #edf2f7;
color: #2d3748;
}
</style>
</head>
<body class="flex flex-col h-full">
<!-- Configuration -->
<div id="editor-config" data-get-url="{{ get_content_url }}" data-save-url="{{ save_content_url }}" class="hidden">
</div>
<!-- Header -->
<div class="bg-white border-b border-gray-200 px-4 py-3 flex justify-between items-center shrink-0 z-10">
<h3 class="text-sm font-medium text-gray-700 truncate mr-4">{{ file_name }}</h3>
<button id="save-btn"
class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 px-4 rounded transition">
Save Changes
</button>
</div>
<!-- Bubble Menu -->
<div id="bubble-menu" class="bubble-menu hidden">
<button id="btn-bold" class="font-bold">B</button>
<button id="btn-italic" class="italic">i</button>
<button id="btn-strike" class="line-through">S</button>
<button id="btn-code" class="font-mono">&lt;&gt;</button>
</div>
<!-- Editor Container -->
<div class="flex-1 overflow-auto relative cursor-text" id="editor-container">
<div id="editor" class="prose prose-sm sm:prose lg:prose-lg max-w-none h-full"></div>
</div>
<!-- Modules -->
<script type="module" src="{% static 'file/editor_dist/assets/editor.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDF Viewer - {{ file_name }}</title>
<style>
body,
html {
margin: 0;
padding: 0;
height: 100%;
overflow: auto;
background-color: #525659;
}
</style>
</head>
<body>
<embed src="{{ pdf_url }}" type="application/pdf" width="100%" height="100%" title="Embedded PDF Viewer" />
</body>
</html>

View File

@@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Files</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 40px;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
color: #2d3748;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #718096;
margin-bottom: 30px;
font-size: 14px;
}
.upload-section {
margin-bottom: 25px;
}
.upload-actions {
margin-top: 20px;
display: flex;
gap: 15px;
justify-content: center;
align-items: center;
}
.upload-btn-select {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.upload-btn-select:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.upload-zone {
border: 2px dashed #cbd5e0;
border-radius: 12px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f7fafc;
}
.upload-zone:hover {
border-color: #667eea;
background: #edf2f7;
}
.upload-zone.dragover {
border-color: #667eea;
background: #e6f2ff;
}
.upload-icon {
font-size: 48px;
margin-bottom: 15px;
color: #a0aec0;
}
.upload-text {
color: #4a5568;
font-size: 16px;
margin-bottom: 8px;
}
.upload-hint {
color: #a0aec0;
font-size: 13px;
}
input[type="file"] {
display: none;
}
.file-list {
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
}
.file-item {
background: #f7fafc;
padding: 12px 15px;
border-radius: 8px;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.file-name {
color: #2d3748;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-path {
color: #a0aec0;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
color: #718096;
font-size: 12px;
margin-left: 10px;
}
.upload-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 30px;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
width: 100%;
margin-top: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.upload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
}
.upload-btn:active {
transform: translateY(0);
}
.upload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.progress-container {
margin-top: 20px;
display: none;
}
.progress-bar {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
margin-top: 10px;
color: #4a5568;
font-size: 14px;
}
.success-message {
background: #c6f6d5;
color: #22543d;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
animation: slideIn 0.3s ease;
}
.error-message {
background: #fed7d7;
color: #742a2a;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
animation: slideIn 0.3s ease;
}
</style>
</head>
<body>
<div class="container">
<h1>📁 Upload Content</h1>
<p class="subtitle">Drag & drop files or folders, or use the buttons below</p>
<div class="upload-section">
<div class="upload-zone" id="uploadZone">
<div class="upload-icon">📤</div>
<div class="upload-text">Drag & drop here</div>
<div class="upload-hint">or click to browse:</div>
<div class="upload-actions">
<button type="button" class="upload-btn-select" id="selectFilesBtn">Select Files</button>
<button type="button" class="upload-btn-select" id="selectFolderBtn">Select Folder</button>
</div>
</div>
<input type="file" id="fileInput" multiple>
<input type="file" id="folderInput" webkitdirectory directory>
</div>
<div class="file-list" id="fileList"></div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">Uploading...</div>
</div>
<div class="success-message" id="successMessage">
✓ Files uploaded successfully!
</div>
<div class="error-message" id="errorMessage"></div>
</div>
<script>
const fileInput = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const uploadZone = document.getElementById('uploadZone');
const selectFilesBtn = document.getElementById('selectFilesBtn');
const selectFolderBtn = document.getElementById('selectFolderBtn');
const fileList = document.getElementById('fileList');
const fileCount = document.getElementById('fileCount');
const progressContainer = document.getElementById('progressContainer');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const successMessage = document.getElementById('successMessage');
const errorMessage = document.getElementById('errorMessage');
let selectedFiles = [];
// Manual selection buttons
selectFilesBtn.addEventListener('click', () => fileInput.click());
selectFolderBtn.addEventListener('click', () => folderInput.click());
// Upload zone click (default to file selection)
uploadZone.addEventListener('click', (e) => {
// Only trigger if clicking the zone itself, not the buttons
if (e.target.tagName !== 'BUTTON') {
fileInput.click();
}
});
// File selection
fileInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
});
folderInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
});
// Drag and drop
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
addFiles(files);
});
function addFiles(files) {
selectedFiles = [...selectedFiles, ...files];
renderFileList();
if (selectedFiles.length > 0) {
startUpload();
}
}
function renderFileList() {
fileList.innerHTML = '';
selectedFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'file-item';
const info = document.createElement('div');
info.className = 'file-info';
const name = document.createElement('div');
name.className = 'file-name';
name.textContent = file.name;
const path = document.createElement('div');
path.className = 'file-path';
path.textContent = file.webkitRelativePath || file.name;
info.appendChild(name);
if (file.webkitRelativePath) {
info.appendChild(path);
}
const size = document.createElement('div');
size.className = 'file-size';
size.textContent = formatFileSize(file.size);
item.appendChild(info);
item.appendChild(size);
fileList.appendChild(item);
});
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async function startUpload() {
if (selectedFiles.length === 0) return;
progressContainer.style.display = 'block';
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
const formData = new FormData();
selectedFiles.forEach((file, index) => {
formData.append('files', file);
if (file.webkitRelativePath) {
formData.append(`path_${index}`, file.webkitRelativePath);
}
});
try {
const response = await fetch('/file/upload/api/', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
});
if (response.ok) {
const result = await response.json();
progressFill.style.width = '100%';
progressText.textContent = `Uploaded ${result.count} file(s) successfully!`;
setTimeout(() => {
successMessage.style.display = 'block';
progressContainer.style.display = 'none';
selectedFiles = [];
renderFileList();
progressFill.style.width = '0%';
}, 500);
} else {
throw new Error('Upload failed');
}
} catch (error) {
errorMessage.textContent = '✗ Upload failed: ' + error.message;
errorMessage.style.display = 'block';
progressContainer.style.display = 'none';
}
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
</script>
</body>
</html>

21
stroma/file/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from file.views import (
upload_files_page, upload_files_api, explorer_view, pdf_viewer_page,
markdown_editor_page, get_file_tree, get_file_content, save_file_content,
serve_pdf_api
)
app_name = 'file'
urlpatterns = [
path('upload/', upload_files_page, name='upload_page'),
path('upload/api/', upload_files_api, name='upload_api'),
path('explorer/', explorer_view, name='explorer'),
path('viewer/pdf/<int:file_id>/', pdf_viewer_page, name='pdf_viewer'),
path('viewer/markdown/<int:file_id>/', markdown_editor_page, name='markdown_editor'),
path('tree/', get_file_tree, name='tree_api'),
path('content/<int:file_id>/', get_file_content, name='get_content'),
path('content/<int:file_id>/save/', save_file_content, name='save_content'),
path('pdf/<int:file_id>/', serve_pdf_api, name='serve_pdf'),
]

View File

@@ -0,0 +1,18 @@
from .upload_files_page_view import upload_files_page
from .upload_files_api_view import upload_files_api
from .explorer_view import explorer_view, pdf_viewer_page, markdown_editor_page
from .tree_api_view import get_file_tree
from .content_api_view import get_file_content, save_file_content, serve_pdf_api
__all__ = [
'upload_files_page',
'upload_files_api',
'explorer_view',
'pdf_viewer_page',
'markdown_editor_page',
'get_file_tree',
'get_file_content',
'save_file_content',
'serve_pdf_api',
]

View File

@@ -0,0 +1,48 @@
import json
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.shortcuts import get_object_or_404
from file.models import File
@require_http_methods(["GET"])
def get_file_content(request, file_id):
"""Get the text content of a specific file."""
file_obj = get_object_or_404(File, id=file_id, user=request.quiz_user)
return JsonResponse({
'id': file_obj.id,
'name': file_obj.name,
'content': file_obj.text
})
@require_http_methods(["POST"])
def save_file_content(request, file_id):
"""Save updated text content to a file."""
file_obj = get_object_or_404(File, id=file_id, user=request.quiz_user)
try:
data = json.loads(request.body)
new_content = data.get('content', '')
file_obj.text = new_content
file_obj.save()
return JsonResponse({'success': True})
except (json.JSONDecodeError, KeyError):
return JsonResponse({'success': False, 'error': 'Invalid data'}, status=400)
from django.http import FileResponse
def serve_pdf_api(request, file_id):
"""Serve the raw PDF file for viewing."""
file_obj = get_object_or_404(File, id=file_id, user=request.quiz_user)
if not file_obj.mime_type == 'application/pdf' or not file_obj.file_content:
return JsonResponse({'error': 'Not a PDF file'}, status=400)
response = FileResponse(
file_obj.file_content.open('rb'),
content_type='application/pdf'
)
# Add CORS headers to allow CDN-hosted PDF.js viewer to fetch the PDF
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Content-Type'
return response

View File

@@ -0,0 +1,32 @@
from django.shortcuts import render
def explorer_view(request):
"""Render the file explorer page."""
return render(request, 'file/explorer.html')
def pdf_viewer_page(request, file_id):
"""Render the PDF viewer template for a specific file."""
from django.urls import reverse
from file.models import File
file_obj = File.objects.get(id=file_id, user=request.quiz_user)
relative_url = reverse('file:serve_pdf', args=[file_id])
# Build absolute URL for PDF.js library
pdf_url = request.build_absolute_uri(relative_url)
return render(request, 'file/pdf_viewer.html', {
'pdf_url': pdf_url,
'file_name': file_obj.name
})
def markdown_editor_page(request, file_id):
"""Render the Markdown editor template for a specific file."""
from django.urls import reverse
from file.models import File
file_obj = File.objects.get(id=file_id, user=request.quiz_user)
context = {
'file_id': file_id,
'file_name': file_obj.name,
'get_content_url': reverse('file:get_content', args=[file_id]),
'save_content_url': reverse('file:save_content', args=[file_id]),
}
return render(request, 'file/markdown_editor.html', context)

View File

@@ -0,0 +1,31 @@
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from file.models import File
@require_http_methods(["GET"])
def get_file_tree(request):
"""Return the hierarchical file tree for the user."""
files = File.objects.filter(user=request.quiz_user).select_related('parent').order_by('name')
# Create a mapping of id -> item
item_map = {}
for f in files:
item_map[f.id] = {
'id': f.id,
'name': f.name,
'path': f.path,
'type': 'folder' if f.mime_type == 'application/x-folder' else 'file',
'mime_type': f.mime_type,
'children': [],
'content': f.text if f.mime_type.startswith('text/') else None
}
root_items = []
for f in files:
item = item_map[f.id]
if f.parent_id:
if f.parent_id in item_map:
item_map[f.parent_id]['children'].append(item)
else:
root_items.append(item)
return JsonResponse(root_items, safe=False)

View File

@@ -0,0 +1,101 @@
import os
import mimetypes
from pathlib import Path
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from file.models import File
@require_http_methods(["POST"])
def upload_files_api(request):
"""Handle file/folder uploads and create File model instances"""
uploaded_files = request.FILES.getlist('files')
if not uploaded_files:
return JsonResponse({'error': 'No files uploaded'}, status=400)
created_files = []
folder_cache = {} # Cache for created folder objects
for idx, uploaded_file in enumerate(uploaded_files):
# Get the relative path if it exists (from webkitRelativePath)
relative_path = request.POST.get(f'path_{idx}', '')
if relative_path:
# This is from a folder upload
path_obj = Path(relative_path)
parts = path_obj.parts
# Create parent folders if needed
parent = None
for i, part in enumerate(parts[:-1]): # Exclude the file itself
folder_path = os.path.join(*parts[:i+1])
if folder_path not in folder_cache:
# Create or get folder
folder, created = File.objects.get_or_create(
user=request.quiz_user,
path=folder_path,
defaults={
'name': part,
'mime_type': 'application/x-folder',
'parent': parent
}
)
folder_cache[folder_path] = folder
parent = folder_cache[folder_path]
file_path = relative_path
file_name = parts[-1]
else:
# Single file upload
file_path = uploaded_file.name
file_name = uploaded_file.name
parent = None
# Determine MIME type
mime_type, _ = mimetypes.guess_type(file_name)
if not mime_type:
mime_type = 'application/octet-stream'
# Read file content (for text files, store in text field)
text_content = ''
if mime_type.startswith('text/'):
try:
content_bytes = uploaded_file.read()
text_content = content_bytes.decode('utf-8')
uploaded_file.seek(0) # Reset for saving to disk
except (UnicodeDecodeError, AttributeError):
uploaded_file.seek(0)
# Generate unique filename with 8-digit hash
import hashlib
file_hash = hashlib.md5(f"{file_path}{uploaded_file.name}".encode()).hexdigest()[:8]
name_parts = os.path.splitext(file_name)
unique_filename = f"{name_parts[0]}_{file_hash}{name_parts[1]}"
# Create File instance
file_obj = File.objects.create(
user=request.quiz_user,
name=file_name,
path=file_path,
mime_type=mime_type,
parent=parent,
text=text_content
)
# Save the uploaded file to disk (not for folders)
if mime_type != 'application/x-folder':
file_obj.file_content.save(unique_filename, uploaded_file, save=True)
created_files.append(file_obj)
return JsonResponse({
'success': True,
'count': len(created_files),
'files': [{'name': f.name, 'path': f.path} for f in created_files]
})

View File

@@ -0,0 +1,7 @@
from django.shortcuts import render
def upload_files_page(request):
"""Render the file upload interface"""
return render(request, 'file/upload_files.html')

View File

@@ -0,0 +1,18 @@
from .course_admin import CourseAdmin
from .exam_admin import ExamAdmin
from .option_inline import OptionInline
from .question_admin import QuestionAdmin
from .option_admin import OptionAdmin
from .quiz_user_admin import QuizUserAdmin
from .quiz_result_admin import QuizResultAdmin
__all__ = [
'CourseAdmin',
'ExamAdmin',
'OptionInline',
'QuestionAdmin',
'OptionAdmin',
'QuizUserAdmin',
'QuizResultAdmin',
]

View File

@@ -0,0 +1,16 @@
from django.contrib import admin
from quiz.models import Course
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
"""Admin interface for Courses"""
list_display = ['id', 'name', 'code', 'exam_count', 'created_at']
search_fields = ['name', 'code']
readonly_fields = ['created_at']
def exam_count(self, obj):
"""Show number of exams"""
return obj.exams.count()
exam_count.short_description = '# Exams'

View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from quiz.models import Exam
@admin.register(Exam)
class ExamAdmin(admin.ModelAdmin):
"""Admin interface for Exams"""
list_display = ['id', 'course', 'date', 'question_count', 'folder_path', 'created_at']
list_filter = ['course', 'date']
search_fields = ['name', 'folder_path']
readonly_fields = ['created_at']
def question_count(self, obj):
"""Show number of questions"""
return obj.questions.count()
question_count.short_description = '# Questions'

View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from django.utils.html import format_html
from quiz.models import Option
@admin.register(Option)
class OptionAdmin(admin.ModelAdmin):
"""Admin interface for Options"""
list_display = ['id', 'question_preview', 'letter', 'text_preview', 'is_correct']
list_filter = ['letter']
search_fields = ['text', 'question__text']
readonly_fields = ['question']
def question_preview(self, obj):
"""Show question preview"""
return obj.question.text[:40] + '...'
question_preview.short_description = 'Question'
def text_preview(self, obj):
"""Show option text preview"""
return obj.text[:50] + '...' if len(obj.text) > 50 else obj.text
text_preview.short_description = 'Option Text'
def is_correct(self, obj):
"""Highlight if this is the correct answer"""
if obj.question.correct_answer and obj.letter in obj.question.correct_answer:
return format_html('<span style="color: green; font-weight: bold;">✓ Correct</span>')
return format_html('<span style="color: #999;">-</span>')
is_correct.short_description = 'Status'

View File

@@ -0,0 +1,11 @@
from django.contrib import admin
from quiz.models import Option
class OptionInline(admin.TabularInline):
"""Inline admin for question options"""
model = Option
extra = 0
fields = ['letter', 'text']
ordering = ['letter']

View File

@@ -0,0 +1,58 @@
from django.contrib import admin
from django.utils.html import format_html
from quiz.models import Question
from .option_inline import OptionInline
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
"""Admin interface for Questions"""
list_display = ['id', 'question_preview', 'exam', 'correct_answer', 'option_count', 'file_source', 'updated_at']
list_filter = ['exam__course', 'exam', 'created_at', 'updated_at']
search_fields = ['text', 'file_path', 'correct_answer']
readonly_fields = ['file_path', 'file_mtime', 'created_at', 'updated_at', 'formatted_mtime']
fieldsets = [
('Question Content', {
'fields': ['exam', 'text', 'correct_answer']
}),
('File Tracking', {
'fields': ['file_path', 'file_mtime', 'formatted_mtime'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
inlines = [OptionInline]
def question_preview(self, obj):
"""Show question text preview"""
return obj.text[:60] + '...' if len(obj.text) > 60 else obj.text
question_preview.short_description = 'Question'
def option_count(self, obj):
"""Show number of options"""
return obj.options.count()
option_count.short_description = '# Options'
def file_source(self, obj):
"""Show file path with folder highlight"""
if obj.file_path:
parts = obj.file_path.split('/')
if len(parts) > 1:
folder = parts[-2]
filename = parts[-1]
return format_html('<span style="color: #666;">{}/</span><strong>{}</strong>', folder, filename)
return obj.file_path or '-'
file_source.short_description = 'Source File'
def formatted_mtime(self, obj):
"""Show formatted modification time"""
if obj.file_mtime:
from datetime import datetime
dt = datetime.fromtimestamp(obj.file_mtime)
return dt.strftime('%Y-%m-%d %H:%M:%S')
return '-'
formatted_mtime.short_description = 'File Modified'

View File

@@ -0,0 +1,35 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
from quiz.models import QuizResult
@admin.register(QuizResult)
class QuizResultAdmin(admin.ModelAdmin):
"""Admin interface for Quiz Results"""
list_display = ['id', 'user_preview', 'question_preview', 'selected_answer', 'correct_answer', 'result_status', 'answered_at']
list_filter = ['is_correct', 'answered_at']
search_fields = ['user__session_key', 'question__text']
readonly_fields = ['user', 'question', 'selected_answer', 'is_correct', 'answered_at']
def user_preview(self, obj):
"""Show user session preview"""
return f"{obj.user.session_key[:8]}..."
user_preview.short_description = 'User'
def question_preview(self, obj):
"""Show question preview"""
return obj.question.text[:40] + '...'
question_preview.short_description = 'Question'
def correct_answer(self, obj):
"""Show correct answer"""
return obj.question.correct_answer
correct_answer.short_description = 'Correct'
def result_status(self, obj):
"""Show visual result status"""
if obj.is_correct:
return mark_safe('<span style="color: green; font-weight: bold;">✓ Correct</span>')
return mark_safe('<span style="color: red; font-weight: bold;">✗ Wrong</span>')
result_status.short_description = 'Result'

View File

@@ -0,0 +1,46 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
from quiz.models import QuizUser
@admin.register(QuizUser)
class QuizUserAdmin(admin.ModelAdmin):
"""Admin interface for Quiz Users"""
list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at']
list_filter = ['created_at']
search_fields = ['session_key']
readonly_fields = ['session_key', 'created_at', 'full_session_key']
fieldsets = [
('User Info', {
'fields': ['full_session_key', 'created_at']
}),
]
def session_preview(self, obj):
"""Show session key preview"""
return f"{obj.session_key[:12]}..."
session_preview.short_description = 'Session'
def result_count(self, obj):
"""Show number of quiz results"""
return obj.results.count()
result_count.short_description = '# Answers'
def score_percentage(self, obj):
"""Show score percentage"""
total = obj.results.count()
if total == 0:
return '-'
correct = obj.results.filter(is_correct=True).count()
percentage = (correct / total * 100)
color = 'green' if percentage >= 70 else 'orange' if percentage >= 50 else 'red'
return mark_safe(
f'<span style="color: {color}; font-weight: bold;">{percentage:.1f}%</span> ({correct}/{total})'
)
score_percentage.short_description = 'Score'
def full_session_key(self, obj):
"""Show full session key"""
return obj.session_key
full_session_key.short_description = 'Full Session Key'

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0 on 2025-12-25 11:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('quiz', '0010_add_matching_question_fields'),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name or filename', max_length=500)),
('path', models.CharField(blank=True, help_text='Path relative to content root', max_length=1000)),
('mime_type', models.CharField(help_text='MIME type of the entity (e.g. application/pdf, application/x-folder)', max_length=100)),
('text', models.TextField(blank=True, help_text='Text content, OCR, or embedded query')),
('external_url', models.URLField(blank=True, help_text='External link (e.g. YouTube)')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Frontmatter (created_at, user, etc.)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('parent', models.ForeignKey(blank=True, help_text='Parent folder or parent document (for sidecars/sub-entries)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='quiz.file')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='quiz.quizuser')),
],
options={
'verbose_name': 'File',
'verbose_name_plural': 'Files',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-25 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('quiz', '0011_file'),
]
operations = [
migrations.AddField(
model_name='file',
name='file_content',
field=models.FileField(blank=True, help_text='Uploaded file content', null=True, upload_to='uploads/'),
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 6.0 on 2025-12-25 13:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('quiz', '0012_file_file_content'),
]
operations = [
migrations.DeleteModel(
name='File',
),
]

View File

@@ -0,0 +1,22 @@
from .quiz_user_model import QuizUser
from .course_model import Course
from .exam_model import Exam
from .tag_model import Tag
from .question_model import Question
from .option_model import Option
from .quiz_session_model import QuizSession
from .quiz_result_model import QuizResult
__all__ = [
'QuizUser',
'Course',
'Exam',
'Tag',
'Question',
'Option',
'QuizSession',
'QuizResult',
]

View File

@@ -0,0 +1,12 @@
from django.db import models
class Course(models.Model):
name = models.CharField(max_length=200, unique=True)
code = models.CharField(max_length=50, blank=True)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name

View File

@@ -0,0 +1,18 @@
from django.db import models
from .course_model import Course
class Exam(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='exams')
date = models.DateField()
name = models.CharField(max_length=200, blank=True) # e.g., "2022-01-15"
folder_path = models.CharField(max_length=500, blank=True) # Path to exam folder in content
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['course', 'date']
ordering = ['-date']
def __str__(self):
return f"{self.course.name} - {self.date}"

View File

@@ -0,0 +1,15 @@
from django.db import models
from .question_model import Question
class Option(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='options')
letter = models.CharField(max_length=1)
text = models.TextField()
class Meta:
unique_together = ['question', 'letter']
def __str__(self):
return f"{self.letter}. {self.text[:30]}"

View File

@@ -0,0 +1,38 @@
from django.db import models
from .exam_model import Exam
from .tag_model import Tag
class Question(models.Model):
exam = models.ForeignKey(Exam, on_delete=models.CASCADE, related_name='questions', null=True, blank=True)
file_path = models.CharField(max_length=500, unique=True)
text = models.TextField()
correct_answer = models.CharField(max_length=50) # Support multi-select answers like "A,B,C"
file_mtime = models.FloatField(null=True, blank=True) # Track file modification time
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
tags = models.ManyToManyField(Tag, blank=True, related_name='questions')
# Question type field
question_type = models.CharField(
max_length=20,
default='mcq',
choices=[
('mcq', 'Multiple Choice'),
('scq', 'Single Choice'),
('matching', 'Matching'),
('textalternativ', 'Text Alternative'),
('textfält', 'Text Field'),
]
)
# JSON field for matching questions
matching_data = models.JSONField(
null=True,
blank=True,
help_text="JSON data for matching questions: {left_items: [...], top_items: [...], correct_pairs: [[0,1], [1,2], ...]}"
)
def __str__(self):
return self.text[:50]

View File

@@ -0,0 +1,26 @@
from django.db import models
from .quiz_user_model import QuizUser
from .quiz_session_model import QuizSession
from .question_model import Question
class QuizResult(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results')
quiz_session = models.ForeignKey(QuizSession, on_delete=models.CASCADE, related_name='results', null=True, blank=True)
question = models.ForeignKey(Question, on_delete=models.CASCADE)
selected_answer = models.CharField(max_length=1)
is_correct = models.BooleanField()
difficulty = models.CharField(max_length=10, blank=True, null=True, choices=[
('again', 'Again'),
('hard', 'Hard'),
('good', 'Good'),
('easy', 'Easy'),
])
answered_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['user', 'question']
def __str__(self):
return f"{self.user} - {self.question.text[:30]} - {'' if self.is_correct else ''}"

View File

@@ -0,0 +1,22 @@
from django.db import models
from .quiz_user_model import QuizUser
from .course_model import Course
from .exam_model import Exam
from .tag_model import Tag
class QuizSession(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='quiz_sessions')
course = models.ForeignKey(Course, on_delete=models.SET_NULL, null=True, blank=True)
exams = models.ManyToManyField(Exam, blank=True)
tags = models.ManyToManyField(Tag, blank=True)
question_types = models.JSONField(default=list, blank=True) # Store as list of strings
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"Session {self.id} for {self.user}"

View File

@@ -0,0 +1,14 @@
from django.db import models
class QuizUser(models.Model):
session_key = models.CharField(max_length=40, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Quiz User"
verbose_name_plural = "Quiz Users"
def __str__(self):
return f"User {self.session_key[:8]}"

View File

@@ -0,0 +1,10 @@
from django.db import models
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
def __str__(self):
return self.name

View File

@@ -1,17 +1,16 @@
from django.contrib import admin
from django.urls import path
from .views import (
from quiz.views import (
index, get_next_question, submit_answer, stats, create_quiz, close_quiz,
quiz_mode, quiz_question, navigate_question, submit_difficulty, tag_count_api
)
app_name = 'quiz'
urlpatterns = [
path('admin/', admin.site.urls),
path('', index, name='index'),
path('quiz/<int:session_id>/', quiz_mode, name='quiz_mode'),
path('quiz/<int:session_id>/question/', quiz_question, name='quiz_question'),
path('quiz/<int:session_id>/<str:direction>/', navigate_question, name='navigate_question'),
path('session/<int:session_id>/', quiz_mode, name='quiz_mode'),
path('session/<int:session_id>/question/', quiz_question, name='quiz_question'),
path('session/<int:session_id>/<str:direction>/', navigate_question, name='navigate_question'),
path('next/<int:session_id>/', get_next_question, name='next_question'),
path('submit/<int:session_id>/', submit_answer, name='submit_answer'),
path('difficulty/<int:session_id>/', submit_difficulty, name='submit_difficulty'),

View File

@@ -6,7 +6,7 @@ from quiz.forms import CreateQuizForm
@pytest.mark.django_db
class TestQuizCreation:
@pytest.fixture(autouse=True)
def setup_data(self, client):
def setup_data(self, client: pytest.FixtureRequest) -> None:
# Clear database to ensure fresh state
Question.objects.all().delete()
Tag.objects.all().delete()
@@ -35,7 +35,7 @@ class TestQuizCreation:
exam=self.exam2, text="UNIQUE_Q2_TEXT", correct_answer="A,B", file_path="path2"
)
def test_create_quiz_form_valid(self):
def test_create_quiz_form_valid(self) -> None:
form_data = {
'course': self.course1.id,
'tags': [self.tag1.id],
@@ -44,8 +44,8 @@ class TestQuizCreation:
form = CreateQuizForm(data=form_data)
assert form.is_valid()
def test_create_quiz_view_post(self):
response = self.client.post(reverse('create_quiz'), {
def test_create_quiz_view_post(self) -> None:
response = self.client.post(reverse('quiz:create_quiz'), {
'course': self.course1.id,
'tags': [self.tag1.id],
'question_type': ['single']
@@ -53,33 +53,33 @@ class TestQuizCreation:
session = QuizSession.objects.get(user=self.user)
assert response.status_code == 302
assert response.url == reverse('quiz_mode', args=[session.id])
assert response.url == reverse('quiz:quiz_mode', args=[session.id])
assert session.course.id == self.course1.id
assert list(session.tags.values_list('id', flat=True)) == [self.tag1.id]
assert session.question_types == ['single']
def test_get_next_question_filters(self):
def test_get_next_question_filters(self) -> None:
session = QuizSession.objects.create(user=self.user, course=self.course1)
response = self.client.get(reverse('next_question', args=[session.id]))
response = self.client.get(reverse('quiz:next_question', args=[session.id]))
assert response.status_code == 200
assert "UNIQUE_Q1_TEXT" in response.content.decode()
# Now change filter to Course 2
session.course = self.course2
session.save()
response = self.client.get(reverse('next_question', args=[session.id]))
response = self.client.get(reverse('quiz:next_question', args=[session.id]))
assert "UNIQUE_Q2_TEXT" in response.content.decode()
def test_filter_by_type(self):
def test_filter_by_type(self) -> None:
session = QuizSession.objects.create(user=self.user, question_types=['multi'])
response = self.client.get(reverse('next_question', args=[session.id]))
response = self.client.get(reverse('quiz:next_question', args=[session.id]))
assert "UNIQUE_Q2_TEXT" in response.content.decode()
session.question_types = ['single']
session.save()
response = self.client.get(reverse('next_question', args=[session.id]))
response = self.client.get(reverse('quiz:next_question', args=[session.id]))
assert "UNIQUE_Q1_TEXT" in response.content.decode()

View File

@@ -63,7 +63,7 @@ class QuizViewsTestCase(TestCase):
def test_index_view(self):
"""Test dashboard index view"""
response = self.client.get(reverse('index'))
response = self.client.get(reverse('quiz:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Snabbstart')
self.assertIn('active_sessions', response.context)
@@ -71,7 +71,7 @@ class QuizViewsTestCase(TestCase):
def test_create_quiz(self):
"""Test quiz creation"""
response = self.client.post(reverse('create_quiz'), {
response = self.client.post(reverse('quiz:create_quiz'), {
'course': self.course.id,
'tags': [self.tag1.id],
})
@@ -90,7 +90,7 @@ class QuizViewsTestCase(TestCase):
course=self.course
)
response = self.client.get(reverse('quiz_mode', args=[session.id]))
response = self.client.get(reverse('quiz:quiz_mode', args=[session.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Test Course')
self.assertIn('session', response.context)
@@ -102,7 +102,7 @@ class QuizViewsTestCase(TestCase):
course=self.course
)
response = self.client.get(reverse('quiz_question', args=[session.id]))
response = self.client.get(reverse('quiz:quiz_question', args=[session.id]))
self.assertEqual(response.status_code, 200)
self.assertIn('question', response.context)
self.assertIn('session', response.context)
@@ -116,14 +116,14 @@ class QuizViewsTestCase(TestCase):
# Test next navigation
response = self.client.get(
reverse('navigate_question', args=[session.id, 'next']),
reverse('quiz:navigate_question', args=[session.id, 'next']),
{'q': self.question1.id}
)
self.assertEqual(response.status_code, 200)
# Test previous navigation
response = self.client.get(
reverse('navigate_question', args=[session.id, 'previous']),
reverse('quiz:navigate_question', args=[session.id, 'previous']),
{'q': self.question2.id}
)
self.assertEqual(response.status_code, 200)
@@ -136,7 +136,7 @@ class QuizViewsTestCase(TestCase):
)
response = self.client.post(
reverse('submit_answer', args=[session.id]),
reverse('quiz:submit_answer', args=[session.id]),
{
'question_id': self.question1.id,
'answer': 'A'
@@ -162,7 +162,7 @@ class QuizViewsTestCase(TestCase):
# Test correct multi-answer (order shouldn't matter)
response = self.client.post(
reverse('submit_answer', args=[session.id]),
reverse('quiz:submit_answer', args=[session.id]),
{
'question_id': self.question2.id,
'answer': 'B,A' # Reversed order
@@ -195,7 +195,7 @@ class QuizViewsTestCase(TestCase):
# Then submit difficulty
response = self.client.post(
reverse('submit_difficulty', args=[session.id]),
reverse('quiz:submit_difficulty', args=[session.id]),
{
'question_id': self.question1.id,
'difficulty': 'easy'
@@ -219,7 +219,7 @@ class QuizViewsTestCase(TestCase):
is_active=True
)
response = self.client.post(reverse('close_quiz', args=[session.id]))
response = self.client.post(reverse('quiz:close_quiz', args=[session.id]))
self.assertEqual(response.status_code, 302) # Redirect to index
# Verify session was deactivated
@@ -235,7 +235,7 @@ class QuizViewsTestCase(TestCase):
)
# Try to access it
response = self.client.get(reverse('quiz_mode', args=[session.id]))
response = self.client.get(reverse('quiz:quiz_mode', args=[session.id]))
self.assertEqual(response.status_code, 404)
def test_answer_without_question_id(self):
@@ -246,7 +246,7 @@ class QuizViewsTestCase(TestCase):
)
response = self.client.post(
reverse('submit_answer', args=[session.id]),
reverse('quiz:submit_answer', args=[session.id]),
{'answer': 'A'} # Missing question_id
)
self.assertEqual(response.status_code, 400)
@@ -259,7 +259,7 @@ class QuizViewsTestCase(TestCase):
)
response = self.client.post(
reverse('submit_difficulty', args=[session.id]),
reverse('quiz:submit_difficulty', args=[session.id]),
{
'question_id': self.question1.id,
'difficulty': 'easy'
@@ -290,7 +290,7 @@ class QuizViewsTestCase(TestCase):
).delete() # Clean up
response = self.client.post(
reverse('submit_answer', args=[session.id]),
reverse('quiz:submit_answer', args=[session.id]),
{
'question_id': self.question2.id,
'answer': answer

11
stroma/quiz/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('quiz/', include('quiz.quiz_urls')),
path('file/', include('file.urls')),
]

View File

View File

@@ -3,6 +3,7 @@ from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Tuple
import django.db.utils
from django.conf import settings
@@ -493,7 +494,7 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, f
return 'imported' if created else 'updated'
except Exception as e:
except (OSError, ValueError, django.db.utils.Error) as e:
stats.errors += 1
print(f"Error importing {file_path}: {e}")
return 'error'
@@ -520,7 +521,7 @@ def delete_question_by_path(file_path: Path):
if deleted_count > 0:
print(f"[Auto-delete] ✓ Deleted question: {file_path.name}")
return deleted_count > 0
except Exception as e:
except (OSError, django.db.utils.Error) as e:
print(f"[Auto-delete] ✗ Error deleting question {file_path}: {e}")
return False

View File

Some files were not shown because too many files have changed in this diff Show More