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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions .github/workflows/core-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,15 @@ jobs:
- name: Start services
run: docker compose up -d

- name: Bootstrap django
run: |
commands="python manage.py migrate && \
python manage.py collectstatic --noinput && \
python manage.py createsuperuser --noinput || true && \
python manage.py create_roles && \
python manage.py opensearch index create --force --ignore-error && \
python manage.py create_platform_partner && \
python manage.py create_base_policies && \
python manage.py load_ncs_data && \
python manage.py loaddata ncs_category_en.json"
docker compose exec -T minima sh -c "$commands"
docker compose restart minima worker

- name: Install uv and dev tools
run: |
pip install uv
uv tool install pyrefly
uv tool install ruff

- name: Bootstrap apps
run: uv run dev.py bootstrap --no-tty

- name: Run linting
run: |
uv run pyrefly check
Expand Down
1 change: 1 addition & 0 deletions core/apps/common/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class ErrorCode:
SSO_SESSION_EXPIRED = "SSO_SESSION_EXPIRED"
THUMBNAIL_GENERATION_FAILED = "THUMBNAIL_GENERATION_FAILED"
TITLE_ALREADY_EXISTS = "TITLE_ALREADY_EXISTS"
UNKNOWN_CONTENT = "UNKNOWN_CONTENT"
UNKNOWN_COURSE_CONTENT = "UNKNOWN_COURSE_CONTENT"
USER_ALREADY_ACTIVE = "USER_ALREADY_ACTIVE"
USER_NOT_ACTIVE = "USER_NOT_ACTIVE"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

from django.core.management.base import BaseCommand
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from apps.operation.models import Policy
from apps.operation.models import FAQ, HonorCode, Policy

log = logging.getLogger(__name__)


class Command(BaseCommand):
help = "Create empty policies"

def handle(self, *args: object, **options: dict[str, object]):

