Skip to content

Commit 402f22e

Browse files
authored
Merge pull request learningequality#6046 from rtibbles/hotfixesintounstable
Hotfixes into unstable
2 parents eb8f899 + 4f158f3 commit 402f22e

38 files changed

Lines changed: 1310 additions & 130 deletions

.github/workflows/pythontest.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ jobs:
6666
- 6379:6379
6767
steps:
6868
- uses: actions/checkout@v7
69+
with:
70+
fetch-depth: 0
6971
- name: Set up minio
7072
run: |
7173
docker run -d -p 9000:9000 --name minio \
@@ -83,6 +85,17 @@ jobs:
8385
run: |
8486
# Use uv to install dependencies directly from requirements files
8587
uv pip sync requirements.txt requirements-dev.txt
88+
- name: Lint new migrations for unsafe operations
89+
if: github.event_name == 'pull_request'
90+
env:
91+
BASE_REF: ${{ github.base_ref }}
92+
DJANGO_SETTINGS_MODULE: contentcuration.not_production_settings
93+
run: |
94+
set -euo pipefail
95+
git fetch --no-tags origin "$BASE_REF"
96+
base="$(git merge-base "origin/$BASE_REF" HEAD)"
97+
test -n "$base"
98+
python contentcuration/manage.py lintmigrations --git-commit-id "$base" --no-cache --warnings-as-errors
8699
- name: Test pytest
87100
run: |
88101
sh -c './contentcuration/manage.py makemigrations --check'

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ migrate:
3939
# 4) Remove the management command from this `deploy-migrate` recipe
4040
# 5) Repeat!
4141
deploy-migrate:
42-
echo "Nothing to do here!"
42+
# studio#5974: remove at cutover.
43+
python contentcuration/manage.py backfill_column --model contentcuration.File --source-field file_size --target-field file_size_bigint
4344

4445
contentnodegc:
4546
python contentcuration/manage.py garbage_collect
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import hashlib
2+
3+
import pgtrigger
4+
5+
6+
def mirror_field(source, target):
7+
"""Mirror Django field `source` into `target` via a BEFORE INSERT/UPDATE
8+
trigger (expand/contract dual-write)."""
9+
10+
def decorator(model):
11+
source_col = model._meta.get_field(source).column
12+
target_col = model._meta.get_field(target).column
13+
name = "mirror_{}_to_{}".format(source_col, target_col)
14+
if len(name) > 43: # stay safely under pgtrigger's trigger-name limit
15+
digest = hashlib.sha1(
16+
"{}_{}".format(source_col, target_col).encode()
17+
).hexdigest()[:8]
18+
name = "mirror_{}".format(digest)
19+
# Change-guard (IS DISTINCT FROM): keeps a read cutover from clobbering
20+
# writes to the repointed column with the stale source value.
21+
trigger = pgtrigger.Trigger(
22+
name=name,
23+
when=pgtrigger.Before,
24+
operation=pgtrigger.Insert | pgtrigger.Update,
25+
func="IF NEW.{s} IS DISTINCT FROM OLD.{s} THEN NEW.{t} = NEW.{s}; END IF; RETURN NEW;".format(
26+
s=source_col, t=target_col
27+
),
28+
)
29+
return pgtrigger.register(trigger)(model)
30+
31+
return decorator

