pretalx.submission.domain.submission
Functions 21
add_speaker(submission, speaker, *, log_user=None)source
def add_speaker(submission, speaker, *, log_user=None):
submission.speakers.add(speaker)
max_position = (
submission.speaker_roles.exclude(speaker=speaker)
.aggregate(max_pos=Max("position"))
.get("max_pos")
)
submission.speaker_roles.filter(speaker=speaker).update(
position=(max_position or 0) + 1
)
if log_user:
submission.log_action(
"pretalx.submission.speakers.add",
person=log_user,
orga=True,
data={
"code": speaker.code,
"name": speaker.get_display_name(),
"email": speaker.effective_email,
},
)
return speaker
apply_field_changes(submission, changed_fields)sourceRun the side-effects keyed off the fields a caller just persisted.
Callers pass an iterable of field names (typically form.changed_data or a manually-built set in a serializer); this function dispatches to update_duration / update_talk_slots / recalculate_submission_scores for the fields that demand it. Other field names are ignored, so callers can pass their full changed_data without filtering.
def apply_field_changes(submission, changed_fields):
"""Run the side-effects keyed off the fields a caller just persisted.
Callers pass an iterable of field names (typically ``form.changed_data``
or a manually-built set in a serializer); this function dispatches to
``update_duration`` / ``update_talk_slots`` /
``recalculate_submission_scores`` for the fields that demand it. Other
field names are ignored, so callers can pass their full ``changed_data``
without filtering.
"""
fields = set(changed_fields)
if "duration" in fields:
update_duration(submission)
if "slot_count" in fields:
update_talk_slots(submission)
if "track" in fields:
recalculate_submission_scores(submission)
apply_invite_addresses(submission, addresses, *, sender)sourceRoute additional-speaker email addresses based on submission state.
DRAFT proposals park addresses on draft_additional_speakers so the speaker can resume the wizard later; non-DRAFT proposals dispatch each address as a SubmissionInvitation and clear any leftover parking. Idempotent on the parked list — only writes when the value changes.
def apply_invite_addresses(submission, addresses, *, sender):
"""Route additional-speaker email addresses based on submission state.
DRAFT proposals park ``addresses`` on ``draft_additional_speakers``
so the speaker can resume the wizard later; non-DRAFT proposals
dispatch each address as a ``SubmissionInvitation`` and clear any
leftover parking. Idempotent on the parked list — only writes when
the value changes.
"""
addresses = list(addresses or [])
if submission.state == SubmissionStates.DRAFT:
if submission.draft_additional_speakers != addresses:
submission.draft_additional_speakers = addresses
submission.save(update_fields=["draft_additional_speakers"])
return
for address in addresses:
send_invitation(submission, email=address, sender=sender)
if submission.draft_additional_speakers:
submission.draft_additional_speakers = []
submission.save(update_fields=["draft_additional_speakers"])
apply_pending_state(submission, *, person=None)sourceResolve a queued pending_state by transitioning to it.
Pending applies are always orga-attributed: pending_state is set and applied from orga views exclusively.
def apply_pending_state(submission, *, person=None):
"""Resolve a queued ``pending_state`` by transitioning to it.
Pending applies are always orga-attributed: ``pending_state`` is set
and applied from orga views exclusively.
"""
if not submission.pending_state:
return
set_submission_state(
submission,
submission.pending_state,
person=person,
orga=True,
from_pending=True,
)
available_submission_types_for_submitter(event, *, access_code=None, instance=None)sourceSubmission types a submitter may pick when creating or editing.
Locks to the instance's current type once the proposal has moved past SUBMITTED or the CfP is closed; otherwise filters by access code, by per-type and global deadlines, and always includes the instance's current type so an existing selection survives a deadline.
Returns the types and whether the access code determined them.
def available_submission_types_for_submitter(event, *, access_code=None, instance=None):
"""Submission types a submitter may pick when creating or editing.
Locks to the instance's current type once the proposal has moved past
SUBMITTED or the CfP is closed; otherwise filters by access code, by
per-type and global deadlines, and always includes the instance's
current type so an existing selection survives a deadline.
Returns the types and whether the access code determined them.
"""
submission_types = event.submission_types
if (
instance
and not instance._state.adding
and (instance.state != SubmissionStates.SUBMITTED or not event.cfp.is_open)
):
return submission_types.filter(pk=instance.submission_type_id), False
restricted_by_access_code = False
if access_code and access_code.submission_types.exists():
restricted_by_access_code = True
pks = set(access_code.submission_types.values_list("pk", flat=True))
elif access_code:
pks = set(
submission_types.filter(requires_access_code=False).values_list(
"pk", flat=True
)
)
else:
queryset = submission_types.filter(requires_access_code=False)
_now = now()
if not event.cfp.deadline or event.cfp.deadline >= _now:
queryset = queryset.exclude(deadline__lt=_now)
else:
queryset = queryset.filter(deadline__gte=_now)
pks = set(queryset.values_list("pk", flat=True))
if instance and not instance._state.adding:
pks |= {instance.submission_type_id}
return submission_types.filter(pk__in=pks), restricted_by_access_code
available_tracks_for_submitter(event, *, access_code=None, instance=None)sourceTracks a submitter may pick when creating or editing a proposal.
With an access code that has dedicated tracks, returns those. Otherwise returns tracks not requiring an access code, plus the instance's current track if any (so an existing selection is preserved even if its track later gains an access-code requirement).
Returns the tracks and whether the access code determined them.
def available_tracks_for_submitter(event, *, access_code=None, instance=None):
"""Tracks a submitter may pick when creating or editing a proposal.
With an access code that has dedicated tracks, returns those.
Otherwise returns tracks not requiring an access code, plus the
instance's current track if any (so an existing selection is
preserved even if its track later gains an access-code requirement).
Returns the tracks and whether the access code determined them.
"""
if access_code and access_code.tracks.exists():
return access_code.tracks.all(), True
track_filter = Q(requires_access_code=False)
if instance and instance.track and instance.track.requires_access_code:
track_filter |= Q(pk=instance.track_id)
return event.tracks.filter(track_filter), False
create_submission(*, submission, user, orga=False, speakers=(), tags=(), invite_addresses=(), send_initial_mails=False)sourcePersist a new Submission and apply create-time side effects.
user is the creator (for log attribution); orga=True marks the creation as an organiser action in the log. invite_addresses is routed through apply_invite_addresses (parked for drafts, dispatched as invitations otherwise).
Access-code redemption is skipped for DRAFT proposals (they may be abandoned, so the code must remain available); submit_draft consumes it once the draft becomes real. Likewise the create log is silently dropped by Submission.log_action while the state is DRAFT, and re-fired by submit_draft.
With send_initial_mails=True, schedules the speaker acknowledgment (and optional organiser notification).
Creates with a non-DRAFT, non-SUBMITTED initial state (the orga creating an already-accepted talk, say) fire before_submission_state_change first so plugins can veto. Initial DRAFT/SUBMITTED transitions skip the signal, mirroring set_submission_state.
def create_submission(
*,
submission,
user,
orga=False,
speakers=(),
tags=(),
invite_addresses=(),
send_initial_mails=False,
):
"""Persist a new Submission and apply create-time side effects.
``user`` is the creator (for log attribution); ``orga=True`` marks the
creation as an organiser action in the log.
``invite_addresses`` is routed through ``apply_invite_addresses`` (parked
for drafts, dispatched as invitations otherwise).
Access-code redemption is skipped for DRAFT proposals (they may be
abandoned, so the code must remain available); ``submit_draft`` consumes
it once the draft becomes real. Likewise the create log is silently
dropped by ``Submission.log_action`` while the state is DRAFT, and
re-fired by ``submit_draft``.
With ``send_initial_mails=True``, schedules the speaker acknowledgment
(and optional organiser notification).
Creates with a non-DRAFT, non-SUBMITTED initial state (the orga
creating an already-accepted talk, say) fire ``before_submission_state_change``
first so plugins can veto. Initial DRAFT/SUBMITTED transitions skip the
signal, mirroring ``set_submission_state``.
"""
is_speaker_state = submission.state in (
SubmissionStates.SUBMITTED,
SubmissionStates.DRAFT,
)
if not is_speaker_state:
responses = before_submission_state_change.send_robust(
submission.event,
submission=submission,
new_state=submission.state,
user=user,
)
exceptions = [r[1] for r in responses if isinstance(r[1], SubmissionError)]
if exceptions:
raise exceptions[0]
if submission._state.adding:
submission.save()
if submission.state != SubmissionStates.DRAFT:
submission_state_change.send_robust(
submission.event, submission=submission, old_state=None, user=user
)
update_talk_slots(submission)
if tags:
submission.tags.set(tags)
# Initial speakers are folded into the ``.create`` log entry below;
# passing no ``log_user`` to ``add_speaker`` skips its own
# ``speakers.add`` log so the audit trail isn't duplicated.
for speaker_user in speakers:
add_speaker(submission, speaker_user.get_speaker(submission.event))
submission.log_action(".create", person=user, orga=orga)
if submission.image:
submission.process_image("image")
if submission.access_code and submission.state != SubmissionStates.DRAFT:
redeem_access_code(submission.access_code)
apply_invite_addresses(submission, invite_addresses, sender=user)
if send_initial_mails:
queue_initial_mails(submission, person=user)
return submission
delete_submission(submission, *, person=None, orga=True)source
def delete_submission(submission, *, person=None, orga=True):
with transaction.atomic():
submission.slots.all().delete()
submission.feedback.all().delete()
# Delete answers and resources one-by-one so media files
# get deleted too.
for answer in submission.answers.select_related(
"question", "submission__event"
):
answer.delete()
for answer in Answer.objects.select_related(
"question", "review__submission__event"
).filter(review__submission=submission):
answer.delete()
for resource in submission.resources.select_related("submission__event"):
resource.delete()
submission.delete(
log_kwargs={
"person": person,
"orga": orga,
"data": {
"title": submission.title,
"code": submission.code,
"state": submission.state,
},
}
)
notify_speaker_added(submission, speaker, *, locale=None)sourceNotify an existing speaker that they have been added to a proposal.
For managed speakers / new profiles, instead use pretalx.person.domain.profile.send_speaker_invite.
def notify_speaker_added(submission, speaker, *, locale=None):
"""Notify an existing speaker that they have been added to a
proposal. For managed speakers / new profiles, instead use
pretalx.person.domain.profile.send_speaker_invite."""
event = submission.event
template = mail_template_by_role(event, MailTemplateRoles.EXISTING_SPEAKER_INVITE)
mail = render_template_to_mail(
template,
context_kwargs={
"user": Recipient(speaker),
"submission": submission,
"event": event,
},
locale=locale or speaker.effective_locale,
)
save_draft(mail, to_speakers=[speaker], submissions=[submission])
return mail
pin_signup_required(submissions)sourceWhen a track or submission type switches attendee_signup_required from True to False, we manually set individual submissions to require signups if they already have attendees to prevent stranding them.
def pin_signup_required(submissions):
"""When a track or submission type switches attendee_signup_required
from True to False, we manually set individual submissions to require
signups if they already have attendees to prevent stranding them."""
candidates = (
submissions.filter(
attendee_signup_required__isnull=True,
attendee_signups__state=AttendeeSignupStates.CONFIRMED,
submission_type__attendee_signup_required=False,
)
.filter(Q(track__isnull=True) | Q(track__attendee_signup_required=False))
.distinct()
)
affected = list(candidates)
if affected:
Submission._base_manager.filter(pk__in=[s.pk for s in affected]).update(
attendee_signup_required=True
)
return affected
queue_initial_mails(submission, *, person)sourceQueue the post-submit speaker acknowledgment with a 60-second delay for corrections and safety.
def queue_initial_mails(submission, *, person):
"""Queue the post-submit speaker acknowledgment with a 60-second
delay for corrections and safety.
"""
transaction.on_commit(
lambda: task_send_initial_mails.apply_async(
kwargs={"submission_id": submission.pk, "person_id": person.pk},
countdown=60,
)
)
remove_speaker(submission, speaker, *, orga=True, user=None)sourceDetach speaker from the submission and log the removal.
Idempotent: removing a speaker who is not attached is a no-op.
def remove_speaker(submission, speaker, *, orga=True, user=None):
"""Detach ``speaker`` from the submission and log the removal.
Idempotent: removing a speaker who is not attached is a no-op."""
if not submission.speakers.filter(code=speaker.code).exists():
return
submission.speakers.remove(speaker)
submission.log_action(
"pretalx.submission.speakers.remove",
person=user or speaker.user,
orga=orga,
data={
"code": speaker.code,
"email": speaker.effective_email,
"name": speaker.get_display_name(),
},
)
reorder_speakers(submission, *, role_ids, person=None, orga=True)source
def reorder_speakers(submission, *, role_ids, person=None, orga=True):
roles = list(submission.speaker_roles.select_related("speaker", "speaker__user"))
old_order = "\n".join(f"- {role.speaker.get_display_name()}" for role in roles)
role_map = {str(role.pk): role for role in roles}
for index, pk in enumerate(role_ids):
if pk not in role_map:
raise ValueError(f"Unknown speaker role: {pk!r}")
role_map[pk].position = index
SpeakerRole.objects.bulk_update(role_map.values(), ["position"])
new_order = "\n".join(
f"- {role_map[pk].speaker.get_display_name()}"
for pk in role_ids
if pk in role_map
)
if old_order == new_order:
return
submission.log_action(
"pretalx.submission.speakers.reorder",
person=person,
orga=orga,
old_data={"speakers": old_order},
new_data={"speakers": new_order},
)
send_initial_mails(submission, *, person)sourceSend the post-submit speaker confirmation and (optionally) the organiser notification.
Both mails are dispatched immediately rather than left in the outbox for later sending. The speaker mail is also recorded in the outbox (via save_draft() + send_draft()); the organiser notification is fire-and-forget. The organiser-side mail is gated on mail_on_new_submission.
def send_initial_mails(submission, *, person):
"""Send the post-submit speaker confirmation and (optionally) the
organiser notification.
Both mails are dispatched immediately rather than left in the outbox
for later sending. The speaker mail is also recorded in the outbox
(via :func:`save_draft` + :func:`send_draft`); the organiser
notification is fire-and-forget. The organiser-side mail is gated on
``mail_on_new_submission``."""
template = mail_template_by_role(submission.event, MailTemplateRoles.NEW_SUBMISSION)
speaker = person.get_speaker(submission.event)
locale = submission.get_email_locale(speaker.effective_locale)
with override(locale):
if "{full_submission_content}" not in str(template.text):
template.text = (
str(template.text)
+ "\n\n\n***********\n\n"
+ str(_("Full proposal content:\n\n") + "{full_submission_content}")
)
mail = render_template_to_mail(
template,
context_kwargs={"user": Recipient(speaker), "submission": submission},
safe_extra_context={
"full_submission_content": _content_for_mail_placeholder(
submission, locale=locale
)
},
locale=locale,
)
save_draft(mail, to_speakers=[speaker], submissions=[submission])
send_draft(mail)
if submission.event.mail_settings["mail_on_new_submission"]:
internal_mail = render_template_to_mail(
mail_template_by_role(
submission.event, MailTemplateRoles.NEW_SUBMISSION_INTERNAL
),
context_kwargs={"user": person, "submission": submission},
safe_extra_context={"orga_url": submission.orga_urls.base},
locale=submission.event.locale,
)
internal_mail.to = submission.event.email
send_transient(internal_mail)
send_state_mail(submission)sourceQueue the per-state notification mail for accept/reject.
def send_state_mail(submission):
"""Queue the per-state notification mail for accept/reject."""
if submission.state == SubmissionStates.ACCEPTED:
template = mail_template_by_role(
submission.event, MailTemplateRoles.SUBMISSION_ACCEPT
)
elif submission.state == SubmissionStates.REJECTED:
template = mail_template_by_role(
submission.event, MailTemplateRoles.SUBMISSION_REJECT
)
else:
return
for speaker in submission.sorted_speakers:
# Managed speakers cannot use confirmation links or even see
# rejected proposals, so they are skipped.
if not speaker.user_id:
continue
mail = render_template_to_mail(
template,
locale=submission.get_email_locale(speaker.effective_locale),
context_kwargs={"submission": submission, "user": Recipient(speaker)},
)
save_draft(mail, to_speakers=[speaker], submissions=[submission])
set_pending_state(submission, new_state)source
def set_pending_state(submission, new_state):
previous = submission.pending_state
submission.pending_state = new_state
submission.save(update_fields=["pending_state"])
if (
previous in SubmissionStates.accepted_states
or new_state in SubmissionStates.accepted_states
):
update_talk_slots(submission)
set_submission_state(submission, new_state, *, person=None, orga=False, from_pending=False)sourceTransition a submission to new_state and persist.
Handles the full lifecycle of a state change, including - before_submission_state_change veto signal (except for DRAFTs) - data handling, e.g. clearing is_featured in rejection states - database write - slot reconciliation - logging - state change email generation - submission_state_change signal
When the old state and the new state are the same, none of this applies and only the pending state is cleared.
def set_submission_state(
submission, new_state, *, person=None, orga=False, from_pending=False
):
"""Transition a submission to ``new_state`` and persist.
Handles the full lifecycle of a state change, including
- ``before_submission_state_change`` veto signal (except for DRAFTs)
- data handling, e.g. clearing ``is_featured`` in rejection states
- database write
- slot reconciliation
- logging
- state change email generation
- ``submission_state_change`` signal
When the old state and the new state are the same, none of this applies
and only the pending state is cleared.
"""
previous = submission.state
if previous == new_state:
set_pending_state(submission, None)
return
is_initial_submit = new_state == SubmissionStates.SUBMITTED and previous in (
None,
SubmissionStates.DRAFT,
)
if not is_initial_submit:
responses = before_submission_state_change.send_robust(
submission.event, submission=submission, new_state=new_state, user=person
)
exceptions = [r[1] for r in responses if isinstance(r[1], SubmissionError)]
if exceptions:
raise exceptions[0]
submission.state = new_state
submission.pending_state = None
update_fields = ["state", "pending_state"]
if new_state in (
SubmissionStates.REJECTED,
SubmissionStates.CANCELED,
SubmissionStates.WITHDRAWN,
):
submission.is_featured = False
update_fields.append("is_featured")
submission.save(update_fields=update_fields)
update_talk_slots(submission)
if not is_initial_submit:
submission.log_action(
SubmissionStates.log_actions[new_state],
person=person,
orga=orga,
data={"previous": previous, "from_pending": from_pending},
)
# Acceptance / rejection generates a decision mail. Un-confirming
# (CONFIRMED → ACCEPTED) skips it; the speaker already got that mail.
if new_state == SubmissionStates.REJECTED or (
new_state == SubmissionStates.ACCEPTED
and previous != SubmissionStates.CONFIRMED
):
send_state_mail(submission)
submission_state_change.send_robust(
submission.event,
submission=submission,
old_state=previous if previous != SubmissionStates.DRAFT else None,
user=person,
)
set_wip_slot(submission, *, room, start, end)sourceApply room/start/end to the submission's wip slot, or drop scheduling entirely, then queue the unreleased-changes task.
With room and start set (and the submission accepted), the earliest wip slot is updated in place. With start cleared, all wip slots are deleted and update_talk_slots recreates the bare ones.
def set_wip_slot(submission, *, room, start, end):
"""Apply ``room``/``start``/``end`` to the submission's wip slot, or
drop scheduling entirely, then queue the unreleased-changes task.
With ``room`` and ``start`` set (and the submission accepted), the
earliest wip slot is updated in place. With ``start`` cleared, all wip
slots are deleted and ``update_talk_slots`` recreates the bare ones.
"""
if not (room and start and submission.state in SubmissionStates.accepted_states):
submission.slots.filter(schedule=submission.event.wip_schedule).delete()
update_talk_slots(submission)
else:
slot = (
submission.slots.select_related("submission")
.filter(schedule=submission.event.wip_schedule)
.order_by("start")
.first()
)
if slot is None: # accepted submission with no wip slot — should not happen
return
move_slot(slot, start, room=room, end=end)
task_update_unreleased_schedule_changes.apply_async(
kwargs={"event": submission.event.slug}
)
submit_draft(submission, *, user, invite_addresses=())sourceTransition a DRAFT submission to SUBMITTED.
Redeems the access code (deferred from draft creation), fires pretalx.submission.create — silenced while the proposal was a draft — now that the proposal exists for real, and routes invite_addresses through apply_invite_addresses (which by then dispatches them as real invitations). Wrapped in a transaction so the state change, code redemption, create log and invitation dispatch succeed or fail together.
def submit_draft(submission, *, user, invite_addresses=()):
"""Transition a DRAFT submission to SUBMITTED.
Redeems the access code (deferred from draft creation), fires
``pretalx.submission.create`` — silenced while the proposal was a
draft — now that the proposal exists for real, and routes
``invite_addresses`` through ``apply_invite_addresses`` (which by
then dispatches them as real invitations). Wrapped in a transaction
so the state change, code redemption, create log and invitation
dispatch succeed or fail together.
"""
with transaction.atomic():
set_submission_state(submission, SubmissionStates.SUBMITTED, person=user)
if submission.access_code:
redeem_access_code(submission.access_code)
submission.log_action(".create", person=user)
apply_invite_addresses(submission, invite_addresses, sender=user)
queue_initial_mails(submission, person=user)
return submission
update_duration(submission)sourcePush the submission's duration onto its currently scheduled wip slots so the schedule reflects the new length.
def update_duration(submission):
"""Push the submission's duration onto its currently scheduled wip
slots so the schedule reflects the new length."""
duration = submission.get_duration()
for slot in submission.event.wip_schedule.talks.filter(
submission=submission, start__isnull=False
):
move_slot(slot, slot.start, duration=duration)
update_talk_slots(submission)sourceReconcile TalkSlot rows on the wip schedule with the submission's state and slot_count.
If the submission is not (or pending-) accepted, all slots are removed; otherwise the count is brought up or down to slot_count, deleting unscheduled slots first. Slot visibility tracks the CONFIRMED state.
def update_talk_slots(submission):
"""Reconcile ``TalkSlot`` rows on the wip schedule with the
submission's state and ``slot_count``.
If the submission is not (or pending-) accepted, all slots are
removed; otherwise the count is brought up or down to ``slot_count``,
deleting unscheduled slots first. Slot visibility tracks the
CONFIRMED state.
"""
wip = submission.event.wip_schedule
talks = wip.talks.filter(submission=submission)
scheduling_allowed = (
submission.state in SubmissionStates.accepted_states
or submission.pending_state in SubmissionStates.accepted_states
)
if not scheduling_allowed:
talks.delete()
return
diff = talks.count() - submission.slot_count
if diff > 0:
# We delete unscheduled slots first; ``.delete()`` doesn't work on
# sliced querysets, so collect IDs separately.
to_delete = list(
talks.order_by("start", "room", "is_visible")[:diff].values_list(
"id", flat=True
)
)
wip.talks.filter(pk__in=to_delete).delete()
elif diff < 0:
for __ in range(-diff):
wip.talks.create(submission=submission)
talks.update(is_visible=submission.state == SubmissionStates.CONFIRMED)