Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,31 @@ If upgrading from previous OpenTutorAI installation:
3. Import data into new models (schema may differ)
4. Verify data integrity

### Schema strategy & the teacher section

This project does **not** use a schema-migration framework (e.g. Alembic). On startup the app
calls SQLAlchemy's `create_all()`, which **creates any missing tables** but **never alters an
existing table** (it will not add a column to a table that already exists).

**Teacher section:** it introduces **only new tables** — `classrooms`, `enrollments`,
`invitations`, `guardian_links`, `assignments`, `submissions`, `class_resources`,
`assignment_templates`, `monitor_states`, `monitor_away_events`, `conversations`,
`conversation_participants`, `messages`, `exam_configs`, `exam_sessions`, `exam_violations`. It
adds **no columns to any pre-existing table**. So on a clean existing install these tables are
created automatically on the next start — **no manual step, no loss of existing data**.

**General caveat (any future column change, or a DB that already holds an _older_ version of the
teacher tables):** because there are no migrations, a new/renamed column on an already-created
table won't be applied, and queries against it will fail. On a **development** database, reset it:

```bash
rm -f var/tutorai.db # or the file named by your DATABASE_URL, then restart
```

⚠️ This **deletes all local data** — development/throwaway installs only, never a database with
real user data. The long-term fix is to adopt **Alembic** (versioned, in-place migrations); it is
tracked as a project-wide improvement, independent of the teacher section.

## Known Differences

1. **Authentication**: Now uses JWT tokens instead of open_webui session tokens
Expand Down
29 changes: 29 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,35 @@ class Settings:
except ValueError:
raise ValueError(f"MAX_UPLOAD_SIZE_MB must be a number, got: {_upload_mb}")

# Server-side MIME allowlist for teacher-uploaded class materials / attachments.
# Exact types plus the prefixes below (image/*). Keeps executables, scripts and
# other risky payloads out of the materials library. Tunable via env (comma-list).
#
# NOTE: the broad `text/` prefix was intentionally removed — it admitted
# `text/html`, which combined with an inline download let an uploaded page run
# as stored XSS. Only the safe, non-executable text types below are allowed, and
# downloads are served as `attachment` (never rendered) as a second layer.
ALLOWED_MATERIAL_MIME_PREFIXES = ("image/",)
_default_material_mime = (
"application/pdf,"
"application/msword,"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document,"
"application/vnd.ms-excel,"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,"
"application/vnd.ms-powerpoint,"
"application/vnd.openxmlformats-officedocument.presentationml.presentation,"
"application/vnd.oasis.opendocument.text,"
"application/vnd.oasis.opendocument.spreadsheet,"
"application/vnd.oasis.opendocument.presentation,"
"application/rtf,application/json,application/zip,"
"text/plain,text/csv,text/markdown"
)
ALLOWED_MATERIAL_MIME = frozenset(
t.strip()
for t in os.getenv("ALLOWED_MATERIAL_MIME", _default_material_mime).split(",")
if t.strip()
)