# policies
for i, (value, label) in enumerate(Policy.KindChoices.choices):
policy, created = Policy.objects.get_or_create(
kind=value,
Expand All @@ -26,11 +27,27 @@ def handle(self, *args: object, **options: dict[str, object]):

if created:
policy.policy_versions.create(
body=f"Write your {label} policy here",
body=f"Write default {label} policy here",
data_category={},
version="1.0",
effective_date=timezone.now(),
)
self.stdout.write(self.style.SUCCESS(f"Successfully created policy: {policy.title}"))
else:
self.stdout.write(self.style.WARNING(f"Policy already exists: {policy.title}"))

# honor code
HonorCode.objects.get_or_create(
title=_("Student Honor Code"), defaults={"code": "Write default student honor code here"}
)

# faq
faq, created = FAQ.objects.get_or_create(
name=_("Frequently Asked Questions"), defaults={"description": _("default faq")}
)

if created:
for i in range(1, 5):
faq.items.create(
question=_("Write FAQ question %d here") % i, answer=_("Write FAQ answer %d here") % i, ordering=i
)
18 changes: 18 additions & 0 deletions core/apps/operation/migrations/0002_alter_faq_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-04 01:48

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('operation', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='faq',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
]
19 changes: 16 additions & 3 deletions core/apps/operation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def __str__(self):

@pghistory.track()
class HonorCode(TimeStampedMixin):
title = CharField(max_length=255, verbose_name=_("Title"))
title = CharField(_("Title"), max_length=255, unique=True)
code = TextField(_("Code"))

class Meta(TimeStampedMixin.Meta):
Expand Down Expand Up @@ -292,8 +292,19 @@ async def update_attachments(self, *, files: Sequence[File] | None, owner_id: st

existing_attachments = []
if self.pk:
async for a in self.attachments.all():
existing_attachments.append(a)
cached = (
self._prefetched_objects_cache.get("attachments")
if hasattr(self, "_prefetched_objects_cache")
else None
)

if cached is not None:
existing_attachments = list(cached)
else:
async for a in self.attachments.all():
existing_attachments.append(a)

for a in existing_attachments:
if self.restore_filename(a.file.name) in used_filenames:
attachments_to_keep.append(a)
seen_hashes.add((a.hash, owner_id))
Expand All @@ -320,6 +331,8 @@ async def update_attachments(self, *, files: Sequence[File] | None, owner_id: st
existing_ids = set(a.id for a in existing_attachments)
new_ids = set(a.pk for a in attachments_to_keep)
if existing_ids != new_ids:
if hasattr(self, "_prefetched_objects_cache"):
self._prefetched_objects_cache.pop("attachments", None)
await self.attachments.aset(attachments_to_keep)

if hasattr(self, "_prefetched_objects_cache"):
Expand Down
5 changes: 2 additions & 3 deletions core/apps/operation/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from factory.declarations import Iterator, LazyAttribute, LazyFunction, Sequence, SubFactory
from factory.django import DjangoModelFactory
from factory.helpers import lazy_attribute, post_generation
Expand Down Expand Up @@ -58,7 +57,7 @@ class Meta:


class HonorCodeFactory(DjangoModelFactory[HonorCode]):
title = LazyFunction(lambda: f"{generic.food.fruit()} {_('Honor Code')}")
title = LazyFunction(lambda: " ".join(generic.text.words(quantity=generic.random.randint(3, 5))))
code = LazyFunction(
lambda: "\n\n".join([
generic.text.text(quantity=generic.random.randint(2, 4)) for _ in range(generic.random.randint(5, 10))
Expand All @@ -71,7 +70,7 @@ class Meta:


class FAQFactory(DjangoModelFactory[FAQ]):
name = FactoryField("text.title")
name = LazyFunction(lambda: " ".join(generic.text.words(quantity=generic.random.randint(3, 5))))
description = FactoryField("sentence")

class Meta:
Expand Down
114 changes: 45 additions & 69 deletions core/apps/studio/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@
from asgiref.sync import sync_to_async
from django.conf import settings
from django.db import connection
from django.db.models import Value
from django.db.models import F, Value
from django.db.models.functions import JSONObject
from ninja import Router
from ninja.params import functions

from apps.assignment.models import Assignment
from apps.common.error import ErrorCode
from apps.common.schema import ContentTypeSchema, Schema
from apps.common.util import HttpRequest, PaginatedResponse
from apps.competency.models import Certificate
from apps.content.models import Media
from apps.course.models import ASSESSIBLE_MODELS, Course
from apps.discussion.models import Discussion
from apps.exam.models import Exam
from apps.operation.models import FAQ, Category, FAQItem, Instructor
from apps.operation.models import FAQ, Category, HonorCode, Instructor
from apps.quiz.models import Quiz
from apps.studio.api.v1.assignment import router as assignment_router
from apps.studio.api.v1.course import router as course_router
Expand Down Expand Up @@ -48,11 +49,6 @@
}


class StudioContentTypeSpec(Schema):
app_label: str
model: StudioModel


class StudioContentSpec(Schema):
id: str
title: str
Expand Down Expand Up @@ -125,103 +121,83 @@ def execute():
return {"items": rows, "count": total, "size": size, "page": page, "pages": ceil(total / size) if total else 1}


class ContentSuggestionSpec(Schema):
id: str
title: str


MAX_SUGGESTIONS = 1000


@router.get("/suggestion/content", response=list[ContentSuggestionSpec])
@editor_required()
async def content_suggestions(request: HttpRequest, kind: Annotated[StudioModel, functions.Query(...)]):
return [
raw
async for raw in STUDIO_MODELS[kind]
.objects.filter(owner_id=request.auth)
.values("title", "id")
.order_by("-modified")[:MAX_SUGGESTIONS]
]


class AssessmentSuggestion(Schema):
class AssessmentSuggestionSpec(Schema):
id: str
title: str
label: str
item_type: ContentTypeSchema


@router.get("/suggestion/assessment", response=list[AssessmentSuggestion])
@router.get("/suggestion/assessment", response=list[AssessmentSuggestionSpec])
@editor_required()
async def assessment_suggestions(request: HttpRequest):
qs = [
M.objects
.filter(owner_id=request.auth)
.filter(owner_id=request.auth) # required owner permission
.annotate(item_type=JSONObject(app_label=Value(M._meta.app_label), model=Value(M._meta.model_name)))
.values("id", "title", "item_type")
.order_by("-modified")[:MAX_SUGGESTIONS]
.annotate(label=F("title"))
.values("id", "label", "item_type")
.order_by("label")[:MAX_SUGGESTIONS]
for M in ASSESSIBLE_MODELS
]
return [o async for o in qs[0].union(*qs[1:], all=True)]


class CertificateSuggestionSpec(Schema):
id: int
name: str


@router.get("/suggestion/certificate", response=list[CertificateSuggestionSpec])
@editor_required()
async def certificate_suggestions(request: HttpRequest):
return [c async for c in Certificate.objects.filter(active=True).order_by("-modified")]


class CategorySuggestionSpec(Schema):
id: int
full_path: str
class ContentSuggestionSpec(Schema):
id: str
label: str


@router.get("/suggestion/category", response=list[CategorySuggestionSpec])
@router.get("/suggestion/content", response=list[ContentSuggestionSpec])
@editor_required()
async def category_suggestions(request: HttpRequest):
async def content_suggestions(request: HttpRequest, kind: Annotated[StudioModel, functions.Query(...)]):
return [
{"id": c.id, "full_path": " / ".join([*c.ancestors, c.name])}
async for c in Category.objects.filter(depth=3).order_by("id")
raw
async for raw in STUDIO_MODELS[kind]
.objects.annotate(label=F("title"))
.filter(owner_id=request.auth) # required owner permission
.values("label", "id")
.order_by("label")[:MAX_SUGGESTIONS]
]


class InstructorSuggestionSpec(Schema):
class InlineSuggestionSpec(Schema):
id: int
name: str
label: str


@router.get("/suggestion/instructor", response=list[InstructorSuggestionSpec])
@editor_required()
async def instructor_suggestions(request: HttpRequest):
return [c async for c in Instructor.objects.filter(active=True).order_by("-modified")]
InlineItem = Literal["honor_code", "faq", "category", "instructor", "certificate"]


class FAQSuggestionSpec(Schema):
id: int
name: str
@router.get("/suggestion/inline", response=list[InlineSuggestionSpec])
@editor_required()
async def inline_suggestions(request: HttpRequest, kind: Annotated[InlineItem, functions.Query(...)]):
qs = None

if kind == "honor_code":
qs = HonorCode.objects.annotate(label=F("title"))

@router.get("/suggestion/faq", response=list[FAQSuggestionSpec])
@editor_required()
async def faq_suggestions(request: HttpRequest):
return [c async for c in FAQ.objects.order_by("-id")]
elif kind == "faq":
qs = FAQ.objects.annotate(label=F("name"))

elif kind == "instructor":
qs = Instructor.objects.filter(active=True).annotate(label=F("name"))

class FAQItemCopySpec(Schema):
question: str
answer: str
active: bool
elif kind == "certificate":
qs = Certificate.objects.filter(active=True).annotate(label=F("name"))

elif kind == "category":
return [
{"id": c.id, "label": " / ".join([*c.ancestors, c.name])}
async for c in Category.objects.filter(depth=3).order_by("id")
][:MAX_SUGGESTIONS]

@router.get("/faq/{id}/item", response=list[FAQItemCopySpec])
@editor_required()
async def get_faq_items(request: HttpRequest, id: int):
return [c async for c in FAQItem.objects.filter(faq_id=id, active=True).order_by("ordering")]
if qs is None:
raise ValueError(ErrorCode.UNKNOWN_CONTENT)

return [item async for item in qs.values("id", "label").order_by("label")][:MAX_SUGGESTIONS]


router.add_router("", exam_router, tags=["studio"])
Expand Down
Loading