Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ced3330
refactor(TipTapEditor): emit markdown on blur and cache last emitted …
AlexVelezLl Jun 4, 2026
f709301
Additional coverage for user CSV export
bjester Jun 11, 2026
3745410
Optimize user CSV export query
bjester Jun 11, 2026
eec65c0
Add test asserting join behavior
bjester Jun 11, 2026
a0c7298
Merge pull request #5960 from AlexVelezLl/tiptap-emit-on-blur
rtibbles Jun 16, 2026
4e4599d
Merge pull request #5969 from bjester/user-csv-perf
bjester Jun 23, 2026
ae076bc
Route admin users from notification into submission review mode
rtibblesbot Jun 26, 2026
8a56525
Merge pull request #5996 from rtibblesbot/issue-5994-8385cb
rtibbles Jun 26, 2026
101e200
feat(migrations): add almost zero-downtime migration capability and l…
rtibbles Jun 23, 2026
659cacc
docs(migrations): add expand/contract zero-downtime runbook
rtibbles Jun 23, 2026
2c079c0
feat(models): widen File.file_size to bigint, expand stage (studio#5974)
rtibbles Jun 23, 2026
702f802
Merge pull request #5986 from rtibbles/widen_file_size_bigint
bjester Jul 6, 2026
e8951f7
feat: add GCS resumable upload storage helpers
rtibbles Jun 25, 2026
a972a5c
feat: add opt-in resumable scheme to the upload_url endpoint
rtibbles Jun 25, 2026
67181da
Merge pull request #5995 from rtibbles/resumable_uploads
rtibbles Jul 10, 2026
5dab922
fix: scope models imports in utils/files.py to break boot-time circul…
rtibbles Jul 11, 2026
a01bd10
Merge pull request #6036 from rtibbles/fix/resumable-upload-circular-…
rtibbles Jul 11, 2026
7366349
fix: return None from CompositeGCS.get_stored_object_md5 for missing …
rtibbles Jul 11, 2026
07b4f58
Merge pull request #6037 from rtibbles/fix/resumable-md5-missing-object
rtibbles Jul 11, 2026
2e326c0
chore: bump le-utils to 0.2.18
rtibblesbot Jul 12, 2026
c50f3b0
feat: add included_presets column to File mirrors
rtibblesbot Jul 12, 2026
fe88cd4
feat: populate included_presets at publish
rtibblesbot Jul 12, 2026
78596ac
feat: advertise content schema version 6
rtibblesbot Jul 12, 2026
21edf6f
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jul 12, 2026
da92f80
Merge pull request #6040 from rtibblesbot/issue-6004-1973c5
rtibbles Jul 13, 2026
ba73334
feat: add file_size_bigint column to LocalFile mirrors
rtibblesbot Jul 13, 2026
f2cb89b
feat: populate file_size_bigint at publish
rtibblesbot Jul 13, 2026
f5f87d0
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jul 13, 2026
936dc23
Merge pull request #6041 from rtibblesbot/issue-5987-e45197
rtibbles Jul 13, 2026
d0b294b
Add ability to send a notification email on review of community libra…
marcellamaki Jul 21, 2026
5e176a5
fix: dedupe ChannelVersion.included_licenses in API responses
AlexVelezLl Jul 31, 2026
9406e65
fix: preserve channel status filter when navigating back to channels …
AlexVelezLl Jul 31, 2026
7295cc7
fix: remove fixed width from community library status button
AlexVelezLl Jul 31, 2026
701a756
Merge pull request #6076 from AlexVelezLl/esocc-miscelaneous-fixes
rtibbles Aug 4, 2026
c4f699e
Merge pull request #6061 from AlexVelezLl/fix/channel-version-include…
rtibbles Aug 4, 2026
3fab968
Simplify strings and update the place in the workflow that the mail i…
marcellamaki Aug 4, 2026
1bc0c9a
Merge pull request #6050 from marcellamaki/send-notification-email
marcellamaki Aug 5, 2026
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
13 changes: 13 additions & 0 deletions .github/workflows/pythontest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up minio
run: |
docker run -d -p 9000:9000 --name minio \
Expand All @@ -79,6 +81,17 @@ jobs:
run: |
# Use uv to install dependencies directly from requirements files
uv pip sync requirements.txt requirements-dev.txt
- name: Lint new migrations for unsafe operations
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
DJANGO_SETTINGS_MODULE: contentcuration.not_production_settings
run: |
set -euo pipefail
git fetch --no-tags origin "$BASE_REF"
base="$(git merge-base "origin/$BASE_REF" HEAD)"
test -n "$base"
python contentcuration/manage.py lintmigrations --git-commit-id "$base" --no-cache --warnings-as-errors
- name: Test pytest
run: |
sh -c './contentcuration/manage.py makemigrations --check'
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ migrate:
# 4) Remove the management command from this `deploy-migrate` recipe
# 5) Repeat!
deploy-migrate:
echo "Nothing to do here!"
# studio#5974: remove at cutover.
python contentcuration/manage.py backfill_column --model contentcuration.File --source-field file_size --target-field file_size_bigint

contentnodegc:
python contentcuration/manage.py garbage_collect
Expand Down
31 changes: 31 additions & 0 deletions contentcuration/contentcuration/db/dual_write.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import hashlib

import pgtrigger


def mirror_field(source, target):
"""Mirror Django field `source` into `target` via a BEFORE INSERT/UPDATE
trigger (expand/contract dual-write)."""

def decorator(model):
source_col = model._meta.get_field(source).column
target_col = model._meta.get_field(target).column
name = "mirror_{}_to_{}".format(source_col, target_col)
if len(name) > 43: # stay safely under pgtrigger's trigger-name limit
digest = hashlib.sha1(
"{}_{}".format(source_col, target_col).encode()
).hexdigest()[:8]
name = "mirror_{}".format(digest)
# Change-guard (IS DISTINCT FROM): keeps a read cutover from clobbering
# writes to the repointed column with the stale source value.
trigger = pgtrigger.Trigger(
name=name,
when=pgtrigger.Before,
operation=pgtrigger.Insert | pgtrigger.Update,
func="IF NEW.{s} IS DISTINCT FROM OLD.{s} THEN NEW.{t} = NEW.{s}; END IF; RETURN NEW;".format(
s=source_col, t=target_col
),
)
return pgtrigger.register(trigger)(model)

return decorator
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@
.community-library-status-button {
@extend %md-standard-func;

width: 9em;
padding: 4px;
color: v-bind('labelColor');
background-color: v-bind('color');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,16 @@
fetchQueryParams: keywordSearchFetchQueryParams,
} = useKeywordSearch();

// channelStatusFilter is derived from the current channelType's options, so an
// existing status that's no longer valid for the new type already reads back as
// unset - only default it to the first option in that case.
watch(
channelTypeFilter,
() => {
const options = channelStatusOptions.value;
channelStatusFilter.value = options.length ? options[0].value : null;
if (!channelStatusFilter.value) {
const options = channelStatusOptions.value;
channelStatusFilter.value = options.length ? options[0].value : null;
}
},
{ immediate: true },
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ localVue.use(router);

const channelList = ['test', 'channel', 'table'];

function makeWrapper(store) {
router.replace({ name: RouteNames.CHANNELS });
function makeWrapper(store, query = {}) {
router.replace({ name: RouteNames.CHANNELS, query });

return mount(ChannelTable, {
router,
Expand Down Expand Up @@ -71,13 +71,32 @@ describe('channelTable', () => {

expect(router.currentRoute.query.keywords).toBe('keyword test');
});
it('changing channel type filter should reset channel status filter', async () => {
it('changing channel type filter should reset channel status filter when it is no longer valid', async () => {
wrapper.vm.channelTypeFilter = ChannelTypeFilter.COMMUNITY_LIBRARY;
wrapper.vm.channelStatusFilter = 'needsReview';
await wrapper.vm.$nextTick();
// Kolibri library channels have no "needs review" status
wrapper.vm.channelTypeFilter = ChannelTypeFilter.KOLIBRI_LIBRARY;
await wrapper.vm.$nextTick();
expect(wrapper.vm.channelStatusFilter).toBe('live');
});
it('changing channel type filter should keep the channel status filter when it is still valid', async () => {
wrapper.vm.channelTypeFilter = ChannelTypeFilter.COMMUNITY_LIBRARY;
wrapper.vm.channelStatusFilter = 'published';
await wrapper.vm.$nextTick();
// "published" is a valid status for unlisted channels too
wrapper.vm.channelTypeFilter = ChannelTypeFilter.UNLISTED;
await wrapper.vm.$nextTick();
expect(wrapper.vm.channelStatusFilter).toBe('live');
expect(wrapper.vm.channelStatusFilter).toBe('published');
});
it('should preserve a valid channel status filter already present in the URL on mount', () => {
// Simulates navigating back to this page with filters still in the URL
// (e.g. after opening a channel and hitting the browser back button).
const backNavWrapper = makeWrapper(store, {
channelType: ChannelTypeFilter.COMMUNITY_LIBRARY,
channelStatus: 'needsReview',
});
expect(backNavWrapper.vm.channelStatusFilter).toBe('needsReview');
});
});
describe('selection', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,7 @@ const router = new VueRouter({
name: RouteNames.COMMUNITY_LIBRARY_SUBMISSION,
path: '/community-library/:channelId/:submissionId',
component: SubmissionDetailsModal,
props: route => ({
channelId: route.params.channelId,
submissionId: route.params.submissionId,
adminReview: true,
}),
props: true,
},
// Catch-all redirect to channels tab
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@
return editor.value.storage.markdown.getMarkdown();
};

let isUpdatingFromOutside = false; // A flag to prevent infinite update loops
// Cache of the latest markdown value emitted by this component. Used to
// detect when an incoming prop.value is just our own emitted value echoed
// back, so we can skip unnecessary re-rendering of the editor content.
let lastEmittedMarkdown = null;

watch(
() => props.mode,
Expand All @@ -217,6 +220,13 @@
watch(
() => props.value,
newValue => {
// If the incoming value matches what we last emitted, the editor
// already reflects this content, so skip re-rendering to avoid
// unnecessary work and resetting the editor state.
if (newValue === lastEmittedMarkdown) {
return;
}

const processedContent = preprocessMarkdown(newValue);

if (!editor.value) {
Expand All @@ -228,36 +238,31 @@

const editorContent = getMarkdownContent();
if (editorContent !== newValue) {
isUpdatingFromOutside = true;
editor.value.commands.setContent(processedContent, false);
nextTick(() => {
isUpdatingFromOutside = false;
});
}
},
{ immediate: true },
);

// sync changes from the editor to the parent component
watch(
() => editor.value?.state,
() => {
if (
!editor.value ||
!isReady.value ||
isUpdatingFromOutside ||
!editor.value.storage?.markdown
) {
return;
}
// sync changes from the editor to the parent component, only on blur
const emitMarkdownUpdate = () => {
if (!editor.value || !isReady.value || !editor.value.storage?.markdown) {
return;
}

const markdown = getMarkdownContent();
if (markdown !== props.value) {
emit('update', markdown);
}
},
{ deep: true },
);
const markdown = getMarkdownContent();
if (markdown !== props.value) {
lastEmittedMarkdown = markdown;
emit('update', markdown);
}
};

// Emit the markdown update only when the editor loses focus (blur).
watch(isFocused, (focused, wasFocused) => {
if (wasFocused && !focused) {
emitMarkdownUpdate();
}
});

const handleContainerKeydown = event => {
if (event.key === 'Enter') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import VueRouter from 'vue-router';
import SubmissionDetailsModal from '../index.vue';
import {
AdminCommunityLibrarySubmission,
ChannelVersion,
CommunityLibrarySubmission,
} from 'shared/data/resources';

jest.mock('shared/data/resources', () => ({
AdminCommunityLibrarySubmission: { fetchModel: jest.fn() },
ChannelVersion: { fetchCollection: jest.fn() },
CommunityLibrarySubmission: {
fetchModel: jest.fn(),
fetchCollection: jest.fn(() => Promise.resolve({ results: [] })),
},
}));

const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueRouter);

const stubChannel = {
id: 'ch1',
name: 'Test',
thumbnail_url: null,
thumbnail_encoding: null,
description: '',
};
const stubSubmission = {
id: 'sub1',
channel_id: 'ch1',
channel_version: 1,
status: 'PENDING',
version_token: null,
};
const stubChannelVersion = { id: 'cv1' };

function makeStore(isAdmin) {
return new Vuex.Store({
getters: { isAdmin: () => isAdmin },
modules: {
channel: {
namespaced: true,
actions: { loadChannel: jest.fn(() => Promise.resolve(stubChannel)) },
},
errors: { namespaced: true, actions: { handleAxiosError: jest.fn() } },
},
});
}

describe('SubmissionDetailsModal', () => {
beforeEach(() => {
AdminCommunityLibrarySubmission.fetchModel.mockResolvedValue(stubSubmission);
CommunityLibrarySubmission.fetchModel.mockResolvedValue(stubSubmission);
ChannelVersion.fetchCollection.mockResolvedValue([stubChannelVersion]);
});

afterEach(() => jest.clearAllMocks());

it('uses AdminCommunityLibrarySubmission when user is admin', () => {
shallowMount(SubmissionDetailsModal, {
localVue,
store: makeStore(true),
router: new VueRouter(),
propsData: { channelId: 'ch1', submissionId: 'sub1' },
});
expect(AdminCommunityLibrarySubmission.fetchModel).toHaveBeenCalledWith('sub1');
});

it('uses CommunityLibrarySubmission when user is not admin', () => {
shallowMount(SubmissionDetailsModal, {
localVue,
store: makeStore(false),
router: new VueRouter(),
propsData: { channelId: 'ch1', submissionId: 'sub1' },
});
expect(CommunityLibrarySubmission.fetchModel).toHaveBeenCalledWith('sub1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@
</div>
<div class="actions">
<KButton
v-if="adminReview && submission.status === CommunityLibraryStatus.PENDING"
v-if="isAdmin && submission.status === CommunityLibraryStatus.PENDING"
:text="reviewAction$()"
@click="showReviewSidePanel = true"
/>
<ChannelActionsDropdown
v-if="adminReview"
v-if="isAdmin"
primary
:channelId="channelId"
/>
Expand All @@ -105,7 +105,7 @@
:channelId="channelId"
/>
<ReviewSubmissionSidePanel
v-if="adminReview && showReviewSidePanel"
v-if="isAdmin && showReviewSidePanel"
:submissionId="submission.id"
:channel="channel"
@close="showReviewSidePanel = false"
Expand Down Expand Up @@ -143,10 +143,6 @@
import logging from 'shared/logging';

const props = defineProps({
adminReview: {
type: Boolean,
default: false,
},
channelId: {
type: String,
required: true,
Expand All @@ -163,6 +159,7 @@
const route = useRoute();
const router = useRouter();
const store = useStore();
const isAdmin = computed(() => store.getters.isAdmin);
const { windowBreakpoint } = useKResponsiveWindow();

const isModalOpen = computed({
Expand Down Expand Up @@ -206,7 +203,7 @@
} = useFetch({
asyncFetchFunc: async () => {
try {
const Resource = props.adminReview
const Resource = isAdmin.value
? AdminCommunityLibrarySubmission
: CommunityLibrarySubmission;
const submission = await Resource.fetchModel(props.submissionId);
Expand Down
Loading
Loading