Skip to content

Commit ec7dbdd

Browse files
committed
Match more videos
1 parent f4cec7f commit ec7dbdd

2 files changed

Lines changed: 184 additions & 8 deletions

File tree

backend/conferences/admin/conference.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
OrderedTabularInline,
1919
)
2020
from itertools import permutations
21-
from unicodedata import normalize
21+
from unicodedata import combining, normalize
2222
from conferences.models import ConferenceVoucher
2323
from schedule.models import ScheduleItem
2424
from sponsors.models import SponsorLevel
@@ -307,14 +307,30 @@ def run_video_uploaded_path_matcher(self, request, object_id, ignore_cache):
307307
conference = Conference.objects.get(pk=object_id)
308308
all_events = (
309309
conference.schedule_items.select_related(
310-
"submission__speaker", "keynote", "language"
310+
"submission__speaker", "keynote", "language", "slot__day"
311311
)
312312
.prefetch_related(
313313
"additional_speakers__user",
314314
"keynote__speakers__user",
315315
)
316316
.all()
317317
)
318+
# files are numbered by talk day (D1 = first day with talks),
319+
# days with only workshops/trainings don't count
320+
talk_days = (
321+
conference.days.filter(
322+
slots__items__type__in=[
323+
ScheduleItem.TYPES.submission,
324+
ScheduleItem.TYPES.talk,
325+
ScheduleItem.TYPES.keynote,
326+
ScheduleItem.TYPES.panel,
327+
]
328+
)
329+
.order_by("day")
330+
.values_list("day", flat=True)
331+
.distinct()
332+
)
333+
day_numbers = {day: number for number, day in enumerate(talk_days, start=1)}
318334

319335
cache_key = f"{conference.code}:video-upload-files-cache"
320336
files = cache.get(cache_key)
@@ -332,7 +348,9 @@ def run_video_uploaded_path_matcher(self, request, object_id, ignore_cache):
332348
used_files = set()
333349

334350
for event in all_events:
335-
video_uploaded_path = self.match_event_to_video_file(event, files)
351+
video_uploaded_path = self.match_event_to_video_file(
352+
event, files, day_numbers
353+
)
336354
event.video_uploaded_path = video_uploaded_path
337355
event.save(update_fields=["video_uploaded_path"])
338356

@@ -361,15 +379,27 @@ def run_video_uploaded_path_matcher(self, request, object_id, ignore_cache):
361379
messages.WARNING,
362380
)
363381

364-
def match_event_to_video_file(self, event, files):
382+
def match_event_to_video_file(self, event, files, day_numbers):
365383
possible_file_names = []
366384

367385
def best_name(speaker):
368386
return cleanup_string(speaker.full_name.strip() or speaker.name.strip())
369387

370-
normalized_files = [
371-
(cleanup_string(video_file), video_file) for video_file in files
372-
]
388+
event_day = None
389+
if event.slot_id:
390+
event_day = day_numbers.get(event.slot.day.day)
391+
392+
# when both the event and the file have a known conference day,
393+
# a mismatch means the file belongs to another day's event
394+
normalized_files = []
395+
for video_file in files:
396+
normalized_video_file = cleanup_string(video_file)
397+
file_day = extract_day_from_video_path(normalized_video_file)
398+
399+
if event_day and file_day and event_day != file_day:
400+
continue
401+
402+
normalized_files.append((normalized_video_file, video_file))
373403

374404
all_speakers_names = [best_name(speaker) for speaker in event.speakers]
375405

@@ -418,9 +448,26 @@ def best_name(speaker):
418448
if possible_file_name in video_file:
419449
return original_video_file
420450

451+
# multi-speaker talks are sometimes uploaded with the name
452+
# of only one of the speakers
453+
if count_speakers > 1:
454+
for video_file, original_video_file in normalized_files:
455+
if "," in video_file:
456+
continue
457+
458+
if any(name in video_file for name in all_speakers_names):
459+
return original_video_file
460+
421461
return ""
422462

423463

464+
def extract_day_from_video_path(normalized_video_path: str) -> int | None:
465+
match = re.search(r"/d(\d+)/", normalized_video_path) or re.search(
466+
r"\bday (\d+)\b", normalized_video_path
467+
)
468+
return int(match.group(1)) if match else None
469+
470+
424471
def walk_conference_videos_folder(storage, base_path):
425472
folders, files = storage.listdir(base_path)
426473
all_files = [f"{base_path}{file_}" for file_ in files]
@@ -566,6 +613,7 @@ def cleanup_string(string: str) -> str:
566613
new_string = normalize(
567614
"NFKD", "".join(char for char in string if char.isprintable())
568615
).lower()
569-
new_string = new_string.replace("-", " ")
616+
new_string = "".join(char for char in new_string if not combining(char))
617+
new_string = new_string.replace("-", " ").replace("_", " ")
570618
new_string = re.sub(r"\s+", " ", new_string)
571619
return new_string.strip()

