Repository: pretalx/pretalx

Issue:
Event submission_state_change is not sent when a submission is initially placed
### Problem and impact

When a submission is initially made through the CFP wizard, no `submission_state_change` event is sent when the submission changes from its draft state to submitted for the first time.
The event should also be sent in this situation to ease the integration of issue tracking systems like Request Tracker in order to create an issue every time a new proposal is made in pretalx.

### Expected behaviour

When a person submits a proposal, the `submission_state_change` should immediately be sent once the submission state changes from `draft` to `submitted` even before the first notifications are sent out.
This is in particular necessary when an integration with an issue tracking system expects to find an existing issue related to the submission when the submission related notifications shall be stored in the context of that issue.

### Steps to reproduce

_No response_

### Screenshots

_No response_

### Link

_No response_

### Browser (software, desktop or mobile?) and version

_No response_

### Operating system, dependency versions

_No response_

### Version

_No response_

Discussion:
github-actions[bot]: <!-- Pytest Coverage Comment: test -->
<a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/README.md"><img alt="Coverage" src="https://img.shields.io/badge/Coverage-92%25-brightgreen.svg" /></a><details><summary>Full coverage report • </summary><table><tr><th>File</th><th>Stmts</th><th>Miss</th><th>Cover</th><th>Missing</th></tr><tbody><tr><td colspan="5"><b>pretalx/submission/models</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py">submission.py</a></td><td>396</td><td>29</td><td>91%</td><td><a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L309">309</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L376-L377">376&ndash;377</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L530">530</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L606-L>exit">606&ndash;>exit</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L613">613</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L629-L631">629&ndash;631</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L634">634</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L715">715</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L728-L743">728&ndash;743</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L794">794</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L816-L819">816&ndash;819</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L826">826</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L864-L866">864&ndash;866</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L877-L882">877&ndash;882</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L955">955</a>, <a href="https://github.com/pretalx/pretalx/blob/db88bef1f2be49c9b4319a1fa7e664131b6fa1ce/pretalx/submission/models/submission.py#L958">958</a></td></tr><tr><td><b>TOTAL</b></td><td><b>23116</b></td><td><b>1219</b></td><td><b>92%</b></td><td>&nbsp;</td></tr></tbody></table></details>

## Test summary
| Tests | Skipped | Failures | Errors | Time |
| ----- | ------- | -------- | -------- | ------------------ |
| 1353 | 1 :zzz: | 0 :x: | 0 :fire: | 2m 45s :stopwatch: |

badbadc0ffee: Excewllent. Works flawlessly.

Patch:
diff --git a/src/pretalx/submission/models/submission.py b/src/pretalx/submission/models/submission.py
index 653b9eacf..e027edf52 100644
--- a/src/pretalx/submission/models/submission.py
+++ b/src/pretalx/submission/models/submission.py
@@ -357,6 +357,17 @@ def update_duration(self):
 
     update_duration.alters_data = True
 
+    def save(self, *args, **kwargs):
+        is_creating = not self.pk
+        super().save(*args, **kwargs)
+        if is_creating and not self.state == SubmissionStates.DRAFT:
+            submission_state_change.send_robust(
+                self.event,
+                submission=self,
+                old_state=None,
+                user=None,
+            )
+
     def update_review_scores(self):
         """Apply the submission's calculated review scores.
 
@@ -393,7 +404,10 @@ def _set_state(self, new_state, force=False, person=None):
             self.save(update_fields=["state", "pending_state"])
             self.update_talk_slots()
             submission_state_change.send_robust(
-                self.event, submission=self, old_state=old_state, user=person
+                self.event,
+                submission=self,
+                old_state=old_state if old_state != SubmissionStates.DRAFT else None,
+                user=person,
             )
         else:
             source_states = (
diff --git a/src/pretalx/submission/signals.py b/src/pretalx/submission/signals.py
index 21c997474..956bf57b3 100644
--- a/src/pretalx/submission/signals.py
+++ b/src/pretalx/submission/signals.py
@@ -10,4 +10,6 @@
 As with all plugin signals, the ``sender`` keyword argument will contain the event.
 Additionally, you will receive the keyword arguments ``submission``, ``old_state``,
 and ``user`` (which may be ``None``).
+When the submission is created or submitted from a draft state, ``old_state`` will be
+``None``.
 """


Test patch:
diff --git a/src/tests/cfp/views/test_cfp_wizard.py b/src/tests/cfp/views/test_cfp_wizard.py
index 3ca682316..ad69e7097 100644
--- a/src/tests/cfp/views/test_cfp_wizard.py
+++ b/src/tests/cfp/views/test_cfp_wizard.py
@@ -212,7 +212,9 @@ def test_info_wizard_query_string_handling(self, event, client, track):
     @pytest.mark.django_db
     def test_wizard_new_user(self, event, question, client):
         event.mail_settings["mail_on_new_submission"] = True
+        event.plugins = "tests"
         event.save()
+        event.settings.submission_state_change_called = ""
         with scope(event=event):
             submission_type = SubmissionType.objects.filter(event=event).first()
             submission_type.deadline = event.cfp.deadline
