Add Sejoli tryout JSON snapshot importer

This commit is contained in:
dwindown
2026-04-02 17:04:01 +07:00
parent 51c577be05
commit b4ebdc9c4f
7 changed files with 910 additions and 1 deletions

View File

@@ -8,6 +8,8 @@ from app.database import Base
from app.models.item import Item
from app.models.session import Session
from app.models.tryout import Tryout
from app.models.tryout_import_snapshot import TryoutImportSnapshot
from app.models.tryout_snapshot_question import TryoutSnapshotQuestion
from app.models.tryout_stats import TryoutStats
from app.models.user import User
from app.models.user_answer import UserAnswer
@@ -18,6 +20,8 @@ __all__ = [
"User",
"Website",
"Tryout",
"TryoutImportSnapshot",
"TryoutSnapshotQuestion",
"Item",
"Session",
"UserAnswer",

View File

@@ -0,0 +1,103 @@
"""
Snapshot archive for imported external tryout payloads.
Stores each imported JSON export so the backend can trace source changes
without treating the source file itself as the system of record.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TryoutImportSnapshot(Base):
__tablename__ = "tryout_import_snapshots"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
website_id: Mapped[int] = mapped_column(
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="Website identifier",
)
source_tryout_id: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
comment="External source tryout identifier",
)
source_key: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="External tryout object key in source payload",
)
title: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="Imported tryout title",
)
source_permalink: Mapped[Optional[str]] = mapped_column(
String(1024),
nullable=True,
comment="Imported source permalink",
)
source_status: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
comment="Imported source status",
)
exported_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Timestamp from source export metadata",
)
source_created_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Source tryout created timestamp",
)
source_modified_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Source tryout modified timestamp",
)
exported_by: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="Source exporter identity",
)
question_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="Number of questions in imported payload",
)
result_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="Number of result rows in imported payload",
)
payload_checksum: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="Checksum for the imported payload",
)
raw_payload: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="Original imported payload",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,139 @@
"""
Read-only normalized reference rows for imported tryout questions.
These rows reflect the latest imported source version of each question and are
kept separate from operational items and AI-generated variants.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TryoutSnapshotQuestion(Base):
__tablename__ = "tryout_snapshot_questions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
website_id: Mapped[int] = mapped_column(
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="Website identifier",
)
source_tryout_id: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
comment="External source tryout identifier",
)
source_question_id: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="External source question identifier",
)
latest_snapshot_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("tryout_import_snapshots.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
comment="Latest snapshot containing this question",
)
question_title: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="Imported title or short label",
)
question_html: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="Imported question body HTML",
)
explanation_html: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="Imported explanation HTML",
)
raw_options: Mapped[list] = mapped_column(
JSON,
nullable=False,
comment="Raw source options payload",
)
correct_answer: Mapped[str] = mapped_column(
String(10),
nullable=False,
comment="Imported correct answer key",
)
category_id: Mapped[Optional[int]] = mapped_column(
Integer,
nullable=True,
comment="Imported category id",
)
category_name: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="Imported category name",
)
category_code: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="Imported category code",
)
option_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="Count of source options",
)
has_option_labels: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="Whether source options include visible labels",
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="Whether question is still present in latest source import",
)
content_checksum: Mapped[str] = mapped_column(
String(64),
nullable=False,
comment="Checksum of normalized question content",
)
raw_payload: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="Original source question payload",
)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
__table_args__ = (
UniqueConstraint(
"website_id",
"source_tryout_id",
"source_question_id",
name="uq_snapshot_questions_website_tryout_question",
),
)