backend/conferences/tests/test_admin.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
from datetime import date, time
2+
13
from schedule.tests.factories import (
4+
DayFactory,
25
ScheduleItemAdditionalSpeakerFactory,
36
ScheduleItemFactory,
7+
SlotFactory,
48
)
59
from submissions.tests.factories import SubmissionFactory
610
from conferences.tests.factories import (
@@ -645,6 +649,130 @@ def test_video_uploaded_path_matcher(
645649
)
646650

647651

652+
def test_video_uploaded_path_matcher_with_day_signal(rf, mocker):
653+
conference = ConferenceFactory(code="pycon2026")
654+
# workshops only on the first day: videos are numbered by talk day,
655+
# so D1 in the uploaded files is the second conference day
656+
workshops_day = DayFactory(conference=conference, day=date(2026, 5, 27))
657+
day_1 = DayFactory(conference=conference, day=date(2026, 5, 28))
658+
day_2 = DayFactory(conference=conference, day=date(2026, 5, 29))
659+
day_3 = DayFactory(conference=conference, day=date(2026, 5, 30))
660+
661+
marco = UserFactory(name="Marco", full_name="Marco Santoni")
662+
igor = UserFactory(name="Igor", full_name="Igor Saggese")
663+
marc_andre = UserFactory(name="Marc-André", full_name="Marc-André Lemburg")
664+
trainer = UserFactory(name="Trainer", full_name="Some Trainer")
665+
666+
mocker.patch(
667+
"conferences.admin.conference.walk_conference_videos_folder",
668+
return_value=[
669+
"conference-videos/pycon2026/SPAGHETTI/D1/02 - igor-saggese.mp4",
670+
"conference-videos/pycon2026/SPAGHETTI/D2/03 - igor-saggese.mp4",
671+
"conference-videos/pycon2026/TORTELLINI/D1/05 - lightning_talks_day_1.mp4",
672+
"conference-videos/pycon2026/TORTELLINI/D2/04 - lightning_talks_day_2.mp4",
673+
"conference-videos/pycon2026/TORTELLINI/D3/05 - lightning_talks_day_3.mp4",
674+
"conference-videos/pycon2026/TORTELLINI/D3/01 - marc-andre-lemburg.mp4",
675+
],
676+
)
677+
678+
def slot_for(day):
679+
return SlotFactory(day=day, hour=time(10, 0), duration=45)
680+
681+
workshop_event = ScheduleItemFactory(
682+
conference=conference,
683+
title="Workshop",
684+
type=ScheduleItem.TYPES.training,
685+
submission__speaker=trainer,
686+
slot=slot_for(workshops_day),
687+
)
688+
689+
multi_speaker_event = ScheduleItemFactory(
690+
conference=conference,
691+
title="Talk with two speakers",
692+
type=ScheduleItem.TYPES.talk,
693+
submission__speaker=marco,
694+
slot=slot_for(day_1),
695+
)
696+
ScheduleItemAdditionalSpeakerFactory(scheduleitem=multi_speaker_event, user=igor)
697+
698+
igor_solo_event = ScheduleItemFactory(
699+
conference=conference,
700+
title="Igor solo talk",
701+
type=ScheduleItem.TYPES.talk,
702+
submission__speaker=igor,
703+
slot=slot_for(day_2),
704+
)
705+
706+
lightning_talks_events = [
707+
ScheduleItemFactory(
708+
conference=conference,
709+
title="Lightning Talks",
710+
type=ScheduleItem.TYPES.custom,
711+
submission=None,
712+
slot=slot_for(day),
713+
)
714+
for day in (day_1, day_2, day_3)
715+
]
716+
717+
marc_andre_event = ScheduleItemFactory(
718+
conference=conference,
719+
title="Accented speaker talk",
720+
type=ScheduleItem.TYPES.talk,
721+
submission__speaker=marc_andre,
722+
slot=slot_for(day_3),
723+
)
724+
725+
admin = ConferenceAdmin(
726+
model=conference.__class__,
727+
admin_site=AdminSite(),
728+
)
729+
admin.message_user = mocker.Mock()
730+
731+
request = rf.post("/", data={"run_matcher": "1"})
732+
ret = admin.map_videos(request, conference.id)
733+
734+
assert ret.status_code == 302
735+
736+
workshop_event.refresh_from_db()
737+
multi_speaker_event.refresh_from_db()
738+
igor_solo_event.refresh_from_db()
739+
marc_andre_event.refresh_from_db()
740+
for event in lightning_talks_events:
741+
event.refresh_from_db()
742+
743+
assert workshop_event.video_uploaded_path == ""
744+
# the file is named after only one of the two speakers,
745+
# the day disambiguates it from igor's solo talk
746+
assert (
747+
multi_speaker_event.video_uploaded_path
748+
== "conference-videos/pycon2026/SPAGHETTI/D1/02 - igor-saggese.mp4"
749+
)
750+
assert (
751+
igor_solo_event.video_uploaded_path
752+
== "conference-videos/pycon2026/SPAGHETTI/D2/03 - igor-saggese.mp4"
753+
)
754+
assert (
755+
lightning_talks_events[0].video_uploaded_path
756+
== "conference-videos/pycon2026/TORTELLINI/D1/05 - lightning_talks_day_1.mp4"
757+
)
758+
assert (
759+
lightning_talks_events[1].video_uploaded_path
760+
== "conference-videos/pycon2026/TORTELLINI/D2/04 - lightning_talks_day_2.mp4"
761+
)
762+
assert (
763+
lightning_talks_events[2].video_uploaded_path
764+
== "conference-videos/pycon2026/TORTELLINI/D3/05 - lightning_talks_day_3.mp4"
765+
)
766+
assert (
767+
marc_andre_event.video_uploaded_path
768+
== "conference-videos/pycon2026/TORTELLINI/D3/01 - marc-andre-lemburg.mp4"
769+
)
770+
771+
assert admin.message_user.mock_calls[0].args[1] == "Matched 6 videos to events."
772+
# every file was matched exactly once: no reuse or unused warnings
773+
assert admin.message_user.call_count == 1
774+
775+
648776
def test_storage_walk_conference_videos_folder(mocker):
649777
mock_storage = mocker.Mock()
650778
mock_storage.listdir.side_effect = [

0 commit comments

Comments
 (0)