vault backup: 2025-12-26 02:09:22
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s
This commit is contained in:
45
.github/copilot-instructions.md
vendored
Normal file
45
.github/copilot-instructions.md
vendored
Normal 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 client’s 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.
|
||||||
10
content/.obsidian/workspace.json
vendored
10
content/.obsidian/workspace.json
vendored
@@ -13,13 +13,13 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "z-Tech/Mega-App/Project level design goals.md",
|
"file": "Biokemi/Metabolism/🍕 β-oxidation/Provfrågor.md",
|
||||||
"mode": "source",
|
"mode": "source",
|
||||||
"source": false,
|
"source": false,
|
||||||
"backlinks": false
|
"backlinks": false
|
||||||
},
|
},
|
||||||
"icon": "lucide-file",
|
"icon": "lucide-file",
|
||||||
"title": "Project level design goals"
|
"title": "Provfrågor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -191,10 +191,11 @@
|
|||||||
"agent-client:Open agent client": false
|
"agent-client:Open agent client": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active": "b6de1b6650c09ff3",
|
"active": "ef51d026ab2efaae",
|
||||||
"lastOpenFiles": [
|
"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/Project level design goals.md",
|
||||||
|
"z-Tech/Mega-App/UX.md",
|
||||||
"z-Tech/Quiz-app.md",
|
"z-Tech/Quiz-app.md",
|
||||||
"z-Tech/Mega-App",
|
"z-Tech/Mega-App",
|
||||||
"Slides.pdf.md",
|
"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/14.md",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/13.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/12.md",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md",
|
|
||||||
"attachments/image-48.png",
|
"attachments/image-48.png",
|
||||||
"Biokemi/Plasmidlabb/Articles/Report guidelines 2025.pdf",
|
"Biokemi/Plasmidlabb/Articles/Report guidelines 2025.pdf",
|
||||||
"Biokemi/Plasmidlabb/Protokoll.pdf",
|
"Biokemi/Plasmidlabb/Protokoll.pdf",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
obligatorisk och viktig
|
||||||
BIN
quiz/db.sqlite3
BIN
quiz/db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'
|
|
||||||
|
|
||||||
@@ -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 '✗'}"
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
4
stroma/file/admin/__init__.py
Normal file
4
stroma/file/admin/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .file_admin import FileAdmin
|
||||||
|
|
||||||
|
__all__ = ['FileAdmin']
|
||||||
|
|
||||||
88
stroma/file/admin/file_admin.py
Normal file
88
stroma/file/admin/file_admin.py
Normal 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
6
stroma/file/apps.py
Normal 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
3249
stroma/file/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
stroma/file/frontend/package.json
Normal file
29
stroma/file/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
stroma/file/frontend/postcss.config.js
Normal file
6
stroma/file/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
130
stroma/file/frontend/src/editor.js
Normal file
130
stroma/file/frontend/src/editor.js
Normal 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();
|
||||||
|
}
|
||||||
2
stroma/file/frontend/src/styles.css
Normal file
2
stroma/file/frontend/src/styles.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import 'remixicon/fonts/remixicon.css';
|
||||||
13
stroma/file/frontend/tailwind.config.js
Normal file
13
stroma/file/frontend/tailwind.config.js
Normal 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'),
|
||||||
|
],
|
||||||
|
}
|
||||||
25
stroma/file/frontend/vite.config.js
Normal file
25
stroma/file/frontend/vite.config.js
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
37
stroma/file/migrations/0001_initial.py
Normal file
37
stroma/file/migrations/0001_initial.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
4
stroma/file/models/__init__.py
Normal file
4
stroma/file/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .file_model import File
|
||||||
|
|
||||||
|
__all__ = ['File']
|
||||||
|
|
||||||
39
stroma/file/models/file_model.py
Normal file
39
stroma/file/models/file_model.py
Normal 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}"
|
||||||
|
|
||||||
1
stroma/file/static/file/editor_dist/assets/editor.css
Normal file
1
stroma/file/static/file/editor_dist/assets/editor.css
Normal file
File diff suppressed because one or more lines are too long
124
stroma/file/static/file/editor_dist/assets/editor.js
Normal file
124
stroma/file/static/file/editor_dist/assets/editor.js
Normal file
File diff suppressed because one or more lines are too long
BIN
stroma/file/static/file/editor_dist/assets/remixicon.eot
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.eot
Normal file
Binary file not shown.
9427
stroma/file/static/file/editor_dist/assets/remixicon.svg
Normal file
9427
stroma/file/static/file/editor_dist/assets/remixicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.8 MiB |
BIN
stroma/file/static/file/editor_dist/assets/remixicon.ttf
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.ttf
Normal file
Binary file not shown.
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff
Normal file
Binary file not shown.
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff2
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff2
Normal file
Binary file not shown.
342
stroma/file/templates/file/explorer.html
Normal file
342
stroma/file/templates/file/explorer.html
Normal 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"><></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>
|
||||||
92
stroma/file/templates/file/markdown_editor.html
Normal file
92
stroma/file/templates/file/markdown_editor.html
Normal 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"><></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>
|
||||||
24
stroma/file/templates/file/pdf_viewer.html
Normal file
24
stroma/file/templates/file/pdf_viewer.html
Normal 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>
|
||||||
455
stroma/file/templates/file/upload_files.html
Normal file
455
stroma/file/templates/file/upload_files.html
Normal 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
21
stroma/file/urls.py
Normal 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'),
|
||||||
|
]
|
||||||
|
|
||||||
18
stroma/file/views/__init__.py
Normal file
18
stroma/file/views/__init__.py
Normal 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',
|
||||||
|
]
|
||||||
|
|
||||||
48
stroma/file/views/content_api_view.py
Normal file
48
stroma/file/views/content_api_view.py
Normal 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
|
||||||
32
stroma/file/views/explorer_view.py
Normal file
32
stroma/file/views/explorer_view.py
Normal 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)
|
||||||
31
stroma/file/views/tree_api_view.py
Normal file
31
stroma/file/views/tree_api_view.py
Normal 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)
|
||||||
101
stroma/file/views/upload_files_api_view.py
Normal file
101
stroma/file/views/upload_files_api_view.py
Normal 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]
|
||||||
|
})
|
||||||
|
|
||||||
7
stroma/file/views/upload_files_page_view.py
Normal file
7
stroma/file/views/upload_files_page_view.py
Normal 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')
|
||||||
|
|
||||||
18
stroma/quiz/admin/__init__.py
Normal file
18
stroma/quiz/admin/__init__.py
Normal 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',
|
||||||
|
]
|
||||||
|
|
||||||
16
stroma/quiz/admin/course_admin.py
Normal file
16
stroma/quiz/admin/course_admin.py
Normal 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'
|
||||||
|
|
||||||
17
stroma/quiz/admin/exam_admin.py
Normal file
17
stroma/quiz/admin/exam_admin.py
Normal 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'
|
||||||
|
|
||||||
30
stroma/quiz/admin/option_admin.py
Normal file
30
stroma/quiz/admin/option_admin.py
Normal 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'
|
||||||
|
|
||||||
11
stroma/quiz/admin/option_inline.py
Normal file
11
stroma/quiz/admin/option_inline.py
Normal 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']
|
||||||
|
|
||||||
58
stroma/quiz/admin/question_admin.py
Normal file
58
stroma/quiz/admin/question_admin.py
Normal 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'
|
||||||
|
|
||||||
35
stroma/quiz/admin/quiz_result_admin.py
Normal file
35
stroma/quiz/admin/quiz_result_admin.py
Normal 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'
|
||||||
|
|
||||||
46
stroma/quiz/admin/quiz_user_admin.py
Normal file
46
stroma/quiz/admin/quiz_user_admin.py
Normal 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'
|
||||||
|
|
||||||
34
stroma/quiz/migrations/0011_file.py
Normal file
34
stroma/quiz/migrations/0011_file.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
stroma/quiz/migrations/0012_file_file_content.py
Normal file
18
stroma/quiz/migrations/0012_file_file_content.py
Normal 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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
16
stroma/quiz/migrations/0013_delete_file.py
Normal file
16
stroma/quiz/migrations/0013_delete_file.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
22
stroma/quiz/models/__init__.py
Normal file
22
stroma/quiz/models/__init__.py
Normal 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',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
12
stroma/quiz/models/course_model.py
Normal file
12
stroma/quiz/models/course_model.py
Normal 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
|
||||||
|
|
||||||
18
stroma/quiz/models/exam_model.py
Normal file
18
stroma/quiz/models/exam_model.py
Normal 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}"
|
||||||
|
|
||||||
15
stroma/quiz/models/option_model.py
Normal file
15
stroma/quiz/models/option_model.py
Normal 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]}"
|
||||||
|
|
||||||
38
stroma/quiz/models/question_model.py
Normal file
38
stroma/quiz/models/question_model.py
Normal 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]
|
||||||
|
|
||||||
26
stroma/quiz/models/quiz_result_model.py
Normal file
26
stroma/quiz/models/quiz_result_model.py
Normal 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 '✗'}"
|
||||||
|
|
||||||
22
stroma/quiz/models/quiz_session_model.py
Normal file
22
stroma/quiz/models/quiz_session_model.py
Normal 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}"
|
||||||
|
|
||||||
14
stroma/quiz/models/quiz_user_model.py
Normal file
14
stroma/quiz/models/quiz_user_model.py
Normal 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]}"
|
||||||
|
|
||||||
10
stroma/quiz/models/tag_model.py
Normal file
10
stroma/quiz/models/tag_model.py
Normal 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
|
||||||
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from quiz.views import (
|
||||||
from .views import (
|
|
||||||
index, get_next_question, submit_answer, stats, create_quiz, close_quiz,
|
index, get_next_question, submit_answer, stats, create_quiz, close_quiz,
|
||||||
quiz_mode, quiz_question, navigate_question, submit_difficulty, tag_count_api
|
quiz_mode, quiz_question, navigate_question, submit_difficulty, tag_count_api
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app_name = 'quiz'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
|
||||||
path('', index, name='index'),
|
path('', index, name='index'),
|
||||||
path('quiz/<int:session_id>/', quiz_mode, name='quiz_mode'),
|
path('session/<int:session_id>/', quiz_mode, name='quiz_mode'),
|
||||||
path('quiz/<int:session_id>/question/', quiz_question, name='quiz_question'),
|
path('session/<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>/<str:direction>/', navigate_question, name='navigate_question'),
|
||||||
path('next/<int:session_id>/', get_next_question, name='next_question'),
|
path('next/<int:session_id>/', get_next_question, name='next_question'),
|
||||||
path('submit/<int:session_id>/', submit_answer, name='submit_answer'),
|
path('submit/<int:session_id>/', submit_answer, name='submit_answer'),
|
||||||
path('difficulty/<int:session_id>/', submit_difficulty, name='submit_difficulty'),
|
path('difficulty/<int:session_id>/', submit_difficulty, name='submit_difficulty'),
|
||||||
@@ -6,7 +6,7 @@ from quiz.forms import CreateQuizForm
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestQuizCreation:
|
class TestQuizCreation:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_data(self, client):
|
def setup_data(self, client: pytest.FixtureRequest) -> None:
|
||||||
# Clear database to ensure fresh state
|
# Clear database to ensure fresh state
|
||||||
Question.objects.all().delete()
|
Question.objects.all().delete()
|
||||||
Tag.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"
|
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 = {
|
form_data = {
|
||||||
'course': self.course1.id,
|
'course': self.course1.id,
|
||||||
'tags': [self.tag1.id],
|
'tags': [self.tag1.id],
|
||||||
@@ -44,8 +44,8 @@ class TestQuizCreation:
|
|||||||
form = CreateQuizForm(data=form_data)
|
form = CreateQuizForm(data=form_data)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
def test_create_quiz_view_post(self):
|
def test_create_quiz_view_post(self) -> None:
|
||||||
response = self.client.post(reverse('create_quiz'), {
|
response = self.client.post(reverse('quiz:create_quiz'), {
|
||||||
'course': self.course1.id,
|
'course': self.course1.id,
|
||||||
'tags': [self.tag1.id],
|
'tags': [self.tag1.id],
|
||||||
'question_type': ['single']
|
'question_type': ['single']
|
||||||
@@ -53,33 +53,33 @@ class TestQuizCreation:
|
|||||||
|
|
||||||
session = QuizSession.objects.get(user=self.user)
|
session = QuizSession.objects.get(user=self.user)
|
||||||
assert response.status_code == 302
|
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 session.course.id == self.course1.id
|
||||||
assert list(session.tags.values_list('id', flat=True)) == [self.tag1.id]
|
assert list(session.tags.values_list('id', flat=True)) == [self.tag1.id]
|
||||||
assert session.question_types == ['single']
|
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)
|
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 response.status_code == 200
|
||||||
assert "UNIQUE_Q1_TEXT" in response.content.decode()
|
assert "UNIQUE_Q1_TEXT" in response.content.decode()
|
||||||
|
|
||||||
# Now change filter to Course 2
|
# Now change filter to Course 2
|
||||||
session.course = self.course2
|
session.course = self.course2
|
||||||
session.save()
|
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()
|
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'])
|
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()
|
assert "UNIQUE_Q2_TEXT" in response.content.decode()
|
||||||
|
|
||||||
session.question_types = ['single']
|
session.question_types = ['single']
|
||||||
session.save()
|
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()
|
assert "UNIQUE_Q1_TEXT" in response.content.decode()
|
||||||
@@ -63,7 +63,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
|
|
||||||
def test_index_view(self):
|
def test_index_view(self):
|
||||||
"""Test dashboard index view"""
|
"""Test dashboard index view"""
|
||||||
response = self.client.get(reverse('index'))
|
response = self.client.get(reverse('quiz:index'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Snabbstart')
|
self.assertContains(response, 'Snabbstart')
|
||||||
self.assertIn('active_sessions', response.context)
|
self.assertIn('active_sessions', response.context)
|
||||||
@@ -71,7 +71,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
|
|
||||||
def test_create_quiz(self):
|
def test_create_quiz(self):
|
||||||
"""Test quiz creation"""
|
"""Test quiz creation"""
|
||||||
response = self.client.post(reverse('create_quiz'), {
|
response = self.client.post(reverse('quiz:create_quiz'), {
|
||||||
'course': self.course.id,
|
'course': self.course.id,
|
||||||
'tags': [self.tag1.id],
|
'tags': [self.tag1.id],
|
||||||
})
|
})
|
||||||
@@ -90,7 +90,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
course=self.course
|
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.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Test Course')
|
self.assertContains(response, 'Test Course')
|
||||||
self.assertIn('session', response.context)
|
self.assertIn('session', response.context)
|
||||||
@@ -102,7 +102,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
course=self.course
|
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.assertEqual(response.status_code, 200)
|
||||||
self.assertIn('question', response.context)
|
self.assertIn('question', response.context)
|
||||||
self.assertIn('session', response.context)
|
self.assertIn('session', response.context)
|
||||||
@@ -116,14 +116,14 @@ class QuizViewsTestCase(TestCase):
|
|||||||
|
|
||||||
# Test next navigation
|
# Test next navigation
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse('navigate_question', args=[session.id, 'next']),
|
reverse('quiz:navigate_question', args=[session.id, 'next']),
|
||||||
{'q': self.question1.id}
|
{'q': self.question1.id}
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Test previous navigation
|
# Test previous navigation
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse('navigate_question', args=[session.id, 'previous']),
|
reverse('quiz:navigate_question', args=[session.id, 'previous']),
|
||||||
{'q': self.question2.id}
|
{'q': self.question2.id}
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -136,7 +136,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('submit_answer', args=[session.id]),
|
reverse('quiz:submit_answer', args=[session.id]),
|
||||||
{
|
{
|
||||||
'question_id': self.question1.id,
|
'question_id': self.question1.id,
|
||||||
'answer': 'A'
|
'answer': 'A'
|
||||||
@@ -162,7 +162,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
|
|
||||||
# Test correct multi-answer (order shouldn't matter)
|
# Test correct multi-answer (order shouldn't matter)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('submit_answer', args=[session.id]),
|
reverse('quiz:submit_answer', args=[session.id]),
|
||||||
{
|
{
|
||||||
'question_id': self.question2.id,
|
'question_id': self.question2.id,
|
||||||
'answer': 'B,A' # Reversed order
|
'answer': 'B,A' # Reversed order
|
||||||
@@ -195,7 +195,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
|
|
||||||
# Then submit difficulty
|
# Then submit difficulty
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('submit_difficulty', args=[session.id]),
|
reverse('quiz:submit_difficulty', args=[session.id]),
|
||||||
{
|
{
|
||||||
'question_id': self.question1.id,
|
'question_id': self.question1.id,
|
||||||
'difficulty': 'easy'
|
'difficulty': 'easy'
|
||||||
@@ -219,7 +219,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
is_active=True
|
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
|
self.assertEqual(response.status_code, 302) # Redirect to index
|
||||||
|
|
||||||
# Verify session was deactivated
|
# Verify session was deactivated
|
||||||
@@ -235,7 +235,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Try to access it
|
# 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)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_answer_without_question_id(self):
|
def test_answer_without_question_id(self):
|
||||||
@@ -246,7 +246,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('submit_answer', args=[session.id]),
|
reverse('quiz:submit_answer', args=[session.id]),
|
||||||
{'answer': 'A'} # Missing question_id
|
{'answer': 'A'} # Missing question_id
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
@@ -259,7 +259,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('submit_difficulty', args=[session.id]),
|
reverse('quiz:submit_difficulty', args=[session.id]),
|
||||||
{
|
{
|
||||||
'question_id': self.question1.id,
|
'question_id': self.question1.id,
|
||||||
'difficulty': 'easy'
|
'difficulty': 'easy'
|
||||||
@@ -290,7 +290,7 @@ class QuizViewsTestCase(TestCase):
|
|||||||
).delete() # Clean up
|
).delete() # Clean up
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('submit_answer', args=[session.id]),
|
reverse('quiz:submit_answer', args=[session.id]),
|
||||||
{
|
{
|
||||||
'question_id': self.question2.id,
|
'question_id': self.question2.id,
|
||||||
'answer': answer
|
'answer': answer
|
||||||
11
stroma/quiz/urls.py
Normal file
11
stroma/quiz/urls.py
Normal 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')),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
0
stroma/quiz/utils/__init__.py
Normal file
0
stroma/quiz/utils/__init__.py
Normal file
@@ -3,6 +3,7 @@ from collections import defaultdict
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
import django.db.utils
|
||||||
|
|
||||||
from django.conf import settings
|
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'
|
return 'imported' if created else 'updated'
|
||||||
|
|
||||||
except Exception as e:
|
except (OSError, ValueError, django.db.utils.Error) as e:
|
||||||
stats.errors += 1
|
stats.errors += 1
|
||||||
print(f"Error importing {file_path}: {e}")
|
print(f"Error importing {file_path}: {e}")
|
||||||
return 'error'
|
return 'error'
|
||||||
@@ -520,7 +521,7 @@ def delete_question_by_path(file_path: Path):
|
|||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
print(f"[Auto-delete] ✓ Deleted question: {file_path.name}")
|
print(f"[Auto-delete] ✓ Deleted question: {file_path.name}")
|
||||||
return deleted_count > 0
|
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}")
|
print(f"[Auto-delete] ✗ Error deleting question {file_path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
0
stroma/quiz/utils/tests/__init__.py
Normal file
0
stroma/quiz/utils/tests/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user