@@ -259,6 +261,9 @@ def test_wizard_new_user(self, event, question, client):
         submission = self.assert_submission(event, question=question)
         self.assert_user(submission, email="testuser@example.com")
         assert len(djmail.outbox) == 2  # user email plus orga email
+        assert (
+            submission.event.settings.submission_state_change_called == submission.code
+        )
 
     @pytest.mark.django_db
     def test_wizard_existing_user(
diff --git a/src/tests/dummy_signals.py b/src/tests/dummy_signals.py
index 0197f0ea6..1a5f3c27b 100644
--- a/src/tests/dummy_signals.py
+++ b/src/tests/dummy_signals.py
@@ -8,6 +8,7 @@
     nav_event_settings,
     nav_global,
 )
+from pretalx.submission.signals import submission_state_change
 
 
 @receiver(register_locales)
@@ -53,3 +54,9 @@ def nav_global_test(sender, request, **kwargs):
 def activate_event_test(sender, request, **kwargs):
     if sender.slug == "donottakelive":
         raise Exception("It's not safe to go alone take this")
+
+
+@receiver(submission_state_change)
+def submission_state_change_test(sender, submission, **kwargs):
+    submission._state_change_called = getattr(submission, "_state_change_called", 0) + 1
+    submission.event.settings.submission_state_change_called = submission.code
diff --git a/src/tests/submission/test_submission_model.py b/src/tests/submission/test_submission_model.py
index a1d0d733c..61b204038 100644
--- a/src/tests/submission/test_submission_model.py
+++ b/src/tests/submission/test_submission_model.py
@@ -19,7 +19,9 @@
     ),
 )
 @pytest.mark.django_db
-def test_accept_success(submission, state):
+def test_accept_success(submission, state, mocker):
+    submission.event.plugins = "tests"
+    submission.event.save()
     with scope(event=submission.event):
         submission.state = state
         submission.save()
@@ -32,12 +34,17 @@ def test_accept_success(submission, state):
         )
         assert submission.logged_actions().count() == (count + 1)
         assert submission.event.wip_schedule.talks.count() == 1
+        assert getattr(submission, "_state_change_called", 0) == (
+            1 if state != SubmissionStates.ACCEPTED else 0
+        )
 
 
 @pytest.mark.parametrize("state", (SubmissionStates.WITHDRAWN,))
 @pytest.mark.parametrize("force", (True, False))
 @pytest.mark.django_db
 def test_accept_fail(submission, state, force):
+    submission.event.plugins = "tests"
+    submission.event.save()
     with scope(event=submission.event):
         submission.state = state
         submission.save()
@@ -50,6 +57,7 @@ def test_accept_fail(submission, state, force):
             assert submission.logged_actions().count() == (count + 1)
             assert submission.event.queued_mails.count() == 1
             assert submission.event.wip_schedule.talks.count() == 1
+            assert submission._state_change_called == 1
 
         else:
             with pytest.raises(SubmissionError):
@@ -57,6 +65,7 @@ def test_accept_fail(submission, state, force):
             assert submission.state == state
             assert submission.event.queued_mails.count() == 0
             assert submission.event.wip_schedule.talks.count() == 0
+            assert getattr(submission, "_state_change_called", 0) == 0
 
 
 @pytest.mark.parametrize(
@@ -64,6 +73,8 @@ def test_accept_fail(submission, state, force):
 )
 @pytest.mark.django_db
 def test_reject_success(submission, state):
+    submission.event.plugins = "tests"
+    submission.event.save()
     with scope(event=submission.event):
         submission.state = state
         submission.save()
@@ -75,6 +86,7 @@ def test_reject_success(submission, state):
         assert submission.logged_actions().count() == (count + 1)
         assert submission.event.queued_mails.count() == 1
         assert submission.event.wip_schedule.talks.count() == 0
+        assert submission._state_change_called == 1
 
 
 @pytest.mark.parametrize(
@@ -84,6 +96,8 @@ def test_reject_success(submission, state):
 @pytest.mark.parametrize("force", (True, False))
 @pytest.mark.django_db
 def test_reject_fail(submission, state, force):
+    submission.event.plugins = "tests"
+    submission.event.save()
     with scope(event=submission.event):
         submission.state = state
         submission.save()
@@ -96,6 +110,7 @@ def test_reject_fail(submission, state, force):
             assert submission.logged_actions().count() == (count + 1)
             assert submission.event.queued_mails.count() == 1
             assert submission.event.wip_schedule.talks.count() == 0
+            assert submission._state_change_called == 1
         else:
             with pytest.raises(SubmissionError):
                 submission.reject(force=force)
@@ -103,6 +118,7 @@ def test_reject_fail(submission, state, force):
             assert submission.logged_actions().count() == count
             assert submission.event.queued_mails.count() == 0
             assert submission.event.wip_schedule.talks.count() == 0
+            assert getattr(submission, "_state_change_called", 0) == 0
 
 
 @pytest.mark.parametrize(