# Vector database / RAG configuration
VECTOR_DB_PATH: str = os.getenv("VECTOR_DB_PATH", "./var/vector_db")
EMBEDDING_MODEL: str = os.getenv(
Expand Down
2 changes: 1 addition & 1 deletion content/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""Learning resource content boundary."""
"""Resources bounded context — class materials + reusable assignment templates."""
40 changes: 40 additions & 0 deletions content/resources/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Resource repositories — data access only (SQLAlchemy, no business logic)."""

from typing import List

from data.models import AssignmentTemplate, ClassResource
from data.repositories import BaseRepository


class ClassResourceRepository(BaseRepository[ClassResource]):
"""Data access for class materials."""

def list_for_classroom(self, classroom_id: str) -> List[ClassResource]:
return (
self.session.query(ClassResource)
.filter(ClassResource.classroom_id == classroom_id)
.order_by(ClassResource.created_at.desc())
.all()
)

def list_for_classrooms(self, classroom_ids: List[str]) -> List[ClassResource]:
if not classroom_ids:
return []
return (
self.session.query(ClassResource)
.filter(ClassResource.classroom_id.in_(classroom_ids))
.order_by(ClassResource.created_at.desc())
.all()
)


class AssignmentTemplateRepository(BaseRepository[AssignmentTemplate]):
"""Data access for assignment templates (teacher-owned)."""

def list_for_owner(self, owner_teacher_id: str) -> List[AssignmentTemplate]:
return (
self.session.query(AssignmentTemplate)
.filter(AssignmentTemplate.owner_teacher_id == owner_teacher_id)
.order_by(AssignmentTemplate.created_at.desc())
.all()
)
211 changes: 211 additions & 0 deletions content/resources/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""Resources service — business logic + authorization (no ORM directly).

Two kinds of thing live here, both metadata-only (blobs stay in `content/files`):
• **class materials** — a file linked to a class; teachers manage, enrolled students read.
• **assignment templates** — teacher-owned, reusable; creating an assignment from one is
an E7 action delegated to the `assignments` context.
"""

import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy.orm import Session

from common.exceptions import AuthorizationError, NotFoundError, ValidationError
from content.files.service import FilesService
from content.resources.repository import (
AssignmentTemplateRepository,
ClassResourceRepository,
)
from data.models import (
AssignmentTemplate,
ClassResource,
Classroom,
Enrollment,
)
from learning.assignments.service import AssignmentsService
from learning.classrooms.repository import ClassroomRepository, EnrollmentRepository


class ResourcesService:
"""Service for class materials + assignment templates."""

def __init__(self, session: Session):
self.session = session
self.materials = ClassResourceRepository(session, ClassResource)
self.templates = AssignmentTemplateRepository(session, AssignmentTemplate)
self.classrooms = ClassroomRepository(session, Classroom)
self.enrollments = EnrollmentRepository(session, Enrollment)
self.files = FilesService(session)
# Create-from-template produces a real assignment in the E7 context.
self.assignments = AssignmentsService(session)

# ── access helpers ───────────────────────────────────────────────────────

def _owned_class(self, classroom_id: str, teacher_id: str) -> Classroom:
room = self.classrooms.get_by_id(classroom_id)
if not room:
raise NotFoundError("Classroom", classroom_id)
if room.teacher_id != teacher_id:
raise AuthorizationError("Not authorized for this classroom")
return room

def _readable_class(self, classroom_id: str, user_id: str) -> Classroom:
"""A class is readable by its teacher OR an enrolled student."""
room = self.classrooms.get_by_id(classroom_id)
if not room:
raise NotFoundError("Classroom", classroom_id)
if room.teacher_id == user_id:
return room
if self.enrollments.get(classroom_id, user_id):
return room
raise AuthorizationError("Not authorized for this classroom")

def _material_dict(self, resource: ClassResource) -> Dict[str, Any]:
"""Material metadata enriched with the linked file's name/type/size."""
file = self.files.get(resource.file_id)
return {
**resource.to_dict(),
"filename": file.filename if file else None,
"content_type": file.content_type if file else None,
"size": file.size if file else None,
}

# ── class materials ──────────────────────────────────────────────────────

def upload_material(
self,
classroom_id: str,
teacher_id: str,
filename: str,
content_type: Optional[str],
contents: bytes,
title: str,
description: Optional[str] = None,
) -> Dict[str, Any]:
self._owned_class(classroom_id, teacher_id)
if not (isinstance(title, str) and title.strip()):
raise ValidationError("title is required")
# Blob storage is delegated to content/files (owned by the uploading teacher).
file = self.files.upload(teacher_id, filename, content_type, contents)
resource = self.materials.create(
id=str(uuid.uuid4()),
classroom_id=classroom_id,
file_id=file.id,
title=title.strip(),
description=(
description.strip() if description and description.strip() else None
),
uploaded_by=teacher_id,
created_at=datetime.utcnow(),
)
return self._material_dict(resource)

def list_materials(self, classroom_id: str, user_id: str) -> List[Dict[str, Any]]:
self._readable_class(classroom_id, user_id)
return [
self._material_dict(r)
for r in self.materials.list_for_classroom(classroom_id)
]

def _owned_material(self, classroom_id: str, rid: str) -> ClassResource:
resource = self.materials.get_by_id(rid)
if not resource or resource.classroom_id != classroom_id:
raise NotFoundError("Resource", rid)
return resource

def read_material(
self, classroom_id: str, rid: str, user_id: str
) -> Tuple[bytes, str, str]:
"""Return (bytes, content_type, filename) for download. Access-checked."""
self._readable_class(classroom_id, user_id)
resource = self._owned_material(classroom_id, rid)
data, content_type = self.files.read_bytes(resource.file_id)
file = self.files.get(resource.file_id)
return data, content_type, (file.filename if file else "download")

def delete_material(self, classroom_id: str, teacher_id: str, rid: str) -> None:
self._owned_class(classroom_id, teacher_id)
resource = self._owned_material(classroom_id, rid)
# Remove the blob too (best-effort) then the metadata link.
try:
self.files.delete(resource.file_id, teacher_id)
except (NotFoundError, AuthorizationError):
pass
self.materials.delete(rid)

# ── flat library (across the teacher's classes) ──────────────────────────

def list_library(self, teacher_id: str) -> Dict[str, Any]:
rooms = self.classrooms.get_by_teacher(teacher_id)
names = {r.id: r.name for r in rooms}
materials = self.materials.list_for_classrooms(list(names.keys()))
return {
"materials": [
{**self._material_dict(r), "class_name": names.get(r.classroom_id)}
for r in materials
],
"templates": [
t.to_dict() for t in self.templates.list_for_owner(teacher_id)
],
}

# ── assignment templates (teacher-owned, reusable) ───────────────────────

def list_templates(self, teacher_id: str) -> List[Dict[str, Any]]:
return [t.to_dict() for t in self.templates.list_for_owner(teacher_id)]

def save_template(
self,
teacher_id: str,
title: str,
instructions: Optional[str] = None,
attachment_id: Optional[str] = None,
) -> Dict[str, Any]:
if not (isinstance(title, str) and title.strip()):
raise ValidationError("title is required")
template = self.templates.create(
id=str(uuid.uuid4()),
owner_teacher_id=teacher_id,
title=title.strip(),
instructions=(
instructions.strip() if instructions and instructions.strip() else None
),
attachment_id=(attachment_id or None),
created_at=datetime.utcnow(),
)
return template.to_dict()

def _owned_template(self, tid: str, teacher_id: str) -> AssignmentTemplate:
template = self.templates.get_by_id(tid)
if not template:
raise NotFoundError("Template", tid)
if template.owner_teacher_id != teacher_id:
raise AuthorizationError("Not authorized for this template")
return template

def delete_template(self, tid: str, teacher_id: str) -> None:
self._owned_template(tid, teacher_id)
self.templates.delete(tid)

def create_assignment_from_template(
self,
classroom_id: str,
teacher_id: str,
template_id: str,
due_date: Optional[str] = None,
) -> Dict[str, Any]:
"""Pre-fill a new assignment from a template (delegates to the E7 context)."""
self._owned_class(classroom_id, teacher_id)
template = self._owned_template(template_id, teacher_id)
return self.assignments.create(
classroom_id,
teacher_id,
{
"title": template.title,
"instructions": template.instructions,
"attachment_id": template.attachment_id,
"due_date": due_date,
},
)
23 changes: 23 additions & 0 deletions data/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
from .assignment import Assignment, Submission
from .chat import Chat
from .classroom import Classroom, Enrollment, Invitation
from .config import AppConfig
from .exam import ExamConfig, ExamSession, ExamViolation
from .feedback import Feedback
from .file import FileRecord
from .guardian import GuardianLink
from .knowledge import KnowledgeBase, KnowledgeFile
from .message import Conversation, ConversationParticipant, Message
from .model import ModelConfig
from .monitor import MonitorAwayEvent, MonitorState
from .resource import AssignmentTemplate, ClassResource
from .support import Support, SupportFile
from .user import User

Expand All @@ -18,4 +25,20 @@
"AppConfig",
"KnowledgeBase",
"KnowledgeFile",
"Classroom",
"Enrollment",
"Invitation",
"GuardianLink",
"Assignment",
"Submission",
"ClassResource",
"AssignmentTemplate",
"MonitorState",
"MonitorAwayEvent",
"Conversation",
"ConversationParticipant",
"Message",
"ExamConfig",
"ExamSession",
"ExamViolation",
]
Loading
Loading