contentcuration/contentcuration/frontend/administration/router.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,7 @@ const router = new VueRouter({
3434
name: RouteNames.COMMUNITY_LIBRARY_SUBMISSION,
3535
path: '/community-library/:channelId/:submissionId',
3636
component: SubmissionDetailsModal,
37-
props: route => ({
38-
channelId: route.params.channelId,
39-
submissionId: route.params.submissionId,
40-
adminReview: true,
41-
}),
37+
props: true,
4238
},
4339
// Catch-all redirect to channels tab
4440
{

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@
200200
return editor.value.storage.markdown.getMarkdown();
201201
};
202202
203-
let isUpdatingFromOutside = false; // A flag to prevent infinite update loops
203+
// Cache of the latest markdown value emitted by this component. Used to
204+
// detect when an incoming prop.value is just our own emitted value echoed
205+
// back, so we can skip unnecessary re-rendering of the editor content.
206+
let lastEmittedMarkdown = null;
204207
205208
watch(
206209
() => props.mode,
@@ -220,6 +223,13 @@
220223
watch(
221224
() => props.value,
222225
newValue => {
226+
// If the incoming value matches what we last emitted, the editor
227+
// already reflects this content, so skip re-rendering to avoid
228+
// unnecessary work and resetting the editor state.
229+
if (newValue === lastEmittedMarkdown) {
230+
return;
231+
}
232+
223233
const processedContent =
224234
props.format === 'html' ? newValue : preprocessMarkdown(newValue);
225235
@@ -231,29 +241,31 @@
231241
}
232242
233243
if (getContent() !== newValue) {
234-
isUpdatingFromOutside = true;
235244
editor.value.commands.setContent(processedContent, false);
236-
nextTick(() => {
237-
isUpdatingFromOutside = false;
238-
});
239245
}
240246
},
241247
{ immediate: true },
242248
);
243249
244-
// sync changes from the editor to the parent component
245-
watch(
246-
() => editor.value?.state,
247-
() => {
248-
if (!editor.value || !isReady.value || isUpdatingFromOutside) return;
250+
// sync changes from the editor to the parent component, only on blur
251+
const emitContentUpdate = () => {
252+
if (!editor.value || !isReady.value) {
253+
return;
254+
}
249255
250-
const content = getContent();
251-
if (content !== props.value) {
252-
emit('update', content);
253-
}
254-
},
255-
{ deep: true },
256-
);
256+
const content = getContent();
257+
if (content !== props.value) {
258+
lastEmittedMarkdown = content;
259+
emit('update', content);
260+
}
261+
};
262+
263+
// Emit the content update only when the editor loses focus (blur).
264+
watch(isFocused, (focused, wasFocused) => {
265+
if (wasFocused && !focused) {
266+
emitContentUpdate();
267+
}
268+
});
257269
258270
const handleContainerKeydown = event => {
259271
if (event.key === 'Enter') {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { shallowMount, createLocalVue } from '@vue/test-utils';
2+
import Vuex from 'vuex';
3+
import VueRouter from 'vue-router';
4+
import SubmissionDetailsModal from '../index.vue';
5+
import {
6+
AdminCommunityLibrarySubmission,
7+
ChannelVersion,
8+
CommunityLibrarySubmission,
9+
} from 'shared/data/resources';
10+
11+
jest.mock('shared/data/resources', () => ({
12+
AdminCommunityLibrarySubmission: { fetchModel: jest.fn() },
13+
ChannelVersion: { fetchCollection: jest.fn() },
14+
CommunityLibrarySubmission: {
15+
fetchModel: jest.fn(),
16+
fetchCollection: jest.fn(() => Promise.resolve({ results: [] })),
17+
},
18+
}));
19+
20+
const localVue = createLocalVue();
21+
localVue.use(Vuex);
22+
localVue.use(VueRouter);
23+
24+
const stubChannel = {
25+
id: 'ch1',
26+
name: 'Test',
27+
thumbnail_url: null,
28+
thumbnail_encoding: null,
29+
description: '',
30+
};
31+
const stubSubmission = {
32+
id: 'sub1',
33+
channel_id: 'ch1',
34+
channel_version: 1,
35+
status: 'PENDING',
36+
version_token: null,
37+
};
38+
const stubChannelVersion = { id: 'cv1' };
39+
40+
function makeStore(isAdmin) {
41+
return new Vuex.Store({
42+
getters: { isAdmin: () => isAdmin },
43+
modules: {
44+
channel: {
45+
namespaced: true,
46+
actions: { loadChannel: jest.fn(() => Promise.resolve(stubChannel)) },
47+
},
48+
errors: { namespaced: true, actions: { handleAxiosError: jest.fn() } },
49+
},
50+
});
51+
}
52+
53+
describe('SubmissionDetailsModal', () => {
54+
beforeEach(() => {
55+
AdminCommunityLibrarySubmission.fetchModel.mockResolvedValue(stubSubmission);
56+
CommunityLibrarySubmission.fetchModel.mockResolvedValue(stubSubmission);
57+
ChannelVersion.fetchCollection.mockResolvedValue([stubChannelVersion]);
58+
});
59+
60+
afterEach(() => jest.clearAllMocks());
61+
62+
it('uses AdminCommunityLibrarySubmission when user is admin', () => {
63+
shallowMount(SubmissionDetailsModal, {
64+
localVue,
65+
store: makeStore(true),
66+
router: new VueRouter(),
67+
propsData: { channelId: 'ch1', submissionId: 'sub1' },
68+
});
69+
expect(AdminCommunityLibrarySubmission.fetchModel).toHaveBeenCalledWith('sub1');
70+
});
71+
72+
it('uses CommunityLibrarySubmission when user is not admin', () => {
73+
shallowMount(SubmissionDetailsModal, {
74+
localVue,
75+
store: makeStore(false),
76+
router: new VueRouter(),
77+
propsData: { channelId: 'ch1', submissionId: 'sub1' },
78+
});
79+
expect(CommunityLibrarySubmission.fetchModel).toHaveBeenCalledWith('sub1');
80+
});
81+
});

contentcuration/contentcuration/frontend/shared/views/communityLibrary/SubmissionDetailsModal/index.vue

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,12 @@
7474
</div>
7575
<div class="actions">
7676
<KButton
77-
v-if="adminReview && submission.status === CommunityLibraryStatus.PENDING"
77+
v-if="isAdmin && submission.status === CommunityLibraryStatus.PENDING"
7878
:text="reviewAction$()"
7979
@click="showReviewSidePanel = true"
8080
/>
8181
<ChannelActionsDropdown
82-
v-if="adminReview"
82+
v-if="isAdmin"
8383
primary
8484
:channelId="channelId"
8585
/>
@@ -105,7 +105,7 @@
105105
:channelId="channelId"
106106
/>
107107
<ReviewSubmissionSidePanel
108-
v-if="adminReview && showReviewSidePanel"
108+
v-if="isAdmin && showReviewSidePanel"
109109
:submissionId="submission.id"
110110
:channel="channel"
111111
@close="showReviewSidePanel = false"
@@ -143,10 +143,6 @@
143143
import logging from 'shared/logging';
144144
145145
const props = defineProps({
146-
adminReview: {
147-
type: Boolean,
148-
default: false,
149-
},
150146
channelId: {
151147
type: String,
152148
required: true,
@@ -163,6 +159,7 @@
163159
const route = useRoute();
164160
const router = useRouter();
165161
const store = useStore();
162+
const isAdmin = computed(() => store.getters.isAdmin);
166163
const { windowBreakpoint } = useKResponsiveWindow();
167164
168165
const isModalOpen = computed({
@@ -206,7 +203,7 @@
206203
} = useFetch({
207204
asyncFetchFunc: async () => {
208205
try {
209-
const Resource = props.adminReview
206+
const Resource = isAdmin.value
210207
? AdminCommunityLibrarySubmission
211208
: CommunityLibrarySubmission;
212209
const submission = await Resource.fetchModel(props.submissionId);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from django.apps import apps
2+
from django.core.exceptions import FieldDoesNotExist
3+
from django.core.management.base import BaseCommand
4+
from django.core.management.base import CommandError
5+
from django.db import transaction
6+
from django.db.models import F
7+
8+
9+
class Command(BaseCommand):
10+
help = (
11+
"Idempotent, resumable online backfill of one column into another, in batches."
12+
)
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument("--model", required=True, help="app_label.ModelName")
16+
parser.add_argument("--source-field", required=True)
17+
parser.add_argument("--target-field", required=True)
18+
parser.add_argument("--batch-size", type=int, default=10000)
19+
parser.add_argument("--start-id", default=None, help="resume from this pk")
20+
parser.add_argument(
21+
"--progress-check",
22+
action="store_true",
23+
help="report unbackfilled rows, exit nonzero if any",
24+
)
25+
26+
def _resolve_model_fields(self, model_label, source, target):
27+
try:
28+
model = apps.get_model(model_label)
29+
except (LookupError, ValueError) as e:
30+
raise CommandError("Bad --model {!r}: {}".format(model_label, e))
31+
try:
32+
model._meta.get_field(source)
33+
model._meta.get_field(target)
34+
except FieldDoesNotExist as e:
35+
raise CommandError(str(e))
36+
return model
37+
38+
def _batch_end_pk(self, queryset, pk_name, start_pk, batch_size):
39+
"""Last pk of the batch of `batch_size` rows starting at `start_pk`.
40+
41+
Returns None when fewer than `batch_size` rows remain at/after
42+
`start_pk` — the final, short batch. Keyset paging by pk, so it works
43+
for any pk type (int or UUID).
44+
"""
45+
return (
46+
queryset.filter(pk__gte=start_pk)
47+
.order_by(pk_name)
48+
.values_list("pk", flat=True)[batch_size - 1 : batch_size]
49+
.first()
50+
)
51+
52+
def handle(self, *args, **options):
53+
if options["batch_size"] < 1:
54+
raise CommandError("--batch-size must be >= 1")
55+
source = options["source_field"]
56+
target = options["target_field"]
57+
model = self._resolve_model_fields(options["model"], source, target)
58+
59+
pk_name = model._meta.pk.name
60+
batch_size = options["batch_size"]
61+
only_unfilled = {target + "__isnull": True, source + "__isnull": False}
62+
unfilled = model.objects.filter(**only_unfilled)
63+
unfilled_pks = unfilled.order_by(pk_name).values_list("pk", flat=True)
64+
65+
if options["progress_check"]:
66+
# exists(), not count() — the target table can have millions of rows.
67+
if unfilled.exists():
68+
raise CommandError("backfill incomplete: rows still pending")
69+
self.stdout.write("Backfill complete: no rows pending.")
70+
return
71+
72+
# Start at the first unfilled pk (>= --start-id if given); re-runs and
73+
# resumes skip straight past an already-filled prefix.
74+
batch_start = unfilled_pks
75+
if options["start_id"] is not None:
76+
batch_start = batch_start.filter(pk__gte=options["start_id"])
77+
batch_start = batch_start.first()
78+
79+
total = 0
80+
while batch_start is not None:
81+
batch_end = self._batch_end_pk(
82+
model.objects, pk_name, batch_start, batch_size
83+
)
84+
if batch_end is None:
85+
window = {"pk__gte": batch_start}
86+
else:
87+
window = {"pk__gte": batch_start, "pk__lte": batch_end}
88+
with transaction.atomic():
89+
total += model.objects.filter(**window, **only_unfilled).update(
90+
**{target: F(source)}
91+
)
92+
self.stdout.write(
93+
"backfilled through pk={} (updated {} so far)".format(
94+
batch_start if batch_end is None else batch_end, total
95+
)
96+
)
97+
if batch_end is None:
98+
break
99+
batch_start = unfilled_pks.filter(pk__gt=batch_end).first()
100+
self.stdout.write("Done. {} rows updated.".format(total))

0 commit comments

Comments
 (0)